devlab-auth 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,22 @@
1
+ # Python artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+
8
+ # Distribution / Packaging
9
+ build/
10
+ dist/
11
+ *.egg-info/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+
17
+ # Testing & Linting caches
18
+ .ruff_cache/
19
+ .mypy_cache/
20
+ .pytest_cache/
21
+ .coverage
22
+ htmlcov/
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: devlab-auth
3
+ Version: 0.1.0
4
+ Summary: Universal DevLab Authentication SDK for Python (RS256 JWKS & M2M API Keys with FastAPI guards)
5
+ Author-email: Yuvaraj Pattabi <yuva.devlab@gmail.com>
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: cachetools>=5.3.0
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pydantic>=2.0.0
10
+ Requires-Dist: pyjwt[crypto]>=2.8.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: fastapi>=0.100.0; extra == 'dev'
13
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
17
+ Provides-Extra: fastapi
18
+ Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # DevLab Auth — Python SDK
22
+
23
+ Enterprise-grade authentication SDK and FastAPI security guards for the Yuva DevLab platform ecosystem.
24
+
25
+ ## Features
26
+ - **Strict Typing:** 100% type-annotated with `py.typed` marker (PEP 561) and strict mypy compliance.
27
+ - **RS256 JWKS Verification:** Validates DevLab Portal SSO tokens without network overhead using local in-memory public key caching.
28
+ - **M2M API Key Validation:** Authenticates machine-to-machine callers (`dlk_...`) via DevLab Portal API with local TTL caching.
29
+ - **FastAPI Dependency Injection:** Drop-in `Depends(get_current_principal)`, `require_role`, and `require_scope` guards.
30
+ - **ASGI Middleware:** Global route protection via `DevLabAuthMiddleware`.
31
+
32
+ ## Installation
33
+ ```bash
34
+ # Using uv
35
+ uv add devlab-auth
36
+
37
+ # Using pip
38
+ pip install devlab-auth
39
+ ```
40
+
41
+ ## Quickstart (FastAPI)
42
+ ```python
43
+ from fastapi import FastAPI, Depends
44
+ from devlab_auth import AuthPrincipal, get_current_principal, require_role
45
+
46
+ app = FastAPI()
47
+
48
+
49
+ @app.get("/api/v1/forecast")
50
+ async def get_forecast(principal: AuthPrincipal = Depends(get_current_principal)):
51
+ return {
52
+ "caller_id": principal.id,
53
+ "is_service": principal.is_m2m,
54
+ "role": principal.role,
55
+ }
56
+
57
+
58
+ @app.post("/api/v1/admin/purge", dependencies=[Depends(require_role("admin", "super_admin"))])
59
+ async def admin_purge():
60
+ return {"status": "purged"}
61
+ ```
62
+
63
+ ## Development & Verification
64
+ ```bash
65
+ # Run test suite
66
+ uv run pytest
67
+
68
+ # Strict lint check & format
69
+ uv run ruff check .
70
+ uv run ruff format --check .
71
+
72
+ # Strict typechecking (zero any, strict mode)
73
+ uv run mypy src
74
+ ```
@@ -0,0 +1,54 @@
1
+ # DevLab Auth — Python SDK
2
+
3
+ Enterprise-grade authentication SDK and FastAPI security guards for the Yuva DevLab platform ecosystem.
4
+
5
+ ## Features
6
+ - **Strict Typing:** 100% type-annotated with `py.typed` marker (PEP 561) and strict mypy compliance.
7
+ - **RS256 JWKS Verification:** Validates DevLab Portal SSO tokens without network overhead using local in-memory public key caching.
8
+ - **M2M API Key Validation:** Authenticates machine-to-machine callers (`dlk_...`) via DevLab Portal API with local TTL caching.
9
+ - **FastAPI Dependency Injection:** Drop-in `Depends(get_current_principal)`, `require_role`, and `require_scope` guards.
10
+ - **ASGI Middleware:** Global route protection via `DevLabAuthMiddleware`.
11
+
12
+ ## Installation
13
+ ```bash
14
+ # Using uv
15
+ uv add devlab-auth
16
+
17
+ # Using pip
18
+ pip install devlab-auth
19
+ ```
20
+
21
+ ## Quickstart (FastAPI)
22
+ ```python
23
+ from fastapi import FastAPI, Depends
24
+ from devlab_auth import AuthPrincipal, get_current_principal, require_role
25
+
26
+ app = FastAPI()
27
+
28
+
29
+ @app.get("/api/v1/forecast")
30
+ async def get_forecast(principal: AuthPrincipal = Depends(get_current_principal)):
31
+ return {
32
+ "caller_id": principal.id,
33
+ "is_service": principal.is_m2m,
34
+ "role": principal.role,
35
+ }
36
+
37
+
38
+ @app.post("/api/v1/admin/purge", dependencies=[Depends(require_role("admin", "super_admin"))])
39
+ async def admin_purge():
40
+ return {"status": "purged"}
41
+ ```
42
+
43
+ ## Development & Verification
44
+ ```bash
45
+ # Run test suite
46
+ uv run pytest
47
+
48
+ # Strict lint check & format
49
+ uv run ruff check .
50
+ uv run ruff format --check .
51
+
52
+ # Strict typechecking (zero any, strict mode)
53
+ uv run mypy src
54
+ ```
@@ -0,0 +1,71 @@
1
+ [project]
2
+ name = "devlab-auth"
3
+ version = "0.1.0"
4
+ description = "Universal DevLab Authentication SDK for Python (RS256 JWKS & M2M API Keys with FastAPI guards)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ authors = [
8
+ { name = "Yuvaraj Pattabi", email = "yuva.devlab@gmail.com" }
9
+ ]
10
+ dependencies = [
11
+ "pyjwt[crypto]>=2.8.0",
12
+ "httpx>=0.27.0",
13
+ "pydantic>=2.0.0",
14
+ "cachetools>=5.3.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ fastapi = [
19
+ "fastapi>=0.100.0",
20
+ ]
21
+ dev = [
22
+ "pytest>=8.0.0",
23
+ "pytest-asyncio>=0.23.0",
24
+ "ruff>=0.4.0",
25
+ "mypy>=1.10.0",
26
+ "fastapi>=0.100.0",
27
+ ]
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+ target-version = "py311"
36
+
37
+ [tool.ruff.lint]
38
+ select = [
39
+ "E", # pycodestyle errors
40
+ "W", # pycodestyle warnings
41
+ "F", # pyflakes
42
+ "I", # isort
43
+ "UP", # pyupgrade
44
+ "B", # flake8-bugbear
45
+ "SIM", # flake8-simplify
46
+ "RUF", # ruff-specific rules
47
+ ]
48
+ ignore = [
49
+ "B008", # Allow Depends/Header in FastAPI argument defaults
50
+ ]
51
+
52
+ [tool.mypy]
53
+ python_version = "3.11"
54
+ strict = true
55
+ disallow_untyped_defs = true
56
+ disallow_incomplete_defs = true
57
+ check_untyped_defs = true
58
+ no_implicit_optional = true
59
+ warn_redundant_casts = true
60
+ warn_unused_ignores = true
61
+ warn_return_any = true
62
+ show_error_codes = true
63
+ pretty = true
64
+
65
+ [[tool.mypy.overrides]]
66
+ module = "cachetools.*"
67
+ ignore_missing_imports = true
68
+
69
+ [[tool.mypy.overrides]]
70
+ module = "jwt.*"
71
+ ignore_missing_imports = true
@@ -0,0 +1,33 @@
1
+ """DevLab Authentication SDK for Python (FastAPI, LangChain, Agents)."""
2
+
3
+ from devlab_auth.client import DevLabAuth
4
+ from devlab_auth.jwks import JwksValidator
5
+ from devlab_auth.m2m import M2mApiKeyValidator
6
+ from devlab_auth.models import AuthPrincipal, M2mValidationResult
7
+
8
+ __all__ = [
9
+ "AuthPrincipal",
10
+ "DevLabAuth",
11
+ "JwksValidator",
12
+ "M2mApiKeyValidator",
13
+ "M2mValidationResult",
14
+ ]
15
+
16
+ try:
17
+ from devlab_auth.fastapi import (
18
+ get_current_principal,
19
+ require_role,
20
+ require_scope,
21
+ set_devlab_auth,
22
+ )
23
+ from devlab_auth.middleware import DevLabAuthMiddleware
24
+
25
+ __all__ += [
26
+ "DevLabAuthMiddleware",
27
+ "get_current_principal",
28
+ "require_role",
29
+ "require_scope",
30
+ "set_devlab_auth",
31
+ ]
32
+ except ImportError:
33
+ pass
@@ -0,0 +1,50 @@
1
+ """High-level DevLabAuth orchestrator combining JWKS and M2M validation."""
2
+
3
+ import os
4
+
5
+ from devlab_auth.jwks import JwksValidator
6
+ from devlab_auth.m2m import M2mApiKeyValidator
7
+ from devlab_auth.models import AuthPrincipal
8
+
9
+
10
+ class DevLabAuth:
11
+ """Primary entry point for DevLab authentication verification."""
12
+
13
+ def __init__(
14
+ self,
15
+ portal_url: str | None = None,
16
+ required_app_id: str | None = None,
17
+ jwks_cache_ttl: int = 3600,
18
+ m2m_cache_ttl: int = 300,
19
+ ):
20
+ self.portal_url = (
21
+ portal_url or os.getenv("DEVLAB_PORTAL_API_URL") or "http://localhost:4000"
22
+ )
23
+ self.jwks_validator = JwksValidator(
24
+ portal_url=self.portal_url, cache_ttl_seconds=jwks_cache_ttl
25
+ )
26
+ self.m2m_validator = M2mApiKeyValidator(
27
+ portal_url=self.portal_url,
28
+ cache_ttl_seconds=m2m_cache_ttl,
29
+ required_app_id=required_app_id,
30
+ )
31
+
32
+ async def authenticate_async(self, credential: str) -> AuthPrincipal:
33
+ """Dispatches credential to M2M or JWKS validator asynchronously."""
34
+ cred = credential.strip()
35
+ if cred.startswith("Bearer "):
36
+ cred = cred[7:].strip()
37
+
38
+ if cred.startswith("dlk_"):
39
+ return await self.m2m_validator.validate_key_async(cred)
40
+ return self.jwks_validator.validate_token(cred)
41
+
42
+ def authenticate_sync(self, credential: str) -> AuthPrincipal:
43
+ """Dispatches credential to M2M or JWKS validator synchronously."""
44
+ cred = credential.strip()
45
+ if cred.startswith("Bearer "):
46
+ cred = cred[7:].strip()
47
+
48
+ if cred.startswith("dlk_"):
49
+ return self.m2m_validator.validate_key_sync(cred)
50
+ return self.jwks_validator.validate_token(cred)
@@ -0,0 +1,93 @@
1
+ """FastAPI dependencies and security guards."""
2
+
3
+ from collections.abc import Callable, Coroutine
4
+ from typing import Any
5
+
6
+ from fastapi import Header, HTTPException, Security, status
7
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
8
+
9
+ from devlab_auth.client import DevLabAuth
10
+ from devlab_auth.models import AuthPrincipal
11
+
12
+ security = HTTPBearer(auto_error=False)
13
+ _default_auth: DevLabAuth | None = None
14
+
15
+
16
+ def get_devlab_auth() -> DevLabAuth:
17
+ """Resolves or initializes the global DevLabAuth singleton."""
18
+ global _default_auth
19
+ if _default_auth is None:
20
+ _default_auth = DevLabAuth()
21
+ return _default_auth
22
+
23
+
24
+ def set_devlab_auth(auth_instance: DevLabAuth) -> None:
25
+ """Configures a custom DevLabAuth instance globally."""
26
+ global _default_auth
27
+ _default_auth = auth_instance
28
+
29
+
30
+ async def get_current_principal(
31
+ credentials: HTTPAuthorizationCredentials | None = Security(security),
32
+ x_api_key: str | None = Header(None, alias="x-api-key"),
33
+ x_devlab_key: str | None = Header(None, alias="x-devlab-key"),
34
+ ) -> AuthPrincipal:
35
+ """Universal FastAPI dependency: resolves user JWT or M2M key."""
36
+ token = None
37
+ if credentials and credentials.credentials:
38
+ token = credentials.credentials.strip()
39
+ elif x_api_key:
40
+ token = x_api_key.strip()
41
+ elif x_devlab_key:
42
+ token = x_devlab_key.strip()
43
+
44
+ if not token:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_401_UNAUTHORIZED,
47
+ detail="Missing authentication credentials (Bearer token or x-api-key)",
48
+ headers={"WWW-Authenticate": "Bearer"},
49
+ )
50
+
51
+ auth = get_devlab_auth()
52
+ try:
53
+ return await auth.authenticate_async(token)
54
+ except PermissionError as pe:
55
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(pe)) from pe
56
+ except Exception as e:
57
+ raise HTTPException(
58
+ status_code=status.HTTP_401_UNAUTHORIZED,
59
+ detail=f"Authentication failed: {e!s}",
60
+ headers={"WWW-Authenticate": "Bearer"},
61
+ ) from e
62
+
63
+
64
+ def require_role(*roles: str) -> Callable[..., Coroutine[Any, Any, AuthPrincipal]]:
65
+ """FastAPI dependency factory enforcing specified roles."""
66
+
67
+ async def role_checker(
68
+ principal: AuthPrincipal = Security(get_current_principal),
69
+ ) -> AuthPrincipal:
70
+ if not principal.has_role(*roles):
71
+ raise HTTPException(
72
+ status_code=status.HTTP_403_FORBIDDEN,
73
+ detail=f"Insufficient role permissions. Required: {', '.join(roles)}",
74
+ )
75
+ return principal
76
+
77
+ return role_checker
78
+
79
+
80
+ def require_scope(scope: str) -> Callable[..., Coroutine[Any, Any, AuthPrincipal]]:
81
+ """FastAPI dependency factory enforcing specified permission scope."""
82
+
83
+ async def scope_checker(
84
+ principal: AuthPrincipal = Security(get_current_principal),
85
+ ) -> AuthPrincipal:
86
+ if not principal.has_scope(scope):
87
+ raise HTTPException(
88
+ status_code=status.HTTP_403_FORBIDDEN,
89
+ detail=f"Missing required permission scope: '{scope}'",
90
+ )
91
+ return principal
92
+
93
+ return scope_checker
@@ -0,0 +1,63 @@
1
+ """RS256 JWKS token validation against DevLab Portal."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import jwt
7
+ from jwt import PyJWKClient
8
+
9
+ from devlab_auth.models import AuthPrincipal
10
+
11
+
12
+ class JwksValidator:
13
+ """Cryptographic RS256 JWT validator using DevLab Portal JWKS endpoint."""
14
+
15
+ def __init__(
16
+ self,
17
+ portal_url: str | None = None,
18
+ jwks_uri: str | None = None,
19
+ cache_ttl_seconds: int = 3600,
20
+ ):
21
+ base_url = portal_url or os.getenv("DEVLAB_PORTAL_API_URL") or "http://localhost:4000"
22
+ self.jwks_uri = jwks_uri or f"{base_url.rstrip('/')}/.well-known/jwks.json"
23
+ self._client = PyJWKClient(self.jwks_uri, cache_jwk_set=True, lifespan=cache_ttl_seconds)
24
+
25
+ def validate_token(self, token: str) -> AuthPrincipal:
26
+ """Validates an RS256 JWT and returns the parsed AuthPrincipal."""
27
+ signing_key = self._client.get_signing_key_from_jwt(token)
28
+ payload: dict[str, Any] = jwt.decode(
29
+ token,
30
+ signing_key.key,
31
+ algorithms=["RS256"],
32
+ options={"verify_aud": False},
33
+ )
34
+
35
+ sub = str(payload.get("sub") or payload.get("id") or "")
36
+ email = payload.get("email")
37
+ name = payload.get("name")
38
+ role = payload.get("role")
39
+ tenant_id = payload.get("tenant_id")
40
+ app_id = payload.get("app_id")
41
+
42
+ raw_scopes = payload.get("scopes") or []
43
+ if isinstance(raw_scopes, str):
44
+ scopes = [raw_scopes]
45
+ elif isinstance(raw_scopes, list):
46
+ scopes = [str(s) for s in raw_scopes]
47
+ else:
48
+ scopes = []
49
+
50
+ if role and role not in scopes:
51
+ scopes.append(role)
52
+
53
+ return AuthPrincipal(
54
+ id=sub,
55
+ email=email,
56
+ name=name,
57
+ role=role,
58
+ tenant_id=tenant_id,
59
+ app_id=app_id,
60
+ scopes=scopes,
61
+ is_m2m=False,
62
+ metadata=payload,
63
+ )
@@ -0,0 +1,102 @@
1
+ """Machine-to-Machine (M2M) API key validation against DevLab Portal."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from cachetools import TTLCache
8
+
9
+ from devlab_auth.models import AuthPrincipal
10
+
11
+
12
+ class M2mApiKeyValidator:
13
+ """Validates DevLab M2M API keys (dlk_...) with in-memory caching."""
14
+
15
+ def __init__(
16
+ self,
17
+ portal_url: str | None = None,
18
+ cache_ttl_seconds: int = 300,
19
+ cache_maxsize: int = 1000,
20
+ required_app_id: str | None = None,
21
+ ):
22
+ base_url = portal_url or os.getenv("DEVLAB_PORTAL_API_URL") or "http://localhost:4000"
23
+ self.validate_url = f"{base_url.rstrip('/')}/api/v1/api-keys/validate"
24
+ self.cache: TTLCache[str, AuthPrincipal] = TTLCache(
25
+ maxsize=cache_maxsize, ttl=cache_ttl_seconds
26
+ )
27
+ self.required_app_id = required_app_id
28
+
29
+ async def validate_key_async(self, api_key: str) -> AuthPrincipal:
30
+ """Asynchronously validates an API key, using cache if available."""
31
+ if api_key in self.cache:
32
+ return self.cache[api_key]
33
+
34
+ async with httpx.AsyncClient(timeout=4.0) as client:
35
+ response = await client.post(
36
+ self.validate_url,
37
+ headers={
38
+ "Authorization": f"Bearer {api_key}",
39
+ "Content-Type": "application/json",
40
+ },
41
+ )
42
+ if response.status_code != 200:
43
+ raise ValueError(f"M2M validation failed with status {response.status_code}")
44
+
45
+ data = response.json()
46
+ if not data.get("valid"):
47
+ raise ValueError(data.get("message") or "Invalid API key")
48
+
49
+ principal = self._build_principal(data)
50
+ self._check_app_id(principal)
51
+ self.cache[api_key] = principal
52
+ return principal
53
+
54
+ def validate_key_sync(self, api_key: str) -> AuthPrincipal:
55
+ """Synchronously validates an API key, using cache if available."""
56
+ if api_key in self.cache:
57
+ return self.cache[api_key]
58
+
59
+ with httpx.Client(timeout=4.0) as client:
60
+ response = client.post(
61
+ self.validate_url,
62
+ headers={
63
+ "Authorization": f"Bearer {api_key}",
64
+ "Content-Type": "application/json",
65
+ },
66
+ )
67
+ if response.status_code != 200:
68
+ raise ValueError(f"M2M validation failed with status {response.status_code}")
69
+
70
+ data = response.json()
71
+ if not data.get("valid"):
72
+ raise ValueError(data.get("message") or "Invalid API key")
73
+
74
+ principal = self._build_principal(data)
75
+ self._check_app_id(principal)
76
+ self.cache[api_key] = principal
77
+ return principal
78
+
79
+ def _build_principal(self, data: dict[str, Any]) -> AuthPrincipal:
80
+ scopes = data.get("scopes", [])
81
+ if isinstance(scopes, str):
82
+ scopes = [scopes]
83
+ return AuthPrincipal(
84
+ id=str(data.get("keyId") or "m2m-key"),
85
+ name=data.get("name"),
86
+ app_id=data.get("appId"),
87
+ role="service",
88
+ scopes=scopes,
89
+ is_m2m=True,
90
+ metadata=data,
91
+ )
92
+
93
+ def _check_app_id(self, principal: AuthPrincipal) -> None:
94
+ if not self.required_app_id:
95
+ return
96
+ if principal.has_scope("*") or principal.has_scope(f"{self.required_app_id}:*"):
97
+ return
98
+ if principal.app_id and principal.app_id == self.required_app_id:
99
+ return
100
+ raise PermissionError(
101
+ f"API key belonging to '{principal.app_id}' lacks scope for '{self.required_app_id}'"
102
+ )
@@ -0,0 +1,52 @@
1
+ """Pure ASGI / FastAPI middleware for global request authentication."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import Request, Response
6
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
7
+ from starlette.responses import JSONResponse
8
+
9
+ from devlab_auth.client import DevLabAuth
10
+
11
+
12
+ class DevLabAuthMiddleware(BaseHTTPMiddleware):
13
+ """ASGI middleware authenticating requests and injecting 'request.state.principal'."""
14
+
15
+ def __init__(
16
+ self,
17
+ app: Any,
18
+ auth: DevLabAuth | None = None,
19
+ public_paths: set[str] | None = None,
20
+ ) -> None:
21
+ super().__init__(app)
22
+ self.auth = auth or DevLabAuth()
23
+ self.public_paths = public_paths or {
24
+ "/health",
25
+ "/ready",
26
+ "/docs",
27
+ "/openapi.json",
28
+ "/redoc",
29
+ }
30
+
31
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
32
+ if request.url.path in self.public_paths:
33
+ return await call_next(request)
34
+
35
+ header = request.headers.get("Authorization") or request.headers.get("x-api-key")
36
+ if not header:
37
+ return JSONResponse(
38
+ status_code=401,
39
+ content={"error": "Unauthorized: Missing Authorization Bearer token or x-api-key"},
40
+ )
41
+
42
+ try:
43
+ principal = await self.auth.authenticate_async(header)
44
+ request.state.principal = principal
45
+ return await call_next(request)
46
+ except PermissionError as pe:
47
+ return JSONResponse(status_code=403, content={"error": str(pe)})
48
+ except Exception as e:
49
+ return JSONResponse(
50
+ status_code=401,
51
+ content={"error": f"Authentication rejected: {e!s}"},
52
+ )
@@ -0,0 +1,46 @@
1
+ """Data models for DevLab authentication principals, tokens, and verification results."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class AuthPrincipal(BaseModel):
9
+ """Represents an authenticated entity in the DevLab ecosystem."""
10
+
11
+ id: str = Field(description="Unique subject or key identifier")
12
+ email: str | None = Field(default=None, description="User email if user session")
13
+ name: str | None = Field(default=None, description="Display name if available")
14
+ role: str | None = Field(default=None, description="User role (operator/admin)")
15
+ tenant_id: str | None = Field(default=None, description="Workspace or tenant identifier")
16
+ app_id: str | None = Field(default=None, description="Originating application identifier")
17
+ scopes: list[str] = Field(default_factory=list, description="Granted permission scopes")
18
+ is_m2m: bool = Field(default=False, description="True if authenticated via M2M API key")
19
+ metadata: dict[str, Any] = Field(default_factory=dict, description="Raw claims metadata")
20
+
21
+ def has_scope(self, required_scope: str) -> bool:
22
+ """Checks if principal has wildcard '*' or specific scope."""
23
+ if "*" in self.scopes:
24
+ return True
25
+ if required_scope in self.scopes:
26
+ return True
27
+ prefix = required_scope.split(":")[0] + ":*"
28
+ return prefix in self.scopes
29
+
30
+ def has_role(self, *required_roles: str) -> bool:
31
+ """Checks if principal has one of the required roles."""
32
+ if self.role == "super_admin":
33
+ return True
34
+ return bool(self.role and self.role in required_roles)
35
+
36
+
37
+ class M2mValidationResult(BaseModel):
38
+ """Result of an M2M API key validation against DevLab Portal."""
39
+
40
+ valid: bool
41
+ key_id: str | None = None
42
+ name: str | None = None
43
+ app_id: str | None = None
44
+ scopes: list[str] = Field(default_factory=list)
45
+ rate_limit_rpm: int | None = None
46
+ error: str | None = None
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561. This package uses inline types.