svc-infra 0.1.562__py3-none-any.whl → 0.1.654__py3-none-any.whl
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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +142 -4
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +178 -12
- svc_infra/apf_payments/provider/stripe.py +757 -48
- svc_infra/apf_payments/schemas.py +163 -1
- svc_infra/apf_payments/service.py +582 -42
- svc_infra/apf_payments/settings.py +22 -2
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/router.py +792 -73
- svc_infra/api/fastapi/apf_payments/setup.py +13 -4
- svc_infra/api/fastapi/auth/add.py +10 -4
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +13 -1
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +82 -42
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +244 -38
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +133 -32
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +23 -14
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
- svc_infra-0.1.562.dist-info/METADATA +0 -79
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
svc_infra/db/sql/utils.py
CHANGED
|
@@ -196,10 +196,17 @@ def _ensure_timeout_default(u: URL) -> URL:
|
|
|
196
196
|
"""
|
|
197
197
|
Ensure a conservative connection timeout is present for libpq-based drivers.
|
|
198
198
|
For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
|
|
199
|
+
For asyncpg, timeout is set via connect_args (not query string).
|
|
199
200
|
"""
|
|
200
201
|
backend = (u.get_backend_name() or "").lower()
|
|
201
202
|
if backend not in ("postgresql", "postgres"):
|
|
202
203
|
return u
|
|
204
|
+
|
|
205
|
+
# asyncpg doesn't support connect_timeout in query string - use connect_args instead
|
|
206
|
+
dn = (u.drivername or "").lower()
|
|
207
|
+
if "+asyncpg" in dn:
|
|
208
|
+
return u
|
|
209
|
+
|
|
203
210
|
if "connect_timeout" in u.query:
|
|
204
211
|
return u
|
|
205
212
|
# Default 10s unless overridden
|
|
@@ -337,9 +344,8 @@ def _ensure_ssl_default(u: URL) -> URL:
|
|
|
337
344
|
mode = (mode_env or "").strip()
|
|
338
345
|
|
|
339
346
|
if "+asyncpg" in driver:
|
|
340
|
-
# asyncpg:
|
|
341
|
-
|
|
342
|
-
return u.set(query={**u.query, "ssl": "true"})
|
|
347
|
+
# asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
|
|
348
|
+
# Do not add ssl parameter to URL query for asyncpg
|
|
343
349
|
return u
|
|
344
350
|
else:
|
|
345
351
|
# libpq-based drivers: use sslmode (default 'require' for hosted PG)
|
|
@@ -382,10 +388,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
382
388
|
"Async driver URL provided but SQLAlchemy async extras are not available."
|
|
383
389
|
)
|
|
384
390
|
|
|
385
|
-
# asyncpg: honor connection timeout
|
|
391
|
+
# asyncpg: honor connection timeout only (NOT connect_timeout)
|
|
386
392
|
if "+asyncpg" in (u.drivername or ""):
|
|
387
393
|
connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
|
|
388
394
|
|
|
395
|
+
# asyncpg doesn't accept sslmode or ssl=true in query params
|
|
396
|
+
# Remove these and set ssl='require' in connect_args
|
|
397
|
+
if "ssl" in u.query or "sslmode" in u.query:
|
|
398
|
+
new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
|
|
399
|
+
u = u.set(query=new_query)
|
|
400
|
+
# Set ssl in connect_args - 'require' is safest for hosted databases
|
|
401
|
+
connect_args["ssl"] = "require"
|
|
402
|
+
|
|
389
403
|
# NEW: aiomysql SSL default
|
|
390
404
|
if "+aiomysql" in (u.drivername or "") and not any(
|
|
391
405
|
k in u.query for k in ("ssl", "ssl_ca", "sslmode")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Integer
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Versioned:
|
|
8
|
+
"""Mixin for optimistic locking with integer version.
|
|
9
|
+
|
|
10
|
+
- Initialize version=1 on insert (via default=1)
|
|
11
|
+
- Bump version in app code before commit to detect mismatches.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Acceptance Matrix (A-IDs)
|
|
2
|
+
|
|
3
|
+
This document maps Acceptance scenarios (A-IDs) to endpoints, CLIs, fixtures, and seed data. Use it to drive the CI promotion gate and local `make accept` runs.
|
|
4
|
+
|
|
5
|
+
## A0. Harness
|
|
6
|
+
- Stack: docker-compose.test.yml (api, db, redis)
|
|
7
|
+
- Makefile targets: accept, compose_up, wait, seed, down
|
|
8
|
+
- Tests bootstrap: tests/acceptance/conftest.py (BASE_URL), _auth.py, _seed.py, _http.py
|
|
9
|
+
|
|
10
|
+
## A1. Security & Auth
|
|
11
|
+
- A1-01 Register → Verify → Login → /auth/me
|
|
12
|
+
- Endpoints: POST /auth/register, POST /auth/verify, POST /auth/login, GET /auth/me
|
|
13
|
+
- Fixtures: admin, user
|
|
14
|
+
- A1-02 Password policy & breach check
|
|
15
|
+
- Endpoints: POST /auth/register
|
|
16
|
+
- A1-03 Lockout escalation and cooldown
|
|
17
|
+
- Endpoints: POST /auth/login
|
|
18
|
+
- A1-04 RBAC/ABAC enforced
|
|
19
|
+
- Endpoints: GET /admin/*, resource GET with owner guard
|
|
20
|
+
- A1-05 Session list & revoke
|
|
21
|
+
- Endpoints: GET/DELETE /auth/sessions
|
|
22
|
+
- A1-06 API keys lifecycle
|
|
23
|
+
- Endpoints: POST/GET/DELETE /auth/api-keys, usage via Authorization header
|
|
24
|
+
- A1-07 MFA lifecycle
|
|
25
|
+
- Endpoints: /auth/mfa/*
|
|
26
|
+
|
|
27
|
+
## A2. Rate Limiting
|
|
28
|
+
- A2-01 Global limit → 429 with Retry-After
|
|
29
|
+
- A2-02 Per-route & tenant override honored
|
|
30
|
+
- A2-03 Window reset
|
|
31
|
+
|
|
32
|
+
## A3. Idempotency & Concurrency
|
|
33
|
+
- A3-01 Same Idempotency-Key → identical 2xx
|
|
34
|
+
- A3-02 Conflicting payload + same key → 409
|
|
35
|
+
- A3-03 Optimistic lock mismatch → 409; success increments version
|
|
36
|
+
|
|
37
|
+
## A4. Jobs & Scheduling
|
|
38
|
+
- A4-01 Custom job consumed
|
|
39
|
+
- A4-02 Backoff & DLQ
|
|
40
|
+
- A4-03 Cron tick observed
|
|
41
|
+
|
|
42
|
+
## A5. Webhooks
|
|
43
|
+
- A5-01 Producer → delivery (HMAC verified)
|
|
44
|
+
- A5-02 Retry stops on success
|
|
45
|
+
- A5-03 Secret rotation window accepts old+new
|
|
46
|
+
|
|
47
|
+
## A6. Tenancy
|
|
48
|
+
- A6-01 tenant_id injected on create; list scoped
|
|
49
|
+
- A6-02 Cross-tenant → 404/403
|
|
50
|
+
- A6-03 Per-tenant quotas enforced
|
|
51
|
+
|
|
52
|
+
## A7. Data Lifecycle
|
|
53
|
+
- A7-01 Soft delete hides; undelete restores
|
|
54
|
+
- A7-02 GDPR erasure steps with audit
|
|
55
|
+
- A7-03 Retention purge soft→hard
|
|
56
|
+
- A7-04 Backup verification healthy
|
|
57
|
+
|
|
58
|
+
## A8. SLOs & Ops
|
|
59
|
+
- A8-01 Metrics http_server_* and db_pool_* present
|
|
60
|
+
- A8-02 Maintenance mode 503; circuit breaker trips/recover
|
|
61
|
+
- A8-03 Liveness/readiness under DB up/down
|
|
62
|
+
|
|
63
|
+
## A9. OpenAPI & Error Contracts
|
|
64
|
+
- A9-01 /openapi.json valid; examples present
|
|
65
|
+
- A9-02 Problem+JSON conforms
|
|
66
|
+
- A9-03 Spectral + API Doctor pass
|
|
67
|
+
|
|
68
|
+
## A10. CLI & DX
|
|
69
|
+
- A10-01 DB migrate/rollback/seed
|
|
70
|
+
- A10-02 Jobs runner consumes a sample job
|
|
71
|
+
- A10-03 SDK smoke-import and /ping
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Pre-Deploy Acceptance (Promotion Gate)
|
|
2
|
+
|
|
3
|
+
This guide describes the acceptance harness that runs post-build against an ephemeral stack. Artifacts are promoted only if acceptance checks pass.
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
- docker-compose.test.yml: api (uvicorn serving tests.acceptance.app), optional db/redis (via profiles), and a tester container to run pytest inside
|
|
7
|
+
- Makefile targets: accept, compose_up, wait, seed, down
|
|
8
|
+
- Health probes: /healthz (liveness), /readyz (readiness), /startupz (startup)
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
1. Build image
|
|
12
|
+
2. docker compose up -d (test stack)
|
|
13
|
+
3. CLI DB checks & seed: run `sql setup-and-migrate`, `sql current`, `sql downgrade -1`, `sql upgrade head` against an ephemeral SQLite DB, then call `sql seed tests.acceptance._seed:acceptance_seed` (no-op by default)
|
|
14
|
+
4. Run pytest inside tester: docker compose run --rm tester (Makefile wires this)
|
|
15
|
+
5. OpenAPI lint & API Doctor
|
|
16
|
+
6. Teardown
|
|
17
|
+
|
|
18
|
+
## Supply-chain & Matrix (v1 scope)
|
|
19
|
+
- SBOM: generate and upload as artifact; image scan (Trivy/Grype) with severity gate.
|
|
20
|
+
- Provenance: sign/attest images (cosign/SLSA) on best-effort basis.
|
|
21
|
+
- Backend matrix: run acceptance against two stacks via COMPOSE_PROFILES:
|
|
22
|
+
1) in-memory stores (default), 2) Redis + Postgres (COMPOSE_PROFILES=pg-redis).
|
|
23
|
+
|
|
24
|
+
## Additional Acceptance Checks (fast wins)
|
|
25
|
+
- Headers/CORS: assert HSTS, X-Content-Type-Options, Referrer-Policy, X-Frame-Options/SameSite; OPTIONS preflight behavior.
|
|
26
|
+
- Resilience: restart DB/Redis during request; expect breaker trip and recovery.
|
|
27
|
+
- DR drill: restore a tiny SQL dump then run smoke.
|
|
28
|
+
- OpenAPI invariants: no orphan routes; servers block correctness for versions; 100% examples for public JSON; stable operationIds; reject /auth/{id} path via lint rule.
|
|
29
|
+
- CLI contracts: `svc-infra --help` and key subcommands exit 0 and print expected flags.
|
|
30
|
+
|
|
31
|
+
## Local usage
|
|
32
|
+
- make accept (runs the full flow locally)
|
|
33
|
+
- make down (tears down the stack)
|
|
34
|
+
- To run tests manually: docker compose run --rm tester
|
|
35
|
+
- To target a different backend: COMPOSE_PROFILES=pg-redis make accept
|
|
36
|
+
|
|
37
|
+
## Files
|
|
38
|
+
- tests/acceptance/conftest.py: BASE_URL, httpx client, fixtures
|
|
39
|
+
- tests/acceptance/_auth.py: login/register helpers
|
|
40
|
+
- tests/acceptance/_seed.py: seed users/tenants/api keys
|
|
41
|
+
- tests/acceptance/_http.py: HTTP helpers
|
|
42
|
+
|
|
43
|
+
## Scenarios
|
|
44
|
+
See docs/acceptance-matrix.md for A-IDs and mapping to endpoints.
|
svc_infra/docs/admin.md
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# Admin Scope & Operations
|
|
2
|
+
|
|
3
|
+
This guide covers the admin subsystem: admin-only routes, permissions, impersonation, and operational guardrails.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The admin module provides:
|
|
8
|
+
- **Admin router pattern**: Role-gated endpoints under `/admin` with fine-grained permission checks
|
|
9
|
+
- **Impersonation**: Controlled user impersonation for support and debugging with full audit trails
|
|
10
|
+
- **Permission alignment**: `admin.impersonate` permission integrated with the RBAC system
|
|
11
|
+
- **Easy integration**: One-line setup via `add_admin(app, ...)`
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Basic Setup
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from svc_infra.api.fastapi.admin import add_admin
|
|
20
|
+
|
|
21
|
+
app = FastAPI()
|
|
22
|
+
|
|
23
|
+
# Mount admin endpoints with defaults
|
|
24
|
+
add_admin(app)
|
|
25
|
+
|
|
26
|
+
# Endpoints are now available:
|
|
27
|
+
# POST /admin/impersonate/start
|
|
28
|
+
# POST /admin/impersonate/stop
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Custom User Loader
|
|
32
|
+
|
|
33
|
+
If you have a custom user model or retrieval logic:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from fastapi import Request
|
|
37
|
+
|
|
38
|
+
async def my_user_getter(request: Request, user_id: str):
|
|
39
|
+
# Your custom user loading logic
|
|
40
|
+
user = await my_user_service.get_user(user_id)
|
|
41
|
+
if not user:
|
|
42
|
+
raise HTTPException(404, "user_not_found")
|
|
43
|
+
return user
|
|
44
|
+
|
|
45
|
+
add_admin(app, impersonation_user_getter=my_user_getter)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Configuration
|
|
49
|
+
|
|
50
|
+
Environment variables:
|
|
51
|
+
|
|
52
|
+
- `ADMIN_IMPERSONATION_SECRET`: Secret for signing impersonation tokens (falls back to `APP_SECRET` or `"dev-secret"`)
|
|
53
|
+
- `ADMIN_IMPERSONATION_TTL`: Token TTL in seconds (default: 900 = 15 minutes)
|
|
54
|
+
- `ADMIN_IMPERSONATION_COOKIE`: Cookie name (default: `"impersonation"`)
|
|
55
|
+
|
|
56
|
+
Function parameters:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
add_admin(
|
|
60
|
+
app,
|
|
61
|
+
base_path="/admin", # Base path for admin routes
|
|
62
|
+
enable_impersonation=True, # Enable impersonation endpoints
|
|
63
|
+
secret=None, # Override token signing secret
|
|
64
|
+
ttl_seconds=15 * 60, # Token TTL (15 minutes)
|
|
65
|
+
cookie_name="impersonation", # Cookie name
|
|
66
|
+
impersonation_user_getter=None, # Custom user loader
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Permissions & RBAC
|
|
71
|
+
|
|
72
|
+
### Admin Role
|
|
73
|
+
|
|
74
|
+
The `admin` role includes the following permissions by default:
|
|
75
|
+
|
|
76
|
+
- `user.read`, `user.write`: User management
|
|
77
|
+
- `billing.read`, `billing.write`: Billing operations
|
|
78
|
+
- `security.session.list`, `security.session.revoke`: Session management
|
|
79
|
+
- `admin.impersonate`: User impersonation
|
|
80
|
+
|
|
81
|
+
### Permission Guards
|
|
82
|
+
|
|
83
|
+
Admin endpoints use layered guards:
|
|
84
|
+
|
|
85
|
+
1. **Role gate** at router level: `RequireRoles("admin")`
|
|
86
|
+
2. **Permission gate** at endpoint level: `RequirePermission("admin.impersonate")`
|
|
87
|
+
|
|
88
|
+
This ensures both coarse-grained role membership and fine-grained permission enforcement.
|
|
89
|
+
|
|
90
|
+
### Custom Admin Routes
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from svc_infra.api.fastapi.admin import admin_router
|
|
94
|
+
from svc_infra.security.permissions import RequirePermission
|
|
95
|
+
|
|
96
|
+
# Create an admin-only router
|
|
97
|
+
router = admin_router(prefix="/admin", tags=["admin"])
|
|
98
|
+
|
|
99
|
+
@router.get("/analytics", dependencies=[RequirePermission("analytics.read")])
|
|
100
|
+
async def admin_analytics():
|
|
101
|
+
return {"data": "..."}
|
|
102
|
+
|
|
103
|
+
app.include_router(router)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Impersonation
|
|
107
|
+
|
|
108
|
+
### Use Cases
|
|
109
|
+
|
|
110
|
+
- **Customer support**: Debug issues as the affected user
|
|
111
|
+
- **Testing**: Verify permission boundaries and user-specific behavior
|
|
112
|
+
- **Compliance**: Audit access patterns under controlled conditions
|
|
113
|
+
|
|
114
|
+
### Workflow
|
|
115
|
+
|
|
116
|
+
#### 1. Start Impersonation
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
POST /admin/impersonate/start
|
|
120
|
+
Content-Type: application/json
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
"user_id": "u-12345",
|
|
124
|
+
"reason": "Investigating billing issue #789"
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Requirements:**
|
|
129
|
+
- Authenticated user must have `admin` role
|
|
130
|
+
- User must have `admin.impersonate` permission
|
|
131
|
+
- `reason` field is mandatory
|
|
132
|
+
|
|
133
|
+
**Response:** `204 No Content` with impersonation cookie set
|
|
134
|
+
|
|
135
|
+
#### 2. Make Requests as Impersonated User
|
|
136
|
+
|
|
137
|
+
All subsequent requests will be made as the target user while preserving the admin's permissions for authorization checks:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
GET /api/v1/profile
|
|
141
|
+
Cookie: impersonation=<token>
|
|
142
|
+
|
|
143
|
+
# Returns the impersonated user's profile
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Behavior:**
|
|
147
|
+
- `request.user` reflects the impersonated user
|
|
148
|
+
- `request.user.roles` inherits the actor's roles (admin maintains permissions)
|
|
149
|
+
- `principal.via` is set to `"impersonated"` for tracking
|
|
150
|
+
|
|
151
|
+
#### 3. Stop Impersonation
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
POST /admin/impersonate/stop
|
|
155
|
+
|
|
156
|
+
# Response: 204 No Content
|
|
157
|
+
# Cookie deleted, subsequent requests use original identity
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Security Guardrails
|
|
161
|
+
|
|
162
|
+
#### Short TTL
|
|
163
|
+
- Default: 15 minutes
|
|
164
|
+
- No sliding refresh: token expires after TTL regardless of activity
|
|
165
|
+
- Rationale: Minimize blast radius of compromised impersonation sessions
|
|
166
|
+
|
|
167
|
+
#### Explicit Reason
|
|
168
|
+
- Required for every impersonation start
|
|
169
|
+
- Logged in audit trail for compliance and forensics
|
|
170
|
+
|
|
171
|
+
#### Audit Trail
|
|
172
|
+
Every impersonation action is logged with:
|
|
173
|
+
- `admin.impersonation.started`: actor, target, reason, expiry
|
|
174
|
+
- `admin.impersonation.stopped`: termination reason (manual/expired)
|
|
175
|
+
|
|
176
|
+
Example log entry:
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"message": "admin.impersonation.started",
|
|
180
|
+
"actor_id": "u-admin-42",
|
|
181
|
+
"target_id": "u-12345",
|
|
182
|
+
"reason": "Investigating billing issue #789",
|
|
183
|
+
"expires_in": 900,
|
|
184
|
+
"timestamp": "2025-11-01T12:00:00Z"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### Token Security
|
|
189
|
+
- HMAC-SHA256 signed tokens with nonce
|
|
190
|
+
- Includes: actor_id, target_id, issued_at, expires_at, nonce
|
|
191
|
+
- Tamper detection via signature verification
|
|
192
|
+
- Cookie attributes:
|
|
193
|
+
- `httponly=true`: No JavaScript access
|
|
194
|
+
- `samesite=lax`: CSRF protection
|
|
195
|
+
- `secure=true` in production: HTTPS only
|
|
196
|
+
|
|
197
|
+
#### Permission Preservation
|
|
198
|
+
- Impersonated requests maintain the actor's permissions
|
|
199
|
+
- Prevents privilege escalation by impersonating a higher-privileged user
|
|
200
|
+
- Target user context for data scoping, actor permissions for authorization
|
|
201
|
+
|
|
202
|
+
### Operational Recommendations
|
|
203
|
+
|
|
204
|
+
#### Development
|
|
205
|
+
```python
|
|
206
|
+
# Relaxed for local testing
|
|
207
|
+
add_admin(
|
|
208
|
+
app,
|
|
209
|
+
secret="dev-secret",
|
|
210
|
+
ttl_seconds=60 * 60, # 1 hour for convenience
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### Production
|
|
215
|
+
```python
|
|
216
|
+
# Strict settings
|
|
217
|
+
add_admin(
|
|
218
|
+
app,
|
|
219
|
+
secret=os.environ["ADMIN_IMPERSONATION_SECRET"], # Strong secret from vault
|
|
220
|
+
ttl_seconds=15 * 60, # 15 minutes max
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Best practices:**
|
|
225
|
+
- Rotate `ADMIN_IMPERSONATION_SECRET` periodically
|
|
226
|
+
- Monitor impersonation logs for anomalies
|
|
227
|
+
- Set up alerts for frequent impersonation by the same actor
|
|
228
|
+
- Consider org/tenant scoping for multi-tenant systems
|
|
229
|
+
- Document allowed impersonation reasons in your runbook
|
|
230
|
+
|
|
231
|
+
## Monitoring & Observability
|
|
232
|
+
|
|
233
|
+
### Metrics
|
|
234
|
+
|
|
235
|
+
Label admin routes with `route_class=admin` for SLO tracking:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from svc_infra.obs.add import add_observability
|
|
239
|
+
|
|
240
|
+
def route_classifier(path: str) -> str:
|
|
241
|
+
if path.startswith("/admin"):
|
|
242
|
+
return "admin"
|
|
243
|
+
# ... other classifications
|
|
244
|
+
return "public"
|
|
245
|
+
|
|
246
|
+
add_observability(app, route_classifier=route_classifier)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Audit Log Queries
|
|
250
|
+
|
|
251
|
+
Search for impersonation events:
|
|
252
|
+
```python
|
|
253
|
+
# Example: Query structured logs
|
|
254
|
+
logs.filter(message="admin.impersonation.started") \
|
|
255
|
+
.filter(actor_id="u-admin-42") \
|
|
256
|
+
.order_by(timestamp.desc()) \
|
|
257
|
+
.limit(100)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Compliance report:
|
|
261
|
+
```python
|
|
262
|
+
# Generate monthly impersonation summary
|
|
263
|
+
impersonations = audit_log.filter(
|
|
264
|
+
event_type__in=["admin.impersonation.started", "admin.impersonation.stopped"],
|
|
265
|
+
timestamp__gte=start_of_month,
|
|
266
|
+
)
|
|
267
|
+
report = impersonations.group_by("actor_id").agg(count="id", targets=unique("target_id"))
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Testing
|
|
271
|
+
|
|
272
|
+
### Unit Tests
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
import pytest
|
|
276
|
+
from svc_infra.api.fastapi.admin import add_admin
|
|
277
|
+
|
|
278
|
+
@pytest.mark.admin
|
|
279
|
+
def test_impersonation_requires_permission():
|
|
280
|
+
app = make_test_app()
|
|
281
|
+
add_admin(app, impersonation_user_getter=lambda req, uid: User(id=uid))
|
|
282
|
+
|
|
283
|
+
# Without admin role → 403
|
|
284
|
+
client = TestClient(app)
|
|
285
|
+
r = client.post("/admin/impersonate/start", json={"user_id": "u-2", "reason": "test"})
|
|
286
|
+
assert r.status_code == 403
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Acceptance Tests
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
@pytest.mark.acceptance
|
|
293
|
+
@pytest.mark.admin
|
|
294
|
+
def test_impersonation_lifecycle(admin_client):
|
|
295
|
+
# Start impersonation
|
|
296
|
+
r = admin_client.post(
|
|
297
|
+
"/admin/impersonate/start",
|
|
298
|
+
json={"user_id": "u-target", "reason": "acceptance test"}
|
|
299
|
+
)
|
|
300
|
+
assert r.status_code == 204
|
|
301
|
+
|
|
302
|
+
# Verify impersonated context
|
|
303
|
+
profile = admin_client.get("/api/v1/profile")
|
|
304
|
+
assert profile.json()["id"] == "u-target"
|
|
305
|
+
|
|
306
|
+
# Stop impersonation
|
|
307
|
+
r = admin_client.post("/admin/impersonate/stop")
|
|
308
|
+
assert r.status_code == 204
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Run admin tests:
|
|
312
|
+
```bash
|
|
313
|
+
pytest -m admin
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Troubleshooting
|
|
317
|
+
|
|
318
|
+
### Impersonation Not Working
|
|
319
|
+
|
|
320
|
+
**Symptom:** Impersonation cookie set but requests still use original identity
|
|
321
|
+
|
|
322
|
+
**Check:**
|
|
323
|
+
1. Cookie is being sent: verify `Cookie: impersonation=<token>` in request headers
|
|
324
|
+
2. Token is valid: check signature and expiry
|
|
325
|
+
3. User getter succeeds: ensure `impersonation_user_getter` doesn't raise exceptions
|
|
326
|
+
4. Dependency override is active: `add_admin` registers a global override on startup
|
|
327
|
+
|
|
328
|
+
**Debug:**
|
|
329
|
+
```python
|
|
330
|
+
# Enable debug logging
|
|
331
|
+
import logging
|
|
332
|
+
logging.getLogger("svc_infra.api.fastapi.admin").setLevel(logging.DEBUG)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Permission Denied
|
|
336
|
+
|
|
337
|
+
**Symptom:** 403 when calling `/admin/impersonate/start`
|
|
338
|
+
|
|
339
|
+
**Check:**
|
|
340
|
+
1. User has `admin` role: verify `user.roles` includes `"admin"`
|
|
341
|
+
2. Permission registered: ensure `admin.impersonate` is in the permission registry
|
|
342
|
+
3. Permission assigned to role: check `PERMISSION_REGISTRY["admin"]` includes `"admin.impersonate"`
|
|
343
|
+
|
|
344
|
+
### Token Expired Too Soon
|
|
345
|
+
|
|
346
|
+
**Symptom:** Impersonation session ends before expected TTL
|
|
347
|
+
|
|
348
|
+
**Possible causes:**
|
|
349
|
+
1. TTL misconfigured: check `ADMIN_IMPERSONATION_TTL` environment variable
|
|
350
|
+
2. Server time skew: verify system clock is synchronized (NTP)
|
|
351
|
+
3. Cookie attributes: ensure `max_age` matches TTL
|
|
352
|
+
|
|
353
|
+
## Security Considerations
|
|
354
|
+
|
|
355
|
+
### Threat Model
|
|
356
|
+
|
|
357
|
+
| Threat | Mitigation |
|
|
358
|
+
|--------|-----------|
|
|
359
|
+
| Token theft (XSS) | `httponly=true` cookie prevents JavaScript access |
|
|
360
|
+
| Token theft (network) | `secure=true` requires HTTPS in production |
|
|
361
|
+
| CSRF attacks | `samesite=lax` prevents cross-site cookie sending |
|
|
362
|
+
| Privilege escalation | Actor permissions preserved during impersonation |
|
|
363
|
+
| Prolonged access | Short TTL (15 min default) with no refresh |
|
|
364
|
+
| Abuse detection | Audit logs with reason, actor, and target tracking |
|
|
365
|
+
| Insider threat | Required reason and comprehensive audit trail |
|
|
366
|
+
|
|
367
|
+
### Compliance
|
|
368
|
+
|
|
369
|
+
**SOC 2 / ISO 27001:**
|
|
370
|
+
- Audit trail requirement: ✅ All impersonation events logged
|
|
371
|
+
- Access justification: ✅ Mandatory `reason` field
|
|
372
|
+
- Time-bound access: ✅ Short TTL with no renewal
|
|
373
|
+
- Least privilege: ✅ Permission-based access control
|
|
374
|
+
|
|
375
|
+
**GDPR / Data Protection:**
|
|
376
|
+
- Lawful basis: Support/debugging under legitimate interest or contract performance
|
|
377
|
+
- Data minimization: Only necessary user context loaded
|
|
378
|
+
- Transparency: Log access for data subject access requests (DSAR)
|
|
379
|
+
- Documentation: This guide serves as basis for DPA documentation
|
|
380
|
+
|
|
381
|
+
## API Reference
|
|
382
|
+
|
|
383
|
+
### `add_admin(app, **kwargs)`
|
|
384
|
+
|
|
385
|
+
Wire admin endpoints and impersonation to a FastAPI app.
|
|
386
|
+
|
|
387
|
+
**Parameters:**
|
|
388
|
+
- `app` (FastAPI): Target application
|
|
389
|
+
- `base_path` (str): Admin router base path (default: `"/admin"`)
|
|
390
|
+
- `enable_impersonation` (bool): Enable impersonation endpoints (default: `True`)
|
|
391
|
+
- `secret` (str | None): Token signing secret (default: env `ADMIN_IMPERSONATION_SECRET`)
|
|
392
|
+
- `ttl_seconds` (int): Token TTL (default: `900` = 15 minutes)
|
|
393
|
+
- `cookie_name` (str): Cookie name (default: `"impersonation"`)
|
|
394
|
+
- `impersonation_user_getter` (Callable | None): Custom user loader `(request, user_id) -> user`
|
|
395
|
+
|
|
396
|
+
**Returns:** None (modifies app in place)
|
|
397
|
+
|
|
398
|
+
**Idempotency:** Safe to call multiple times; only wires once per app instance
|
|
399
|
+
|
|
400
|
+
### `admin_router(**kwargs)`
|
|
401
|
+
|
|
402
|
+
Create an admin-only router with role gate.
|
|
403
|
+
|
|
404
|
+
**Parameters:** Same as `APIRouter` (FastAPI)
|
|
405
|
+
|
|
406
|
+
**Returns:** APIRouter with `RequireRoles("admin")` dependency
|
|
407
|
+
|
|
408
|
+
**Example:**
|
|
409
|
+
```python
|
|
410
|
+
from svc_infra.api.fastapi.admin import admin_router
|
|
411
|
+
|
|
412
|
+
router = admin_router(prefix="/admin/reports", tags=["admin-reports"])
|
|
413
|
+
|
|
414
|
+
@router.get("/summary")
|
|
415
|
+
async def admin_summary():
|
|
416
|
+
return {"total_users": 1234}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Further Reading
|
|
420
|
+
|
|
421
|
+
- [ADR 0011: Admin scope and impersonation](../src/svc_infra/docs/adr/0011-admin-scope-and-impersonation.md)
|
|
422
|
+
- [Security & Auth Hardening](./security.md)
|
|
423
|
+
- [Permissions & RBAC](./security.md#permissions-and-rbac)
|
|
424
|
+
- [Audit Logging](./security.md#audit-logging)
|
|
425
|
+
- [Observability](./observability.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ADR 0002: Background Jobs & Scheduling
|
|
2
|
+
|
|
3
|
+
Date: 2025-10-15
|
|
4
|
+
|
|
5
|
+
Status: Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
We need production-grade background job processing and simple scheduling with a one-call setup. The library already includes in-memory queue/scheduler for tests/local. We need a production backend and a minimal runner.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
- JobQueue protocol defines enqueue/reserve/ack/fail with retry and exponential backoff (base seconds * attempts). Jobs have: id, name, payload, available_at, attempts, max_attempts, backoff_seconds, last_error.
|
|
12
|
+
- Backends:
|
|
13
|
+
- InMemoryJobQueue for tests/local.
|
|
14
|
+
- RedisJobQueue for production using Redis primitives with visibility timeout and atomic operations.
|
|
15
|
+
- Scheduler:
|
|
16
|
+
- InMemoryScheduler providing interval-based scheduling via next_run_at. Cron parsing is out of scope initially; a simple YAML loader can be added later.
|
|
17
|
+
- Runner:
|
|
18
|
+
- A CLI loop `svc-infra jobs run` will tick the scheduler and process jobs in a loop with small sleep/backoff.
|
|
19
|
+
- Configuration:
|
|
20
|
+
- One-call `easy_jobs()` returns (queue, scheduler). Picks backend via `JOBS_DRIVER` env (memory|redis). Redis URL via `REDIS_URL`.
|
|
21
|
+
|
|
22
|
+
## Alternatives Considered
|
|
23
|
+
- Using RQ/Huey/Celery: heavier dependency and less control over API ergonomic goals; we prefer thin primitives aligned with svc-infra patterns.
|
|
24
|
+
- SQL-backed queue first: we will consider later; Redis is sufficient for v1.
|
|
25
|
+
|
|
26
|
+
## Consequences
|
|
27
|
+
- Enables outbox/webhook processors on a reliable queue.
|
|
28
|
+
- Minimal cognitive load: consistent APIs, ENV-driven.
|
|
29
|
+
- Future work: SQL queue, cron YAML loader, metrics, concurrency controls.
|
|
30
|
+
|
|
31
|
+
## Redis Data Model (initial)
|
|
32
|
+
- List `jobs:ready` holds ready job IDs; a ZSET `jobs:delayed` with score=available_at keeps delayed jobs; a HASH per job `job:{id}` stores fields.
|
|
33
|
+
- Reserve uses RPOPLPUSH from `jobs:ready` to `jobs:processing` or BRPOPLPUSH with timeout; sets `visible_at` on job as now+vt and increments `attempts`.
|
|
34
|
+
- Ack removes job from `jobs:processing` and deletes `job:{id}`.
|
|
35
|
+
- Fail increments attempts and computes next available_at = now + backoff_seconds * attempts; moves job to delayed ZSET.
|
|
36
|
+
- A housekeeping step periodically moves due jobs from delayed ZSET to ready list. Reserve also checks ZSET for due jobs opportunistically.
|
|
37
|
+
|
|
38
|
+
## Testing Strategy
|
|
39
|
+
- Unit tests cover enqueue/reserve/ack/fail, visibility timeout behavior, and DLQ after max_attempts.
|
|
40
|
+
- Runner tests cover one iteration loop processing.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# ADR 0003: Webhooks Framework
|
|
2
|
+
|
|
3
|
+
Date: 2025-10-15
|
|
4
|
+
|
|
5
|
+
Status: Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
Services need a consistent way to publish domain events to external consumers via webhooks, verify inbound signatures, and handle retries with backoff. We already have an outbox pattern, a job queue, and a webhook delivery worker.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
- Event Schema: minimal fields {topic, payload, version, created_at}. Versioning included to evolve payloads.
|
|
12
|
+
- Signing: HMAC-SHA256 over canonical JSON payload; header `X-Signature` carries hex digest. Future: include timestamp and v1 signature header variant.
|
|
13
|
+
- Outbox → Job Queue: Producer writes events to Outbox; outbox tick enqueues delivery jobs; worker performs HTTP POST with signature.
|
|
14
|
+
- Subscriptions: In-memory subscription store maps topic → {url, secret}. Persistence deferred.
|
|
15
|
+
- Verification: Provide helper for verifying incoming webhook requests by recomputing the HMAC.
|
|
16
|
+
- Retry: Already handled by JobQueue backoff; DLQ after max attempts.
|
|
17
|
+
|
|
18
|
+
## Consequences
|
|
19
|
+
- Clear boundary: producers don't call HTTP directly; they publish to Outbox.
|
|
20
|
+
- Deterministic signing & verification across producer/consumer.
|
|
21
|
+
- Extensibility: timestamped signed headers, secret rotation, persisted subscriptions are future extensions.
|
|
22
|
+
|
|
23
|
+
## Testing
|
|
24
|
+
- Unit tests for verification helper and end-to-end publish→outbox→queue→delivery using in-memory components and a fake HTTP client.
|