svc-infra 0.1.706__py3-none-any.whl → 1.1.0__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/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -8,8 +8,8 @@ Import this module only when enable_oauth=True is passed to add_auth_users.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import uuid
|
|
11
|
-
from datetime import
|
|
12
|
-
from typing import TYPE_CHECKING
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
13
|
|
|
14
14
|
from sqlalchemy import (
|
|
15
15
|
JSON,
|
|
@@ -44,13 +44,13 @@ class ProviderAccount(ModelBase):
|
|
|
44
44
|
)
|
|
45
45
|
provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
46
46
|
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
47
|
-
access_token: Mapped[
|
|
48
|
-
refresh_token: Mapped[
|
|
49
|
-
expires_at: Mapped[
|
|
50
|
-
raw_claims: Mapped[
|
|
47
|
+
access_token: Mapped[str | None] = mapped_column(Text)
|
|
48
|
+
refresh_token: Mapped[str | None] = mapped_column(Text)
|
|
49
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
50
|
+
raw_claims: Mapped[dict | None] = mapped_column(JSON)
|
|
51
51
|
|
|
52
52
|
# Bidirectional relationship to User model
|
|
53
|
-
user: Mapped[
|
|
53
|
+
user: Mapped[User] = relationship(back_populates="provider_accounts")
|
|
54
54
|
|
|
55
55
|
created_at = mapped_column(
|
|
56
56
|
DateTime(timezone=True),
|
|
@@ -60,7 +60,7 @@ class ProviderAccount(ModelBase):
|
|
|
60
60
|
updated_at = mapped_column(
|
|
61
61
|
DateTime(timezone=True),
|
|
62
62
|
server_default=text("CURRENT_TIMESTAMP"),
|
|
63
|
-
onupdate=lambda: datetime.now(
|
|
63
|
+
onupdate=lambda: datetime.now(UTC),
|
|
64
64
|
nullable=False,
|
|
65
65
|
)
|
|
66
66
|
|
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import uuid
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
from typing import Any
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
from sqlalchemy import select
|
|
@@ -29,7 +29,7 @@ async def issue_invitation(
|
|
|
29
29
|
org_id: uuid.UUID,
|
|
30
30
|
email: str,
|
|
31
31
|
role: str,
|
|
32
|
-
created_by:
|
|
32
|
+
created_by: uuid.UUID | None = None,
|
|
33
33
|
ttl_hours: int = 72,
|
|
34
34
|
) -> tuple[str, OrganizationInvitation]:
|
|
35
35
|
"""Create a new invitation; revoke any existing active invites for the same email+org."""
|
|
@@ -50,7 +50,7 @@ async def issue_invitation(
|
|
|
50
50
|
.scalars()
|
|
51
51
|
.all()
|
|
52
52
|
)
|
|
53
|
-
now = datetime.now(
|
|
53
|
+
now = datetime.now(UTC)
|
|
54
54
|
for r in rows:
|
|
55
55
|
r.revoked_at = now
|
|
56
56
|
except Exception: # pragma: no cover
|
|
@@ -58,8 +58,8 @@ async def issue_invitation(
|
|
|
58
58
|
else:
|
|
59
59
|
# FakeDB path: revoke in-memory invites
|
|
60
60
|
if hasattr(db, "added"):
|
|
61
|
-
now = datetime.now(
|
|
62
|
-
for r in list(
|
|
61
|
+
now = datetime.now(UTC)
|
|
62
|
+
for r in list(db.added):
|
|
63
63
|
if (
|
|
64
64
|
isinstance(r, OrganizationInvitation)
|
|
65
65
|
and r.org_id == org_id
|
|
@@ -75,9 +75,9 @@ async def issue_invitation(
|
|
|
75
75
|
email=email.lower().strip(),
|
|
76
76
|
role=role,
|
|
77
77
|
token_hash=_hash_token(raw),
|
|
78
|
-
expires_at=datetime.now(
|
|
78
|
+
expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
|
|
79
79
|
created_by=created_by,
|
|
80
|
-
last_sent_at=datetime.now(
|
|
80
|
+
last_sent_at=datetime.now(UTC),
|
|
81
81
|
resend_count=0,
|
|
82
82
|
)
|
|
83
83
|
if hasattr(db, "add"):
|
|
@@ -90,7 +90,7 @@ async def issue_invitation(
|
|
|
90
90
|
async def resend_invitation(db: Any, *, invitation: OrganizationInvitation) -> str:
|
|
91
91
|
raw = _new_token()
|
|
92
92
|
invitation.token_hash = _hash_token(raw)
|
|
93
|
-
invitation.last_sent_at = datetime.now(
|
|
93
|
+
invitation.last_sent_at = datetime.now(UTC)
|
|
94
94
|
invitation.resend_count = (invitation.resend_count or 0) + 1
|
|
95
95
|
if hasattr(db, "flush"):
|
|
96
96
|
await db.flush()
|
|
@@ -103,7 +103,7 @@ async def accept_invitation(
|
|
|
103
103
|
invitation: OrganizationInvitation,
|
|
104
104
|
user_id: uuid.UUID,
|
|
105
105
|
) -> OrganizationMembership:
|
|
106
|
-
now = datetime.now(
|
|
106
|
+
now = datetime.now(UTC)
|
|
107
107
|
if invitation.revoked_at or invitation.used_at:
|
|
108
108
|
raise ValueError("invitation_unusable")
|
|
109
109
|
if invitation.expires_at and invitation.expires_at < now:
|
|
@@ -113,9 +113,7 @@ async def accept_invitation(
|
|
|
113
113
|
invitation.used_at = now
|
|
114
114
|
|
|
115
115
|
# create membership (upsert-like enforced by DB unique constraint)
|
|
116
|
-
mem = OrganizationMembership(
|
|
117
|
-
org_id=invitation.org_id, user_id=user_id, role=invitation.role
|
|
118
|
-
)
|
|
116
|
+
mem = OrganizationMembership(org_id=invitation.org_id, user_id=user_id, role=invitation.role)
|
|
119
117
|
if hasattr(db, "add"):
|
|
120
118
|
db.add(mem)
|
|
121
119
|
if hasattr(db, "flush"):
|
svc_infra/security/passwords.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
4
5
|
from dataclasses import dataclass
|
|
5
|
-
from typing import Callable, Iterable, Optional
|
|
6
6
|
|
|
7
7
|
COMMON_PASSWORDS = {"password", "123456", "qwerty", "letmein", "admin"}
|
|
8
8
|
|
|
@@ -36,10 +36,10 @@ SYMBOL = re.compile(r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]")
|
|
|
36
36
|
BreachedChecker = Callable[[str], bool]
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
_breached_checker:
|
|
39
|
+
_breached_checker: BreachedChecker | None = None
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def configure_breached_checker(checker:
|
|
42
|
+
def configure_breached_checker(checker: BreachedChecker | None) -> None:
|
|
43
43
|
global _breached_checker
|
|
44
44
|
_breached_checker = checker
|
|
45
45
|
|
|
@@ -60,9 +60,7 @@ def validate_password(pw: str, policy: PasswordPolicy | None = None) -> None:
|
|
|
60
60
|
if policy.forbid_common:
|
|
61
61
|
lowered = pw.lower()
|
|
62
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
|
-
):
|
|
63
|
+
if lowered in COMMON_PASSWORDS or any(term in lowered for term in COMMON_PASSWORDS):
|
|
66
64
|
reasons.append("common_password")
|
|
67
65
|
if policy.forbid_breached and not HIBP_DISABLED:
|
|
68
66
|
if _breached_checker and _breached_checker(pw):
|
|
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
4
|
import threading
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
from fastapi import Depends, HTTPException
|
|
8
9
|
|
|
@@ -12,7 +13,7 @@ from svc_infra.api.fastapi.auth.security import Identity
|
|
|
12
13
|
_PERMISSION_LOCK = threading.Lock()
|
|
13
14
|
|
|
14
15
|
# Central role -> permissions mapping. Projects can extend at startup.
|
|
15
|
-
PERMISSION_REGISTRY:
|
|
16
|
+
PERMISSION_REGISTRY: dict[str, set[str]] = {
|
|
16
17
|
"admin": {
|
|
17
18
|
"user.read",
|
|
18
19
|
"user.write",
|
|
@@ -27,13 +28,13 @@ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def register_role(role: str, permissions:
|
|
31
|
+
def register_role(role: str, permissions: set[str]) -> None:
|
|
31
32
|
"""Thread-safe registration of a role and its permissions."""
|
|
32
33
|
with _PERMISSION_LOCK:
|
|
33
34
|
PERMISSION_REGISTRY[role] = permissions
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def extend_role(role: str, permissions:
|
|
37
|
+
def extend_role(role: str, permissions: set[str]) -> None:
|
|
37
38
|
"""Thread-safe extension of an existing role's permissions."""
|
|
38
39
|
with _PERMISSION_LOCK:
|
|
39
40
|
if role in PERMISSION_REGISTRY:
|
|
@@ -42,15 +43,15 @@ def extend_role(role: str, permissions: Set[str]) -> None:
|
|
|
42
43
|
PERMISSION_REGISTRY[role] = permissions
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
def get_permissions_for_roles(roles: Iterable[str]) ->
|
|
46
|
-
perms:
|
|
46
|
+
def get_permissions_for_roles(roles: Iterable[str]) -> set[str]:
|
|
47
|
+
perms: set[str] = set()
|
|
47
48
|
with _PERMISSION_LOCK:
|
|
48
49
|
for r in roles:
|
|
49
50
|
perms |= PERMISSION_REGISTRY.get(r, set())
|
|
50
51
|
return perms
|
|
51
52
|
|
|
52
53
|
|
|
53
|
-
def principal_permissions(principal: Identity) ->
|
|
54
|
+
def principal_permissions(principal: Identity) -> set[str]:
|
|
54
55
|
roles = getattr(principal.user, "roles", []) or []
|
|
55
56
|
return get_permissions_for_roles(roles)
|
|
56
57
|
|
svc_infra/security/session.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
-
from datetime import datetime, timedelta
|
|
5
|
-
from typing import Optional
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
6
5
|
|
|
7
6
|
try:
|
|
8
7
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -25,9 +24,9 @@ async def issue_session_and_refresh(
|
|
|
25
24
|
db: AsyncSession,
|
|
26
25
|
*,
|
|
27
26
|
user_id: uuid.UUID,
|
|
28
|
-
tenant_id:
|
|
29
|
-
user_agent:
|
|
30
|
-
ip_hash:
|
|
27
|
+
tenant_id: str | None = None,
|
|
28
|
+
user_agent: str | None = None,
|
|
29
|
+
ip_hash: str | None = None,
|
|
31
30
|
ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
|
|
32
31
|
) -> tuple[str, RefreshToken]:
|
|
33
32
|
"""Persist a new AuthSession + initial RefreshToken and return raw refresh token.
|
|
@@ -43,7 +42,7 @@ async def issue_session_and_refresh(
|
|
|
43
42
|
db.add(session_row)
|
|
44
43
|
raw = generate_refresh_token()
|
|
45
44
|
token_hash = hash_refresh_token(raw)
|
|
46
|
-
expires_at = datetime.now(
|
|
45
|
+
expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
|
|
47
46
|
rt = RefreshToken(
|
|
48
47
|
session=session_row,
|
|
49
48
|
token_hash=token_hash,
|
|
@@ -64,7 +63,7 @@ async def rotate_session_refresh(
|
|
|
64
63
|
|
|
65
64
|
Returns: (new_raw_refresh_token, new_refresh_token_model)
|
|
66
65
|
"""
|
|
67
|
-
rotation_ts = datetime.now(
|
|
66
|
+
rotation_ts = datetime.now(UTC)
|
|
68
67
|
if current.revoked_at:
|
|
69
68
|
raise ValueError("refresh token already revoked")
|
|
70
69
|
if current.expires_at and current.expires_at <= rotation_ts:
|
|
@@ -5,7 +5,7 @@ import hmac
|
|
|
5
5
|
import json
|
|
6
6
|
import time
|
|
7
7
|
from hashlib import sha256
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _b64e(b: bytes) -> str:
|
|
@@ -26,12 +26,12 @@ def _now() -> int:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def sign_cookie(
|
|
29
|
-
payload:
|
|
29
|
+
payload: dict[str, Any],
|
|
30
30
|
*,
|
|
31
31
|
key: str,
|
|
32
|
-
expires_in:
|
|
33
|
-
path:
|
|
34
|
-
domain:
|
|
32
|
+
expires_in: int | None = None,
|
|
33
|
+
path: str | None = None,
|
|
34
|
+
domain: str | None = None,
|
|
35
35
|
) -> str:
|
|
36
36
|
"""Produce a compact signed cookie value with optional expiry and scope binding.
|
|
37
37
|
|
|
@@ -57,10 +57,10 @@ def verify_cookie(
|
|
|
57
57
|
value: str,
|
|
58
58
|
*,
|
|
59
59
|
key: str,
|
|
60
|
-
old_keys:
|
|
61
|
-
expected_path:
|
|
62
|
-
expected_domain:
|
|
63
|
-
) ->
|
|
60
|
+
old_keys: list[str] | None = None,
|
|
61
|
+
expected_path: str | None = None,
|
|
62
|
+
expected_domain: str | None = None,
|
|
63
|
+
) -> tuple[bool, dict[str, Any] | None]:
|
|
64
64
|
"""Verify a signed cookie against the primary key or any old key.
|
|
65
65
|
|
|
66
66
|
Returns (ok, payload). If ok is False, payload will be None.
|
svc_infra/storage/add.py
CHANGED
|
@@ -6,7 +6,7 @@ Provides helpers to integrate storage backends with FastAPI applications.
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
from contextlib import asynccontextmanager
|
|
9
|
-
from typing import
|
|
9
|
+
from typing import cast
|
|
10
10
|
|
|
11
11
|
from fastapi import FastAPI, HTTPException, Query, Request
|
|
12
12
|
from fastapi.responses import StreamingResponse
|
|
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
|
|
20
20
|
def add_storage(
|
|
21
21
|
app: FastAPI,
|
|
22
|
-
backend:
|
|
22
|
+
backend: StorageBackend | None = None,
|
|
23
23
|
serve_files: bool = False,
|
|
24
24
|
file_route_prefix: str = "/files",
|
|
25
25
|
) -> StorageBackend:
|
|
@@ -145,9 +145,7 @@ def add_storage(
|
|
|
145
145
|
# Return file
|
|
146
146
|
return StreamingResponse(
|
|
147
147
|
iter([data]),
|
|
148
|
-
media_type=metadata.get(
|
|
149
|
-
"content_type", "application/octet-stream"
|
|
150
|
-
),
|
|
148
|
+
media_type=metadata.get("content_type", "application/octet-stream"),
|
|
151
149
|
headers={
|
|
152
150
|
"Content-Disposition": content_disposition,
|
|
153
151
|
"Content-Length": str(len(data)),
|
|
@@ -197,11 +195,10 @@ def get_storage(request: Request) -> StorageBackend:
|
|
|
197
195
|
"""
|
|
198
196
|
if not hasattr(request.app.state, "storage"):
|
|
199
197
|
raise RuntimeError(
|
|
200
|
-
"Storage not initialized. "
|
|
201
|
-
"Call add_storage(app) during application setup."
|
|
198
|
+
"Storage not initialized. Call add_storage(app) during application setup."
|
|
202
199
|
)
|
|
203
200
|
|
|
204
|
-
return cast(StorageBackend, request.app.state.storage)
|
|
201
|
+
return cast("StorageBackend", request.app.state.storage)
|
|
205
202
|
|
|
206
203
|
|
|
207
204
|
async def health_check_storage(request: Request) -> dict:
|
|
@@ -8,9 +8,9 @@ import hashlib
|
|
|
8
8
|
import hmac
|
|
9
9
|
import json
|
|
10
10
|
import secrets
|
|
11
|
-
from datetime import
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Any,
|
|
13
|
+
from typing import Any, cast
|
|
14
14
|
from urllib.parse import urlencode
|
|
15
15
|
|
|
16
16
|
import aiofiles
|
|
@@ -50,7 +50,7 @@ class LocalBackend:
|
|
|
50
50
|
self,
|
|
51
51
|
base_path: str = "/data/uploads",
|
|
52
52
|
base_url: str = "http://localhost:8000/files",
|
|
53
|
-
signing_secret:
|
|
53
|
+
signing_secret: str | None = None,
|
|
54
54
|
):
|
|
55
55
|
self.base_path = Path(base_path)
|
|
56
56
|
self.base_url = base_url.rstrip("/")
|
|
@@ -71,9 +71,7 @@ class LocalBackend:
|
|
|
71
71
|
raise InvalidKeyError("Key cannot exceed 1024 characters")
|
|
72
72
|
|
|
73
73
|
# Check for safe characters
|
|
74
|
-
safe_chars = set(
|
|
75
|
-
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/"
|
|
76
|
-
)
|
|
74
|
+
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
|
|
77
75
|
if not all(c in safe_chars for c in key):
|
|
78
76
|
raise InvalidKeyError(
|
|
79
77
|
"Key can only contain alphanumeric, dot, dash, underscore, and slash"
|
|
@@ -97,9 +95,7 @@ class LocalBackend:
|
|
|
97
95
|
).hexdigest()
|
|
98
96
|
return signature
|
|
99
97
|
|
|
100
|
-
def _verify_signature(
|
|
101
|
-
self, key: str, expires_at: int, download: bool, signature: str
|
|
102
|
-
) -> bool:
|
|
98
|
+
def _verify_signature(self, key: str, expires_at: int, download: bool, signature: str) -> bool:
|
|
103
99
|
"""Verify HMAC signature."""
|
|
104
100
|
expected = self._sign_url(key, expires_at, download)
|
|
105
101
|
return hmac.compare_digest(expected, signature)
|
|
@@ -109,7 +105,7 @@ class LocalBackend:
|
|
|
109
105
|
key: str,
|
|
110
106
|
data: bytes,
|
|
111
107
|
content_type: str,
|
|
112
|
-
metadata:
|
|
108
|
+
metadata: dict | None = None,
|
|
113
109
|
) -> str:
|
|
114
110
|
"""Store file on local filesystem."""
|
|
115
111
|
self._validate_key(key)
|
|
@@ -133,7 +129,7 @@ class LocalBackend:
|
|
|
133
129
|
meta_data = {
|
|
134
130
|
"size": len(data),
|
|
135
131
|
"content_type": content_type,
|
|
136
|
-
"created_at": datetime.now(
|
|
132
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
137
133
|
**(metadata or {}),
|
|
138
134
|
}
|
|
139
135
|
|
|
@@ -224,7 +220,7 @@ class LocalBackend:
|
|
|
224
220
|
raise StorageFileNotFoundError(f"File not found: {key}")
|
|
225
221
|
|
|
226
222
|
# Calculate expiration timestamp
|
|
227
|
-
expires_at = int(datetime.now(
|
|
223
|
+
expires_at = int(datetime.now(UTC).timestamp()) + expires_in
|
|
228
224
|
|
|
229
225
|
# Generate signature
|
|
230
226
|
signature = self._sign_url(key, expires_at, download)
|
|
@@ -240,9 +236,7 @@ class LocalBackend:
|
|
|
240
236
|
url = f"{self.base_url}/{key}?{urlencode(params)}"
|
|
241
237
|
return url
|
|
242
238
|
|
|
243
|
-
def verify_url(
|
|
244
|
-
self, key: str, expires: str, signature: str, download: bool = False
|
|
245
|
-
) -> bool:
|
|
239
|
+
def verify_url(self, key: str, expires: str, signature: str, download: bool = False) -> bool:
|
|
246
240
|
"""
|
|
247
241
|
Verify a signed URL (for use in file serving endpoint).
|
|
248
242
|
|
|
@@ -266,7 +260,7 @@ class LocalBackend:
|
|
|
266
260
|
return False
|
|
267
261
|
|
|
268
262
|
# Check expiration
|
|
269
|
-
now = int(datetime.now(
|
|
263
|
+
now = int(datetime.now(UTC).timestamp())
|
|
270
264
|
if now > expires_at:
|
|
271
265
|
return False
|
|
272
266
|
|
|
@@ -323,15 +317,13 @@ class LocalBackend:
|
|
|
323
317
|
return {
|
|
324
318
|
"size": stat.st_size,
|
|
325
319
|
"content_type": "application/octet-stream",
|
|
326
|
-
"created_at": datetime.fromtimestamp(
|
|
327
|
-
stat.st_ctime, tz=timezone.utc
|
|
328
|
-
).isoformat(),
|
|
320
|
+
"created_at": datetime.fromtimestamp(stat.st_ctime, tz=UTC).isoformat(),
|
|
329
321
|
}
|
|
330
322
|
|
|
331
323
|
try:
|
|
332
|
-
async with aiofiles.open(meta_path
|
|
324
|
+
async with aiofiles.open(meta_path) as f:
|
|
333
325
|
content = await f.read()
|
|
334
|
-
return cast(dict[Any, Any], json.loads(content))
|
|
326
|
+
return cast("dict[Any, Any]", json.loads(content))
|
|
335
327
|
except (OSError, json.JSONDecodeError) as e:
|
|
336
328
|
raise StorageError(f"Failed to read metadata for {key}: {e}")
|
|
337
329
|
|
|
@@ -5,8 +5,7 @@ WARNING: Data is not persisted across restarts. Use only for testing or developm
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
|
-
from datetime import
|
|
9
|
-
from typing import Optional
|
|
8
|
+
from datetime import UTC, datetime
|
|
10
9
|
|
|
11
10
|
from ..base import FileNotFoundError, InvalidKeyError, QuotaExceededError
|
|
12
11
|
|
|
@@ -57,9 +56,7 @@ class MemoryBackend:
|
|
|
57
56
|
raise InvalidKeyError("Key cannot exceed 1024 characters")
|
|
58
57
|
|
|
59
58
|
# Check for safe characters
|
|
60
|
-
safe_chars = set(
|
|
61
|
-
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/"
|
|
62
|
-
)
|
|
59
|
+
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
|
|
63
60
|
if not all(c in safe_chars for c in key):
|
|
64
61
|
raise InvalidKeyError(
|
|
65
62
|
"Key can only contain alphanumeric, dot, dash, underscore, and slash"
|
|
@@ -74,7 +71,7 @@ class MemoryBackend:
|
|
|
74
71
|
key: str,
|
|
75
72
|
data: bytes,
|
|
76
73
|
content_type: str,
|
|
77
|
-
metadata:
|
|
74
|
+
metadata: dict | None = None,
|
|
78
75
|
) -> str:
|
|
79
76
|
"""Store file in memory."""
|
|
80
77
|
self._validate_key(key)
|
|
@@ -101,7 +98,7 @@ class MemoryBackend:
|
|
|
101
98
|
self._metadata[key] = {
|
|
102
99
|
"size": len(data),
|
|
103
100
|
"content_type": content_type,
|
|
104
|
-
"created_at": datetime.now(
|
|
101
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
105
102
|
**(metadata or {}),
|
|
106
103
|
}
|
|
107
104
|
|
svc_infra/storage/backends/s3.py
CHANGED
|
@@ -5,7 +5,7 @@ Works with AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio, and
|
|
|
5
5
|
any S3-compatible object storage service.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import cast
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
11
|
import aioboto3
|
|
@@ -68,14 +68,13 @@ class S3Backend:
|
|
|
68
68
|
self,
|
|
69
69
|
bucket: str,
|
|
70
70
|
region: str = "us-east-1",
|
|
71
|
-
endpoint:
|
|
72
|
-
access_key:
|
|
73
|
-
secret_key:
|
|
71
|
+
endpoint: str | None = None,
|
|
72
|
+
access_key: str | None = None,
|
|
73
|
+
secret_key: str | None = None,
|
|
74
74
|
):
|
|
75
75
|
if aioboto3 is None:
|
|
76
76
|
raise ImportError(
|
|
77
|
-
"aioboto3 is required for S3Backend. "
|
|
78
|
-
"Install it with: pip install aioboto3"
|
|
77
|
+
"aioboto3 is required for S3Backend. Install it with: pip install aioboto3"
|
|
79
78
|
)
|
|
80
79
|
|
|
81
80
|
self.bucket = bucket
|
|
@@ -118,7 +117,7 @@ class S3Backend:
|
|
|
118
117
|
key: str,
|
|
119
118
|
data: bytes,
|
|
120
119
|
content_type: str,
|
|
121
|
-
metadata:
|
|
120
|
+
metadata: dict | None = None,
|
|
122
121
|
) -> str:
|
|
123
122
|
"""Store file in S3."""
|
|
124
123
|
self._validate_key(key)
|
|
@@ -131,9 +130,7 @@ class S3Backend:
|
|
|
131
130
|
|
|
132
131
|
try:
|
|
133
132
|
session = aioboto3.Session()
|
|
134
|
-
async with session.client(
|
|
135
|
-
"s3", **self._session_config, **self._client_config
|
|
136
|
-
) as s3:
|
|
133
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
137
134
|
# Upload file
|
|
138
135
|
await s3.put_object(
|
|
139
136
|
Bucket=self.bucket,
|
|
@@ -165,12 +162,10 @@ class S3Backend:
|
|
|
165
162
|
|
|
166
163
|
try:
|
|
167
164
|
session = aioboto3.Session()
|
|
168
|
-
async with session.client(
|
|
169
|
-
"s3", **self._session_config, **self._client_config
|
|
170
|
-
) as s3:
|
|
165
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
171
166
|
response = await s3.get_object(Bucket=self.bucket, Key=key)
|
|
172
167
|
async with response["Body"] as stream:
|
|
173
|
-
return cast(bytes, await stream.read())
|
|
168
|
+
return cast("bytes", await stream.read())
|
|
174
169
|
|
|
175
170
|
except ClientError as e:
|
|
176
171
|
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
@@ -193,9 +188,7 @@ class S3Backend:
|
|
|
193
188
|
|
|
194
189
|
try:
|
|
195
190
|
session = aioboto3.Session()
|
|
196
|
-
async with session.client(
|
|
197
|
-
"s3", **self._session_config, **self._client_config
|
|
198
|
-
) as s3:
|
|
191
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
199
192
|
await s3.delete_object(Bucket=self.bucket, Key=key)
|
|
200
193
|
return True
|
|
201
194
|
|
|
@@ -214,9 +207,7 @@ class S3Backend:
|
|
|
214
207
|
|
|
215
208
|
try:
|
|
216
209
|
session = aioboto3.Session()
|
|
217
|
-
async with session.client(
|
|
218
|
-
"s3", **self._session_config, **self._client_config
|
|
219
|
-
) as s3:
|
|
210
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
220
211
|
await s3.head_object(Bucket=self.bucket, Key=key)
|
|
221
212
|
return True
|
|
222
213
|
|
|
@@ -257,9 +248,7 @@ class S3Backend:
|
|
|
257
248
|
|
|
258
249
|
try:
|
|
259
250
|
session = aioboto3.Session()
|
|
260
|
-
async with session.client(
|
|
261
|
-
"s3", **self._session_config, **self._client_config
|
|
262
|
-
) as s3:
|
|
251
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
263
252
|
# Prepare parameters
|
|
264
253
|
params = {"Bucket": self.bucket, "Key": key}
|
|
265
254
|
|
|
@@ -267,9 +256,7 @@ class S3Backend:
|
|
|
267
256
|
if download:
|
|
268
257
|
# Extract filename from key
|
|
269
258
|
filename = key.split("/")[-1]
|
|
270
|
-
params["ResponseContentDisposition"] =
|
|
271
|
-
f'attachment; filename="{filename}"'
|
|
272
|
-
)
|
|
259
|
+
params["ResponseContentDisposition"] = f'attachment; filename="{filename}"'
|
|
273
260
|
|
|
274
261
|
# Generate presigned URL
|
|
275
262
|
url = await s3.generate_presigned_url(
|
|
@@ -277,7 +264,7 @@ class S3Backend:
|
|
|
277
264
|
Params=params,
|
|
278
265
|
ExpiresIn=expires_in,
|
|
279
266
|
)
|
|
280
|
-
return cast(str, url)
|
|
267
|
+
return cast("str", url)
|
|
281
268
|
|
|
282
269
|
except ClientError as e:
|
|
283
270
|
raise StorageError(f"Failed to generate presigned URL: {e}")
|
|
@@ -292,9 +279,7 @@ class S3Backend:
|
|
|
292
279
|
"""List stored keys with optional prefix filter."""
|
|
293
280
|
try:
|
|
294
281
|
session = aioboto3.Session()
|
|
295
|
-
async with session.client(
|
|
296
|
-
"s3", **self._session_config, **self._client_config
|
|
297
|
-
) as s3:
|
|
282
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
298
283
|
params = {
|
|
299
284
|
"Bucket": self.bucket,
|
|
300
285
|
"MaxKeys": limit,
|
|
@@ -320,17 +305,13 @@ class S3Backend:
|
|
|
320
305
|
|
|
321
306
|
try:
|
|
322
307
|
session = aioboto3.Session()
|
|
323
|
-
async with session.client(
|
|
324
|
-
"s3", **self._session_config, **self._client_config
|
|
325
|
-
) as s3:
|
|
308
|
+
async with session.client("s3", **self._session_config, **self._client_config) as s3:
|
|
326
309
|
response = await s3.head_object(Bucket=self.bucket, Key=key)
|
|
327
310
|
|
|
328
311
|
# Extract metadata
|
|
329
312
|
metadata = {
|
|
330
313
|
"size": response["ContentLength"],
|
|
331
|
-
"content_type": response.get(
|
|
332
|
-
"ContentType", "application/octet-stream"
|
|
333
|
-
),
|
|
314
|
+
"content_type": response.get("ContentType", "application/octet-stream"),
|
|
334
315
|
"created_at": response["LastModified"].isoformat(),
|
|
335
316
|
}
|
|
336
317
|
|
svc_infra/storage/base.py
CHANGED
|
@@ -4,7 +4,7 @@ Base storage abstractions and exceptions.
|
|
|
4
4
|
Defines the StorageBackend protocol that all storage implementations must follow.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import Protocol
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class StorageError(Exception):
|
|
@@ -60,7 +60,7 @@ class StorageBackend(Protocol):
|
|
|
60
60
|
key: str,
|
|
61
61
|
data: bytes,
|
|
62
62
|
content_type: str,
|
|
63
|
-
metadata:
|
|
63
|
+
metadata: dict | None = None,
|
|
64
64
|
) -> str:
|
|
65
65
|
"""
|
|
66
66
|
Store file content and return its URL.
|