svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- 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/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -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 +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,255 @@
|
|
|
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), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RefreshToken(ModelBase):
|
|
52
|
+
__tablename__ = "refresh_tokens"
|
|
53
|
+
|
|
54
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
55
|
+
session_id: Mapped[uuid.UUID] = mapped_column(
|
|
56
|
+
GUID(), ForeignKey("auth_sessions.id", ondelete="CASCADE"), index=True
|
|
57
|
+
)
|
|
58
|
+
session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
|
|
59
|
+
|
|
60
|
+
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
61
|
+
rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
62
|
+
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
63
|
+
revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
|
|
64
|
+
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
|
|
65
|
+
|
|
66
|
+
created_at = mapped_column(
|
|
67
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RefreshTokenRevocation(ModelBase):
|
|
74
|
+
__tablename__ = "refresh_token_revocations"
|
|
75
|
+
|
|
76
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
77
|
+
token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
|
78
|
+
revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
79
|
+
reason: Mapped[Optional[str]] = mapped_column(Text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FailedAuthAttempt(ModelBase):
|
|
83
|
+
__tablename__ = "failed_auth_attempts"
|
|
84
|
+
|
|
85
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
86
|
+
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
87
|
+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
|
|
88
|
+
)
|
|
89
|
+
ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
90
|
+
ts: Mapped[datetime] = mapped_column(
|
|
91
|
+
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
|
92
|
+
)
|
|
93
|
+
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
94
|
+
|
|
95
|
+
__table_args__ = (Index("ix_failed_attempt_user_time", "user_id", "ts"),)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RolePermission(ModelBase):
|
|
99
|
+
__tablename__ = "role_permissions"
|
|
100
|
+
|
|
101
|
+
role: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
102
|
+
permission: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AuditLog(ModelBase):
|
|
106
|
+
__tablename__ = "audit_logs"
|
|
107
|
+
|
|
108
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
109
|
+
ts: Mapped[datetime] = mapped_column(
|
|
110
|
+
DateTime(timezone=True),
|
|
111
|
+
nullable=False,
|
|
112
|
+
default=lambda: datetime.now(timezone.utc),
|
|
113
|
+
index=True,
|
|
114
|
+
)
|
|
115
|
+
actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
116
|
+
GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
|
117
|
+
)
|
|
118
|
+
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
119
|
+
event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
|
120
|
+
resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
|
|
121
|
+
event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
|
122
|
+
prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
|
|
123
|
+
hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
124
|
+
|
|
125
|
+
__table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ------------------------ Org / Teams ----------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Organization(ModelBase):
|
|
132
|
+
__tablename__ = "organizations"
|
|
133
|
+
|
|
134
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
135
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
136
|
+
slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
137
|
+
tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
|
|
138
|
+
created_at = mapped_column(
|
|
139
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Team(ModelBase):
|
|
144
|
+
__tablename__ = "teams"
|
|
145
|
+
|
|
146
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
147
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
148
|
+
GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
|
|
149
|
+
)
|
|
150
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
151
|
+
created_at = mapped_column(
|
|
152
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class OrganizationMembership(ModelBase):
|
|
157
|
+
__tablename__ = "organization_memberships"
|
|
158
|
+
|
|
159
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
160
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
161
|
+
GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
|
|
162
|
+
)
|
|
163
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
164
|
+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
165
|
+
)
|
|
166
|
+
role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
167
|
+
created_at = mapped_column(
|
|
168
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
169
|
+
)
|
|
170
|
+
deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
171
|
+
|
|
172
|
+
__table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class OrganizationInvitation(ModelBase):
|
|
176
|
+
__tablename__ = "organization_invitations"
|
|
177
|
+
|
|
178
|
+
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
179
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
180
|
+
GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
|
|
181
|
+
)
|
|
182
|
+
email: Mapped[str] = mapped_column(String(255), index=True)
|
|
183
|
+
role: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
184
|
+
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
|
185
|
+
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
|
|
186
|
+
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
187
|
+
GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
188
|
+
)
|
|
189
|
+
created_at = mapped_column(
|
|
190
|
+
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
191
|
+
)
|
|
192
|
+
last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
193
|
+
resend_count: Mapped[int] = mapped_column(default=0)
|
|
194
|
+
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
195
|
+
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ------------------------ Utilities -------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def generate_refresh_token() -> str:
|
|
202
|
+
"""Generate a random refresh token (opaque)."""
|
|
203
|
+
return uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def hash_refresh_token(raw: str) -> str:
|
|
207
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def compute_audit_hash(
|
|
211
|
+
prev_hash: Optional[str],
|
|
212
|
+
*,
|
|
213
|
+
ts: datetime,
|
|
214
|
+
actor_id: Optional[uuid.UUID],
|
|
215
|
+
tenant_id: Optional[str],
|
|
216
|
+
event_type: str,
|
|
217
|
+
resource_ref: Optional[str],
|
|
218
|
+
metadata: dict,
|
|
219
|
+
) -> str:
|
|
220
|
+
"""Compute SHA256 hash chaining previous hash + canonical event payload."""
|
|
221
|
+
prev = prev_hash or "0" * 64
|
|
222
|
+
payload = {
|
|
223
|
+
"ts": ts.isoformat(),
|
|
224
|
+
"actor_id": str(actor_id) if actor_id else None,
|
|
225
|
+
"tenant_id": tenant_id,
|
|
226
|
+
"event_type": event_type,
|
|
227
|
+
"resource_ref": resource_ref,
|
|
228
|
+
"metadata": metadata,
|
|
229
|
+
}
|
|
230
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
231
|
+
return hashlib.sha256((prev + canonical).encode()).hexdigest()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def rotate_refresh_token(
|
|
235
|
+
current_hash: str, *, ttl_minutes: int = 10080
|
|
236
|
+
) -> tuple[str, str, datetime]:
|
|
237
|
+
"""Rotate: returns (new_raw, new_hash, expires_at)."""
|
|
238
|
+
new_raw = generate_refresh_token()
|
|
239
|
+
new_hash = hash_refresh_token(new_raw)
|
|
240
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
|
|
241
|
+
return new_raw, new_hash, expires_at
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
__all__ = [
|
|
245
|
+
"AuthSession",
|
|
246
|
+
"RefreshToken",
|
|
247
|
+
"RefreshTokenRevocation",
|
|
248
|
+
"FailedAuthAttempt",
|
|
249
|
+
"RolePermission",
|
|
250
|
+
"AuditLog",
|
|
251
|
+
"generate_refresh_token",
|
|
252
|
+
"hash_refresh_token",
|
|
253
|
+
"compute_audit_hash",
|
|
254
|
+
"rotate_refresh_token",
|
|
255
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
|
13
|
+
select = None # type: ignore
|
|
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(org_id=invitation.org_id, user_id=user_id, role=invitation.role)
|
|
117
|
+
if hasattr(db, "add"):
|
|
118
|
+
db.add(mem)
|
|
119
|
+
if hasattr(db, "flush"):
|
|
120
|
+
await db.flush()
|
|
121
|
+
return mem
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"issue_invitation",
|
|
126
|
+
"resend_invitation",
|
|
127
|
+
"accept_invitation",
|
|
128
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable, Iterable, Optional
|
|
6
|
+
|
|
7
|
+
COMMON_PASSWORDS = {"password", "123456", "qwerty", "letmein", "admin"}
|
|
8
|
+
|
|
9
|
+
HIBP_DISABLED = False # default enabled; can be toggled via settings at startup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PasswordPolicy:
|
|
14
|
+
min_length: int = 12
|
|
15
|
+
require_upper: bool = True
|
|
16
|
+
require_lower: bool = True
|
|
17
|
+
require_digit: bool = True
|
|
18
|
+
require_symbol: bool = True
|
|
19
|
+
forbid_common: bool = True
|
|
20
|
+
forbid_breached: bool = True # will toggle off if HIBP integration not configured
|
|
21
|
+
symbols_regex: str = r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PasswordValidationError(Exception):
|
|
25
|
+
def __init__(self, reasons: Iterable[str]):
|
|
26
|
+
super().__init__("Password validation failed")
|
|
27
|
+
self.reasons = list(reasons)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
UPPER = re.compile(r"[A-Z]")
|
|
31
|
+
LOWER = re.compile(r"[a-z]")
|
|
32
|
+
DIGIT = re.compile(r"[0-9]")
|
|
33
|
+
SYMBOL = re.compile(r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BreachedChecker = Callable[[str], bool]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_breached_checker: Optional[BreachedChecker] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configure_breached_checker(checker: Optional[BreachedChecker]) -> None:
|
|
43
|
+
global _breached_checker
|
|
44
|
+
_breached_checker = checker
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_password(pw: str, policy: PasswordPolicy | None = None) -> None:
|
|
48
|
+
policy = policy or PasswordPolicy()
|
|
49
|
+
reasons: list[str] = []
|
|
50
|
+
if len(pw) < policy.min_length:
|
|
51
|
+
reasons.append(f"min_length({policy.min_length})")
|
|
52
|
+
if policy.require_upper and not UPPER.search(pw):
|
|
53
|
+
reasons.append("missing_upper")
|
|
54
|
+
if policy.require_lower and not LOWER.search(pw):
|
|
55
|
+
reasons.append("missing_lower")
|
|
56
|
+
if policy.require_digit and not DIGIT.search(pw):
|
|
57
|
+
reasons.append("missing_digit")
|
|
58
|
+
if policy.require_symbol and not SYMBOL.search(pw):
|
|
59
|
+
reasons.append("missing_symbol")
|
|
60
|
+
if policy.forbid_common:
|
|
61
|
+
lowered = pw.lower()
|
|
62
|
+
# Reject if whole password matches a common one or contains it as a substring
|
|
63
|
+
if lowered in COMMON_PASSWORDS or any(term in lowered for term in COMMON_PASSWORDS):
|
|
64
|
+
reasons.append("common_password")
|
|
65
|
+
if policy.forbid_breached and not HIBP_DISABLED:
|
|
66
|
+
if _breached_checker and _breached_checker(pw):
|
|
67
|
+
reasons.append("breached_password")
|
|
68
|
+
if reasons:
|
|
69
|
+
raise PasswordValidationError(reasons)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"PasswordPolicy",
|
|
74
|
+
"validate_password",
|
|
75
|
+
"PasswordValidationError",
|
|
76
|
+
"configure_breached_checker",
|
|
77
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Awaitable, Callable, Dict, Iterable, Set
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException
|
|
7
|
+
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import Identity
|
|
9
|
+
|
|
10
|
+
# Central role -> permissions mapping. Projects can extend at startup.
|
|
11
|
+
PERMISSION_REGISTRY: Dict[str, Set[str]] = {
|
|
12
|
+
"admin": {
|
|
13
|
+
"user.read",
|
|
14
|
+
"user.write",
|
|
15
|
+
"billing.read",
|
|
16
|
+
"billing.write",
|
|
17
|
+
"security.session.revoke",
|
|
18
|
+
"security.session.list",
|
|
19
|
+
"admin.impersonate",
|
|
20
|
+
},
|
|
21
|
+
"support": {"user.read", "billing.read"},
|
|
22
|
+
"auditor": {"user.read", "billing.read", "audit.read"},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
|
|
27
|
+
perms: Set[str] = set()
|
|
28
|
+
for r in roles:
|
|
29
|
+
perms |= PERMISSION_REGISTRY.get(r, set())
|
|
30
|
+
return perms
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def principal_permissions(principal: Identity) -> Set[str]:
|
|
34
|
+
roles = getattr(principal.user, "roles", []) or []
|
|
35
|
+
return get_permissions_for_roles(roles)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def has_permission(principal: Identity, permission: str) -> bool:
|
|
39
|
+
return permission in principal_permissions(principal)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def RequirePermission(*needed: str):
|
|
43
|
+
"""FastAPI dependency enforcing all listed permissions are present."""
|
|
44
|
+
|
|
45
|
+
async def _guard(principal: Identity):
|
|
46
|
+
perms = principal_permissions(principal)
|
|
47
|
+
missing = [p for p in needed if p not in perms]
|
|
48
|
+
if missing:
|
|
49
|
+
raise HTTPException(403, f"missing_permissions:{','.join(missing)}")
|
|
50
|
+
return principal
|
|
51
|
+
|
|
52
|
+
return Depends(_guard)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def RequireAnyPermission(*candidates: str):
|
|
56
|
+
async def _guard(principal: Identity):
|
|
57
|
+
perms = principal_permissions(principal)
|
|
58
|
+
if not (perms & set(candidates)):
|
|
59
|
+
raise HTTPException(403, "insufficient_permissions")
|
|
60
|
+
return principal
|
|
61
|
+
|
|
62
|
+
return Depends(_guard)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ------- ABAC (Attribute-Based Access Control) helpers -------
|
|
66
|
+
ABACPredicate = Callable[[Identity, Any], bool | Awaitable[bool]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def owns_resource(attr: str = "owner_id") -> ABACPredicate:
|
|
70
|
+
def _predicate(principal: Identity, resource: Any) -> bool:
|
|
71
|
+
user = getattr(principal, "user", None)
|
|
72
|
+
uid = getattr(user, "id", None)
|
|
73
|
+
rid = getattr(resource, attr, None) or getattr(resource, "user_id", None)
|
|
74
|
+
return bool(uid is not None and rid is not None and str(uid) == str(rid))
|
|
75
|
+
|
|
76
|
+
return _predicate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _maybe_await(v):
|
|
80
|
+
if inspect.isawaitable(v):
|
|
81
|
+
return await v
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def enforce_abac(
|
|
86
|
+
principal: Identity,
|
|
87
|
+
*,
|
|
88
|
+
permission: str,
|
|
89
|
+
resource: Any,
|
|
90
|
+
predicate: ABACPredicate,
|
|
91
|
+
):
|
|
92
|
+
perms = principal_permissions(principal)
|
|
93
|
+
if permission not in perms:
|
|
94
|
+
raise HTTPException(403, f"missing_permissions:{permission}")
|
|
95
|
+
ok = False
|
|
96
|
+
# allow sync or async predicate
|
|
97
|
+
res = predicate(principal, resource)
|
|
98
|
+
if inspect.isawaitable(res):
|
|
99
|
+
# Fast path for sync contexts: raise clear guidance
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"enforce_abac received an async predicate in a sync context; use RequireABAC for FastAPI dependencies."
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
ok = bool(res)
|
|
105
|
+
if not ok:
|
|
106
|
+
raise HTTPException(403, "forbidden")
|
|
107
|
+
return principal
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def RequireABAC(
|
|
111
|
+
*,
|
|
112
|
+
permission: str,
|
|
113
|
+
predicate: ABACPredicate,
|
|
114
|
+
resource_getter: Callable[..., Any],
|
|
115
|
+
):
|
|
116
|
+
"""FastAPI dependency: enforce permission and attribute check using a resource provider.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
def load_doc(): ...
|
|
120
|
+
@router.get("/docs/{doc_id}", dependencies=[RequireABAC(permission="doc.read", predicate=owns_resource(), resource_getter=load_doc)])
|
|
121
|
+
async def get_doc(identity: Identity, doc = Depends(load_doc)):
|
|
122
|
+
...
|
|
123
|
+
Note: Using the provider in both the dependency and endpoint will call it twice. For heavy
|
|
124
|
+
providers, wire only in the dependency and re-fetch via the dependency override or request.state.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
async def _guard(principal: Identity, resource: Any = Depends(resource_getter)):
|
|
128
|
+
perms = principal_permissions(principal)
|
|
129
|
+
if permission not in perms:
|
|
130
|
+
raise HTTPException(403, f"missing_permissions:{permission}")
|
|
131
|
+
ok = await _maybe_await(predicate(principal, resource))
|
|
132
|
+
if not ok:
|
|
133
|
+
raise HTTPException(403, "forbidden")
|
|
134
|
+
return principal
|
|
135
|
+
|
|
136
|
+
return Depends(_guard)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = [
|
|
140
|
+
"PERMISSION_REGISTRY",
|
|
141
|
+
"get_permissions_for_roles",
|
|
142
|
+
"principal_permissions",
|
|
143
|
+
"has_permission",
|
|
144
|
+
"RequirePermission",
|
|
145
|
+
"RequireAnyPermission",
|
|
146
|
+
"RequireABAC",
|
|
147
|
+
"enforce_abac",
|
|
148
|
+
"owns_resource",
|
|
149
|
+
]
|