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.
- analygo_identity-0.1.0/PKG-INFO +13 -0
- analygo_identity-0.1.0/README.md +5 -0
- analygo_identity-0.1.0/analygo_identity/__init__.py +71 -0
- analygo_identity-0.1.0/analygo_identity/authority_service.py +111 -0
- analygo_identity-0.1.0/analygo_identity/contracts.py +80 -0
- analygo_identity-0.1.0/analygo_identity/identity_resolution_log.py +96 -0
- analygo_identity-0.1.0/analygo_identity/onboarding.py +250 -0
- analygo_identity-0.1.0/analygo_identity/org_scope.py +89 -0
- analygo_identity-0.1.0/analygo_identity/py.typed +0 -0
- analygo_identity-0.1.0/analygo_identity/user_identity.py +139 -0
- analygo_identity-0.1.0/analygo_identity.egg-info/PKG-INFO +13 -0
- analygo_identity-0.1.0/analygo_identity.egg-info/SOURCES.txt +15 -0
- analygo_identity-0.1.0/analygo_identity.egg-info/dependency_links.txt +1 -0
- analygo_identity-0.1.0/analygo_identity.egg-info/requires.txt +4 -0
- analygo_identity-0.1.0/analygo_identity.egg-info/top_level.txt +1 -0
- analygo_identity-0.1.0/pyproject.toml +33 -0
- analygo_identity-0.1.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|