analygo-foundation 1.0.0__tar.gz
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.
- analygo_foundation-1.0.0/PKG-INFO +26 -0
- analygo_foundation-1.0.0/README.md +5 -0
- analygo_foundation-1.0.0/analygo_foundation/__init__.py +117 -0
- analygo_foundation-1.0.0/analygo_foundation/authority_service.py +111 -0
- analygo_foundation-1.0.0/analygo_foundation/contracts.py +187 -0
- analygo_foundation-1.0.0/analygo_foundation/guards.py +49 -0
- analygo_foundation-1.0.0/analygo_foundation/identity_resolution_log.py +96 -0
- analygo_foundation-1.0.0/analygo_foundation/observability.py +494 -0
- analygo_foundation-1.0.0/analygo_foundation/onboarding.py +250 -0
- analygo_foundation-1.0.0/analygo_foundation/org_scope.py +89 -0
- analygo_foundation-1.0.0/analygo_foundation/py.typed +0 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/__init__.py +31 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/boundary_logging.py +98 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/context.py +81 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/correlation_id.py +106 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/depends.py +22 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/exceptions.py +9 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/health.py +104 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/middleware.py +208 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/resolver.py +192 -0
- analygo_foundation-1.0.0/analygo_foundation/runtime/tracer.py +58 -0
- analygo_foundation-1.0.0/analygo_foundation/user_identity.py +139 -0
- analygo_foundation-1.0.0/analygo_foundation.egg-info/PKG-INFO +26 -0
- analygo_foundation-1.0.0/analygo_foundation.egg-info/SOURCES.txt +35 -0
- analygo_foundation-1.0.0/analygo_foundation.egg-info/dependency_links.txt +1 -0
- analygo_foundation-1.0.0/analygo_foundation.egg-info/requires.txt +18 -0
- analygo_foundation-1.0.0/analygo_foundation.egg-info/top_level.txt +1 -0
- analygo_foundation-1.0.0/pyproject.toml +47 -0
- analygo_foundation-1.0.0/setup.cfg +4 -0
- analygo_foundation-1.0.0/tests/test_observability.py +45 -0
- analygo_foundation-1.0.0/tests/test_runtime_boundary_logging.py +176 -0
- analygo_foundation-1.0.0/tests/test_runtime_context.py +126 -0
- analygo_foundation-1.0.0/tests/test_runtime_correlation_id.py +117 -0
- analygo_foundation-1.0.0/tests/test_runtime_health.py +129 -0
- analygo_foundation-1.0.0/tests/test_runtime_middleware.py +159 -0
- analygo_foundation-1.0.0/tests/test_runtime_resolver.py +119 -0
- analygo_foundation-1.0.0/tests/test_runtime_tracer.py +96 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: analygo-foundation
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Foundation runtime — shared identity, org resolution, RBAC, and observability for the Analygo ecosystem
|
|
5
|
+
Author-email: Analygo <engineering@analygo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
Requires-Dist: fastapi>=0.100.0
|
|
13
|
+
Requires-Dist: pyjwt[crypto]>=2.8.0
|
|
14
|
+
Requires-Dist: httpx>=0.24.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
17
|
+
Provides-Extra: observability
|
|
18
|
+
Requires-Dist: sentry-sdk>=2.0.0; extra == "observability"
|
|
19
|
+
Requires-Dist: opentelemetry-api>=1.20.0; extra == "observability"
|
|
20
|
+
Requires-Dist: opentelemetry-sdk>=1.20.0; extra == "observability"
|
|
21
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0; extra == "observability"
|
|
22
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.41b0; extra == "observability"
|
|
23
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.41b0; extra == "observability"
|
|
24
|
+
Requires-Dist: opentelemetry-instrumentation-redis>=0.41b0; extra == "observability"
|
|
25
|
+
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.41b0; extra == "observability"
|
|
26
|
+
Requires-Dist: structlog>=24.0.0; extra == "observability"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""analygo-foundation: Shared identity resolution, authority services, observability, and onboarding state."""
|
|
2
|
+
|
|
3
|
+
from analygo_foundation.contracts import (
|
|
4
|
+
EntitlementGrant,
|
|
5
|
+
Organization,
|
|
6
|
+
Property,
|
|
7
|
+
Role,
|
|
8
|
+
Seat,
|
|
9
|
+
SeatKind,
|
|
10
|
+
Session,
|
|
11
|
+
SubscriptionGuard,
|
|
12
|
+
Target,
|
|
13
|
+
UsageRecord,
|
|
14
|
+
UsageUnit,
|
|
15
|
+
Workspace,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from analygo_foundation.user_identity import UserIdentity, UserIdentityValidator
|
|
19
|
+
|
|
20
|
+
from analygo_foundation.identity_resolution_log import (
|
|
21
|
+
Surface,
|
|
22
|
+
ResolutionStatus,
|
|
23
|
+
log_identity_resolution,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from analygo_foundation.authority_service import AuthorityService
|
|
27
|
+
|
|
28
|
+
from analygo_foundation.onboarding import (
|
|
29
|
+
StepStatus,
|
|
30
|
+
OnboardingStep,
|
|
31
|
+
OnboardingState,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from analygo_foundation.guards import require_staff
|
|
35
|
+
|
|
36
|
+
from analygo_foundation.runtime import (
|
|
37
|
+
AuthTracer,
|
|
38
|
+
BoundaryLogger,
|
|
39
|
+
CorrelationIdMiddleware,
|
|
40
|
+
IdentityContext,
|
|
41
|
+
IdentityHealthResponse,
|
|
42
|
+
IdentityResolutionError,
|
|
43
|
+
REQUEST_ID_HEADER,
|
|
44
|
+
build_identity_health,
|
|
45
|
+
get_current_identity,
|
|
46
|
+
get_request_id,
|
|
47
|
+
identity_middleware,
|
|
48
|
+
install_boundary_logging,
|
|
49
|
+
resolve_identity,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
from analygo_foundation.org_scope import (
|
|
53
|
+
HEADER_AUDITOR_INTERNAL,
|
|
54
|
+
HEADER_CLIENT_ID,
|
|
55
|
+
HEADER_ORG_ID,
|
|
56
|
+
HEADER_PORTAL_REQUEST_ID,
|
|
57
|
+
HEADER_PORTAL_SERVICE,
|
|
58
|
+
LineageSource,
|
|
59
|
+
OrgScope,
|
|
60
|
+
OrgScopeResolvedTo,
|
|
61
|
+
ResolvedOrg,
|
|
62
|
+
SYSTEM_ORG_ID,
|
|
63
|
+
SystemOrgReason,
|
|
64
|
+
get_org_scope,
|
|
65
|
+
is_portal_originated,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"AuthTracer",
|
|
70
|
+
"BoundaryLogger",
|
|
71
|
+
"CorrelationIdMiddleware",
|
|
72
|
+
"IdentityContext",
|
|
73
|
+
"IdentityHealthResponse",
|
|
74
|
+
"IdentityResolutionError",
|
|
75
|
+
"REQUEST_ID_HEADER",
|
|
76
|
+
"build_identity_health",
|
|
77
|
+
"get_current_identity",
|
|
78
|
+
"get_request_id",
|
|
79
|
+
"identity_middleware",
|
|
80
|
+
"install_boundary_logging",
|
|
81
|
+
"require_staff",
|
|
82
|
+
"resolve_identity",
|
|
83
|
+
"EntitlementGrant",
|
|
84
|
+
"Organization",
|
|
85
|
+
"SubscriptionGuard",
|
|
86
|
+
"Property",
|
|
87
|
+
"Role",
|
|
88
|
+
"Seat",
|
|
89
|
+
"SeatKind",
|
|
90
|
+
"Session",
|
|
91
|
+
"Target",
|
|
92
|
+
"UsageRecord",
|
|
93
|
+
"UsageUnit",
|
|
94
|
+
"Workspace",
|
|
95
|
+
"UserIdentity",
|
|
96
|
+
"UserIdentityValidator",
|
|
97
|
+
"Surface",
|
|
98
|
+
"ResolutionStatus",
|
|
99
|
+
"log_identity_resolution",
|
|
100
|
+
"AuthorityService",
|
|
101
|
+
"StepStatus",
|
|
102
|
+
"OnboardingStep",
|
|
103
|
+
"OnboardingState",
|
|
104
|
+
"OrgScope",
|
|
105
|
+
"ResolvedOrg",
|
|
106
|
+
"HEADER_ORG_ID",
|
|
107
|
+
"HEADER_CLIENT_ID",
|
|
108
|
+
"HEADER_PORTAL_REQUEST_ID",
|
|
109
|
+
"HEADER_PORTAL_SERVICE",
|
|
110
|
+
"HEADER_AUDITOR_INTERNAL",
|
|
111
|
+
"SYSTEM_ORG_ID",
|
|
112
|
+
"LineageSource",
|
|
113
|
+
"OrgScopeResolvedTo",
|
|
114
|
+
"SystemOrgReason",
|
|
115
|
+
"get_org_scope",
|
|
116
|
+
"is_portal_originated",
|
|
117
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Abstract base for authority services across Analygo surfaces.
|
|
2
|
+
|
|
3
|
+
An authority service validates whether a user has the right to perform
|
|
4
|
+
an action within an organization context. Concrete implementations
|
|
5
|
+
resolve membership from different backends:
|
|
6
|
+
|
|
7
|
+
- Backend-native: org membership resolver + Portal DB (platform)
|
|
8
|
+
- Portal-forwarded: HTTP-forwarded authority checks (legacy Portal)
|
|
9
|
+
- AEGIS-native: brain-level agent authorization
|
|
10
|
+
|
|
11
|
+
All authority services share a common interface so consumers can
|
|
12
|
+
validate access without knowing which backend is authoritative.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, List, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AuthorityResult:
|
|
24
|
+
"""Result of an authority check.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
granted: True when the action is authorized
|
|
28
|
+
reason: Human-readable explanation when not granted
|
|
29
|
+
context: Additional metadata about the decision (role used, source, etc.)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
granted: bool
|
|
33
|
+
reason: str = ""
|
|
34
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthorityService(ABC):
|
|
38
|
+
"""Abstract base for identity authority services.
|
|
39
|
+
|
|
40
|
+
Concrete subclasses implement ``authorize`` and ``resolve_identity``
|
|
41
|
+
to validate user access for a given organization context.
|
|
42
|
+
|
|
43
|
+
Thread-safe: subclasses should not mutate instance state during checks.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def authorize(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
user_sub: str,
|
|
51
|
+
org_id: Optional[str] = None,
|
|
52
|
+
action: str = "access",
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> AuthorityResult:
|
|
55
|
+
"""Check whether a user is authorized for an action in an org context.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
user_sub: Canonical user identifier (sub claim from auth).
|
|
59
|
+
org_id: Target organization ID. May be None for global actions.
|
|
60
|
+
action: Action being authorized (e.g. "access", "admin", "invite").
|
|
61
|
+
**kwargs: Subclass-specific parameters.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
AuthorityResult with granted=True/False and reason.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def resolve_identity(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
user_sub: str,
|
|
73
|
+
org_id: Optional[str] = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Resolve identity information for a user in an org context.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
user_sub: Canonical user identifier.
|
|
79
|
+
org_id: Optional org to scope the resolution.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Dict with at minimum ``org_ids``, ``role``, and any available profile data.
|
|
83
|
+
"""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
async def require(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
user_sub: str,
|
|
90
|
+
org_id: Optional[str] = None,
|
|
91
|
+
action: str = "access",
|
|
92
|
+
**kwargs: Any,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Authorize and raise if not granted.
|
|
95
|
+
|
|
96
|
+
Convenience wrapper for callers that want exception-based gating.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
PermissionError: If authorization is denied.
|
|
100
|
+
"""
|
|
101
|
+
result = await self.authorize(
|
|
102
|
+
user_sub=user_sub,
|
|
103
|
+
org_id=org_id,
|
|
104
|
+
action=action,
|
|
105
|
+
**kwargs,
|
|
106
|
+
)
|
|
107
|
+
if not result.granted:
|
|
108
|
+
raise PermissionError(
|
|
109
|
+
f"Authorization denied for user={user_sub} "
|
|
110
|
+
f"org={org_id} action={action}: {result.reason}"
|
|
111
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Canonical identity type hierarchy for Analygo platform."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Organization(BaseModel):
|
|
12
|
+
"""Root tenant entity. All other entities belong to an organization."""
|
|
13
|
+
|
|
14
|
+
id: UUID = Field(..., description="Unique organization identifier")
|
|
15
|
+
name: str = Field(..., description="Organization display name")
|
|
16
|
+
slug: str = Field(..., description="URL-safe organization identifier")
|
|
17
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
18
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
19
|
+
|
|
20
|
+
class Config:
|
|
21
|
+
frozen = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Workspace(BaseModel):
|
|
25
|
+
"""Groups projects, teams, or clients within an organization."""
|
|
26
|
+
|
|
27
|
+
id: UUID = Field(..., description="Unique workspace identifier")
|
|
28
|
+
org_id: UUID = Field(..., description="Parent organization ID")
|
|
29
|
+
name: str = Field(..., description="Workspace display name")
|
|
30
|
+
slug: str = Field(..., description="URL-safe workspace identifier")
|
|
31
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
32
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
33
|
+
|
|
34
|
+
class Config:
|
|
35
|
+
frozen = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Property(BaseModel):
|
|
39
|
+
"""Auditable entity: website, mobile app, or digital property."""
|
|
40
|
+
|
|
41
|
+
id: UUID = Field(..., description="Unique property identifier")
|
|
42
|
+
org_id: UUID = Field(..., description="Parent organization ID")
|
|
43
|
+
workspace_id: Optional[UUID] = Field(None, description="Optional workspace grouping")
|
|
44
|
+
name: str = Field(..., description="Property display name (e.g., 'Main Website')")
|
|
45
|
+
domain: str = Field(..., description="Primary domain or identifier")
|
|
46
|
+
property_type: str = Field("website", description="Type: website, mobile_app, etc.")
|
|
47
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
48
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
49
|
+
|
|
50
|
+
class Config:
|
|
51
|
+
frozen = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Target(BaseModel):
|
|
55
|
+
"""Specific URL or identifier to audit within a property."""
|
|
56
|
+
|
|
57
|
+
id: UUID = Field(..., description="Unique target identifier")
|
|
58
|
+
property_id: UUID = Field(..., description="Parent property ID")
|
|
59
|
+
url: str = Field(..., description="Target URL or identifier")
|
|
60
|
+
label: Optional[str] = Field(None, description="Human-readable label")
|
|
61
|
+
is_primary: bool = Field(False, description="Primary target for this property")
|
|
62
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
63
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
64
|
+
|
|
65
|
+
class Config:
|
|
66
|
+
frozen = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Session(BaseModel):
|
|
70
|
+
"""Optional audit run or conversation session instance."""
|
|
71
|
+
|
|
72
|
+
id: UUID = Field(..., description="Unique session identifier")
|
|
73
|
+
org_id: UUID = Field(..., description="Parent organization ID")
|
|
74
|
+
workspace_id: Optional[UUID] = Field(None, description="Optional workspace context")
|
|
75
|
+
property_id: Optional[UUID] = Field(None, description="Optional property context")
|
|
76
|
+
user_id: UUID = Field(..., description="User who initiated session")
|
|
77
|
+
session_type: str = Field("audit", description="Type: audit, conversation, etc.")
|
|
78
|
+
started_at: datetime = Field(default_factory=datetime.utcnow)
|
|
79
|
+
ended_at: Optional[datetime] = Field(None)
|
|
80
|
+
|
|
81
|
+
class Config:
|
|
82
|
+
frozen = True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Role(str, Enum):
|
|
86
|
+
"""Membership role within an organization."""
|
|
87
|
+
|
|
88
|
+
ORG_OWNER = "org_owner"
|
|
89
|
+
ORG_ADMIN = "org_admin"
|
|
90
|
+
PROJECT_MANAGER = "project_manager"
|
|
91
|
+
AUDITOR = "auditor"
|
|
92
|
+
VIEWER = "viewer"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SeatKind(str, Enum):
|
|
96
|
+
"""Kind of seat occupying a license within an organization."""
|
|
97
|
+
|
|
98
|
+
HUMAN = "human"
|
|
99
|
+
AI = "ai"
|
|
100
|
+
SERVICE = "service"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UsageUnit(str, Enum):
|
|
104
|
+
"""Unit of measurement for usage tracking."""
|
|
105
|
+
|
|
106
|
+
API_CALL = "api_call"
|
|
107
|
+
AUDIT_RUN = "audit_run"
|
|
108
|
+
REPORT_GENERATED = "report_generated"
|
|
109
|
+
TOKENS = "tokens"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Seat:
|
|
114
|
+
"""A seat assigned to a user within an organization."""
|
|
115
|
+
|
|
116
|
+
org_id: UUID
|
|
117
|
+
user_id: UUID
|
|
118
|
+
kind: SeatKind
|
|
119
|
+
role: Role
|
|
120
|
+
active: bool = True
|
|
121
|
+
usage_limit: Optional[int] = None
|
|
122
|
+
usage_unit: Optional[UsageUnit] = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class UsageRecord:
|
|
127
|
+
"""A record of resource usage attributed to a seat."""
|
|
128
|
+
|
|
129
|
+
seat_id: UUID
|
|
130
|
+
timestamp: datetime
|
|
131
|
+
unit: UsageUnit
|
|
132
|
+
quantity: int
|
|
133
|
+
metadata: dict = field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class EntitlementGrant:
|
|
138
|
+
"""A grant of a specific capability to an organization."""
|
|
139
|
+
|
|
140
|
+
org_id: UUID
|
|
141
|
+
capability: str
|
|
142
|
+
granted_at: datetime
|
|
143
|
+
expires_at: Optional[datetime] = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SubscriptionGuard:
|
|
147
|
+
"""Prevents duplicate active subscriptions per org per product area.
|
|
148
|
+
|
|
149
|
+
Called before Stripe Checkout session creation to verify the org
|
|
150
|
+
does not already have an active subscription for the given product_area.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
>>> from analygo_foundation import SubscriptionGuard
|
|
154
|
+
>>> exists = await SubscriptionGuard.has_active(org_id, "auditor")
|
|
155
|
+
>>> if exists:
|
|
156
|
+
... return {"status": "already_active", ...}
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def has_active(org_id: UUID, product_area: str) -> bool:
|
|
161
|
+
"""Check if org has an active subscription for product_area.
|
|
162
|
+
|
|
163
|
+
Queries Portal DB billing.stripe_subscriptions via psycopg.
|
|
164
|
+
Returns True if any active subscription exists.
|
|
165
|
+
"""
|
|
166
|
+
import os
|
|
167
|
+
import psycopg
|
|
168
|
+
|
|
169
|
+
dsn = os.environ.get("PORTAL_DATABASE_URL", "")
|
|
170
|
+
if not dsn:
|
|
171
|
+
return False
|
|
172
|
+
# Strip SQLAlchemy dialect prefix if present
|
|
173
|
+
if dsn.startswith("postgresql+"):
|
|
174
|
+
dsn = "postgresql://" + dsn.split("://", 1)[1]
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with psycopg.connect(dsn, connect_timeout=5) as conn:
|
|
178
|
+
with conn.cursor() as cur:
|
|
179
|
+
cur.execute(
|
|
180
|
+
"""SELECT 1 FROM billing.stripe_subscriptions
|
|
181
|
+
WHERE org_id = %s::uuid AND status = 'active'
|
|
182
|
+
LIMIT 1""",
|
|
183
|
+
(str(org_id),),
|
|
184
|
+
)
|
|
185
|
+
return cur.fetchone() is not None
|
|
186
|
+
except Exception:
|
|
187
|
+
return False
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Staff access guard for internal-only services and routes.
|
|
2
|
+
|
|
3
|
+
Checks app_metadata.is_staff from the Supabase JWT claims dict.
|
|
4
|
+
Designed to be composed with existing auth dependencies rather than
|
|
5
|
+
baking in FastAPI Depends(verify_token) — each consumer chains it
|
|
6
|
+
with their own auth pipeline.
|
|
7
|
+
|
|
8
|
+
Raises FastAPI's HTTPException directly since every consumer is a FastAPI
|
|
9
|
+
service. The package adds `fastapi` to its dependencies for this.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException, status
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def require_staff(user: dict) -> dict:
|
|
16
|
+
"""Check that user has is_staff=true in app_metadata.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
user: Decoded JWT claims dict from Supabase (must include
|
|
20
|
+
app_metadata key). Typically the return value of
|
|
21
|
+
verify_token or verify_token_sse.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The user dict unchanged if the check passes.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
HTTPException: 403 if app_metadata.is_staff is not truthy.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
from analygo_foundation import require_staff
|
|
31
|
+
from app.middleware.auth import verify_token
|
|
32
|
+
|
|
33
|
+
# Compose with your auth dependency:
|
|
34
|
+
def require_staff_rest(
|
|
35
|
+
user: Annotated[dict, Depends(verify_token)],
|
|
36
|
+
) -> dict:
|
|
37
|
+
return require_staff(user)
|
|
38
|
+
|
|
39
|
+
@router.get("/admin")
|
|
40
|
+
def admin(user: Annotated[dict, Depends(require_staff_rest)]):
|
|
41
|
+
...
|
|
42
|
+
"""
|
|
43
|
+
app_meta = user.get("app_metadata", {}) if isinstance(user, dict) else {}
|
|
44
|
+
if not app_meta.get("is_staff"):
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
47
|
+
detail="Internal access only",
|
|
48
|
+
)
|
|
49
|
+
return user
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Canonical structured logging for identity / org membership resolution.
|
|
2
|
+
|
|
3
|
+
Uses stdlib ``logging`` with ``extra={"identity_event": {...}}``.
|
|
4
|
+
|
|
5
|
+
**identity_event schema** (stable keys; all values JSON-serializable primitives or lists of strings):
|
|
6
|
+
|
|
7
|
+
- ``event``: always ``identity.resolution`` unless overridden.
|
|
8
|
+
- ``surface``: ``backend_resolver`` | ``backend_me_orgs`` | ``portal_user_orgs`` | ``portal_verify_org_access``.
|
|
9
|
+
- ``resolution_status``: ``ok`` | ``empty`` | ``unavailable``.
|
|
10
|
+
- ``request_id``: correlation id when set.
|
|
11
|
+
- ``sub_prefix``: first 8 chars of ``membership_sub``.
|
|
12
|
+
- ``effective_lookup_sub``: first 8 chars of the canonical Portal/cache lookup key.
|
|
13
|
+
- ``users_logs_sub_prefix``: optional; first 8 of ``UsersLog.sub`` when supplied.
|
|
14
|
+
- ``error_code``: optional when guarded or failed (e.g. ``canonical_sub_missing``).
|
|
15
|
+
- ``org_count``, ``org_ids``: UUID strings from the resolved payload (empty when unavailable).
|
|
16
|
+
- ``identity_source``: how membership sub was derived.
|
|
17
|
+
- ``resolver_source``: ``ResolveResult.source`` when applicable.
|
|
18
|
+
- ``active_org_id``: when known.
|
|
19
|
+
|
|
20
|
+
**Never** log access tokens, refresh tokens, or cookie values.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, List, Literal, Optional
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("analygo_foundation.identity_resolution")
|
|
29
|
+
|
|
30
|
+
Surface = Literal["backend_resolver", "backend_me_orgs", "portal_user_orgs", "portal_verify_org_access"]
|
|
31
|
+
ResolutionStatus = Literal["ok", "empty", "unavailable"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sub_prefix(sub: Optional[str]) -> Optional[str]:
|
|
35
|
+
s = (sub or "").strip()
|
|
36
|
+
if not s:
|
|
37
|
+
return None
|
|
38
|
+
return s[:8]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log_identity_resolution(
|
|
42
|
+
*,
|
|
43
|
+
surface: Surface,
|
|
44
|
+
resolution_status: ResolutionStatus,
|
|
45
|
+
request_id: Optional[str] = None,
|
|
46
|
+
identity_source: Optional[str] = None,
|
|
47
|
+
resolver_source: Optional[str] = None,
|
|
48
|
+
membership_sub: Optional[str] = None,
|
|
49
|
+
users_logs_sub: Optional[str] = None,
|
|
50
|
+
effective_lookup_sub: Optional[str] = None,
|
|
51
|
+
org_ids: Optional[List[str]] = None,
|
|
52
|
+
org_count: Optional[int] = None,
|
|
53
|
+
active_org_id: Optional[str] = None,
|
|
54
|
+
email: Optional[str] = None,
|
|
55
|
+
error_code: Optional[str] = None,
|
|
56
|
+
event: str = "identity.resolution",
|
|
57
|
+
log_level: int = logging.INFO,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Emit one structured ``identity_event`` line for cross-surface correlation."""
|
|
60
|
+
oids = [str(x) for x in (org_ids or []) if x is not None and str(x).strip()]
|
|
61
|
+
count = org_count if org_count is not None else len(oids)
|
|
62
|
+
canonical_for_prefix = (
|
|
63
|
+
effective_lookup_sub if effective_lookup_sub is not None else membership_sub
|
|
64
|
+
)
|
|
65
|
+
mem_prefix = _sub_prefix(membership_sub) or ""
|
|
66
|
+
eff_prefix = _sub_prefix(canonical_for_prefix) or ""
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"event": event,
|
|
69
|
+
"surface": surface,
|
|
70
|
+
"resolution_status": resolution_status,
|
|
71
|
+
"request_id": request_id or "",
|
|
72
|
+
"sub_prefix": mem_prefix,
|
|
73
|
+
"effective_lookup_sub": eff_prefix,
|
|
74
|
+
"org_count": count,
|
|
75
|
+
"org_ids": oids,
|
|
76
|
+
}
|
|
77
|
+
usp = _sub_prefix(users_logs_sub)
|
|
78
|
+
if usp:
|
|
79
|
+
payload["users_logs_sub_prefix"] = usp
|
|
80
|
+
if identity_source:
|
|
81
|
+
payload["identity_source"] = identity_source
|
|
82
|
+
if resolver_source:
|
|
83
|
+
payload["resolver_source"] = resolver_source
|
|
84
|
+
if active_org_id:
|
|
85
|
+
payload["active_org_id"] = str(active_org_id)
|
|
86
|
+
if email is not None:
|
|
87
|
+
payload["has_email"] = bool((email or "").strip())
|
|
88
|
+
ec = (error_code or "").strip()
|
|
89
|
+
if ec:
|
|
90
|
+
payload["error_code"] = ec
|
|
91
|
+
|
|
92
|
+
logger.log(
|
|
93
|
+
log_level,
|
|
94
|
+
"identity.resolution",
|
|
95
|
+
extra={"identity_event": payload},
|
|
96
|
+
)
|