svc-infra 0.1.593__py3-none-any.whl → 0.1.595__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.

Files changed (33) hide show
  1. svc_infra/apf_payments/README.md +26 -0
  2. svc_infra/apf_payments/provider/aiydan.py +28 -2
  3. svc_infra/apf_payments/service.py +113 -20
  4. svc_infra/api/fastapi/apf_payments/router.py +67 -4
  5. svc_infra/api/fastapi/auth/add.py +10 -0
  6. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  7. svc_infra/api/fastapi/auth/routers/oauth_router.py +79 -34
  8. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  9. svc_infra/api/fastapi/auth/settings.py +2 -0
  10. svc_infra/api/fastapi/db/sql/users.py +13 -1
  11. svc_infra/api/fastapi/dependencies/ratelimit.py +66 -0
  12. svc_infra/api/fastapi/middleware/ratelimit.py +26 -11
  13. svc_infra/api/fastapi/middleware/ratelimit_store.py +78 -0
  14. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  15. svc_infra/api/fastapi/setup.py +2 -1
  16. svc_infra/obs/metrics/__init__.py +53 -0
  17. svc_infra/obs/metrics.py +52 -0
  18. svc_infra/security/audit.py +130 -0
  19. svc_infra/security/audit_service.py +73 -0
  20. svc_infra/security/headers.py +39 -0
  21. svc_infra/security/hibp.py +91 -0
  22. svc_infra/security/jwt_rotation.py +53 -0
  23. svc_infra/security/lockout.py +96 -0
  24. svc_infra/security/models.py +245 -0
  25. svc_infra/security/org_invites.py +128 -0
  26. svc_infra/security/passwords.py +77 -0
  27. svc_infra/security/permissions.py +148 -0
  28. svc_infra/security/session.py +98 -0
  29. svc_infra/security/signed_cookies.py +80 -0
  30. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/METADATA +1 -1
  31. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/RECORD +33 -16
  32. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/WHEEL +0 -0
  33. {svc_infra-0.1.593.dist-info → svc_infra-0.1.595.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,245 @@
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 JSON, Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint
10
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
11
+
12
+ from svc_infra.db.sql.base import ModelBase
13
+ from svc_infra.db.sql.types import GUID
14
+
15
+ # ----------------------------- Models -----------------------------------------
16
+
17
+
18
+ class AuthSession(ModelBase):
19
+ __tablename__ = "auth_sessions"
20
+
21
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
22
+ user_id: Mapped[uuid.UUID] = mapped_column(
23
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
24
+ )
25
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
26
+ user_agent: Mapped[Optional[str]] = mapped_column(String(512))
27
+ ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
28
+ last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
29
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
30
+ revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
31
+
32
+ refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
33
+ back_populates="session", cascade="all, delete-orphan", lazy="selectin"
34
+ )
35
+
36
+ created_at = mapped_column(
37
+ DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
38
+ )
39
+
40
+
41
+ class RefreshToken(ModelBase):
42
+ __tablename__ = "refresh_tokens"
43
+
44
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
45
+ session_id: Mapped[uuid.UUID] = mapped_column(
46
+ GUID(), ForeignKey("auth_sessions.id", ondelete="CASCADE"), index=True
47
+ )
48
+ session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
49
+
50
+ token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
51
+ rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
52
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
53
+ revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
54
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
55
+
56
+ created_at = mapped_column(
57
+ DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
58
+ )
59
+
60
+ __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
61
+
62
+
63
+ class RefreshTokenRevocation(ModelBase):
64
+ __tablename__ = "refresh_token_revocations"
65
+
66
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
67
+ token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
68
+ revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
69
+ reason: Mapped[Optional[str]] = mapped_column(Text)
70
+
71
+
72
+ class FailedAuthAttempt(ModelBase):
73
+ __tablename__ = "failed_auth_attempts"
74
+
75
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
76
+ user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
77
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
78
+ )
79
+ ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
80
+ ts: Mapped[datetime] = mapped_column(
81
+ DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
82
+ )
83
+ success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
84
+
85
+ __table_args__ = (Index("ix_failed_attempt_user_time", "user_id", "ts"),)
86
+
87
+
88
+ class RolePermission(ModelBase):
89
+ __tablename__ = "role_permissions"
90
+
91
+ role: Mapped[str] = mapped_column(String(64), primary_key=True)
92
+ permission: Mapped[str] = mapped_column(String(128), primary_key=True)
93
+
94
+
95
+ class AuditLog(ModelBase):
96
+ __tablename__ = "audit_logs"
97
+
98
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
99
+ ts: Mapped[datetime] = mapped_column(
100
+ DateTime(timezone=True),
101
+ nullable=False,
102
+ default=lambda: datetime.now(timezone.utc),
103
+ index=True,
104
+ )
105
+ actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
106
+ GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
107
+ )
108
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
109
+ event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
110
+ resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
111
+ event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
112
+ prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
113
+ hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
114
+
115
+ __table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
116
+
117
+
118
+ # ------------------------ Org / Teams ----------------------------------------
119
+
120
+
121
+ class Organization(ModelBase):
122
+ __tablename__ = "organizations"
123
+
124
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
125
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
126
+ slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
127
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
128
+ created_at = mapped_column(
129
+ DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
130
+ )
131
+
132
+
133
+ class Team(ModelBase):
134
+ __tablename__ = "teams"
135
+
136
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
137
+ org_id: Mapped[uuid.UUID] = mapped_column(
138
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
139
+ )
140
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
141
+ created_at = mapped_column(
142
+ DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
143
+ )
144
+
145
+
146
+ class OrganizationMembership(ModelBase):
147
+ __tablename__ = "organization_memberships"
148
+
149
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
150
+ org_id: Mapped[uuid.UUID] = mapped_column(
151
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
152
+ )
153
+ user_id: Mapped[uuid.UUID] = mapped_column(
154
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
155
+ )
156
+ role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
157
+ created_at = mapped_column(
158
+ DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
159
+ )
160
+ deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
161
+
162
+ __table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
163
+
164
+
165
+ class OrganizationInvitation(ModelBase):
166
+ __tablename__ = "organization_invitations"
167
+
168
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
169
+ org_id: Mapped[uuid.UUID] = mapped_column(
170
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
171
+ )
172
+ email: Mapped[str] = mapped_column(String(255), index=True)
173
+ role: Mapped[str] = mapped_column(String(64), nullable=False)
174
+ token_hash: Mapped[str] = mapped_column(String(64), index=True)
175
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
176
+ created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
177
+ GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
178
+ )
179
+ created_at = mapped_column(
180
+ DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
181
+ )
182
+ last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
183
+ resend_count: Mapped[int] = mapped_column(default=0)
184
+ used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
185
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
186
+
187
+
188
+ # ------------------------ Utilities -------------------------------------------
189
+
190
+
191
+ def generate_refresh_token() -> str:
192
+ """Generate a random refresh token (opaque)."""
193
+ return uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
194
+
195
+
196
+ def hash_refresh_token(raw: str) -> str:
197
+ return hashlib.sha256(raw.encode()).hexdigest()
198
+
199
+
200
+ def compute_audit_hash(
201
+ prev_hash: Optional[str],
202
+ *,
203
+ ts: datetime,
204
+ actor_id: Optional[uuid.UUID],
205
+ tenant_id: Optional[str],
206
+ event_type: str,
207
+ resource_ref: Optional[str],
208
+ metadata: dict,
209
+ ) -> str:
210
+ """Compute SHA256 hash chaining previous hash + canonical event payload."""
211
+ prev = prev_hash or "0" * 64
212
+ payload = {
213
+ "ts": ts.isoformat(),
214
+ "actor_id": str(actor_id) if actor_id else None,
215
+ "tenant_id": tenant_id,
216
+ "event_type": event_type,
217
+ "resource_ref": resource_ref,
218
+ "metadata": metadata,
219
+ }
220
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
221
+ return hashlib.sha256((prev + canonical).encode()).hexdigest()
222
+
223
+
224
+ def rotate_refresh_token(
225
+ current_hash: str, *, ttl_minutes: int = 10080
226
+ ) -> tuple[str, str, datetime]:
227
+ """Rotate: returns (new_raw, new_hash, expires_at)."""
228
+ new_raw = generate_refresh_token()
229
+ new_hash = hash_refresh_token(new_raw)
230
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
231
+ return new_raw, new_hash, expires_at
232
+
233
+
234
+ __all__ = [
235
+ "AuthSession",
236
+ "RefreshToken",
237
+ "RefreshTokenRevocation",
238
+ "FailedAuthAttempt",
239
+ "RolePermission",
240
+ "AuditLog",
241
+ "generate_refresh_token",
242
+ "hash_refresh_token",
243
+ "compute_audit_hash",
244
+ "rotate_refresh_token",
245
+ ]
@@ -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,148 @@
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
+ },
20
+ "support": {"user.read", "billing.read"},
21
+ "auditor": {"user.read", "billing.read", "audit.read"},
22
+ }
23
+
24
+
25
+ def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
26
+ perms: Set[str] = set()
27
+ for r in roles:
28
+ perms |= PERMISSION_REGISTRY.get(r, set())
29
+ return perms
30
+
31
+
32
+ def principal_permissions(principal: Identity) -> Set[str]:
33
+ roles = getattr(principal.user, "roles", []) or []
34
+ return get_permissions_for_roles(roles)
35
+
36
+
37
+ def has_permission(principal: Identity, permission: str) -> bool:
38
+ return permission in principal_permissions(principal)
39
+
40
+
41
+ def RequirePermission(*needed: str):
42
+ """FastAPI dependency enforcing all listed permissions are present."""
43
+
44
+ async def _guard(principal: Identity):
45
+ perms = principal_permissions(principal)
46
+ missing = [p for p in needed if p not in perms]
47
+ if missing:
48
+ raise HTTPException(403, f"missing_permissions:{','.join(missing)}")
49
+ return principal
50
+
51
+ return Depends(_guard)
52
+
53
+
54
+ def RequireAnyPermission(*candidates: str):
55
+ async def _guard(principal: Identity):
56
+ perms = principal_permissions(principal)
57
+ if not (perms & set(candidates)):
58
+ raise HTTPException(403, "insufficient_permissions")
59
+ return principal
60
+
61
+ return Depends(_guard)
62
+
63
+
64
+ # ------- ABAC (Attribute-Based Access Control) helpers -------
65
+ ABACPredicate = Callable[[Identity, Any], bool | Awaitable[bool]]
66
+
67
+
68
+ def owns_resource(attr: str = "owner_id") -> ABACPredicate:
69
+ def _predicate(principal: Identity, resource: Any) -> bool:
70
+ user = getattr(principal, "user", None)
71
+ uid = getattr(user, "id", None)
72
+ rid = getattr(resource, attr, None) or getattr(resource, "user_id", None)
73
+ return bool(uid is not None and rid is not None and str(uid) == str(rid))
74
+
75
+ return _predicate
76
+
77
+
78
+ async def _maybe_await(v):
79
+ if inspect.isawaitable(v):
80
+ return await v
81
+ return v
82
+
83
+
84
+ def enforce_abac(
85
+ principal: Identity,
86
+ *,
87
+ permission: str,
88
+ resource: Any,
89
+ predicate: ABACPredicate,
90
+ ):
91
+ perms = principal_permissions(principal)
92
+ if permission not in perms:
93
+ raise HTTPException(403, f"missing_permissions:{permission}")
94
+ ok = False
95
+ # allow sync or async predicate
96
+ res = predicate(principal, resource)
97
+ if inspect.isawaitable(res):
98
+ # Fast path for sync contexts: raise clear guidance
99
+ raise RuntimeError(
100
+ "enforce_abac received an async predicate in a sync context; use RequireABAC for FastAPI dependencies."
101
+ )
102
+ else:
103
+ ok = bool(res)
104
+ if not ok:
105
+ raise HTTPException(403, "forbidden")
106
+ return principal
107
+
108
+
109
+ def RequireABAC(
110
+ *,
111
+ permission: str,
112
+ predicate: ABACPredicate,
113
+ resource_getter: Callable[..., Any],
114
+ ):
115
+ """FastAPI dependency: enforce permission and attribute check using a resource provider.
116
+
117
+ Example:
118
+ def load_doc(): ...
119
+ @router.get("/docs/{doc_id}", dependencies=[RequireABAC(permission="doc.read", predicate=owns_resource(), resource_getter=load_doc)])
120
+ async def get_doc(identity: Identity, doc = Depends(load_doc)):
121
+ ...
122
+ Note: Using the provider in both the dependency and endpoint will call it twice. For heavy
123
+ providers, wire only in the dependency and re-fetch via the dependency override or request.state.
124
+ """
125
+
126
+ async def _guard(principal: Identity, resource: Any = Depends(resource_getter)):
127
+ perms = principal_permissions(principal)
128
+ if permission not in perms:
129
+ raise HTTPException(403, f"missing_permissions:{permission}")
130
+ ok = await _maybe_await(predicate(principal, resource))
131
+ if not ok:
132
+ raise HTTPException(403, "forbidden")
133
+ return principal
134
+
135
+ return Depends(_guard)
136
+
137
+
138
+ __all__ = [
139
+ "PERMISSION_REGISTRY",
140
+ "get_permissions_for_roles",
141
+ "principal_permissions",
142
+ "has_permission",
143
+ "RequirePermission",
144
+ "RequireAnyPermission",
145
+ "RequireABAC",
146
+ "enforce_abac",
147
+ "owns_resource",
148
+ ]
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional
6
+
7
+ try:
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ except Exception: # pragma: no cover
10
+ AsyncSession = object # type: ignore
11
+
12
+ from svc_infra.security.models import (
13
+ AuthSession,
14
+ RefreshToken,
15
+ RefreshTokenRevocation,
16
+ generate_refresh_token,
17
+ hash_refresh_token,
18
+ rotate_refresh_token,
19
+ )
20
+
21
+ DEFAULT_REFRESH_TTL_MINUTES = 60 * 24 * 7 # 7 days
22
+
23
+
24
+ async def issue_session_and_refresh(
25
+ db: AsyncSession,
26
+ *,
27
+ user_id: uuid.UUID,
28
+ tenant_id: Optional[str] = None,
29
+ user_agent: Optional[str] = None,
30
+ ip_hash: Optional[str] = None,
31
+ ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
32
+ ) -> tuple[str, RefreshToken]:
33
+ """Persist a new AuthSession + initial RefreshToken and return raw refresh token.
34
+
35
+ Returns: (raw_refresh_token, RefreshToken model instance)
36
+ """
37
+ session_row = AuthSession(
38
+ user_id=user_id,
39
+ tenant_id=tenant_id,
40
+ user_agent=user_agent,
41
+ ip_hash=ip_hash,
42
+ )
43
+ db.add(session_row)
44
+ raw = generate_refresh_token()
45
+ token_hash = hash_refresh_token(raw)
46
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
47
+ rt = RefreshToken(
48
+ session=session_row,
49
+ token_hash=token_hash,
50
+ expires_at=expires_at,
51
+ )
52
+ db.add(rt)
53
+ await db.flush()
54
+ return raw, rt
55
+
56
+
57
+ async def rotate_session_refresh(
58
+ db: AsyncSession,
59
+ *,
60
+ current: RefreshToken,
61
+ ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
62
+ ) -> tuple[str, RefreshToken]:
63
+ """Rotate a session's refresh token: mark current rotated, create new, add revocation record.
64
+
65
+ Returns: (new_raw_refresh_token, new_refresh_token_model)
66
+ """
67
+ rotation_ts = datetime.now(timezone.utc)
68
+ if current.revoked_at:
69
+ raise ValueError("refresh token already revoked")
70
+ if current.expires_at and current.expires_at <= rotation_ts:
71
+ raise ValueError("refresh token expired")
72
+ new_raw, new_hash, expires_at = rotate_refresh_token(
73
+ current.token_hash, ttl_minutes=ttl_minutes
74
+ )
75
+ current.rotated_at = rotation_ts
76
+ current.revoked_at = rotation_ts
77
+ current.revoke_reason = "rotated"
78
+ if current.expires_at is None or current.expires_at > rotation_ts:
79
+ current.expires_at = rotation_ts
80
+ # create revocation entry for old hash
81
+ db.add(
82
+ RefreshTokenRevocation(
83
+ token_hash=current.token_hash,
84
+ revoked_at=rotation_ts,
85
+ reason="rotated",
86
+ )
87
+ )
88
+ new_row = RefreshToken(
89
+ session=current.session,
90
+ token_hash=new_hash,
91
+ expires_at=expires_at,
92
+ )
93
+ db.add(new_row)
94
+ await db.flush()
95
+ return new_raw, new_row
96
+
97
+
98
+ __all__ = ["issue_session_and_refresh", "rotate_session_refresh"]