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.
Files changed (37) hide show
  1. analygo_foundation-1.0.0/PKG-INFO +26 -0
  2. analygo_foundation-1.0.0/README.md +5 -0
  3. analygo_foundation-1.0.0/analygo_foundation/__init__.py +117 -0
  4. analygo_foundation-1.0.0/analygo_foundation/authority_service.py +111 -0
  5. analygo_foundation-1.0.0/analygo_foundation/contracts.py +187 -0
  6. analygo_foundation-1.0.0/analygo_foundation/guards.py +49 -0
  7. analygo_foundation-1.0.0/analygo_foundation/identity_resolution_log.py +96 -0
  8. analygo_foundation-1.0.0/analygo_foundation/observability.py +494 -0
  9. analygo_foundation-1.0.0/analygo_foundation/onboarding.py +250 -0
  10. analygo_foundation-1.0.0/analygo_foundation/org_scope.py +89 -0
  11. analygo_foundation-1.0.0/analygo_foundation/py.typed +0 -0
  12. analygo_foundation-1.0.0/analygo_foundation/runtime/__init__.py +31 -0
  13. analygo_foundation-1.0.0/analygo_foundation/runtime/boundary_logging.py +98 -0
  14. analygo_foundation-1.0.0/analygo_foundation/runtime/context.py +81 -0
  15. analygo_foundation-1.0.0/analygo_foundation/runtime/correlation_id.py +106 -0
  16. analygo_foundation-1.0.0/analygo_foundation/runtime/depends.py +22 -0
  17. analygo_foundation-1.0.0/analygo_foundation/runtime/exceptions.py +9 -0
  18. analygo_foundation-1.0.0/analygo_foundation/runtime/health.py +104 -0
  19. analygo_foundation-1.0.0/analygo_foundation/runtime/middleware.py +208 -0
  20. analygo_foundation-1.0.0/analygo_foundation/runtime/resolver.py +192 -0
  21. analygo_foundation-1.0.0/analygo_foundation/runtime/tracer.py +58 -0
  22. analygo_foundation-1.0.0/analygo_foundation/user_identity.py +139 -0
  23. analygo_foundation-1.0.0/analygo_foundation.egg-info/PKG-INFO +26 -0
  24. analygo_foundation-1.0.0/analygo_foundation.egg-info/SOURCES.txt +35 -0
  25. analygo_foundation-1.0.0/analygo_foundation.egg-info/dependency_links.txt +1 -0
  26. analygo_foundation-1.0.0/analygo_foundation.egg-info/requires.txt +18 -0
  27. analygo_foundation-1.0.0/analygo_foundation.egg-info/top_level.txt +1 -0
  28. analygo_foundation-1.0.0/pyproject.toml +47 -0
  29. analygo_foundation-1.0.0/setup.cfg +4 -0
  30. analygo_foundation-1.0.0/tests/test_observability.py +45 -0
  31. analygo_foundation-1.0.0/tests/test_runtime_boundary_logging.py +176 -0
  32. analygo_foundation-1.0.0/tests/test_runtime_context.py +126 -0
  33. analygo_foundation-1.0.0/tests/test_runtime_correlation_id.py +117 -0
  34. analygo_foundation-1.0.0/tests/test_runtime_health.py +129 -0
  35. analygo_foundation-1.0.0/tests/test_runtime_middleware.py +159 -0
  36. analygo_foundation-1.0.0/tests/test_runtime_resolver.py +119 -0
  37. 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,5 @@
1
+ # analygo-foundation (Python)
2
+
3
+ Foundation runtime — shared identity resolution, org resolution, RBAC, observability, and onboarding state management.
4
+
5
+ Consumed as `analygo-foundation` from PyPI.
@@ -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
+ )