svc-infra 0.1.595__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/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -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 +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- 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 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -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 +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- 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 +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -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 +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -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 +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- 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 +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
|
-
import jwt
|
|
6
|
+
import jwt
|
|
6
7
|
from fastapi_users.authentication.strategy.jwt import JWTStrategy
|
|
8
|
+
from fastapi_users.jwt import decode_jwt
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
class RotatingJWTStrategy(JWTStrategy):
|
|
@@ -18,36 +20,83 @@ class RotatingJWTStrategy(JWTStrategy):
|
|
|
18
20
|
*,
|
|
19
21
|
secret: str,
|
|
20
22
|
lifetime_seconds: int,
|
|
21
|
-
old_secrets:
|
|
22
|
-
token_audience:
|
|
23
|
+
old_secrets: Iterable[str] | None = None,
|
|
24
|
+
token_audience: str | list[str] | None = None,
|
|
23
25
|
):
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
# Normalize token_audience to list as required by parent JWTStrategy
|
|
27
|
+
aud_list: list[str] = (
|
|
28
|
+
[token_audience]
|
|
29
|
+
if isinstance(token_audience, str)
|
|
30
|
+
else list(token_audience)
|
|
31
|
+
if token_audience
|
|
32
|
+
else []
|
|
33
|
+
) or ["fastapi-users:auth"]
|
|
34
|
+
super().__init__(secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list)
|
|
35
|
+
self._verify_secrets: list[str] = [secret, *list(old_secrets or [])]
|
|
36
|
+
self._lifetime_seconds = lifetime_seconds
|
|
37
|
+
|
|
38
|
+
async def read_token(
|
|
39
|
+
self,
|
|
40
|
+
token: str | None,
|
|
41
|
+
user_manager: Any = None,
|
|
42
|
+
*,
|
|
43
|
+
audience: str | list[str] | None = None,
|
|
44
|
+
) -> Any:
|
|
45
|
+
"""Read/verify a token against the active + rotated secrets.
|
|
46
|
+
|
|
47
|
+
Compatibility:
|
|
48
|
+
- fastapi-users signature: (token, user_manager) -> user | None
|
|
49
|
+
- legacy/test helper usage: (token, *, audience=...) -> claims | None
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
if token is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
if user_manager is None:
|
|
56
|
+
aud_list: list[str]
|
|
57
|
+
if audience is None:
|
|
58
|
+
aud_list = self.token_audience
|
|
59
|
+
elif isinstance(audience, str):
|
|
60
|
+
aud_list = [audience]
|
|
61
|
+
else:
|
|
62
|
+
aud_list = audience
|
|
38
63
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
s,
|
|
42
|
-
algorithms=["HS256"],
|
|
43
|
-
audience=eff_aud,
|
|
44
|
-
)
|
|
45
|
-
if data is not None:
|
|
46
|
-
return data
|
|
47
|
-
except Exception:
|
|
64
|
+
return decode_jwt(token, self.decode_key, aud_list, algorithms=[self.algorithm])
|
|
65
|
+
except jwt.PyJWTError:
|
|
48
66
|
pass
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
|
|
68
|
+
for secret in self._verify_secrets[1:]:
|
|
69
|
+
candidate: JWTStrategy[Any, Any] = JWTStrategy(
|
|
70
|
+
secret=secret,
|
|
71
|
+
lifetime_seconds=self._lifetime_seconds,
|
|
72
|
+
token_audience=self.token_audience,
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
return decode_jwt(
|
|
76
|
+
token,
|
|
77
|
+
candidate.decode_key,
|
|
78
|
+
aud_list,
|
|
79
|
+
algorithms=[candidate.algorithm],
|
|
80
|
+
)
|
|
81
|
+
except jwt.PyJWTError:
|
|
82
|
+
continue
|
|
83
|
+
raise ValueError("Invalid token for all configured secrets")
|
|
84
|
+
|
|
85
|
+
user = await super().read_token(token, user_manager)
|
|
86
|
+
if user is not None:
|
|
87
|
+
return user
|
|
88
|
+
|
|
89
|
+
for secret in self._verify_secrets[1:]:
|
|
90
|
+
candidate = JWTStrategy(
|
|
91
|
+
secret=secret,
|
|
92
|
+
lifetime_seconds=self._lifetime_seconds,
|
|
93
|
+
token_audience=self.token_audience,
|
|
94
|
+
)
|
|
95
|
+
user = await candidate.read_token(token, user_manager)
|
|
96
|
+
if user is not None:
|
|
97
|
+
return user
|
|
98
|
+
|
|
99
|
+
return None
|
|
51
100
|
|
|
52
101
|
|
|
53
102
|
__all__ = ["RotatingJWTStrategy"]
|
svc_infra/security/lockout.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
+
from collections.abc import Sequence
|
|
4
5
|
from dataclasses import dataclass
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
from typing import Any
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
try:
|
|
9
|
-
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy import or_, select
|
|
10
11
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
12
|
except Exception: # pragma: no cover - optional import for type hints
|
|
12
|
-
AsyncSession = Any # type: ignore[misc]
|
|
13
|
-
select = None # type: ignore
|
|
13
|
+
AsyncSession = Any # type: ignore[misc,assignment]
|
|
14
|
+
select = None # type: ignore[assignment]
|
|
15
|
+
or_ = None # type: ignore[assignment]
|
|
14
16
|
|
|
15
17
|
from svc_infra.security.models import FailedAuthAttempt
|
|
16
18
|
|
|
@@ -26,7 +28,7 @@ class LockoutConfig:
|
|
|
26
28
|
@dataclass
|
|
27
29
|
class LockoutStatus:
|
|
28
30
|
locked: bool
|
|
29
|
-
next_allowed_at:
|
|
31
|
+
next_allowed_at: datetime | None
|
|
30
32
|
failure_count: int
|
|
31
33
|
|
|
32
34
|
|
|
@@ -34,9 +36,9 @@ class LockoutStatus:
|
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def compute_lockout(
|
|
37
|
-
fail_count: int, *, cfg: LockoutConfig, now:
|
|
39
|
+
fail_count: int, *, cfg: LockoutConfig, now: datetime | None = None
|
|
38
40
|
) -> LockoutStatus:
|
|
39
|
-
now = now or datetime.now(
|
|
41
|
+
now = now or datetime.now(UTC)
|
|
40
42
|
if fail_count < cfg.threshold:
|
|
41
43
|
return LockoutStatus(False, None, fail_count)
|
|
42
44
|
# cooldown factor exponent = fail_count - threshold
|
|
@@ -53,8 +55,8 @@ def compute_lockout(
|
|
|
53
55
|
async def record_attempt(
|
|
54
56
|
session: AsyncSession,
|
|
55
57
|
*,
|
|
56
|
-
user_id:
|
|
57
|
-
ip_hash:
|
|
58
|
+
user_id: uuid.UUID | None,
|
|
59
|
+
ip_hash: str | None,
|
|
58
60
|
success: bool,
|
|
59
61
|
) -> None:
|
|
60
62
|
attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
|
|
@@ -65,22 +67,27 @@ async def record_attempt(
|
|
|
65
67
|
async def get_lockout_status(
|
|
66
68
|
session: AsyncSession,
|
|
67
69
|
*,
|
|
68
|
-
user_id:
|
|
69
|
-
ip_hash:
|
|
70
|
-
cfg:
|
|
70
|
+
user_id: uuid.UUID | None,
|
|
71
|
+
ip_hash: str | None,
|
|
72
|
+
cfg: LockoutConfig | None = None,
|
|
71
73
|
) -> LockoutStatus:
|
|
72
74
|
cfg = cfg or LockoutConfig()
|
|
73
|
-
now = datetime.now(
|
|
75
|
+
now = datetime.now(UTC)
|
|
74
76
|
window_start = now - timedelta(minutes=cfg.window_minutes)
|
|
75
77
|
|
|
76
78
|
q = select(FailedAuthAttempt).where(
|
|
77
79
|
FailedAuthAttempt.ts >= window_start,
|
|
78
80
|
FailedAuthAttempt.success == False, # noqa: E712
|
|
79
81
|
)
|
|
82
|
+
# Use OR logic: lock out if EITHER user_id OR ip_hash has too many failures
|
|
83
|
+
# This prevents attackers from rotating IPs to bypass lockout
|
|
84
|
+
filters = []
|
|
80
85
|
if user_id:
|
|
81
|
-
|
|
86
|
+
filters.append(FailedAuthAttempt.user_id == user_id)
|
|
82
87
|
if ip_hash:
|
|
83
|
-
|
|
88
|
+
filters.append(FailedAuthAttempt.ip_hash == ip_hash)
|
|
89
|
+
if filters:
|
|
90
|
+
q = q.where(or_(*filters))
|
|
84
91
|
|
|
85
92
|
rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
|
|
86
93
|
fail_count = len(rows)
|
svc_infra/security/models.py
CHANGED
|
@@ -3,10 +3,19 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import uuid
|
|
6
|
-
from datetime import datetime, timedelta
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import (
|
|
9
|
+
JSON,
|
|
10
|
+
Boolean,
|
|
11
|
+
DateTime,
|
|
12
|
+
ForeignKey,
|
|
13
|
+
Index,
|
|
14
|
+
String,
|
|
15
|
+
Text,
|
|
16
|
+
UniqueConstraint,
|
|
17
|
+
text,
|
|
18
|
+
)
|
|
10
19
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
11
20
|
|
|
12
21
|
from svc_infra.db.sql.base import ModelBase
|
|
@@ -22,19 +31,21 @@ class AuthSession(ModelBase):
|
|
|
22
31
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
23
32
|
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
24
33
|
)
|
|
25
|
-
tenant_id: Mapped[
|
|
26
|
-
user_agent: Mapped[
|
|
27
|
-
ip_hash: Mapped[
|
|
28
|
-
last_seen_at: Mapped[
|
|
29
|
-
revoked_at: Mapped[
|
|
30
|
-
revoke_reason: Mapped[
|
|
31
|
-
|
|
32
|
-
refresh_tokens: Mapped[list[
|
|
34
|
+
tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
35
|
+
user_agent: Mapped[str | None] = mapped_column(String(512))
|
|
36
|
+
ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
37
|
+
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
38
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
39
|
+
revoke_reason: Mapped[str | None] = mapped_column(Text)
|
|
40
|
+
|
|
41
|
+
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
|
33
42
|
back_populates="session", cascade="all, delete-orphan", lazy="selectin"
|
|
34
43
|
)
|
|
35
44
|
|
|
36
45
|
created_at = mapped_column(
|
|
37
|
-
DateTime(timezone=True),
|
|
46
|
+
DateTime(timezone=True),
|
|
47
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
48
|
+
nullable=False,
|
|
38
49
|
)
|
|
39
50
|
|
|
40
51
|
|
|
@@ -48,13 +59,15 @@ class RefreshToken(ModelBase):
|
|
|
48
59
|
session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
|
|
49
60
|
|
|
50
61
|
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
51
|
-
rotated_at: Mapped[
|
|
52
|
-
revoked_at: Mapped[
|
|
53
|
-
revoke_reason: Mapped[
|
|
54
|
-
expires_at: Mapped[
|
|
62
|
+
rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
63
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
64
|
+
revoke_reason: Mapped[str | None] = mapped_column(Text)
|
|
65
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
|
55
66
|
|
|
56
67
|
created_at = mapped_column(
|
|
57
|
-
DateTime(timezone=True),
|
|
68
|
+
DateTime(timezone=True),
|
|
69
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
70
|
+
nullable=False,
|
|
58
71
|
)
|
|
59
72
|
|
|
60
73
|
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
|
|
@@ -66,19 +79,21 @@ class RefreshTokenRevocation(ModelBase):
|
|
|
66
79
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
67
80
|
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
68
81
|
revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
69
|
-
reason: Mapped[
|
|
82
|
+
reason: Mapped[str | None] = mapped_column(Text)
|
|
70
83
|
|
|
71
84
|
|
|
72
85
|
class FailedAuthAttempt(ModelBase):
|
|
73
86
|
__tablename__ = "failed_auth_attempts"
|
|
74
87
|
|
|
75
88
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
76
|
-
user_id: Mapped[
|
|
89
|
+
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
77
90
|
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
|
|
78
91
|
)
|
|
79
|
-
ip_hash: Mapped[
|
|
92
|
+
ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
80
93
|
ts: Mapped[datetime] = mapped_column(
|
|
81
|
-
DateTime(timezone=True),
|
|
94
|
+
DateTime(timezone=True),
|
|
95
|
+
nullable=False,
|
|
96
|
+
default=lambda: datetime.now(UTC),
|
|
82
97
|
)
|
|
83
98
|
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
84
99
|
|
|
@@ -99,17 +114,17 @@ class AuditLog(ModelBase):
|
|
|
99
114
|
ts: Mapped[datetime] = mapped_column(
|
|
100
115
|
DateTime(timezone=True),
|
|
101
116
|
nullable=False,
|
|
102
|
-
default=lambda: datetime.now(
|
|
117
|
+
default=lambda: datetime.now(UTC),
|
|
103
118
|
index=True,
|
|
104
119
|
)
|
|
105
|
-
actor_id: Mapped[
|
|
120
|
+
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
106
121
|
GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
|
107
122
|
)
|
|
108
|
-
tenant_id: Mapped[
|
|
123
|
+
tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
109
124
|
event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
|
110
|
-
resource_ref: Mapped[
|
|
125
|
+
resource_ref: Mapped[str | None] = mapped_column(String(255), index=True)
|
|
111
126
|
event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
|
112
|
-
prev_hash: Mapped[
|
|
127
|
+
prev_hash: Mapped[str | None] = mapped_column(String(64))
|
|
113
128
|
hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
114
129
|
|
|
115
130
|
__table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
|
|
@@ -123,10 +138,12 @@ class Organization(ModelBase):
|
|
|
123
138
|
|
|
124
139
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
125
140
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
126
|
-
slug: Mapped[
|
|
127
|
-
tenant_id: Mapped[
|
|
141
|
+
slug: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
142
|
+
tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
128
143
|
created_at = mapped_column(
|
|
129
|
-
DateTime(timezone=True),
|
|
144
|
+
DateTime(timezone=True),
|
|
145
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
146
|
+
nullable=False,
|
|
130
147
|
)
|
|
131
148
|
|
|
132
149
|
|
|
@@ -139,7 +156,9 @@ class Team(ModelBase):
|
|
|
139
156
|
)
|
|
140
157
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
141
158
|
created_at = mapped_column(
|
|
142
|
-
DateTime(timezone=True),
|
|
159
|
+
DateTime(timezone=True),
|
|
160
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
161
|
+
nullable=False,
|
|
143
162
|
)
|
|
144
163
|
|
|
145
164
|
|
|
@@ -155,9 +174,11 @@ class OrganizationMembership(ModelBase):
|
|
|
155
174
|
)
|
|
156
175
|
role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
157
176
|
created_at = mapped_column(
|
|
158
|
-
DateTime(timezone=True),
|
|
177
|
+
DateTime(timezone=True),
|
|
178
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
179
|
+
nullable=False,
|
|
159
180
|
)
|
|
160
|
-
deactivated_at: Mapped[
|
|
181
|
+
deactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
161
182
|
|
|
162
183
|
__table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
|
|
163
184
|
|
|
@@ -172,17 +193,24 @@ class OrganizationInvitation(ModelBase):
|
|
|
172
193
|
email: Mapped[str] = mapped_column(String(255), index=True)
|
|
173
194
|
role: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
174
195
|
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
|
175
|
-
expires_at: Mapped[
|
|
176
|
-
created_by: Mapped[
|
|
196
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
|
197
|
+
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
177
198
|
GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
178
199
|
)
|
|
179
200
|
created_at = mapped_column(
|
|
180
|
-
DateTime(timezone=True),
|
|
201
|
+
DateTime(timezone=True),
|
|
202
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
203
|
+
nullable=False,
|
|
181
204
|
)
|
|
182
|
-
last_sent_at: Mapped[
|
|
205
|
+
last_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
183
206
|
resend_count: Mapped[int] = mapped_column(default=0)
|
|
184
|
-
used_at: Mapped[
|
|
185
|
-
revoked_at: Mapped[
|
|
207
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
208
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ------------------------ OAuth Provider Accounts -----------------------------
|
|
212
|
+
# MOVED to svc_infra.security.oauth_models for opt-in OAuth support
|
|
213
|
+
# Projects that enable OAuth should import ProviderAccount from there
|
|
186
214
|
|
|
187
215
|
|
|
188
216
|
# ------------------------ Utilities -------------------------------------------
|
|
@@ -198,13 +226,13 @@ def hash_refresh_token(raw: str) -> str:
|
|
|
198
226
|
|
|
199
227
|
|
|
200
228
|
def compute_audit_hash(
|
|
201
|
-
prev_hash:
|
|
229
|
+
prev_hash: str | None,
|
|
202
230
|
*,
|
|
203
231
|
ts: datetime,
|
|
204
|
-
actor_id:
|
|
205
|
-
tenant_id:
|
|
232
|
+
actor_id: uuid.UUID | None,
|
|
233
|
+
tenant_id: str | None,
|
|
206
234
|
event_type: str,
|
|
207
|
-
resource_ref:
|
|
235
|
+
resource_ref: str | None,
|
|
208
236
|
metadata: dict,
|
|
209
237
|
) -> str:
|
|
210
238
|
"""Compute SHA256 hash chaining previous hash + canonical event payload."""
|
|
@@ -227,7 +255,7 @@ def rotate_refresh_token(
|
|
|
227
255
|
"""Rotate: returns (new_raw, new_hash, expires_at)."""
|
|
228
256
|
new_raw = generate_refresh_token()
|
|
229
257
|
new_hash = hash_refresh_token(new_raw)
|
|
230
|
-
expires_at = datetime.now(
|
|
258
|
+
expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
|
|
231
259
|
return new_raw, new_hash, expires_at
|
|
232
260
|
|
|
233
261
|
|
|
@@ -238,6 +266,11 @@ __all__ = [
|
|
|
238
266
|
"FailedAuthAttempt",
|
|
239
267
|
"RolePermission",
|
|
240
268
|
"AuditLog",
|
|
269
|
+
"Organization",
|
|
270
|
+
"Team",
|
|
271
|
+
"OrganizationMembership",
|
|
272
|
+
"OrganizationInvitation",
|
|
273
|
+
# ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
|
|
241
274
|
"generate_refresh_token",
|
|
242
275
|
"hash_refresh_token",
|
|
243
276
|
"compute_audit_hash",
|
|
@@ -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 UTC, datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
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[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
|
+
|
|
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(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"]
|
|
@@ -2,15 +2,15 @@ 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
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
11
|
except Exception: # pragma: no cover
|
|
12
|
-
AsyncSession = object # type: ignore
|
|
13
|
-
select = None # type: ignore
|
|
12
|
+
AsyncSession = object # type: ignore[misc,assignment]
|
|
13
|
+
select = None # type: ignore[assignment]
|
|
14
14
|
|
|
15
15
|
from .models import OrganizationInvitation, OrganizationMembership
|
|
16
16
|
|
|
@@ -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:
|
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
|
|