svc-infra 0.1.631__py3-none-any.whl → 0.1.632__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

@@ -0,0 +1,3 @@
1
+ from .add import add_admin, admin_router
2
+
3
+ __all__ = ["add_admin", "admin_router"]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from hashlib import sha256
11
+ from types import SimpleNamespace
12
+ from typing import Any, Callable, Optional
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
15
+
16
+ from ....app.env import get_current_environment
17
+ from ....security.permissions import RequirePermission
18
+ from ..auth.security import Identity, Principal, _current_principal
19
+ from ..auth.state import get_auth_state
20
+ from ..db.sql.session import SqlSessionDep
21
+ from ..dual.protected import roles_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _b64u(data: bytes) -> str:
27
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
28
+
29
+
30
+ def _b64u_decode(s: str) -> bytes:
31
+ pad = "=" * ((4 - len(s) % 4) % 4)
32
+ return base64.urlsafe_b64decode(s + pad)
33
+
34
+
35
+ def _sign(payload: dict, *, secret: str) -> str:
36
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
37
+ sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
38
+ return _b64u(body) + "." + _b64u(sig)
39
+
40
+
41
+ def _verify(token: str, *, secret: str) -> dict:
42
+ try:
43
+ b64_body, b64_sig = token.split(".", 1)
44
+ body = _b64u_decode(b64_body)
45
+ exp_sig = _b64u_decode(b64_sig)
46
+ got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
47
+ if not hmac.compare_digest(exp_sig, got_sig):
48
+ raise ValueError("bad_signature")
49
+ payload = json.loads(body)
50
+ if int(payload.get("exp", 0)) < int(time.time()):
51
+ raise ValueError("expired")
52
+ return payload
53
+ except Exception as e:
54
+ raise ValueError("invalid_token") from e
55
+
56
+
57
+ def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
58
+ """Role-gated admin router for coarse access control.
59
+
60
+ Use permission guards inside endpoints for fine-grained control.
61
+ """
62
+
63
+ return roles_router("admin", **kwargs)
64
+
65
+
66
+ def add_admin(
67
+ app,
68
+ *,
69
+ base_path: str = "/admin",
70
+ enable_impersonation: bool = True,
71
+ secret: Optional[str] = None,
72
+ ttl_seconds: int = 15 * 60,
73
+ cookie_name: str = "impersonation",
74
+ impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
75
+ ) -> None:
76
+ """Wire admin surfaces with sensible defaults.
77
+
78
+ - Mounts an admin router under base_path.
79
+ - Optionally enables impersonation start/stop endpoints guarded by permissions.
80
+ - Registers a dependency override to honor impersonation cookie globally (idempotent).
81
+
82
+ impersonation_user_getter: optional callable (request, user_id) -> user object.
83
+ If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
84
+ """
85
+
86
+ # Idempotency: only mount once per app instance
87
+ if getattr(app.state, "_admin_added", False):
88
+ return
89
+
90
+ env = get_current_environment()
91
+ _secret = (
92
+ secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET") or "dev-secret"
93
+ )
94
+ _ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
95
+ _cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
96
+
97
+ r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
98
+
99
+ async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
100
+ try:
101
+ UserModel, _, _ = get_auth_state()
102
+ except Exception:
103
+ # Fallback: simple shim if auth state not configured
104
+ return SimpleNamespace(id=user_id)
105
+ obj = await session.get(UserModel, user_id)
106
+ if not obj:
107
+ raise HTTPException(404, "user_not_found")
108
+ return obj
109
+
110
+ user_getter = impersonation_user_getter
111
+
112
+ @r.post(
113
+ "/impersonate/start", status_code=204, dependencies=[RequirePermission("admin.impersonate")]
114
+ )
115
+ async def start_impersonation(
116
+ body: dict, request: Request, response: Response, session: SqlSessionDep, identity: Identity
117
+ ):
118
+ target_id = (body or {}).get("user_id")
119
+ reason = (body or {}).get("reason", "")
120
+ if not target_id:
121
+ raise HTTPException(422, "user_id_required")
122
+ # Load target for validation (custom getter or default)
123
+ _res = (
124
+ user_getter(request, target_id)
125
+ if user_getter
126
+ else _default_user_getter(request, target_id, session)
127
+ )
128
+ target = await _res if inspect.isawaitable(_res) else _res
129
+ actor: Principal = identity
130
+ payload = {
131
+ "actor_id": getattr(getattr(actor, "user", None), "id", None),
132
+ "target_id": str(getattr(target, "id", target_id)),
133
+ "iat": int(time.time()),
134
+ "exp": int(time.time()) + _ttl,
135
+ "nonce": _b64u(os.urandom(8)),
136
+ }
137
+ token = _sign(payload, secret=_secret)
138
+ response.set_cookie(
139
+ key=_cookie,
140
+ value=token,
141
+ httponly=True,
142
+ samesite="lax",
143
+ secure=(env in ("prod", "production")),
144
+ path="/",
145
+ max_age=_ttl,
146
+ )
147
+ logger.info(
148
+ "admin.impersonation.started",
149
+ extra={
150
+ "actor_id": payload["actor_id"],
151
+ "target_id": payload["target_id"],
152
+ "reason": reason,
153
+ "expires_in": _ttl,
154
+ },
155
+ )
156
+ # Re-compose override now to wrap any late overrides set by tests/harness
157
+ try:
158
+ _compose_override()
159
+ except Exception:
160
+ pass
161
+
162
+ @r.post("/impersonate/stop", status_code=204)
163
+ async def stop_impersonation(response: Response):
164
+ response.delete_cookie(_cookie, path="/")
165
+ logger.info("admin.impersonation.stopped")
166
+
167
+ app.include_router(r)
168
+
169
+ # Dependency override: wrap the base principal to honor impersonation cookie.
170
+ # Compose with any existing override (e.g., acceptance app/test harness) and
171
+ # re-compose at startup to capture late overrides.
172
+ def _compose_override():
173
+ existing = app.dependency_overrides.get(_current_principal)
174
+ if existing and getattr(existing, "_is_admin_impersonation_override", False):
175
+ dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
176
+ else:
177
+ dep_provider = existing or _current_principal
178
+
179
+ async def _override_current_principal(
180
+ base: Principal = Depends(dep_provider),
181
+ request: Request = None,
182
+ session: SqlSessionDep = None,
183
+ ) -> Principal:
184
+ token = request.cookies.get(_cookie) if request else None
185
+ if not token:
186
+ return base
187
+ try:
188
+ payload = _verify(token, secret=_secret)
189
+ except Exception:
190
+ return base
191
+ # Load target user
192
+ target_id = payload.get("target_id")
193
+ if not target_id:
194
+ return base
195
+ # Preserve actor roles/claims so permissions remain that of the actor
196
+ actor_user = getattr(base, "user", None)
197
+ actor_roles = getattr(actor_user, "roles", []) or []
198
+ _res = (
199
+ user_getter(request, target_id)
200
+ if user_getter
201
+ else _default_user_getter(request, target_id, session)
202
+ )
203
+ target = await _res if inspect.isawaitable(_res) else _res
204
+ # Swap user but keep actor for audit if needed
205
+ setattr(base, "actor", getattr(base, "user", None))
206
+ # If target lacks roles, inherit actor roles to maintain permission checks
207
+ try:
208
+ if not getattr(target, "roles", None):
209
+ setattr(target, "roles", actor_roles)
210
+ except Exception:
211
+ # Best-effort; if target object is immutable, fallback by wrapping
212
+ target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
213
+ base.user = target
214
+ base.via = "impersonated"
215
+ return base
216
+
217
+ app.dependency_overrides[_current_principal] = _override_current_principal
218
+ _override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
219
+ _override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
220
+
221
+ # Compose now (best-effort) and again on startup to wrap any later overrides
222
+ _compose_override()
223
+ try:
224
+ app.add_event_handler("startup", _compose_override)
225
+ except Exception:
226
+ # Best-effort; if app doesn't support event handlers, we already composed once
227
+ pass
228
+ app.state._admin_added = True
229
+
230
+
231
+ # no extra helpers
@@ -0,0 +1,73 @@
1
+ # 0011 — Admin scope, permissions, and impersonation
2
+
3
+ ## Context
4
+ - The codebase already provides RBAC/permission helpers: `RequireRoles`, `RequirePermission`, ABAC via `RequireABAC`/`owns_resource`.
5
+ - The central permission registry maps roles → permissions (`svc_infra.security.permissions.PERMISSION_REGISTRY`). Notably, the `admin` role includes: `user.read`, `user.write`, `billing.read`, `billing.write`, and `security.session.{list,revoke}`.
6
+ - Acceptance tests demonstrate an “admin-only” route guarded by `RequirePermission("user.write")` and temporary role override to `admin`.
7
+ - There is no dedicated admin API surface yet, and no impersonation flow; observability docs mention an optional route classifier that can label routes like `public|internal|admin`.
8
+
9
+ ## Goals
10
+ - Define a consistent approach for admin-only surfaces and permission alignment.
11
+ - Establish minimal permissions needed for admin operations, including impersonation.
12
+ - Outline an impersonation flow with security and audit guardrails.
13
+ - Prepare for an easy integration helper (`add_admin`) without implementing it yet.
14
+
15
+ ## Non-goals
16
+ - Implement admin endpoints or impersonation logic in this ADR.
17
+ - Replace existing permissions/guards — this ADR aligns and extends them.
18
+
19
+ ## Decisions
20
+
21
+ 1) Permissions alignment and additions
22
+ - Keep permissions as the canonical guard unit; roles remain a mapping to permissions.
23
+ - Extend the registry with a dedicated permission for impersonation:
24
+ - `admin.impersonate`
25
+ - Keep existing entries (`security.session.{list,revoke}` etc.) as-is.
26
+ - Recommended role → permission mapping updates:
27
+ - `admin`: add `admin.impersonate` (retains existing permissions).
28
+ - `auditor`: keep `audit.read` (already present) and may expand in the future.
29
+
30
+ 2) Admin router pattern
31
+ - Provide an admin-only router pattern that layers role and permission checks consistently:
32
+ - Top-level: role gate via `RequireRoles("admin")` to reflect the “admin area”.
33
+ - Endpoint-level: permission gates via `RequirePermission(...)` for specific operations.
34
+ - Rationale: roles communicate the coarse-grained area; fine-grained actions are enforced by permissions.
35
+ - A future helper `admin_router()` can wrap `roles_router("admin")` (from `api.fastapi.dual.protected`) for ergonomic mounting.
36
+
37
+ 3) Impersonation flow (design)
38
+ - Endpoints:
39
+ - `POST /admin/impersonate/start` — body: `{ user_id, reason }`; requires `admin.impersonate`.
40
+ - `POST /admin/impersonate/stop` — ends the session.
41
+ - Mechanics:
42
+ - When starting, issue a short-lived, signed impersonation token (or set a dedicated cookie) that encodes: original admin principal id, target user id, issued-at, expires-at, and nonce.
43
+ - Downstream identity resolution should reflect the impersonated user for request handling, while preserving the original admin as the "actor" for auditing.
44
+ - Stopping invalidates the token/cookie (server-side revocation list or versioned secret), and subsequent requests fall back to the admin’s own identity.
45
+ - Safety guardrails:
46
+ - Always require `admin.impersonate`.
47
+ - Enforce explicit `reason` and capture request fingerprint (ip hash, user-agent) with the event.
48
+ - Limit scope by tenant/org if applicable; optionally block actions explicitly marked non-impersonable.
49
+ - Set short TTL (e.g., 15 minutes) with sliding refresh disabled.
50
+
51
+ 4) Audit logging
52
+ - Emit structured audit events for impersonation lifecycle:
53
+ - `admin.impersonation.started` with actor, target, reason, ip hash, user-agent, and expiry.
54
+ - `admin.impersonation.stopped` with actor, target, and termination reason (expired/manual).
55
+ - Implementation options (future):
56
+ - Minimal: log via the existing logging setup (structured logger, e.g., `logger.bind(...).info("audit", ...)`).
57
+ - Preferred: emit to an audit outbox/table or webhook channel for retention and cross-system visibility.
58
+
59
+ 5) Observability and route classification
60
+ - Encourage passing a `route_classifier` that labels admin routes as `admin` (e.g., for `/admin` base path) so metrics/SLO dashboards can split traffic into `public|internal|admin` classes.
61
+
62
+ ## Consequences
63
+ - Clear, documented permissions and flow for admin-only features.
64
+ - Minimal surface to add later: `admin_router()` and `add_admin(app, ...)` helper that mounts admin routes and wires impersonation endpoints + audit hooks.
65
+ - Tests to plan when implementing:
66
+ - Role vs permission gating behavior on /admin routes.
67
+ - Impersonation start/stop lifecycle and audit emission.
68
+ - Ownership checks that permit admin bypass where intended (e.g., session revocation).
69
+
70
+ ## Follow-ups
71
+ - Update the permission registry to include `admin.impersonate` (and map into `admin`).
72
+ - Implement `admin_router()` and the `add_admin` helper following this ADR.
73
+ - Add admin acceptance tests and documentation for guardrails and operational guidance.
@@ -16,6 +16,7 @@ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
16
16
  "billing.write",
17
17
  "security.session.revoke",
18
18
  "security.session.list",
19
+ "admin.impersonate",
19
20
  },
20
21
  "support": {"user.read", "billing.read"},
21
22
  "auditor": {"user.read", "billing.read", "audit.read"},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.631
3
+ Version: 0.1.632
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -13,6 +13,8 @@ svc_infra/apf_payments/service.py,sha256=Y5wtT9qnL57b_A3AAIL8fruliAitxeVyMPaYLZs
13
13
  svc_infra/apf_payments/settings.py,sha256=vVWsvz_ajtVlRPH4N8ijSI4V7hUfjZFZT3h4wHRNa-s,2032
14
14
  svc_infra/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  svc_infra/api/fastapi/__init__.py,sha256=VVdQjak74_wTDqmvL05_C97vIFugQxPVU-3JQEFBgR8,747
16
+ svc_infra/api/fastapi/admin/__init__.py,sha256=AmuCHI5EQRCqwM40Gs1245z7MTcYZkg5xuoaplCExko,82
17
+ svc_infra/api/fastapi/admin/add.py,sha256=9mCfvKvwBf_sr-SkpCbF1YMNvg-KRovrM_WV3hHs8m8,8649
16
18
  svc_infra/api/fastapi/apf_payments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
19
  svc_infra/api/fastapi/apf_payments/router.py,sha256=JIMCAZ1_Vie_EfvOS999iYSDH9ZBx1Nfizud-F_b5T0,36788
18
20
  svc_infra/api/fastapi/apf_payments/setup.py,sha256=bk_LLLXyqTA-lqf0v-mdpqLEMbXB17U1IQjG-qSBejQ,2677
@@ -236,6 +238,7 @@ svc_infra/docs/adr/0007-docs-and-sdks.md,sha256=uQ-q-5omaOXPL5tW5q0_1FE9P_OmO9aD
236
238
  svc_infra/docs/adr/0008-billing-primitives.md,sha256=6em0RYeDAQScN7oSZfD_XslzrzIZZ-qykROJixCcEQs,8479
237
239
  svc_infra/docs/adr/0009-acceptance-harness.md,sha256=jDmoWn2uJTeK28YZo75YR1ym6NdgcmPOlMfupZlCCBs,2146
238
240
  svc_infra/docs/adr/0010-timeouts-and-resource-limits.md,sha256=tpOTjncKJAjTsDN8jSUOTNqEKHfhVcfooxfW0nnbnro,2815
241
+ svc_infra/docs/adr/0011-admin-scope-and-impersonation.md,sha256=tHg0vXzyefr6qEQOes2OyQByZsK-ogDe1VQ1dQn2Ibc,4850
239
242
  svc_infra/docs/api.md,sha256=AlPL9kBS6_dM0NrOteDQ9WqalSfKf_p9_zdy1CtGJdU,2384
240
243
  svc_infra/docs/auth.md,sha256=PRl9G4UW78cT_7c4koVh5NDlheNAr02CpJT2YFbEXto,1333
241
244
  svc_infra/docs/billing.md,sha256=MArKbKhzFwMLaOMABNDRtT_2D0zGgyFZ2r54o-99v68,7884
@@ -330,7 +333,7 @@ svc_infra/security/lockout.py,sha256=KdKN9FWejuzHRKS9jXzi_f3-lNF6QZyiEDBXCej0LSY
330
333
  svc_infra/security/models.py,sha256=US5jxgeZf7C_tWW3QZRj5RTuRZE_yS6RHZBEK0ea9tA,9535
331
334
  svc_infra/security/org_invites.py,sha256=TuXEstZp5GfRQflz8OR2q6m7GpSOonSxm0QU7ojkbH0,3876
332
335
  svc_infra/security/passwords.py,sha256=zUiduHFOWYT7USzMkBntI3-LNEyVMn2A78CvaKpB7MY,2459
333
- svc_infra/security/permissions.py,sha256=fQm7-OcJJkWsScDcjS2gwmqaW93zQqltaHRl6bviIik,4527
336
+ svc_infra/security/permissions.py,sha256=gQijNud6jh0yY2JIuZazAVE8i9zYhbwAR6tSjbncv5o,4556
334
337
  svc_infra/security/session.py,sha256=JkClqoZ-Moo9yqHzCREXMVSpzyjbn2Zh6zCjtWO93Ik,2848
335
338
  svc_infra/security/signed_cookies.py,sha256=2t61BgjsBaTzU46bt7IUJo7lwDRE9_eS4vmAQXJ8mlY,2219
336
339
  svc_infra/utils.py,sha256=VX1yjTx61-YvAymyRhGy18DhybiVdPddiYD_FlKTbJU,952
@@ -340,7 +343,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
340
343
  svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
341
344
  svc_infra/webhooks/service.py,sha256=hh-rw0otc00vipZ998XaV5mHsk0IDGYqon0FnhaGr60,2229
342
345
  svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
343
- svc_infra-0.1.631.dist-info/METADATA,sha256=B-Klim62FKQqNr_a9lfHuJ3jsuWSf5HF032_SfiDDxM,8748
344
- svc_infra-0.1.631.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
345
- svc_infra-0.1.631.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
346
- svc_infra-0.1.631.dist-info/RECORD,,
346
+ svc_infra-0.1.632.dist-info/METADATA,sha256=qAkPSvHj4fYrc7CknZ2Mjp2-oRsp9Xlc0lQ-y9RgKNo,8748
347
+ svc_infra-0.1.632.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
348
+ svc_infra-0.1.632.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
349
+ svc_infra-0.1.632.dist-info/RECORD,,