svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- 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/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -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 +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -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 +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable, Iterable, Optional
|
|
6
|
+
|
|
7
|
+
COMMON_PASSWORDS = {"password", "123456", "qwerty", "letmein", "admin"}
|
|
8
|
+
|
|
9
|
+
HIBP_DISABLED = False # default enabled; can be toggled via settings at startup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PasswordPolicy:
|
|
14
|
+
min_length: int = 12
|
|
15
|
+
require_upper: bool = True
|
|
16
|
+
require_lower: bool = True
|
|
17
|
+
require_digit: bool = True
|
|
18
|
+
require_symbol: bool = True
|
|
19
|
+
forbid_common: bool = True
|
|
20
|
+
forbid_breached: bool = True # will toggle off if HIBP integration not configured
|
|
21
|
+
symbols_regex: str = r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PasswordValidationError(Exception):
|
|
25
|
+
def __init__(self, reasons: Iterable[str]):
|
|
26
|
+
super().__init__("Password validation failed")
|
|
27
|
+
self.reasons = list(reasons)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
UPPER = re.compile(r"[A-Z]")
|
|
31
|
+
LOWER = re.compile(r"[a-z]")
|
|
32
|
+
DIGIT = re.compile(r"[0-9]")
|
|
33
|
+
SYMBOL = re.compile(r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BreachedChecker = Callable[[str], bool]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_breached_checker: Optional[BreachedChecker] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configure_breached_checker(checker: Optional[BreachedChecker]) -> None:
|
|
43
|
+
global _breached_checker
|
|
44
|
+
_breached_checker = checker
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_password(pw: str, policy: PasswordPolicy | None = None) -> None:
|
|
48
|
+
policy = policy or PasswordPolicy()
|
|
49
|
+
reasons: list[str] = []
|
|
50
|
+
if len(pw) < policy.min_length:
|
|
51
|
+
reasons.append(f"min_length({policy.min_length})")
|
|
52
|
+
if policy.require_upper and not UPPER.search(pw):
|
|
53
|
+
reasons.append("missing_upper")
|
|
54
|
+
if policy.require_lower and not LOWER.search(pw):
|
|
55
|
+
reasons.append("missing_lower")
|
|
56
|
+
if policy.require_digit and not DIGIT.search(pw):
|
|
57
|
+
reasons.append("missing_digit")
|
|
58
|
+
if policy.require_symbol and not SYMBOL.search(pw):
|
|
59
|
+
reasons.append("missing_symbol")
|
|
60
|
+
if policy.forbid_common:
|
|
61
|
+
lowered = pw.lower()
|
|
62
|
+
# Reject if whole password matches a common one or contains it as a substring
|
|
63
|
+
if lowered in COMMON_PASSWORDS or any(
|
|
64
|
+
term in lowered for term in COMMON_PASSWORDS
|
|
65
|
+
):
|
|
66
|
+
reasons.append("common_password")
|
|
67
|
+
if policy.forbid_breached and not HIBP_DISABLED:
|
|
68
|
+
if _breached_checker and _breached_checker(pw):
|
|
69
|
+
reasons.append("breached_password")
|
|
70
|
+
if reasons:
|
|
71
|
+
raise PasswordValidationError(reasons)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"PasswordPolicy",
|
|
76
|
+
"validate_password",
|
|
77
|
+
"PasswordValidationError",
|
|
78
|
+
"configure_breached_checker",
|
|
79
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any, Awaitable, Callable, Dict, Iterable, Set
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException
|
|
8
|
+
|
|
9
|
+
from svc_infra.api.fastapi.auth.security import Identity
|
|
10
|
+
|
|
11
|
+
# Thread-safe permission registry
|
|
12
|
+
_PERMISSION_LOCK = threading.Lock()
|
|
13
|
+
|
|
14
|
+
# Central role -> permissions mapping. Projects can extend at startup.
|
|
15
|
+
PERMISSION_REGISTRY: Dict[str, Set[str]] = {
|
|
16
|
+
"admin": {
|
|
17
|
+
"user.read",
|
|
18
|
+
"user.write",
|
|
19
|
+
"billing.read",
|
|
20
|
+
"billing.write",
|
|
21
|
+
"security.session.revoke",
|
|
22
|
+
"security.session.list",
|
|
23
|
+
"admin.impersonate",
|
|
24
|
+
},
|
|
25
|
+
"support": {"user.read", "billing.read"},
|
|
26
|
+
"auditor": {"user.read", "billing.read", "audit.read"},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_role(role: str, permissions: Set[str]) -> None:
|
|
31
|
+
"""Thread-safe registration of a role and its permissions."""
|
|
32
|
+
with _PERMISSION_LOCK:
|
|
33
|
+
PERMISSION_REGISTRY[role] = permissions
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extend_role(role: str, permissions: Set[str]) -> None:
|
|
37
|
+
"""Thread-safe extension of an existing role's permissions."""
|
|
38
|
+
with _PERMISSION_LOCK:
|
|
39
|
+
if role in PERMISSION_REGISTRY:
|
|
40
|
+
PERMISSION_REGISTRY[role] |= permissions
|
|
41
|
+
else:
|
|
42
|
+
PERMISSION_REGISTRY[role] = permissions
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
|
|
46
|
+
perms: Set[str] = set()
|
|
47
|
+
with _PERMISSION_LOCK:
|
|
48
|
+
for r in roles:
|
|
49
|
+
perms |= PERMISSION_REGISTRY.get(r, set())
|
|
50
|
+
return perms
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def principal_permissions(principal: Identity) -> Set[str]:
|
|
54
|
+
roles = getattr(principal.user, "roles", []) or []
|
|
55
|
+
return get_permissions_for_roles(roles)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def has_permission(principal: Identity, permission: str) -> bool:
|
|
59
|
+
return permission in principal_permissions(principal)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def RequirePermission(*needed: str):
|
|
63
|
+
"""FastAPI dependency enforcing all listed permissions are present."""
|
|
64
|
+
|
|
65
|
+
async def _guard(principal: Identity):
|
|
66
|
+
perms = principal_permissions(principal)
|
|
67
|
+
missing = [p for p in needed if p not in perms]
|
|
68
|
+
if missing:
|
|
69
|
+
raise HTTPException(403, f"missing_permissions:{','.join(missing)}")
|
|
70
|
+
return principal
|
|
71
|
+
|
|
72
|
+
return Depends(_guard)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def RequireAnyPermission(*candidates: str):
|
|
76
|
+
async def _guard(principal: Identity):
|
|
77
|
+
perms = principal_permissions(principal)
|
|
78
|
+
if not (perms & set(candidates)):
|
|
79
|
+
raise HTTPException(403, "insufficient_permissions")
|
|
80
|
+
return principal
|
|
81
|
+
|
|
82
|
+
return Depends(_guard)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ------- ABAC (Attribute-Based Access Control) helpers -------
|
|
86
|
+
ABACPredicate = Callable[[Identity, Any], bool | Awaitable[bool]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def owns_resource(attr: str = "owner_id") -> ABACPredicate:
|
|
90
|
+
def _predicate(principal: Identity, resource: Any) -> bool:
|
|
91
|
+
user = getattr(principal, "user", None)
|
|
92
|
+
uid = getattr(user, "id", None)
|
|
93
|
+
rid = getattr(resource, attr, None) or getattr(resource, "user_id", None)
|
|
94
|
+
return bool(uid is not None and rid is not None and str(uid) == str(rid))
|
|
95
|
+
|
|
96
|
+
return _predicate
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _maybe_await(v):
|
|
100
|
+
if inspect.isawaitable(v):
|
|
101
|
+
return await v
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def enforce_abac(
|
|
106
|
+
principal: Identity,
|
|
107
|
+
*,
|
|
108
|
+
permission: str,
|
|
109
|
+
resource: Any,
|
|
110
|
+
predicate: ABACPredicate,
|
|
111
|
+
):
|
|
112
|
+
perms = principal_permissions(principal)
|
|
113
|
+
if permission not in perms:
|
|
114
|
+
raise HTTPException(403, f"missing_permissions:{permission}")
|
|
115
|
+
ok = False
|
|
116
|
+
# allow sync or async predicate
|
|
117
|
+
res = predicate(principal, resource)
|
|
118
|
+
if inspect.isawaitable(res):
|
|
119
|
+
# Fast path for sync contexts: raise clear guidance
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
"enforce_abac received an async predicate in a sync context; use RequireABAC for FastAPI dependencies."
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
ok = bool(res)
|
|
125
|
+
if not ok:
|
|
126
|
+
raise HTTPException(403, "forbidden")
|
|
127
|
+
return principal
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def RequireABAC(
|
|
131
|
+
*,
|
|
132
|
+
permission: str,
|
|
133
|
+
predicate: ABACPredicate,
|
|
134
|
+
resource_getter: Callable[..., Any],
|
|
135
|
+
):
|
|
136
|
+
"""FastAPI dependency: enforce permission and attribute check using a resource provider.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
def load_doc(): ...
|
|
140
|
+
@router.get("/docs/{doc_id}", dependencies=[RequireABAC(permission="doc.read", predicate=owns_resource(), resource_getter=load_doc)])
|
|
141
|
+
async def get_doc(identity: Identity, doc = Depends(load_doc)):
|
|
142
|
+
...
|
|
143
|
+
Note: Using the provider in both the dependency and endpoint will call it twice. For heavy
|
|
144
|
+
providers, wire only in the dependency and re-fetch via the dependency override or request.state.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
async def _guard(principal: Identity, resource: Any = Depends(resource_getter)):
|
|
148
|
+
perms = principal_permissions(principal)
|
|
149
|
+
if permission not in perms:
|
|
150
|
+
raise HTTPException(403, f"missing_permissions:{permission}")
|
|
151
|
+
ok = await _maybe_await(predicate(principal, resource))
|
|
152
|
+
if not ok:
|
|
153
|
+
raise HTTPException(403, "forbidden")
|
|
154
|
+
return principal
|
|
155
|
+
|
|
156
|
+
return Depends(_guard)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = [
|
|
160
|
+
"PERMISSION_REGISTRY",
|
|
161
|
+
"register_role",
|
|
162
|
+
"extend_role",
|
|
163
|
+
"get_permissions_for_roles",
|
|
164
|
+
"principal_permissions",
|
|
165
|
+
"has_permission",
|
|
166
|
+
"RequirePermission",
|
|
167
|
+
"RequireAnyPermission",
|
|
168
|
+
"RequireABAC",
|
|
169
|
+
"enforce_abac",
|
|
170
|
+
"owns_resource",
|
|
171
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
except Exception: # pragma: no cover
|
|
10
|
+
AsyncSession = object # type: ignore[misc,assignment]
|
|
11
|
+
|
|
12
|
+
from svc_infra.security.models import (
|
|
13
|
+
AuthSession,
|
|
14
|
+
RefreshToken,
|
|
15
|
+
RefreshTokenRevocation,
|
|
16
|
+
generate_refresh_token,
|
|
17
|
+
hash_refresh_token,
|
|
18
|
+
rotate_refresh_token,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DEFAULT_REFRESH_TTL_MINUTES = 60 * 24 * 7 # 7 days
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def issue_session_and_refresh(
|
|
25
|
+
db: AsyncSession,
|
|
26
|
+
*,
|
|
27
|
+
user_id: uuid.UUID,
|
|
28
|
+
tenant_id: Optional[str] = None,
|
|
29
|
+
user_agent: Optional[str] = None,
|
|
30
|
+
ip_hash: Optional[str] = None,
|
|
31
|
+
ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
|
|
32
|
+
) -> tuple[str, RefreshToken]:
|
|
33
|
+
"""Persist a new AuthSession + initial RefreshToken and return raw refresh token.
|
|
34
|
+
|
|
35
|
+
Returns: (raw_refresh_token, RefreshToken model instance)
|
|
36
|
+
"""
|
|
37
|
+
session_row = AuthSession(
|
|
38
|
+
user_id=user_id,
|
|
39
|
+
tenant_id=tenant_id,
|
|
40
|
+
user_agent=user_agent,
|
|
41
|
+
ip_hash=ip_hash,
|
|
42
|
+
)
|
|
43
|
+
db.add(session_row)
|
|
44
|
+
raw = generate_refresh_token()
|
|
45
|
+
token_hash = hash_refresh_token(raw)
|
|
46
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
|
|
47
|
+
rt = RefreshToken(
|
|
48
|
+
session=session_row,
|
|
49
|
+
token_hash=token_hash,
|
|
50
|
+
expires_at=expires_at,
|
|
51
|
+
)
|
|
52
|
+
db.add(rt)
|
|
53
|
+
await db.flush()
|
|
54
|
+
return raw, rt
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def rotate_session_refresh(
|
|
58
|
+
db: AsyncSession,
|
|
59
|
+
*,
|
|
60
|
+
current: RefreshToken,
|
|
61
|
+
ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
|
|
62
|
+
) -> tuple[str, RefreshToken]:
|
|
63
|
+
"""Rotate a session's refresh token: mark current rotated, create new, add revocation record.
|
|
64
|
+
|
|
65
|
+
Returns: (new_raw_refresh_token, new_refresh_token_model)
|
|
66
|
+
"""
|
|
67
|
+
rotation_ts = datetime.now(timezone.utc)
|
|
68
|
+
if current.revoked_at:
|
|
69
|
+
raise ValueError("refresh token already revoked")
|
|
70
|
+
if current.expires_at and current.expires_at <= rotation_ts:
|
|
71
|
+
raise ValueError("refresh token expired")
|
|
72
|
+
new_raw, new_hash, expires_at = rotate_refresh_token(
|
|
73
|
+
current.token_hash, ttl_minutes=ttl_minutes
|
|
74
|
+
)
|
|
75
|
+
current.rotated_at = rotation_ts
|
|
76
|
+
current.revoked_at = rotation_ts
|
|
77
|
+
current.revoke_reason = "rotated"
|
|
78
|
+
if current.expires_at is None or current.expires_at > rotation_ts:
|
|
79
|
+
current.expires_at = rotation_ts
|
|
80
|
+
# create revocation entry for old hash
|
|
81
|
+
db.add(
|
|
82
|
+
RefreshTokenRevocation(
|
|
83
|
+
token_hash=current.token_hash,
|
|
84
|
+
revoked_at=rotation_ts,
|
|
85
|
+
reason="rotated",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
new_row = RefreshToken(
|
|
89
|
+
session=current.session,
|
|
90
|
+
token_hash=new_hash,
|
|
91
|
+
expires_at=expires_at,
|
|
92
|
+
)
|
|
93
|
+
db.add(new_row)
|
|
94
|
+
await db.flush()
|
|
95
|
+
return new_raw, new_row
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["issue_session_and_refresh", "rotate_session_refresh"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _b64e(b: bytes) -> str:
|
|
12
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _b64d(s: str) -> bytes:
|
|
16
|
+
pad = "=" * (-len(s) % 4)
|
|
17
|
+
return base64.urlsafe_b64decode((s + pad).encode())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sign(data: bytes, key: bytes) -> str:
|
|
21
|
+
return _b64e(hmac.new(key, data, sha256).digest())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _now() -> int:
|
|
25
|
+
return int(time.time())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sign_cookie(
|
|
29
|
+
payload: Dict[str, Any],
|
|
30
|
+
*,
|
|
31
|
+
key: str,
|
|
32
|
+
expires_in: Optional[int] = None,
|
|
33
|
+
path: Optional[str] = None,
|
|
34
|
+
domain: Optional[str] = None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Produce a compact signed cookie value with optional expiry and scope binding.
|
|
37
|
+
|
|
38
|
+
Format: base64url(json).base64url(hmac)
|
|
39
|
+
If expires_in is provided, 'exp' epoch seconds is injected into payload prior to signing.
|
|
40
|
+
If path or domain is provided, they are included in the signed payload to prevent
|
|
41
|
+
cookie replay attacks across different paths/domains.
|
|
42
|
+
"""
|
|
43
|
+
body = dict(payload)
|
|
44
|
+
if expires_in is not None:
|
|
45
|
+
body.setdefault("exp", _now() + int(expires_in))
|
|
46
|
+
# Include scope in signature to prevent replay across paths/domains
|
|
47
|
+
if path is not None:
|
|
48
|
+
body.setdefault("_path", path)
|
|
49
|
+
if domain is not None:
|
|
50
|
+
body.setdefault("_domain", domain)
|
|
51
|
+
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
|
|
52
|
+
sig = _sign(data, key.encode())
|
|
53
|
+
return f"{_b64e(data)}.{sig}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verify_cookie(
|
|
57
|
+
value: str,
|
|
58
|
+
*,
|
|
59
|
+
key: str,
|
|
60
|
+
old_keys: Optional[List[str]] = None,
|
|
61
|
+
expected_path: Optional[str] = None,
|
|
62
|
+
expected_domain: Optional[str] = None,
|
|
63
|
+
) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
|
64
|
+
"""Verify a signed cookie against the primary key or any old key.
|
|
65
|
+
|
|
66
|
+
Returns (ok, payload). If ok is False, payload will be None.
|
|
67
|
+
Rejects if exp is present and in the past.
|
|
68
|
+
If expected_path or expected_domain is provided, verifies the cookie was signed
|
|
69
|
+
for that scope (prevents replay attacks across paths/domains).
|
|
70
|
+
"""
|
|
71
|
+
if not value or "." not in value:
|
|
72
|
+
return False, None
|
|
73
|
+
body_b64, sig = value.split(".", 1)
|
|
74
|
+
try:
|
|
75
|
+
data = _b64d(body_b64)
|
|
76
|
+
expected = _sign(data, key.encode())
|
|
77
|
+
if not hmac.compare_digest(sig, expected):
|
|
78
|
+
# try old keys
|
|
79
|
+
for k in old_keys or []:
|
|
80
|
+
if hmac.compare_digest(sig, _sign(data, k.encode())):
|
|
81
|
+
break
|
|
82
|
+
else:
|
|
83
|
+
return False, None
|
|
84
|
+
payload = json.loads(data.decode())
|
|
85
|
+
# Expire when current time reaches or exceeds exp
|
|
86
|
+
if "exp" in payload and _now() >= int(payload["exp"]):
|
|
87
|
+
return False, None
|
|
88
|
+
# Verify scope binding if expected
|
|
89
|
+
if expected_path is not None:
|
|
90
|
+
if payload.get("_path") != expected_path:
|
|
91
|
+
return False, None
|
|
92
|
+
if expected_domain is not None:
|
|
93
|
+
if payload.get("_domain") != expected_domain:
|
|
94
|
+
return False, None
|
|
95
|
+
return True, payload
|
|
96
|
+
except Exception:
|
|
97
|
+
return False, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["sign_cookie", "verify_cookie"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic file storage system for svc-infra.
|
|
3
|
+
|
|
4
|
+
Provides backend-agnostic file storage with support for multiple providers:
|
|
5
|
+
- Local filesystem (Railway volumes, Render, development)
|
|
6
|
+
- S3-compatible (AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio)
|
|
7
|
+
- Google Cloud Storage (coming soon)
|
|
8
|
+
- Cloudinary (coming soon)
|
|
9
|
+
- In-memory (testing)
|
|
10
|
+
|
|
11
|
+
Quick Start:
|
|
12
|
+
>>> from svc_infra.storage import add_storage, easy_storage
|
|
13
|
+
>>> from fastapi import FastAPI
|
|
14
|
+
>>>
|
|
15
|
+
>>> app = FastAPI()
|
|
16
|
+
>>>
|
|
17
|
+
>>> # Auto-detect backend from environment
|
|
18
|
+
>>> storage = add_storage(app)
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Or explicit backend
|
|
21
|
+
>>> backend = easy_storage(backend="s3", bucket="my-uploads")
|
|
22
|
+
>>> storage = add_storage(app, backend)
|
|
23
|
+
|
|
24
|
+
Usage in Routes:
|
|
25
|
+
>>> from svc_infra.storage import get_storage, StorageBackend
|
|
26
|
+
>>> from fastapi import Depends, UploadFile
|
|
27
|
+
>>>
|
|
28
|
+
>>> @router.post("/upload")
|
|
29
|
+
>>> async def upload_file(
|
|
30
|
+
... file: UploadFile,
|
|
31
|
+
... storage: StorageBackend = Depends(get_storage),
|
|
32
|
+
... ):
|
|
33
|
+
... content = await file.read()
|
|
34
|
+
... url = await storage.put(
|
|
35
|
+
... key=f"uploads/{file.filename}",
|
|
36
|
+
... data=content,
|
|
37
|
+
... content_type=file.content_type or "application/octet-stream",
|
|
38
|
+
... metadata={"user_id": "user_123"}
|
|
39
|
+
... )
|
|
40
|
+
... return {"url": url}
|
|
41
|
+
|
|
42
|
+
Environment Variables:
|
|
43
|
+
STORAGE_BACKEND: Backend type (local, s3, gcs, cloudinary, memory)
|
|
44
|
+
|
|
45
|
+
Local:
|
|
46
|
+
STORAGE_BASE_PATH: Directory for files (default: /data/uploads)
|
|
47
|
+
STORAGE_BASE_URL: URL for file serving (default: http://localhost:8000/files)
|
|
48
|
+
|
|
49
|
+
S3:
|
|
50
|
+
STORAGE_S3_BUCKET: Bucket name (required)
|
|
51
|
+
STORAGE_S3_REGION: AWS region (default: us-east-1)
|
|
52
|
+
STORAGE_S3_ENDPOINT: Custom endpoint for S3-compatible services
|
|
53
|
+
STORAGE_S3_ACCESS_KEY: Access key (falls back to AWS_ACCESS_KEY_ID)
|
|
54
|
+
STORAGE_S3_SECRET_KEY: Secret key (falls back to AWS_SECRET_ACCESS_KEY)
|
|
55
|
+
|
|
56
|
+
See Also:
|
|
57
|
+
- ADR-0012: Generic File Storage System design
|
|
58
|
+
- docs/storage.md: Comprehensive storage guide
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from .add import add_storage, get_storage, health_check_storage
|
|
62
|
+
from .backends import LocalBackend, MemoryBackend, S3Backend
|
|
63
|
+
from .base import (
|
|
64
|
+
FileNotFoundError,
|
|
65
|
+
InvalidKeyError,
|
|
66
|
+
PermissionDeniedError,
|
|
67
|
+
QuotaExceededError,
|
|
68
|
+
StorageBackend,
|
|
69
|
+
StorageError,
|
|
70
|
+
)
|
|
71
|
+
from .easy import easy_storage
|
|
72
|
+
from .settings import StorageSettings
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
# Main API
|
|
76
|
+
"add_storage",
|
|
77
|
+
"easy_storage",
|
|
78
|
+
"get_storage",
|
|
79
|
+
"health_check_storage",
|
|
80
|
+
# Base types
|
|
81
|
+
"StorageBackend",
|
|
82
|
+
"StorageSettings",
|
|
83
|
+
# Backends
|
|
84
|
+
"LocalBackend",
|
|
85
|
+
"MemoryBackend",
|
|
86
|
+
"S3Backend",
|
|
87
|
+
# Exceptions
|
|
88
|
+
"StorageError",
|
|
89
|
+
"FileNotFoundError",
|
|
90
|
+
"PermissionDeniedError",
|
|
91
|
+
"QuotaExceededError",
|
|
92
|
+
"InvalidKeyError",
|
|
93
|
+
]
|