analygo-identity 0.1.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.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: analygo-identity
3
+ Version: 0.1.0
4
+ Summary: Canonical identity types and validators for Analygo services
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
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
@@ -0,0 +1,5 @@
1
+ # analygo-identity (Python)
2
+
3
+ Shared identity resolution, authority services, and onboarding state management.
4
+
5
+ Consumed as `analygo-identity` from GitHub Packages.
@@ -0,0 +1,71 @@
1
+ """analygo-identity: Shared identity resolution, authority services, and onboarding state."""
2
+
3
+ from analygo_identity.contracts import (
4
+ Organization,
5
+ Workspace,
6
+ Property,
7
+ Target,
8
+ Session,
9
+ )
10
+
11
+ from analygo_identity.user_identity import UserIdentity, UserIdentityValidator
12
+
13
+ from analygo_identity.identity_resolution_log import (
14
+ Surface,
15
+ ResolutionStatus,
16
+ log_identity_resolution,
17
+ )
18
+
19
+ from analygo_identity.authority_service import AuthorityService
20
+
21
+ from analygo_identity.onboarding import (
22
+ StepStatus,
23
+ OnboardingStep,
24
+ OnboardingState,
25
+ )
26
+
27
+ from analygo_identity.org_scope import (
28
+ HEADER_AUDITOR_INTERNAL,
29
+ HEADER_CLIENT_ID,
30
+ HEADER_ORG_ID,
31
+ HEADER_PORTAL_REQUEST_ID,
32
+ HEADER_PORTAL_SERVICE,
33
+ LineageSource,
34
+ OrgScope,
35
+ OrgScopeResolvedTo,
36
+ ResolvedOrg,
37
+ SYSTEM_ORG_ID,
38
+ SystemOrgReason,
39
+ get_org_scope,
40
+ is_portal_originated,
41
+ )
42
+
43
+ __all__ = [
44
+ "Organization",
45
+ "Workspace",
46
+ "Property",
47
+ "Target",
48
+ "Session",
49
+ "UserIdentity",
50
+ "UserIdentityValidator",
51
+ "Surface",
52
+ "ResolutionStatus",
53
+ "log_identity_resolution",
54
+ "AuthorityService",
55
+ "StepStatus",
56
+ "OnboardingStep",
57
+ "OnboardingState",
58
+ "OrgScope",
59
+ "ResolvedOrg",
60
+ "HEADER_ORG_ID",
61
+ "HEADER_CLIENT_ID",
62
+ "HEADER_PORTAL_REQUEST_ID",
63
+ "HEADER_PORTAL_SERVICE",
64
+ "HEADER_AUDITOR_INTERNAL",
65
+ "SYSTEM_ORG_ID",
66
+ "LineageSource",
67
+ "OrgScopeResolvedTo",
68
+ "SystemOrgReason",
69
+ "get_org_scope",
70
+ "is_portal_originated",
71
+ ]
@@ -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,80 @@
1
+ """Canonical identity type hierarchy for Analygo platform."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+ from uuid import UUID
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Organization(BaseModel):
10
+ """Root tenant entity. All other entities belong to an organization."""
11
+
12
+ id: UUID = Field(..., description="Unique organization identifier")
13
+ name: str = Field(..., description="Organization display name")
14
+ slug: str = Field(..., description="URL-safe organization identifier")
15
+ created_at: datetime = Field(default_factory=datetime.utcnow)
16
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
17
+
18
+ class Config:
19
+ frozen = True
20
+
21
+
22
+ class Workspace(BaseModel):
23
+ """Groups projects, teams, or clients within an organization."""
24
+
25
+ id: UUID = Field(..., description="Unique workspace identifier")
26
+ org_id: UUID = Field(..., description="Parent organization ID")
27
+ name: str = Field(..., description="Workspace display name")
28
+ slug: str = Field(..., description="URL-safe workspace identifier")
29
+ created_at: datetime = Field(default_factory=datetime.utcnow)
30
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
31
+
32
+ class Config:
33
+ frozen = True
34
+
35
+
36
+ class Property(BaseModel):
37
+ """Auditable entity: website, mobile app, or digital property."""
38
+
39
+ id: UUID = Field(..., description="Unique property identifier")
40
+ org_id: UUID = Field(..., description="Parent organization ID")
41
+ workspace_id: Optional[UUID] = Field(None, description="Optional workspace grouping")
42
+ name: str = Field(..., description="Property display name (e.g., 'Main Website')")
43
+ domain: str = Field(..., description="Primary domain or identifier")
44
+ property_type: str = Field("website", description="Type: website, mobile_app, etc.")
45
+ created_at: datetime = Field(default_factory=datetime.utcnow)
46
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
47
+
48
+ class Config:
49
+ frozen = True
50
+
51
+
52
+ class Target(BaseModel):
53
+ """Specific URL or identifier to audit within a property."""
54
+
55
+ id: UUID = Field(..., description="Unique target identifier")
56
+ property_id: UUID = Field(..., description="Parent property ID")
57
+ url: str = Field(..., description="Target URL or identifier")
58
+ label: Optional[str] = Field(None, description="Human-readable label")
59
+ is_primary: bool = Field(False, description="Primary target for this property")
60
+ created_at: datetime = Field(default_factory=datetime.utcnow)
61
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
62
+
63
+ class Config:
64
+ frozen = True
65
+
66
+
67
+ class Session(BaseModel):
68
+ """Optional audit run or conversation session instance."""
69
+
70
+ id: UUID = Field(..., description="Unique session identifier")
71
+ org_id: UUID = Field(..., description="Parent organization ID")
72
+ workspace_id: Optional[UUID] = Field(None, description="Optional workspace context")
73
+ property_id: Optional[UUID] = Field(None, description="Optional property context")
74
+ user_id: UUID = Field(..., description="User who initiated session")
75
+ session_type: str = Field("audit", description="Type: audit, conversation, etc.")
76
+ started_at: datetime = Field(default_factory=datetime.utcnow)
77
+ ended_at: Optional[datetime] = Field(None)
78
+
79
+ class Config:
80
+ frozen = True
@@ -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_identity.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
+ )
@@ -0,0 +1,250 @@
1
+ """Onboarding state machine for multi-surface identity lifecycle.
2
+
3
+ Defines generic steps common to all Analygo surfaces. Per-service
4
+ extensions (e.g. Platform: ``connect_first_site``, AEGIS: ``select_default_agent``,
5
+ Portal: ``invite_team_members``) live in each service's own code, not in this package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Callable, Dict, List, Optional
14
+
15
+
16
+ class StepStatus(Enum):
17
+ """Status of a single onboarding step."""
18
+
19
+ PENDING = "pending"
20
+ IN_PROGRESS = "in_progress"
21
+ COMPLETED = "completed"
22
+ SKIPPED = "skipped"
23
+
24
+
25
+ class OnboardingStep(str, Enum):
26
+ """Generic onboarding steps shared across all Analygo surfaces.
27
+
28
+ These four steps are the minimum common path. Services extend via
29
+ ExtensionStep API (see ``OnboardingState.add_extension_step``).
30
+ """
31
+
32
+ VERIFY_EMAIL = "verify_email"
33
+ CREATE_ORG = "create_org"
34
+ CREATE_WORKSPACE = "create_workspace"
35
+ SETUP_BILLING = "setup_billing"
36
+
37
+
38
+ @dataclass
39
+ class OnboardingState:
40
+ """Onboarding progress for a user across one or more surfaces.
41
+
42
+ The state machine tracks which steps exist, their status, and when
43
+ each was completed. Steps are ordered; the ``current_step`` property
44
+ returns the first non-completed step.
45
+
46
+ Extension steps can be added at runtime via ``add_extension_step``
47
+ for service-specific onboarding requirements.
48
+
49
+ Example:
50
+ >>> state = OnboardingState()
51
+ >>> state.advance(OnboardingStep.VERIFY_EMAIL)
52
+ >>> state.current_step
53
+ <OnboardingStep.CREATE_ORG: 'create_org'>
54
+ >>> state.is_complete
55
+ False
56
+ """
57
+
58
+ steps: list[OnboardingStep] = field(
59
+ default_factory=lambda: [
60
+ OnboardingStep.VERIFY_EMAIL,
61
+ OnboardingStep.CREATE_ORG,
62
+ OnboardingStep.CREATE_WORKSPACE,
63
+ OnboardingStep.SETUP_BILLING,
64
+ ]
65
+ )
66
+ statuses: dict[OnboardingStep, StepStatus] = field(default_factory=dict)
67
+ completed_at: dict[OnboardingStep, Optional[datetime]] = field(default_factory=dict)
68
+
69
+ # Extension-point API for per-service steps
70
+ _extension_loaders: list[Callable[[], list[OnboardingStep]]] = field(default_factory=list, repr=False)
71
+
72
+ def __post_init__(self) -> None:
73
+ """Initialize all steps as PENDING."""
74
+ for step in self.steps:
75
+ if step not in self.statuses:
76
+ self.statuses[step] = StepStatus.PENDING
77
+ if step not in self.completed_at:
78
+ self.completed_at[step] = None
79
+
80
+ @property
81
+ def current_step(self) -> Optional[OnboardingStep]:
82
+ """The first step that is not COMPLETED or SKIPPED, or None if all done."""
83
+ for step in self.steps:
84
+ status = self.statuses.get(step, StepStatus.PENDING)
85
+ if status not in (StepStatus.COMPLETED, StepStatus.SKIPPED):
86
+ return step
87
+ return None
88
+
89
+ @property
90
+ def is_complete(self) -> bool:
91
+ """True when all registered steps are COMPLETED or SKIPPED."""
92
+ return self.current_step is None
93
+
94
+ def advance(self, step: OnboardingStep) -> None:
95
+ """Mark a step as COMPLETED at the current time.
96
+
97
+ Args:
98
+ step: The step to advance. Must be in ``steps``.
99
+ """
100
+ if step not in self.steps:
101
+ raise ValueError(
102
+ f"Unknown onboarding step: {step!r}. "
103
+ f"Known steps: {[s.value for s in self.steps]}"
104
+ )
105
+ self.statuses[step] = StepStatus.COMPLETED
106
+ self.completed_at[step] = datetime.utcnow()
107
+
108
+ def skip(self, step: OnboardingStep) -> None:
109
+ """Mark a step as SKIPPED.
110
+
111
+ Args:
112
+ step: The step to skip. Must be in ``steps``.
113
+ """
114
+ if step not in self.steps:
115
+ raise ValueError(
116
+ f"Unknown onboarding step: {step!r}. "
117
+ f"Known steps: {[s.value for s in self.steps]}"
118
+ )
119
+ self.statuses[step] = StepStatus.SKIPPED
120
+ self.completed_at[step] = None
121
+
122
+ def progress(self, step: OnboardingStep) -> None:
123
+ """Mark a step as IN_PROGRESS.
124
+
125
+ Args:
126
+ step: The step being worked on. Must be in ``steps``.
127
+ """
128
+ if step not in self.steps:
129
+ raise ValueError(
130
+ f"Unknown onboarding step: {step!r}. "
131
+ f"Known steps: {[s.value for s in self.steps]}"
132
+ )
133
+ self.statuses[step] = StepStatus.IN_PROGRESS
134
+
135
+ def reset(self, step: OnboardingStep) -> None:
136
+ """Reset a step back to PENDING.
137
+
138
+ Useful for re-doing a step after a failure.
139
+
140
+ Args:
141
+ step: The step to reset.
142
+ """
143
+ if step not in self.steps:
144
+ raise ValueError(
145
+ f"Unknown onboarding step: {step!r}. "
146
+ f"Known steps: {[s.value for s in self.steps]}"
147
+ )
148
+ self.statuses[step] = StepStatus.PENDING
149
+ self.completed_at[step] = None
150
+
151
+ # ------------------------------------------------------------------
152
+ # Extension Point API
153
+ # ------------------------------------------------------------------
154
+
155
+ def register_extension_loader(
156
+ self,
157
+ loader: Callable[[], list[OnboardingStep]],
158
+ ) -> None:
159
+ """Register a callable that returns service-specific extension steps.
160
+
161
+ The loader is called once, when ``load_extensions`` is invoked.
162
+ This allows lazy discovery of extension steps without tight coupling.
163
+
164
+ Args:
165
+ loader: A zero-argument callable returning a list of OnboardingStep values.
166
+ """
167
+ self._extension_loaders.append(loader)
168
+
169
+ def load_extensions(self) -> list[OnboardingStep]:
170
+ """Invoke all registered extension loaders and append new steps.
171
+
172
+ Returns:
173
+ The list of newly added extension steps.
174
+ """
175
+ added: list[OnboardingStep] = []
176
+ for loader in self._extension_loaders:
177
+ try:
178
+ ext_steps = loader()
179
+ for ext_step in ext_steps:
180
+ if ext_step not in self.steps:
181
+ self.steps.append(ext_step)
182
+ self.statuses[ext_step] = StepStatus.PENDING
183
+ self.completed_at[ext_step] = None
184
+ added.append(ext_step)
185
+ except Exception:
186
+ # Fail-soft: log and continue
187
+ logger = __import__("logging").getLogger(__name__)
188
+ logger.exception("Onboarding extension loader failed: %s", loader)
189
+ return added
190
+
191
+ # ------------------------------------------------------------------
192
+ # Serialization
193
+ # ------------------------------------------------------------------
194
+
195
+ def to_dict(self) -> dict:
196
+ """Serialize onboarding state to a JSON-compatible dict.
197
+
198
+ Returns:
199
+ Dict with ``current_step``, ``is_complete``, ``steps``, and
200
+ per-step status/completed_at entries.
201
+ """
202
+ return {
203
+ "current_step": self.current_step.value if self.current_step else None,
204
+ "is_complete": self.is_complete,
205
+ "steps": [
206
+ {
207
+ "step": s.value,
208
+ "status": (self.statuses.get(s) or StepStatus.PENDING).value,
209
+ "completed_at": (
210
+ self.completed_at[s].isoformat()
211
+ if self.completed_at.get(s)
212
+ else None
213
+ ),
214
+ }
215
+ for s in self.steps
216
+ ],
217
+ }
218
+
219
+ @classmethod
220
+ def from_dict(cls, data: dict) -> "OnboardingState":
221
+ """Deserialize onboarding state from a dict.
222
+
223
+ Args:
224
+ data: A dict previously produced by ``to_dict``.
225
+
226
+ Returns:
227
+ A new OnboardingState instance.
228
+ """
229
+ step_map = {s.value: s for s in OnboardingStep}
230
+ raw_steps = data.get("steps", [])
231
+ steps: list[OnboardingStep] = []
232
+ statuses: dict[OnboardingStep, StepStatus] = {}
233
+ completed_at: dict[OnboardingStep, Optional[datetime]] = {}
234
+
235
+ for entry in raw_steps:
236
+ step = step_map.get(entry["step"])
237
+ if step is None:
238
+ continue
239
+ steps.append(step)
240
+ raw_status = entry.get("status", "pending")
241
+ statuses[step] = StepStatus(raw_status)
242
+ raw_completed = entry.get("completed_at")
243
+ completed_at[step] = (
244
+ datetime.fromisoformat(raw_completed)
245
+ if raw_completed
246
+ else None
247
+ )
248
+
249
+ state = cls(steps=steps, statuses=statuses, completed_at=completed_at)
250
+ return state
@@ -0,0 +1,89 @@
1
+ """Workspace resolution types and header parsing for multi-tenant org scoping."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Literal
5
+ from uuid import UUID
6
+
7
+ # ── Header constants ──────────────────────────────────────────────
8
+
9
+ HEADER_ORG_ID = "X-Org-Id"
10
+ HEADER_CLIENT_ID = "X-Client-Id"
11
+ HEADER_PORTAL_REQUEST_ID = "X-Portal-Request-Id"
12
+ HEADER_PORTAL_SERVICE = "X-Portal-Service"
13
+ HEADER_AUDITOR_INTERNAL = "X-Auditor-Internal"
14
+
15
+ # ── System org ────────────────────────────────────────────────────
16
+
17
+ SYSTEM_ORG_ID = "00000000-0000-0000-0000-000000000001"
18
+
19
+ # ── Literal types ─────────────────────────────────────────────────
20
+
21
+ LineageSource = Literal[
22
+ "portal_request",
23
+ "org_scope",
24
+ "portal_membership",
25
+ "fallback_org",
26
+ "refresh_parent",
27
+ ]
28
+
29
+ OrgScopeResolvedTo = Literal["system", "portal", "header"]
30
+
31
+ SystemOrgReason = Literal["no_portal_orgs", "internal_run", "demo", "worker"]
32
+
33
+
34
+ # ── Dataclasses ───────────────────────────────────────────────────
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ResolvedOrg:
39
+ """Result of org resolution — mirrors TS OrgContext."""
40
+ org_id: UUID
41
+ client_id: Optional[UUID]
42
+ portal_request_id: Optional[UUID]
43
+ lineage_source: LineageSource
44
+ lineage_reason: Optional[str]
45
+ org_scope_resolved_to: OrgScopeResolvedTo
46
+ system_org_reason: Optional[SystemOrgReason] = None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class OrgScope:
51
+ """Tenant scope extracted from request headers."""
52
+ org_id: UUID
53
+ client_id: Optional[UUID] = None
54
+ portal_request_id: Optional[UUID] = None
55
+
56
+
57
+ # ── Header parsing ────────────────────────────────────────────────
58
+
59
+
60
+ def _parse_uuid(value: Optional[str]) -> Optional[UUID]:
61
+ """Parse a string to UUID, returning None for invalid/missing input."""
62
+ if not value:
63
+ return None
64
+ try:
65
+ return UUID(value)
66
+ except (ValueError, AttributeError):
67
+ return None
68
+
69
+
70
+ def get_org_scope(headers: dict[str, str]) -> Optional[OrgScope]:
71
+ """Extract OrgScope from request headers.
72
+
73
+ Returns None if X-Org-Id header is missing or invalid.
74
+ """
75
+ org_id_raw = headers.get(HEADER_ORG_ID)
76
+ org_id = _parse_uuid(org_id_raw)
77
+ if org_id is None:
78
+ return None
79
+
80
+ return OrgScope(
81
+ org_id=org_id,
82
+ client_id=_parse_uuid(headers.get(HEADER_CLIENT_ID)),
83
+ portal_request_id=_parse_uuid(headers.get(HEADER_PORTAL_REQUEST_ID)),
84
+ )
85
+
86
+
87
+ def is_portal_originated(headers: dict[str, str]) -> bool:
88
+ """Check if request originated from the portal service."""
89
+ return HEADER_PORTAL_REQUEST_ID in headers or HEADER_PORTAL_SERVICE in headers
File without changes
@@ -0,0 +1,139 @@
1
+ """User identity protocol for type-safe authentication across services.
2
+
3
+ This module defines the canonical interface for user objects, preventing
4
+ type mismatches like accessing .id when the attribute is .sub.
5
+ """
6
+
7
+ from typing import Protocol, runtime_checkable, cast, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from typing import Any
11
+
12
+
13
+ @runtime_checkable
14
+ class UserIdentity(Protocol):
15
+ """Protocol defining the canonical interface for authenticated users.
16
+
17
+ Any object representing an authenticated user must satisfy this protocol.
18
+ Primary identifier is 'sub' (subject), not 'id', following JWT/OIDC conventions.
19
+
20
+ Example:
21
+ >>> user: UserIdentity = get_current_user()
22
+ >>> user.sub # Canonical user identifier
23
+ 'auth0|123456'
24
+ """
25
+
26
+ @property
27
+ def sub(self) -> str:
28
+ """Canonical user identifier (subject claim from auth provider)."""
29
+ ...
30
+
31
+ @property
32
+ def email(self) -> str | None:
33
+ """User's email address, if available."""
34
+ ...
35
+
36
+
37
+ class UserIdentityValidator:
38
+ """Runtime validator for user objects.
39
+
40
+ Provides clear error messages when objects don't satisfy UserIdentity protocol,
41
+ catching issues like missing .sub or using .id instead.
42
+ """
43
+
44
+ @staticmethod
45
+ def _get_sub(user: object) -> str | None:
46
+ """Extract 'sub' from an object or dict."""
47
+ if isinstance(user, dict):
48
+ return user.get("sub") # type: ignore[return-value]
49
+ if hasattr(user, "sub"):
50
+ return getattr(user, "sub")
51
+ return None
52
+
53
+ @staticmethod
54
+ def _has_sub(user: object) -> bool:
55
+ """Check if user has 'sub' (as attribute or dict key)."""
56
+ if isinstance(user, dict):
57
+ return "sub" in user
58
+ return hasattr(user, "sub")
59
+
60
+ @staticmethod
61
+ def _has_id_not_sub(user: object) -> bool:
62
+ """Check for common mistake: has 'id' but not 'sub'."""
63
+ if isinstance(user, dict):
64
+ return "id" in user and "sub" not in user
65
+ return hasattr(user, "id") and not hasattr(user, "sub")
66
+
67
+ @staticmethod
68
+ def _dir(user: object) -> list[str]:
69
+ """List available keys/attributes for error messages."""
70
+ if isinstance(user, dict):
71
+ return list(user.keys())
72
+ return [a for a in dir(user) if not a.startswith("_")]
73
+
74
+ @staticmethod
75
+ def validate_sub(user: object, context: str = "") -> str:
76
+ """Extract and validate user.sub with clear error on failure.
77
+
78
+ Supports both objects (attribute access) and dicts (key access).
79
+
80
+ Args:
81
+ user: Object or dict expected to satisfy UserIdentity protocol
82
+ context: Additional context for error messages (e.g., function name)
83
+
84
+ Returns:
85
+ The user's sub (subject identifier)
86
+
87
+ Raises:
88
+ AttributeError: If user.sub is missing or user uses wrong attribute name
89
+ TypeError: If user is None or wrong type
90
+ """
91
+ if user is None:
92
+ raise TypeError(
93
+ f"{context}: User object is None. "
94
+ "Ensure authentication dependency returned a valid user."
95
+ )
96
+
97
+ # Check for common mistake: using .id / ["id"] instead of .sub / ["sub"]
98
+ if UserIdentityValidator._has_id_not_sub(user):
99
+ raise AttributeError(
100
+ f"{context}: User object has 'id' but not 'sub'. "
101
+ "Use user.sub (not user.id) for the canonical user identifier."
102
+ )
103
+
104
+ if not UserIdentityValidator._has_sub(user):
105
+ raise AttributeError(
106
+ f"{context}: User object missing 'sub' attribute. "
107
+ f"Available attributes: {UserIdentityValidator._dir(user)}. "
108
+ "User must satisfy UserIdentity protocol."
109
+ )
110
+
111
+ sub = UserIdentityValidator._get_sub(user)
112
+ if not isinstance(sub, str):
113
+ raise TypeError(
114
+ f"{context}: user.sub must be str, got {type(sub).__name__}"
115
+ )
116
+
117
+ return sub
118
+
119
+ @staticmethod
120
+ def validate(user: object, context: str = "") -> "UserIdentity":
121
+ """Validate user object satisfies UserIdentity protocol.
122
+
123
+ Args:
124
+ user: Object to validate
125
+ context: Additional context for error messages
126
+
127
+ Returns:
128
+ The user object cast to UserIdentity
129
+
130
+ Raises:
131
+ TypeError: If user doesn't satisfy the protocol
132
+ """
133
+ if not isinstance(user, UserIdentity):
134
+ raise TypeError(
135
+ f"{context}: User object does not satisfy UserIdentity protocol. "
136
+ f"Type: {type(user).__name__}. "
137
+ "Required: .sub (str), .email (str | None)"
138
+ )
139
+ return cast("UserIdentity", user)
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: analygo-identity
3
+ Version: 0.1.0
4
+ Summary: Canonical identity types and validators for Analygo services
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
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ analygo_identity/__init__.py
4
+ analygo_identity/authority_service.py
5
+ analygo_identity/contracts.py
6
+ analygo_identity/identity_resolution_log.py
7
+ analygo_identity/onboarding.py
8
+ analygo_identity/org_scope.py
9
+ analygo_identity/py.typed
10
+ analygo_identity/user_identity.py
11
+ analygo_identity.egg-info/PKG-INFO
12
+ analygo_identity.egg-info/SOURCES.txt
13
+ analygo_identity.egg-info/dependency_links.txt
14
+ analygo_identity.egg-info/requires.txt
15
+ analygo_identity.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ pydantic>=2.0.0
2
+
3
+ [dev]
4
+ pytest>=7.0.0
@@ -0,0 +1 @@
1
+ analygo_identity
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "analygo-identity"
3
+ version = "0.1.0"
4
+ description = "Canonical identity types and validators for Analygo services"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Analygo", email = "engineering@analygo.com" },
9
+ ]
10
+ dependencies = [
11
+ "pydantic>=2.0.0",
12
+ ]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=7.0.0",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["setuptools>=75.0"]
26
+ build-backend = "setuptools.build_meta"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+ include = ["analygo_identity*"]
31
+
32
+ [tool.setuptools.package-data]
33
+ analygo_identity = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+