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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Security module providing authentication, authorization, and protection utilities.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive security primitives:
|
|
4
|
+
|
|
5
|
+
- **Middleware**: Security headers (CSP, HSTS, etc.) and CORS configuration
|
|
6
|
+
- **Lockout**: Account lockout with exponential backoff for brute force protection
|
|
7
|
+
- **Passwords**: Password policy validation with HIBP breach checking
|
|
8
|
+
- **Sessions**: Session and refresh token management
|
|
9
|
+
- **Audit**: Hash-chain audit logging for tamper detection
|
|
10
|
+
- **JWT Rotation**: Seamless JWT key rotation support
|
|
11
|
+
- **Permissions**: RBAC and ABAC authorization helpers
|
|
12
|
+
- **Signed Cookies**: Cryptographically signed cookies with expiry
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
from fastapi import FastAPI
|
|
16
|
+
from svc_infra.security import add_security
|
|
17
|
+
|
|
18
|
+
app = FastAPI()
|
|
19
|
+
|
|
20
|
+
# Add security headers and CORS
|
|
21
|
+
add_security(app, cors_origins=["https://myapp.com"])
|
|
22
|
+
|
|
23
|
+
# Use password validation
|
|
24
|
+
from svc_infra.security import validate_password, PasswordPolicy
|
|
25
|
+
|
|
26
|
+
policy = PasswordPolicy(min_length=12, require_symbol=True)
|
|
27
|
+
validate_password("MyStr0ng!Pass", policy)
|
|
28
|
+
|
|
29
|
+
# Use lockout protection
|
|
30
|
+
from svc_infra.security import get_lockout_status, LockoutConfig
|
|
31
|
+
|
|
32
|
+
status = await get_lockout_status(session, user_id=user.id, ip_hash=ip_hash)
|
|
33
|
+
if status.locked:
|
|
34
|
+
raise HTTPException(429, "Too many attempts")
|
|
35
|
+
|
|
36
|
+
Environment Variables:
|
|
37
|
+
CORS_ALLOW_ORIGINS: Comma-separated list of allowed origins
|
|
38
|
+
CORS_ALLOW_CREDENTIALS: Allow credentials in CORS (true/false)
|
|
39
|
+
CORS_ALLOW_METHODS: Comma-separated list of allowed methods
|
|
40
|
+
CORS_ALLOW_HEADERS: Comma-separated list of allowed headers
|
|
41
|
+
|
|
42
|
+
See Also:
|
|
43
|
+
- docs/security.md for detailed documentation
|
|
44
|
+
- svc_infra.api.fastapi.auth for authentication routes
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
# FastAPI integration
|
|
50
|
+
from .add import add_security
|
|
51
|
+
|
|
52
|
+
# Audit logging
|
|
53
|
+
from .audit import (
|
|
54
|
+
AuditEvent,
|
|
55
|
+
AuditLogStore,
|
|
56
|
+
InMemoryAuditLogStore,
|
|
57
|
+
append_audit_event,
|
|
58
|
+
verify_audit_chain,
|
|
59
|
+
)
|
|
60
|
+
from .audit_service import append_event, verify_chain_for_tenant
|
|
61
|
+
|
|
62
|
+
# Security headers middleware
|
|
63
|
+
from .headers import SECURE_DEFAULTS, SecurityHeadersMiddleware
|
|
64
|
+
|
|
65
|
+
# HIBP breach checking
|
|
66
|
+
from .hibp import HIBPClient
|
|
67
|
+
|
|
68
|
+
# JWT rotation
|
|
69
|
+
from .jwt_rotation import RotatingJWTStrategy
|
|
70
|
+
|
|
71
|
+
# Account lockout
|
|
72
|
+
from .lockout import (
|
|
73
|
+
LockoutConfig,
|
|
74
|
+
LockoutStatus,
|
|
75
|
+
compute_lockout,
|
|
76
|
+
get_lockout_status,
|
|
77
|
+
record_attempt,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Models (for type hints and direct use)
|
|
81
|
+
from .models import (
|
|
82
|
+
AuditLog,
|
|
83
|
+
AuthSession,
|
|
84
|
+
FailedAuthAttempt,
|
|
85
|
+
RefreshToken,
|
|
86
|
+
RefreshTokenRevocation,
|
|
87
|
+
compute_audit_hash,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Password validation
|
|
91
|
+
from .passwords import (
|
|
92
|
+
PasswordPolicy,
|
|
93
|
+
PasswordValidationError,
|
|
94
|
+
configure_breached_checker,
|
|
95
|
+
validate_password,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# RBAC/ABAC permissions
|
|
99
|
+
from .permissions import (
|
|
100
|
+
PERMISSION_REGISTRY,
|
|
101
|
+
RequireABAC,
|
|
102
|
+
RequireAnyPermission,
|
|
103
|
+
RequirePermission,
|
|
104
|
+
enforce_abac,
|
|
105
|
+
extend_role,
|
|
106
|
+
get_permissions_for_roles,
|
|
107
|
+
has_permission,
|
|
108
|
+
owns_resource,
|
|
109
|
+
principal_permissions,
|
|
110
|
+
register_role,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Signed cookies
|
|
114
|
+
from .signed_cookies import sign_cookie, verify_cookie
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
# FastAPI integration
|
|
118
|
+
"add_security",
|
|
119
|
+
# Headers middleware
|
|
120
|
+
"SecurityHeadersMiddleware",
|
|
121
|
+
"SECURE_DEFAULTS",
|
|
122
|
+
# Lockout
|
|
123
|
+
"LockoutConfig",
|
|
124
|
+
"LockoutStatus",
|
|
125
|
+
"compute_lockout",
|
|
126
|
+
"record_attempt",
|
|
127
|
+
"get_lockout_status",
|
|
128
|
+
# Password validation
|
|
129
|
+
"PasswordPolicy",
|
|
130
|
+
"PasswordValidationError",
|
|
131
|
+
"validate_password",
|
|
132
|
+
"configure_breached_checker",
|
|
133
|
+
# HIBP
|
|
134
|
+
"HIBPClient",
|
|
135
|
+
# Signed cookies
|
|
136
|
+
"sign_cookie",
|
|
137
|
+
"verify_cookie",
|
|
138
|
+
# Audit logging
|
|
139
|
+
"AuditLogStore",
|
|
140
|
+
"AuditEvent",
|
|
141
|
+
"append_audit_event",
|
|
142
|
+
"verify_audit_chain",
|
|
143
|
+
"append_event",
|
|
144
|
+
"verify_chain_for_tenant",
|
|
145
|
+
"InMemoryAuditLogStore",
|
|
146
|
+
# JWT rotation
|
|
147
|
+
"RotatingJWTStrategy",
|
|
148
|
+
# Permissions (RBAC/ABAC)
|
|
149
|
+
"PERMISSION_REGISTRY",
|
|
150
|
+
"register_role",
|
|
151
|
+
"extend_role",
|
|
152
|
+
"get_permissions_for_roles",
|
|
153
|
+
"principal_permissions",
|
|
154
|
+
"has_permission",
|
|
155
|
+
"RequirePermission",
|
|
156
|
+
"RequireAnyPermission",
|
|
157
|
+
"RequireABAC",
|
|
158
|
+
"enforce_abac",
|
|
159
|
+
"owns_resource",
|
|
160
|
+
# Models
|
|
161
|
+
"AuthSession",
|
|
162
|
+
"RefreshToken",
|
|
163
|
+
"RefreshTokenRevocation",
|
|
164
|
+
"FailedAuthAttempt",
|
|
165
|
+
"AuditLog",
|
|
166
|
+
"compute_audit_hash",
|
|
167
|
+
]
|
svc_infra/security/add.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from collections.abc import Mapping
|
|
5
|
-
from typing import
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from typing import Literal, cast
|
|
6
6
|
|
|
7
7
|
from fastapi import FastAPI
|
|
8
8
|
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -138,9 +138,7 @@ def _configure_session_middleware(
|
|
|
138
138
|
)
|
|
139
139
|
https_env = _parse_bool(env.get("SESSION_COOKIE_SECURE"))
|
|
140
140
|
effective_https_only = (
|
|
141
|
-
https_only
|
|
142
|
-
if https_only is not None
|
|
143
|
-
else (https_env if https_env is not None else False)
|
|
141
|
+
https_only if https_only is not None else (https_env if https_env is not None else False)
|
|
144
142
|
)
|
|
145
143
|
same_site_env = env.get("SESSION_COOKIE_SAMESITE")
|
|
146
144
|
same_site_raw = same_site_env.strip() if same_site_env else same_site
|
|
@@ -148,7 +146,7 @@ def _configure_session_middleware(
|
|
|
148
146
|
same_site_value: Literal["lax", "strict", "none"] = (
|
|
149
147
|
"lax"
|
|
150
148
|
if same_site_raw not in ("lax", "strict", "none")
|
|
151
|
-
else cast(Literal[
|
|
149
|
+
else cast("Literal['lax', 'strict', 'none']", same_site_raw)
|
|
152
150
|
)
|
|
153
151
|
|
|
154
152
|
max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
|
|
@@ -158,9 +156,7 @@ def _configure_session_middleware(
|
|
|
158
156
|
max_age_value = max_age
|
|
159
157
|
|
|
160
158
|
session_cookie_env = env.get("SESSION_COOKIE_NAME")
|
|
161
|
-
session_cookie_value = (
|
|
162
|
-
session_cookie_env.strip() if session_cookie_env else session_cookie
|
|
163
|
-
)
|
|
159
|
+
session_cookie_value = session_cookie_env.strip() if session_cookie_env else session_cookie
|
|
164
160
|
|
|
165
161
|
app.add_middleware(
|
|
166
162
|
SessionMiddleware,
|
svc_infra/security/audit.py
CHANGED
|
@@ -13,9 +13,10 @@ Design notes:
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
from collections.abc import Sequence
|
|
16
17
|
from dataclasses import dataclass
|
|
17
|
-
from datetime import
|
|
18
|
-
from typing import Any,
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from typing import Any, Protocol
|
|
19
20
|
|
|
20
21
|
try: # SQLAlchemy may not be present in minimal test context
|
|
21
22
|
from sqlalchemy import select
|
|
@@ -81,7 +82,7 @@ class InMemoryAuditLogStore:
|
|
|
81
82
|
ts: datetime | None = None,
|
|
82
83
|
) -> AuditEvent:
|
|
83
84
|
event = AuditEvent(
|
|
84
|
-
ts=ts or datetime.now(
|
|
85
|
+
ts=ts or datetime.now(UTC),
|
|
85
86
|
actor_id=actor_id,
|
|
86
87
|
tenant_id=tenant_id,
|
|
87
88
|
event_type=event_type,
|
|
@@ -91,9 +92,7 @@ class InMemoryAuditLogStore:
|
|
|
91
92
|
self._events.append(event)
|
|
92
93
|
return event
|
|
93
94
|
|
|
94
|
-
def list(
|
|
95
|
-
self, *, tenant_id: str | None = None, limit: int | None = None
|
|
96
|
-
) -> list[AuditEvent]:
|
|
95
|
+
def list(self, *, tenant_id: str | None = None, limit: int | None = None) -> list[AuditEvent]:
|
|
97
96
|
out = [e for e in self._events if tenant_id is None or e.tenant_id == tenant_id]
|
|
98
97
|
if limit is not None:
|
|
99
98
|
return out[-int(limit) :]
|
|
@@ -104,12 +103,12 @@ async def append_audit_event(
|
|
|
104
103
|
db: Any,
|
|
105
104
|
*,
|
|
106
105
|
actor_id=None,
|
|
107
|
-
tenant_id:
|
|
106
|
+
tenant_id: str | None = None,
|
|
108
107
|
event_type: str,
|
|
109
|
-
resource_ref:
|
|
108
|
+
resource_ref: str | None = None,
|
|
110
109
|
metadata: dict | None = None,
|
|
111
|
-
ts:
|
|
112
|
-
prev_event:
|
|
110
|
+
ts: datetime | None = None,
|
|
111
|
+
prev_event: AuditLog | None = None,
|
|
113
112
|
) -> AuditLog:
|
|
114
113
|
"""Append an audit event returning the persisted row.
|
|
115
114
|
|
|
@@ -117,14 +116,12 @@ async def append_audit_event(
|
|
|
117
116
|
the tenant (or global chain when tenant_id is None).
|
|
118
117
|
"""
|
|
119
118
|
metadata = metadata or {}
|
|
120
|
-
ts = ts or datetime.now(
|
|
119
|
+
ts = ts or datetime.now(UTC)
|
|
121
120
|
|
|
122
|
-
prev_hash:
|
|
121
|
+
prev_hash: str | None = None
|
|
123
122
|
if prev_event is not None:
|
|
124
123
|
prev_hash = prev_event.hash
|
|
125
|
-
elif select is not None and hasattr(
|
|
126
|
-
db, "execute"
|
|
127
|
-
): # attempt DB lookup for previous event
|
|
124
|
+
elif select is not None and hasattr(db, "execute"): # attempt DB lookup for previous event
|
|
128
125
|
try:
|
|
129
126
|
stmt = (
|
|
130
127
|
select(AuditLog)
|
|
@@ -172,14 +169,14 @@ async def append_audit_event(
|
|
|
172
169
|
return row
|
|
173
170
|
|
|
174
171
|
|
|
175
|
-
def verify_audit_chain(events: Sequence[AuditLog]) ->
|
|
172
|
+
def verify_audit_chain(events: Sequence[AuditLog]) -> tuple[bool, list[int]]:
|
|
176
173
|
"""Verify a sequence of audit events.
|
|
177
174
|
|
|
178
175
|
Returns (ok, broken_indices). If any event's hash doesn't match the recomputed
|
|
179
176
|
expected hash (based on previous event), its index is recorded. All events are
|
|
180
177
|
checked so callers can analyze extent of tampering.
|
|
181
178
|
"""
|
|
182
|
-
broken:
|
|
179
|
+
broken: list[int] = []
|
|
183
180
|
prev_hash = "0" * 64
|
|
184
181
|
for idx, ev in enumerate(events):
|
|
185
182
|
expected = compute_audit_hash(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
try: # optional SQLAlchemy import for environments without SA
|
|
6
7
|
from sqlalchemy import select
|
|
@@ -15,11 +16,11 @@ async def append_event(
|
|
|
15
16
|
db: Any,
|
|
16
17
|
*,
|
|
17
18
|
actor_id=None,
|
|
18
|
-
tenant_id:
|
|
19
|
+
tenant_id: str | None = None,
|
|
19
20
|
event_type: str,
|
|
20
|
-
resource_ref:
|
|
21
|
+
resource_ref: str | None = None,
|
|
21
22
|
metadata: dict | None = None,
|
|
22
|
-
prev_event:
|
|
23
|
+
prev_event: AuditLog | None = None,
|
|
23
24
|
) -> AuditLog:
|
|
24
25
|
"""Append an AuditLog event using the shared append utility.
|
|
25
26
|
|
|
@@ -37,8 +38,8 @@ async def append_event(
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
async def verify_chain_for_tenant(
|
|
40
|
-
db: Any, *, tenant_id:
|
|
41
|
-
) ->
|
|
41
|
+
db: Any, *, tenant_id: str | None = None
|
|
42
|
+
) -> tuple[bool, list[int]]:
|
|
42
43
|
"""Fetch all AuditLog events for a tenant and verify hash-chain integrity.
|
|
43
44
|
|
|
44
45
|
Falls back to inspecting an in-memory 'added' list when SQLAlchemy is not available,
|
|
@@ -57,13 +58,12 @@ async def verify_chain_for_tenant(
|
|
|
57
58
|
events = []
|
|
58
59
|
elif hasattr(db, "added"):
|
|
59
60
|
try:
|
|
60
|
-
pool =
|
|
61
|
+
pool = db.added
|
|
61
62
|
# Preserve insertion order for in-memory fake DBs where primary keys may be None
|
|
62
63
|
events = [
|
|
63
64
|
e
|
|
64
65
|
for e in pool
|
|
65
|
-
if isinstance(e, AuditLog)
|
|
66
|
-
and (tenant_id is None or e.tenant_id == tenant_id)
|
|
66
|
+
if isinstance(e, AuditLog) and (tenant_id is None or e.tenant_id == tenant_id)
|
|
67
67
|
]
|
|
68
68
|
except Exception: # pragma: no cover
|
|
69
69
|
events = []
|
svc_infra/security/hibp.py
CHANGED
|
@@ -4,7 +4,6 @@ import hashlib
|
|
|
4
4
|
import logging
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from typing import Dict, Optional
|
|
8
7
|
|
|
9
8
|
from svc_infra.http import new_httpx_client
|
|
10
9
|
|
|
@@ -41,14 +40,14 @@ class HIBPClient:
|
|
|
41
40
|
self.ttl_seconds = ttl_seconds
|
|
42
41
|
self.timeout = timeout
|
|
43
42
|
self.user_agent = user_agent
|
|
44
|
-
self._cache:
|
|
43
|
+
self._cache: dict[str, CacheEntry] = {}
|
|
45
44
|
# Use central factory for consistent defaults; retain explicit timeout override
|
|
46
45
|
self._http = new_httpx_client(
|
|
47
46
|
timeout_seconds=self.timeout,
|
|
48
47
|
headers={"User-Agent": self.user_agent},
|
|
49
48
|
)
|
|
50
49
|
|
|
51
|
-
def _get_cached(self, prefix: str) ->
|
|
50
|
+
def _get_cached(self, prefix: str) -> str | None:
|
|
52
51
|
now = time.time()
|
|
53
52
|
ent = self._cache.get(prefix)
|
|
54
53
|
if ent and ent.expires_at > now:
|
|
@@ -56,9 +55,7 @@ class HIBPClient:
|
|
|
56
55
|
return None
|
|
57
56
|
|
|
58
57
|
def _set_cache(self, prefix: str, body: str) -> None:
|
|
59
|
-
self._cache[prefix] = CacheEntry(
|
|
60
|
-
body=body, expires_at=time.time() + self.ttl_seconds
|
|
61
|
-
)
|
|
58
|
+
self._cache[prefix] = CacheEntry(body=body, expires_at=time.time() + self.ttl_seconds)
|
|
62
59
|
|
|
63
60
|
def range_query(self, prefix: str) -> str:
|
|
64
61
|
cached = self._get_cached(prefix)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
import jwt
|
|
6
7
|
from fastapi_users.authentication.strategy.jwt import JWTStrategy
|
|
@@ -19,8 +20,8 @@ class RotatingJWTStrategy(JWTStrategy):
|
|
|
19
20
|
*,
|
|
20
21
|
secret: str,
|
|
21
22
|
lifetime_seconds: int,
|
|
22
|
-
old_secrets:
|
|
23
|
-
token_audience:
|
|
23
|
+
old_secrets: Iterable[str] | None = None,
|
|
24
|
+
token_audience: str | list[str] | None = None,
|
|
24
25
|
):
|
|
25
26
|
# Normalize token_audience to list as required by parent JWTStrategy
|
|
26
27
|
aud_list: list[str] = (
|
|
@@ -30,10 +31,8 @@ class RotatingJWTStrategy(JWTStrategy):
|
|
|
30
31
|
if token_audience
|
|
31
32
|
else []
|
|
32
33
|
) or ["fastapi-users:auth"]
|
|
33
|
-
super().__init__(
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
|
|
34
|
+
super().__init__(secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list)
|
|
35
|
+
self._verify_secrets: list[str] = [secret, *list(old_secrets or [])]
|
|
37
36
|
self._lifetime_seconds = lifetime_seconds
|
|
38
37
|
|
|
39
38
|
async def read_token(
|
|
@@ -62,9 +61,7 @@ class RotatingJWTStrategy(JWTStrategy):
|
|
|
62
61
|
else:
|
|
63
62
|
aud_list = audience
|
|
64
63
|
try:
|
|
65
|
-
return decode_jwt(
|
|
66
|
-
token, self.decode_key, aud_list, algorithms=[self.algorithm]
|
|
67
|
-
)
|
|
64
|
+
return decode_jwt(token, self.decode_key, aud_list, algorithms=[self.algorithm])
|
|
68
65
|
except jwt.PyJWTError:
|
|
69
66
|
pass
|
|
70
67
|
|
svc_infra/security/lockout.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
10
|
from sqlalchemy import or_, select
|
|
@@ -27,7 +28,7 @@ class LockoutConfig:
|
|
|
27
28
|
@dataclass
|
|
28
29
|
class LockoutStatus:
|
|
29
30
|
locked: bool
|
|
30
|
-
next_allowed_at:
|
|
31
|
+
next_allowed_at: datetime | None
|
|
31
32
|
failure_count: int
|
|
32
33
|
|
|
33
34
|
|
|
@@ -35,9 +36,9 @@ class LockoutStatus:
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
def compute_lockout(
|
|
38
|
-
fail_count: int, *, cfg: LockoutConfig, now:
|
|
39
|
+
fail_count: int, *, cfg: LockoutConfig, now: datetime | None = None
|
|
39
40
|
) -> LockoutStatus:
|
|
40
|
-
now = now or datetime.now(
|
|
41
|
+
now = now or datetime.now(UTC)
|
|
41
42
|
if fail_count < cfg.threshold:
|
|
42
43
|
return LockoutStatus(False, None, fail_count)
|
|
43
44
|
# cooldown factor exponent = fail_count - threshold
|
|
@@ -54,8 +55,8 @@ def compute_lockout(
|
|
|
54
55
|
async def record_attempt(
|
|
55
56
|
session: AsyncSession,
|
|
56
57
|
*,
|
|
57
|
-
user_id:
|
|
58
|
-
ip_hash:
|
|
58
|
+
user_id: uuid.UUID | None,
|
|
59
|
+
ip_hash: str | None,
|
|
59
60
|
success: bool,
|
|
60
61
|
) -> None:
|
|
61
62
|
attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
|
|
@@ -66,12 +67,12 @@ async def record_attempt(
|
|
|
66
67
|
async def get_lockout_status(
|
|
67
68
|
session: AsyncSession,
|
|
68
69
|
*,
|
|
69
|
-
user_id:
|
|
70
|
-
ip_hash:
|
|
71
|
-
cfg:
|
|
70
|
+
user_id: uuid.UUID | None,
|
|
71
|
+
ip_hash: str | None,
|
|
72
|
+
cfg: LockoutConfig | None = None,
|
|
72
73
|
) -> LockoutStatus:
|
|
73
74
|
cfg = cfg or LockoutConfig()
|
|
74
|
-
now = datetime.now(
|
|
75
|
+
now = datetime.now(UTC)
|
|
75
76
|
window_start = now - timedelta(minutes=cfg.window_minutes)
|
|
76
77
|
|
|
77
78
|
q = select(FailedAuthAttempt).where(
|
svc_infra/security/models.py
CHANGED
|
@@ -3,8 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import uuid
|
|
6
|
-
from datetime import datetime, timedelta
|
|
7
|
-
from typing import Optional
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
8
7
|
|
|
9
8
|
from sqlalchemy import (
|
|
10
9
|
JSON,
|
|
@@ -32,14 +31,14 @@ class AuthSession(ModelBase):
|
|
|
32
31
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
33
32
|
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
34
33
|
)
|
|
35
|
-
tenant_id: Mapped[
|
|
36
|
-
user_agent: Mapped[
|
|
37
|
-
ip_hash: Mapped[
|
|
38
|
-
last_seen_at: Mapped[
|
|
39
|
-
revoked_at: Mapped[
|
|
40
|
-
revoke_reason: Mapped[
|
|
41
|
-
|
|
42
|
-
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(
|
|
43
42
|
back_populates="session", cascade="all, delete-orphan", lazy="selectin"
|
|
44
43
|
)
|
|
45
44
|
|
|
@@ -60,12 +59,10 @@ class RefreshToken(ModelBase):
|
|
|
60
59
|
session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
|
|
61
60
|
|
|
62
61
|
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
63
|
-
rotated_at: Mapped[
|
|
64
|
-
revoked_at: Mapped[
|
|
65
|
-
revoke_reason: Mapped[
|
|
66
|
-
expires_at: Mapped[
|
|
67
|
-
DateTime(timezone=True), index=True
|
|
68
|
-
)
|
|
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)
|
|
69
66
|
|
|
70
67
|
created_at = mapped_column(
|
|
71
68
|
DateTime(timezone=True),
|
|
@@ -81,24 +78,22 @@ class RefreshTokenRevocation(ModelBase):
|
|
|
81
78
|
|
|
82
79
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
83
80
|
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
84
|
-
revoked_at: Mapped[datetime] = mapped_column(
|
|
85
|
-
|
|
86
|
-
)
|
|
87
|
-
reason: Mapped[Optional[str]] = mapped_column(Text)
|
|
81
|
+
revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
82
|
+
reason: Mapped[str | None] = mapped_column(Text)
|
|
88
83
|
|
|
89
84
|
|
|
90
85
|
class FailedAuthAttempt(ModelBase):
|
|
91
86
|
__tablename__ = "failed_auth_attempts"
|
|
92
87
|
|
|
93
88
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
94
|
-
user_id: Mapped[
|
|
89
|
+
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
95
90
|
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
|
|
96
91
|
)
|
|
97
|
-
ip_hash: Mapped[
|
|
92
|
+
ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
98
93
|
ts: Mapped[datetime] = mapped_column(
|
|
99
94
|
DateTime(timezone=True),
|
|
100
95
|
nullable=False,
|
|
101
|
-
default=lambda: datetime.now(
|
|
96
|
+
default=lambda: datetime.now(UTC),
|
|
102
97
|
)
|
|
103
98
|
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
104
99
|
|
|
@@ -119,17 +114,17 @@ class AuditLog(ModelBase):
|
|
|
119
114
|
ts: Mapped[datetime] = mapped_column(
|
|
120
115
|
DateTime(timezone=True),
|
|
121
116
|
nullable=False,
|
|
122
|
-
default=lambda: datetime.now(
|
|
117
|
+
default=lambda: datetime.now(UTC),
|
|
123
118
|
index=True,
|
|
124
119
|
)
|
|
125
|
-
actor_id: Mapped[
|
|
120
|
+
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
126
121
|
GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
|
127
122
|
)
|
|
128
|
-
tenant_id: Mapped[
|
|
123
|
+
tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
129
124
|
event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
|
130
|
-
resource_ref: Mapped[
|
|
125
|
+
resource_ref: Mapped[str | None] = mapped_column(String(255), index=True)
|
|
131
126
|
event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
|
132
|
-
prev_hash: Mapped[
|
|
127
|
+
prev_hash: Mapped[str | None] = mapped_column(String(64))
|
|
133
128
|
hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
134
129
|
|
|
135
130
|
__table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
|
|
@@ -143,8 +138,8 @@ class Organization(ModelBase):
|
|
|
143
138
|
|
|
144
139
|
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
145
140
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
146
|
-
slug: Mapped[
|
|
147
|
-
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)
|
|
148
143
|
created_at = mapped_column(
|
|
149
144
|
DateTime(timezone=True),
|
|
150
145
|
server_default=text("CURRENT_TIMESTAMP"),
|
|
@@ -183,11 +178,9 @@ class OrganizationMembership(ModelBase):
|
|
|
183
178
|
server_default=text("CURRENT_TIMESTAMP"),
|
|
184
179
|
nullable=False,
|
|
185
180
|
)
|
|
186
|
-
deactivated_at: Mapped[
|
|
181
|
+
deactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
187
182
|
|
|
188
|
-
__table_args__ = (
|
|
189
|
-
UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),
|
|
190
|
-
)
|
|
183
|
+
__table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
|
|
191
184
|
|
|
192
185
|
|
|
193
186
|
class OrganizationInvitation(ModelBase):
|
|
@@ -200,10 +193,8 @@ class OrganizationInvitation(ModelBase):
|
|
|
200
193
|
email: Mapped[str] = mapped_column(String(255), index=True)
|
|
201
194
|
role: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
202
195
|
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
|
203
|
-
expires_at: Mapped[
|
|
204
|
-
|
|
205
|
-
)
|
|
206
|
-
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
196
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
|
197
|
+
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
207
198
|
GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
208
199
|
)
|
|
209
200
|
created_at = mapped_column(
|
|
@@ -211,10 +202,10 @@ class OrganizationInvitation(ModelBase):
|
|
|
211
202
|
server_default=text("CURRENT_TIMESTAMP"),
|
|
212
203
|
nullable=False,
|
|
213
204
|
)
|
|
214
|
-
last_sent_at: Mapped[
|
|
205
|
+
last_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
215
206
|
resend_count: Mapped[int] = mapped_column(default=0)
|
|
216
|
-
used_at: Mapped[
|
|
217
|
-
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))
|
|
218
209
|
|
|
219
210
|
|
|
220
211
|
# ------------------------ OAuth Provider Accounts -----------------------------
|
|
@@ -235,13 +226,13 @@ def hash_refresh_token(raw: str) -> str:
|
|
|
235
226
|
|
|
236
227
|
|
|
237
228
|
def compute_audit_hash(
|
|
238
|
-
prev_hash:
|
|
229
|
+
prev_hash: str | None,
|
|
239
230
|
*,
|
|
240
231
|
ts: datetime,
|
|
241
|
-
actor_id:
|
|
242
|
-
tenant_id:
|
|
232
|
+
actor_id: uuid.UUID | None,
|
|
233
|
+
tenant_id: str | None,
|
|
243
234
|
event_type: str,
|
|
244
|
-
resource_ref:
|
|
235
|
+
resource_ref: str | None,
|
|
245
236
|
metadata: dict,
|
|
246
237
|
) -> str:
|
|
247
238
|
"""Compute SHA256 hash chaining previous hash + canonical event payload."""
|
|
@@ -264,7 +255,7 @@ def rotate_refresh_token(
|
|
|
264
255
|
"""Rotate: returns (new_raw, new_hash, expires_at)."""
|
|
265
256
|
new_raw = generate_refresh_token()
|
|
266
257
|
new_hash = hash_refresh_token(new_raw)
|
|
267
|
-
expires_at = datetime.now(
|
|
258
|
+
expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
|
|
268
259
|
return new_raw, new_hash, expires_at
|
|
269
260
|
|
|
270
261
|
|