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,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from fastapi_users.authentication.strategy.jwt import JWTStrategy
|
|
7
|
+
from fastapi_users.jwt import decode_jwt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RotatingJWTStrategy(JWTStrategy):
|
|
11
|
+
"""JWTStrategy that can verify tokens against multiple secrets.
|
|
12
|
+
|
|
13
|
+
Signing uses the primary secret (as in base class). Verification accepts any of
|
|
14
|
+
the provided secrets: [primary] + old_secrets.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
secret: str,
|
|
21
|
+
lifetime_seconds: int,
|
|
22
|
+
old_secrets: Optional[Iterable[str]] = None,
|
|
23
|
+
token_audience: Optional[Union[str, List[str]]] = None,
|
|
24
|
+
):
|
|
25
|
+
# Normalize token_audience to list as required by parent JWTStrategy
|
|
26
|
+
aud_list: list[str] = (
|
|
27
|
+
[token_audience]
|
|
28
|
+
if isinstance(token_audience, str)
|
|
29
|
+
else list(token_audience)
|
|
30
|
+
if token_audience
|
|
31
|
+
else []
|
|
32
|
+
) or ["fastapi-users:auth"]
|
|
33
|
+
super().__init__(
|
|
34
|
+
secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list
|
|
35
|
+
)
|
|
36
|
+
self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
|
|
37
|
+
self._lifetime_seconds = lifetime_seconds
|
|
38
|
+
|
|
39
|
+
async def read_token(
|
|
40
|
+
self,
|
|
41
|
+
token: str | None,
|
|
42
|
+
user_manager: Any = None,
|
|
43
|
+
*,
|
|
44
|
+
audience: str | list[str] | None = None,
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""Read/verify a token against the active + rotated secrets.
|
|
47
|
+
|
|
48
|
+
Compatibility:
|
|
49
|
+
- fastapi-users signature: (token, user_manager) -> user | None
|
|
50
|
+
- legacy/test helper usage: (token, *, audience=...) -> claims | None
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
if token is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
if user_manager is None:
|
|
57
|
+
aud_list: list[str]
|
|
58
|
+
if audience is None:
|
|
59
|
+
aud_list = self.token_audience
|
|
60
|
+
elif isinstance(audience, str):
|
|
61
|
+
aud_list = [audience]
|
|
62
|
+
else:
|
|
63
|
+
aud_list = audience
|
|
64
|
+
try:
|
|
65
|
+
return decode_jwt(
|
|
66
|
+
token, self.decode_key, aud_list, algorithms=[self.algorithm]
|
|
67
|
+
)
|
|
68
|
+
except jwt.PyJWTError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
for secret in self._verify_secrets[1:]:
|
|
72
|
+
candidate: JWTStrategy[Any, Any] = JWTStrategy(
|
|
73
|
+
secret=secret,
|
|
74
|
+
lifetime_seconds=self._lifetime_seconds,
|
|
75
|
+
token_audience=self.token_audience,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
return decode_jwt(
|
|
79
|
+
token,
|
|
80
|
+
candidate.decode_key,
|
|
81
|
+
aud_list,
|
|
82
|
+
algorithms=[candidate.algorithm],
|
|
83
|
+
)
|
|
84
|
+
except jwt.PyJWTError:
|
|
85
|
+
continue
|
|
86
|
+
raise ValueError("Invalid token for all configured secrets")
|
|
87
|
+
|
|
88
|
+
user = await super().read_token(token, user_manager)
|
|
89
|
+
if user is not None:
|
|
90
|
+
return user
|
|
91
|
+
|
|
92
|
+
for secret in self._verify_secrets[1:]:
|
|
93
|
+
candidate = JWTStrategy(
|
|
94
|
+
secret=secret,
|
|
95
|
+
lifetime_seconds=self._lifetime_seconds,
|
|
96
|
+
token_audience=self.token_audience,
|
|
97
|
+
)
|
|
98
|
+
user = await candidate.read_token(token, user_manager)
|
|
99
|
+
if user is not None:
|
|
100
|
+
return user
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["RotatingJWTStrategy"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from sqlalchemy import or_, select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
except Exception: # pragma: no cover - optional import for type hints
|
|
12
|
+
AsyncSession = Any # type: ignore[misc,assignment]
|
|
13
|
+
select = None # type: ignore[assignment]
|
|
14
|
+
or_ = None # type: ignore[assignment]
|
|
15
|
+
|
|
16
|
+
from svc_infra.security.models import FailedAuthAttempt
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LockoutConfig:
|
|
21
|
+
threshold: int = 5 # failures before cooldown starts
|
|
22
|
+
window_minutes: int = 15 # look-back window for counting failures
|
|
23
|
+
base_cooldown_seconds: int = 30 # initial cooldown once threshold reached
|
|
24
|
+
max_cooldown_seconds: int = 3600 # cap exponential growth at 1 hour
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LockoutStatus:
|
|
29
|
+
locked: bool
|
|
30
|
+
next_allowed_at: Optional[datetime]
|
|
31
|
+
failure_count: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------- Pure calculation -----------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compute_lockout(
|
|
38
|
+
fail_count: int, *, cfg: LockoutConfig, now: Optional[datetime] = None
|
|
39
|
+
) -> LockoutStatus:
|
|
40
|
+
now = now or datetime.now(timezone.utc)
|
|
41
|
+
if fail_count < cfg.threshold:
|
|
42
|
+
return LockoutStatus(False, None, fail_count)
|
|
43
|
+
# cooldown factor exponent = fail_count - threshold
|
|
44
|
+
exponent = fail_count - cfg.threshold
|
|
45
|
+
cooldown = cfg.base_cooldown_seconds * (2**exponent)
|
|
46
|
+
if cooldown > cfg.max_cooldown_seconds:
|
|
47
|
+
cooldown = cfg.max_cooldown_seconds
|
|
48
|
+
return LockoutStatus(True, now + timedelta(seconds=cooldown), fail_count)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------- Persistence helpers (async) ---------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def record_attempt(
|
|
55
|
+
session: AsyncSession,
|
|
56
|
+
*,
|
|
57
|
+
user_id: Optional[uuid.UUID],
|
|
58
|
+
ip_hash: Optional[str],
|
|
59
|
+
success: bool,
|
|
60
|
+
) -> None:
|
|
61
|
+
attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
|
|
62
|
+
session.add(attempt)
|
|
63
|
+
await session.flush()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def get_lockout_status(
|
|
67
|
+
session: AsyncSession,
|
|
68
|
+
*,
|
|
69
|
+
user_id: Optional[uuid.UUID],
|
|
70
|
+
ip_hash: Optional[str],
|
|
71
|
+
cfg: Optional[LockoutConfig] = None,
|
|
72
|
+
) -> LockoutStatus:
|
|
73
|
+
cfg = cfg or LockoutConfig()
|
|
74
|
+
now = datetime.now(timezone.utc)
|
|
75
|
+
window_start = now - timedelta(minutes=cfg.window_minutes)
|
|
76
|
+
|
|
77
|
+
q = select(FailedAuthAttempt).where(
|
|
78
|
+
FailedAuthAttempt.ts >= window_start,
|
|
79
|
+
FailedAuthAttempt.success == False, # noqa: E712
|
|
80
|
+
)
|
|
81
|
+
# Use OR logic: lock out if EITHER user_id OR ip_hash has too many failures
|
|
82
|
+
# This prevents attackers from rotating IPs to bypass lockout
|
|
83
|
+
filters = []
|
|
84
|
+
if user_id:
|
|
85
|
+
filters.append(FailedAuthAttempt.user_id == user_id)
|
|
86
|
+
if ip_hash:
|
|
87
|
+
filters.append(FailedAuthAttempt.ip_hash == ip_hash)
|
|
88
|
+
if filters:
|
|
89
|
+
q = q.where(or_(*filters))
|
|
90
|
+
|
|
91
|
+
rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
|
|
92
|
+
fail_count = len(rows)
|
|
93
|
+
return compute_lockout(fail_count, cfg=cfg, now=now)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = [
|
|
97
|
+
"LockoutConfig",
|
|
98
|
+
"LockoutStatus",
|
|
99
|
+
"compute_lockout",
|
|
100
|
+
"record_attempt",
|
|
101
|
+
"get_lockout_status",
|
|
102
|
+
]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import (
|
|
10
|
+
JSON,
|
|
11
|
+
Boolean,
|
|
12
|
+
DateTime,
|
|
13
|
+
ForeignKey,
|
|
14
|
+
Index,
|
|
15
|
+
String,
|
|
16
|
+
Text,
|
|
17
|
+
UniqueConstraint,
|
|
18
|
+
text,
|
|
19
|
+
)
|
|
20
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
21
|
+
|
|
22
|
+
from svc_infra.db.sql.base import ModelBase
|
|
23
|
+
from svc_infra.db.sql.types import GUID
|
|
24
|
+
|
|
25
|
+
# ----------------------------- Models -----------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthSession(ModelBase):
|
|
29
|
+
__tablename__ = "auth_sessions"
|
|
30
|
+
|
|
31
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
32
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
33
|
+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
34
|
+
)
|
|
35
|
+
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
36
|
+
user_agent: Mapped[Optional[str]] = mapped_column(String(512))
|
|
37
|
+
ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
38
|
+
last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
39
|
+
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
40
|
+
revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
|
|
41
|
+
|
|
42
|
+
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
|
|
43
|
+
back_populates="session", cascade="all, delete-orphan", lazy="selectin"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
created_at = mapped_column(
|
|
47
|
+
DateTime(timezone=True),
|
|
48
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
49
|
+
nullable=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RefreshToken(ModelBase):
|
|
54
|
+
__tablename__ = "refresh_tokens"
|
|
55
|
+
|
|
56
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
57
|
+
session_id: Mapped[uuid.UUID] = mapped_column(
|
|
58
|
+
GUID(), ForeignKey("auth_sessions.id", ondelete="CASCADE"), index=True
|
|
59
|
+
)
|
|
60
|
+
session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
|
|
61
|
+
|
|
62
|
+
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
63
|
+
rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
64
|
+
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
65
|
+
revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
|
|
66
|
+
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
|
67
|
+
DateTime(timezone=True), index=True
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
created_at = mapped_column(
|
|
71
|
+
DateTime(timezone=True),
|
|
72
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
73
|
+
nullable=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RefreshTokenRevocation(ModelBase):
|
|
80
|
+
__tablename__ = "refresh_token_revocations"
|
|
81
|
+
|
|
82
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
83
|
+
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
84
|
+
revoked_at: Mapped[datetime] = mapped_column(
|
|
85
|
+
DateTime(timezone=True), nullable=False
|
|
86
|
+
)
|
|
87
|
+
reason: Mapped[Optional[str]] = mapped_column(Text)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FailedAuthAttempt(ModelBase):
|
|
91
|
+
__tablename__ = "failed_auth_attempts"
|
|
92
|
+
|
|
93
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
94
|
+
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
95
|
+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
|
|
96
|
+
)
|
|
97
|
+
ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
98
|
+
ts: Mapped[datetime] = mapped_column(
|
|
99
|
+
DateTime(timezone=True),
|
|
100
|
+
nullable=False,
|
|
101
|
+
default=lambda: datetime.now(timezone.utc),
|
|
102
|
+
)
|
|
103
|
+
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
104
|
+
|
|
105
|
+
__table_args__ = (Index("ix_failed_attempt_user_time", "user_id", "ts"),)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RolePermission(ModelBase):
|
|
109
|
+
__tablename__ = "role_permissions"
|
|
110
|
+
|
|
111
|
+
role: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
112
|
+
permission: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AuditLog(ModelBase):
|
|
116
|
+
__tablename__ = "audit_logs"
|
|
117
|
+
|
|
118
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
119
|
+
ts: Mapped[datetime] = mapped_column(
|
|
120
|
+
DateTime(timezone=True),
|
|
121
|
+
nullable=False,
|
|
122
|
+
default=lambda: datetime.now(timezone.utc),
|
|
123
|
+
index=True,
|
|
124
|
+
)
|
|
125
|
+
actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
126
|
+
GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
|
127
|
+
)
|
|
128
|
+
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
129
|
+
event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
|
130
|
+
resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
|
|
131
|
+
event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
|
132
|
+
prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
|
|
133
|
+
hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
134
|
+
|
|
135
|
+
__table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ------------------------ Org / Teams ----------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Organization(ModelBase):
|
|
142
|
+
__tablename__ = "organizations"
|
|
143
|
+
|
|
144
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
145
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
146
|
+
slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
147
|
+
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
148
|
+
created_at = mapped_column(
|
|
149
|
+
DateTime(timezone=True),
|
|
150
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
151
|
+
nullable=False,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Team(ModelBase):
|
|
156
|
+
__tablename__ = "teams"
|
|
157
|
+
|
|
158
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
159
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
160
|
+
GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
|
|
161
|
+
)
|
|
162
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
163
|
+
created_at = mapped_column(
|
|
164
|
+
DateTime(timezone=True),
|
|
165
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
166
|
+
nullable=False,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class OrganizationMembership(ModelBase):
|
|
171
|
+
__tablename__ = "organization_memberships"
|
|
172
|
+
|
|
173
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
174
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
175
|
+
GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
|
|
176
|
+
)
|
|
177
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
178
|
+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
179
|
+
)
|
|
180
|
+
role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
181
|
+
created_at = mapped_column(
|
|
182
|
+
DateTime(timezone=True),
|
|
183
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
184
|
+
nullable=False,
|
|
185
|
+
)
|
|
186
|
+
deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
187
|
+
|
|
188
|
+
__table_args__ = (
|
|
189
|
+
UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class OrganizationInvitation(ModelBase):
|
|
194
|
+
__tablename__ = "organization_invitations"
|
|
195
|
+
|
|
196
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
197
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
198
|
+
GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
|
|
199
|
+
)
|
|
200
|
+
email: Mapped[str] = mapped_column(String(255), index=True)
|
|
201
|
+
role: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
202
|
+
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
|
203
|
+
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
|
204
|
+
DateTime(timezone=True), index=True
|
|
205
|
+
)
|
|
206
|
+
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
207
|
+
GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
208
|
+
)
|
|
209
|
+
created_at = mapped_column(
|
|
210
|
+
DateTime(timezone=True),
|
|
211
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
212
|
+
nullable=False,
|
|
213
|
+
)
|
|
214
|
+
last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
215
|
+
resend_count: Mapped[int] = mapped_column(default=0)
|
|
216
|
+
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
217
|
+
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ------------------------ OAuth Provider Accounts -----------------------------
|
|
221
|
+
# MOVED to svc_infra.security.oauth_models for opt-in OAuth support
|
|
222
|
+
# Projects that enable OAuth should import ProviderAccount from there
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ------------------------ Utilities -------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def generate_refresh_token() -> str:
|
|
229
|
+
"""Generate a random refresh token (opaque)."""
|
|
230
|
+
return uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def hash_refresh_token(raw: str) -> str:
|
|
234
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def compute_audit_hash(
|
|
238
|
+
prev_hash: Optional[str],
|
|
239
|
+
*,
|
|
240
|
+
ts: datetime,
|
|
241
|
+
actor_id: Optional[uuid.UUID],
|
|
242
|
+
tenant_id: Optional[str],
|
|
243
|
+
event_type: str,
|
|
244
|
+
resource_ref: Optional[str],
|
|
245
|
+
metadata: dict,
|
|
246
|
+
) -> str:
|
|
247
|
+
"""Compute SHA256 hash chaining previous hash + canonical event payload."""
|
|
248
|
+
prev = prev_hash or "0" * 64
|
|
249
|
+
payload = {
|
|
250
|
+
"ts": ts.isoformat(),
|
|
251
|
+
"actor_id": str(actor_id) if actor_id else None,
|
|
252
|
+
"tenant_id": tenant_id,
|
|
253
|
+
"event_type": event_type,
|
|
254
|
+
"resource_ref": resource_ref,
|
|
255
|
+
"metadata": metadata,
|
|
256
|
+
}
|
|
257
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
258
|
+
return hashlib.sha256((prev + canonical).encode()).hexdigest()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def rotate_refresh_token(
|
|
262
|
+
current_hash: str, *, ttl_minutes: int = 10080
|
|
263
|
+
) -> tuple[str, str, datetime]:
|
|
264
|
+
"""Rotate: returns (new_raw, new_hash, expires_at)."""
|
|
265
|
+
new_raw = generate_refresh_token()
|
|
266
|
+
new_hash = hash_refresh_token(new_raw)
|
|
267
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
|
|
268
|
+
return new_raw, new_hash, expires_at
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
__all__ = [
|
|
272
|
+
"AuthSession",
|
|
273
|
+
"RefreshToken",
|
|
274
|
+
"RefreshTokenRevocation",
|
|
275
|
+
"FailedAuthAttempt",
|
|
276
|
+
"RolePermission",
|
|
277
|
+
"AuditLog",
|
|
278
|
+
"Organization",
|
|
279
|
+
"Team",
|
|
280
|
+
"OrganizationMembership",
|
|
281
|
+
"OrganizationInvitation",
|
|
282
|
+
# ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
|
|
283
|
+
"generate_refresh_token",
|
|
284
|
+
"hash_refresh_token",
|
|
285
|
+
"compute_audit_hash",
|
|
286
|
+
"rotate_refresh_token",
|
|
287
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth provider account models (opt-in).
|
|
3
|
+
|
|
4
|
+
These models are only registered when a project explicitly enables OAuth.
|
|
5
|
+
Import this module only when enable_oauth=True is passed to add_auth_users.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import TYPE_CHECKING, Optional
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import (
|
|
15
|
+
JSON,
|
|
16
|
+
DateTime,
|
|
17
|
+
ForeignKey,
|
|
18
|
+
Index,
|
|
19
|
+
String,
|
|
20
|
+
Text,
|
|
21
|
+
UniqueConstraint,
|
|
22
|
+
text,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
25
|
+
|
|
26
|
+
from svc_infra.db.sql.base import ModelBase
|
|
27
|
+
from svc_infra.db.sql.types import GUID
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
# User model is application-specific; this is a forward reference for type hints
|
|
33
|
+
User = Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProviderAccount(ModelBase):
|
|
37
|
+
"""OAuth provider account linking (Google, GitHub, etc.)."""
|
|
38
|
+
|
|
39
|
+
__tablename__ = "provider_accounts"
|
|
40
|
+
|
|
41
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
42
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
43
|
+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
|
44
|
+
)
|
|
45
|
+
provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
46
|
+
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
47
|
+
access_token: Mapped[Optional[str]] = mapped_column(Text)
|
|
48
|
+
refresh_token: Mapped[Optional[str]] = mapped_column(Text)
|
|
49
|
+
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
50
|
+
raw_claims: Mapped[Optional[dict]] = mapped_column(JSON)
|
|
51
|
+
|
|
52
|
+
# Bidirectional relationship to User model
|
|
53
|
+
user: Mapped["User"] = relationship(back_populates="provider_accounts")
|
|
54
|
+
|
|
55
|
+
created_at = mapped_column(
|
|
56
|
+
DateTime(timezone=True),
|
|
57
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
58
|
+
nullable=False,
|
|
59
|
+
)
|
|
60
|
+
updated_at = mapped_column(
|
|
61
|
+
DateTime(timezone=True),
|
|
62
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
63
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
64
|
+
nullable=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
__table_args__ = (
|
|
68
|
+
UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
|
|
69
|
+
Index("ix_provider_accounts_user_provider", "user_id", "provider"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["ProviderAccount"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
except Exception: # pragma: no cover
|
|
12
|
+
AsyncSession = object # type: ignore[misc,assignment]
|
|
13
|
+
select = None # type: ignore[assignment]
|
|
14
|
+
|
|
15
|
+
from .models import OrganizationInvitation, OrganizationMembership
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _hash_token(raw: str) -> str:
|
|
19
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _new_token() -> str:
|
|
23
|
+
return uuid.uuid4().hex + uuid.uuid4().hex
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def issue_invitation(
|
|
27
|
+
db: Any,
|
|
28
|
+
*,
|
|
29
|
+
org_id: uuid.UUID,
|
|
30
|
+
email: str,
|
|
31
|
+
role: str,
|
|
32
|
+
created_by: Optional[uuid.UUID] = None,
|
|
33
|
+
ttl_hours: int = 72,
|
|
34
|
+
) -> tuple[str, OrganizationInvitation]:
|
|
35
|
+
"""Create a new invitation; revoke any existing active invites for the same email+org."""
|
|
36
|
+
# Revoke existing active invites
|
|
37
|
+
if select is not None and hasattr(db, "execute"):
|
|
38
|
+
try:
|
|
39
|
+
rows = (
|
|
40
|
+
(
|
|
41
|
+
await db.execute(
|
|
42
|
+
select(OrganizationInvitation).where(
|
|
43
|
+
OrganizationInvitation.org_id == org_id,
|
|
44
|
+
OrganizationInvitation.email == email,
|
|
45
|
+
OrganizationInvitation.used_at.is_(None),
|
|
46
|
+
OrganizationInvitation.revoked_at.is_(None),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
.scalars()
|
|
51
|
+
.all()
|
|
52
|
+
)
|
|
53
|
+
now = datetime.now(timezone.utc)
|
|
54
|
+
for r in rows:
|
|
55
|
+
r.revoked_at = now
|
|
56
|
+
except Exception: # pragma: no cover
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
# FakeDB path: revoke in-memory invites
|
|
60
|
+
if hasattr(db, "added"):
|
|
61
|
+
now = datetime.now(timezone.utc)
|
|
62
|
+
for r in list(getattr(db, "added")):
|
|
63
|
+
if (
|
|
64
|
+
isinstance(r, OrganizationInvitation)
|
|
65
|
+
and r.org_id == org_id
|
|
66
|
+
and r.email == email.lower().strip()
|
|
67
|
+
and r.used_at is None
|
|
68
|
+
and r.revoked_at is None
|
|
69
|
+
):
|
|
70
|
+
r.revoked_at = now
|
|
71
|
+
|
|
72
|
+
raw = _new_token()
|
|
73
|
+
inv = OrganizationInvitation(
|
|
74
|
+
org_id=org_id,
|
|
75
|
+
email=email.lower().strip(),
|
|
76
|
+
role=role,
|
|
77
|
+
token_hash=_hash_token(raw),
|
|
78
|
+
expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
|
|
79
|
+
created_by=created_by,
|
|
80
|
+
last_sent_at=datetime.now(timezone.utc),
|
|
81
|
+
resend_count=0,
|
|
82
|
+
)
|
|
83
|
+
if hasattr(db, "add"):
|
|
84
|
+
db.add(inv)
|
|
85
|
+
if hasattr(db, "flush"):
|
|
86
|
+
await db.flush()
|
|
87
|
+
return raw, inv
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def resend_invitation(db: Any, *, invitation: OrganizationInvitation) -> str:
|
|
91
|
+
raw = _new_token()
|
|
92
|
+
invitation.token_hash = _hash_token(raw)
|
|
93
|
+
invitation.last_sent_at = datetime.now(timezone.utc)
|
|
94
|
+
invitation.resend_count = (invitation.resend_count or 0) + 1
|
|
95
|
+
if hasattr(db, "flush"):
|
|
96
|
+
await db.flush()
|
|
97
|
+
return raw
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def accept_invitation(
|
|
101
|
+
db: Any,
|
|
102
|
+
*,
|
|
103
|
+
invitation: OrganizationInvitation,
|
|
104
|
+
user_id: uuid.UUID,
|
|
105
|
+
) -> OrganizationMembership:
|
|
106
|
+
now = datetime.now(timezone.utc)
|
|
107
|
+
if invitation.revoked_at or invitation.used_at:
|
|
108
|
+
raise ValueError("invitation_unusable")
|
|
109
|
+
if invitation.expires_at and invitation.expires_at < now:
|
|
110
|
+
raise ValueError("invitation_expired")
|
|
111
|
+
|
|
112
|
+
# mark used
|
|
113
|
+
invitation.used_at = now
|
|
114
|
+
|
|
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
|
+
)
|
|
119
|
+
if hasattr(db, "add"):
|
|
120
|
+
db.add(mem)
|
|
121
|
+
if hasattr(db, "flush"):
|
|
122
|
+
await db.flush()
|
|
123
|
+
return mem
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"issue_invitation",
|
|
128
|
+
"resend_invitation",
|
|
129
|
+
"accept_invitation",
|
|
130
|
+
]
|