platform-auth 0.4.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 (39) hide show
  1. platform_auth-0.4.0/PKG-INFO +9 -0
  2. platform_auth-0.4.0/README.md +162 -0
  3. platform_auth-0.4.0/platform_auth/__init__.py +5 -0
  4. platform_auth-0.4.0/platform_auth/config.py +18 -0
  5. platform_auth-0.4.0/platform_auth/dynamodb.py +33 -0
  6. platform_auth-0.4.0/platform_auth/errors.py +83 -0
  7. platform_auth-0.4.0/platform_auth/fastapi/__init__.py +0 -0
  8. platform_auth-0.4.0/platform_auth/fastapi/dependencies.py +69 -0
  9. platform_auth-0.4.0/platform_auth/models.py +139 -0
  10. platform_auth-0.4.0/platform_auth/permissions.py +66 -0
  11. platform_auth-0.4.0/platform_auth/repositories/__init__.py +0 -0
  12. platform_auth-0.4.0/platform_auth/repositories/audit.py +47 -0
  13. platform_auth-0.4.0/platform_auth/repositories/base.py +8 -0
  14. platform_auth-0.4.0/platform_auth/repositories/entitlements.py +38 -0
  15. platform_auth-0.4.0/platform_auth/repositories/invitations.py +96 -0
  16. platform_auth-0.4.0/platform_auth/repositories/memberships.py +88 -0
  17. platform_auth-0.4.0/platform_auth/repositories/overrides.py +43 -0
  18. platform_auth-0.4.0/platform_auth/repositories/payments.py +77 -0
  19. platform_auth-0.4.0/platform_auth/repositories/plans.py +116 -0
  20. platform_auth-0.4.0/platform_auth/repositories/quotas.py +39 -0
  21. platform_auth-0.4.0/platform_auth/repositories/settings.py +82 -0
  22. platform_auth-0.4.0/platform_auth/repositories/subscriptions.py +110 -0
  23. platform_auth-0.4.0/platform_auth/repositories/tenants.py +129 -0
  24. platform_auth-0.4.0/platform_auth/repositories/usage.py +134 -0
  25. platform_auth-0.4.0/platform_auth/repositories/usage_adjustments.py +62 -0
  26. platform_auth-0.4.0/platform_auth/repositories/users.py +147 -0
  27. platform_auth-0.4.0/platform_auth/services/__init__.py +0 -0
  28. platform_auth-0.4.0/platform_auth/services/access_service.py +177 -0
  29. platform_auth-0.4.0/platform_auth/services/factory.py +34 -0
  30. platform_auth-0.4.0/platform_auth/services/quota_service.py +86 -0
  31. platform_auth-0.4.0/platform_auth/services/session_service.py +44 -0
  32. platform_auth-0.4.0/platform_auth/services/user_provisioning_service.py +33 -0
  33. platform_auth-0.4.0/platform_auth.egg-info/PKG-INFO +9 -0
  34. platform_auth-0.4.0/platform_auth.egg-info/SOURCES.txt +37 -0
  35. platform_auth-0.4.0/platform_auth.egg-info/dependency_links.txt +1 -0
  36. platform_auth-0.4.0/platform_auth.egg-info/requires.txt +4 -0
  37. platform_auth-0.4.0/platform_auth.egg-info/top_level.txt +1 -0
  38. platform_auth-0.4.0/pyproject.toml +32 -0
  39. platform_auth-0.4.0/setup.cfg +4 -0
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: platform-auth
3
+ Version: 0.4.0
4
+ Summary: Shared SaaS platform authentication and authorization helpers
5
+ Requires-Python: <3.14,>=3.12
6
+ Requires-Dist: boto3==1.34.162
7
+ Requires-Dist: botocore==1.34.162
8
+ Requires-Dist: fastapi==0.115.6
9
+ Requires-Dist: pydantic==2.10.4
@@ -0,0 +1,162 @@
1
+ # platform-auth-service
2
+
3
+ Shared SaaS platform authentication and authorization service/library.
4
+
5
+ This project owns platform-level concepts:
6
+
7
+ - tenants
8
+ - memberships
9
+ - roles and permissions
10
+ - plans
11
+ - entitlements
12
+ - quotas
13
+ - usage counters
14
+ - tenant subscriptions
15
+ - manual payment records
16
+ - usage adjustments
17
+ - invitations
18
+ - tenant settings
19
+ - logout/session revocation support
20
+ - audit logs
21
+ - reusable `platform_auth` Python package
22
+
23
+ It does not own product-specific logic such as CVE ingestion, darkweb monitoring, IOC processing, or credential-leak detection.
24
+
25
+ ## Current deployment model
26
+
27
+ Recommended production mode:
28
+
29
+ ```text
30
+ Existing Cognito handles signup/login/refresh.
31
+ API Gateway validates JWT.
32
+ platform-auth-service resolves the user from Users table.
33
+ platform-auth-service enforces tenant/membership/role/plan/quota checks.
34
+ ```
35
+
36
+ ## Local quick start
37
+
38
+ ```bash
39
+ make install
40
+ cp .env.example .env
41
+ make test
42
+ make local-up
43
+ make local-init
44
+ make local-seed-plans
45
+ make local-api
46
+ ```
47
+
48
+ In another terminal, run the local API smoke test:
49
+
50
+ ```bash
51
+ make smoke-api
52
+ ```
53
+
54
+ `smoke-api` is a local-only integration check. It expects
55
+ `AUTH_LOCAL_DEV_MODE=true`, calls `http://localhost:8000` by default, and uses
56
+ mock auth headers such as `X-User-Id`. It is not a Cognito/JWT smoke test for
57
+ AWS deployments.
58
+
59
+ Test public API:
60
+
61
+ ```bash
62
+ curl http://localhost:8000/health
63
+ curl http://localhost:8000/api/plans
64
+ ```
65
+
66
+ Create tenant in local mock-auth mode:
67
+
68
+ ```bash
69
+ curl -X POST http://localhost:8000/api/tenants \
70
+ -H "Content-Type: application/json" \
71
+ -H "X-User-Id: user-1" \
72
+ -d '{"name":"Acme Corp","planId":"trial"}'
73
+ ```
74
+
75
+ Use returned tenant ID:
76
+
77
+ ```bash
78
+ curl http://localhost:8000/api/tenants/<tenantId> \
79
+ -H "X-User-Id: user-1" \
80
+ -H "X-Tenant-Id: <tenantId>"
81
+ ```
82
+
83
+ Local admin/operator request:
84
+
85
+ ```bash
86
+ curl http://localhost:8000/api/admin/plans \
87
+ -H "X-User-Id: operator-1" \
88
+ -H "X-Platform-Role: operator"
89
+ ```
90
+
91
+ ## AWS deployment
92
+
93
+ See:
94
+
95
+ - `docs/deployment.md`
96
+ - `docs/users-table.md`
97
+ - `docs/tables.md`
98
+ - `docs/apis.md`
99
+ - `docs/library-usage.md`
100
+ - `docs/platform-auth-library.md`
101
+ - `docs/swagger.md`
102
+
103
+ ## Packaging the library
104
+
105
+ The installable package name is:
106
+
107
+ ```text
108
+ platform-auth
109
+ ```
110
+
111
+ Import path:
112
+
113
+ ```python
114
+ import platform_auth
115
+ ```
116
+
117
+ Early-stage Git dependency example:
118
+
119
+ ```text
120
+ platform-auth @ git+ssh://git@github.com/your-org/platform-auth-service.git@<tag-or-sha>
121
+ ```
122
+
123
+
124
+ ## Swagger / OpenAPI
125
+
126
+ When the local API is running:
127
+
128
+ ```text
129
+ Swagger UI: http://localhost:8000/docs
130
+ ReDoc: http://localhost:8000/redoc
131
+ OpenAPI JSON: http://localhost:8000/openapi.json
132
+ ```
133
+
134
+ Export the OpenAPI document:
135
+
136
+ ```bash
137
+ make export-openapi
138
+ ```
139
+
140
+ ## Cognito Post Confirmation user provisioning
141
+
142
+ This version supports the preferred production path for creating application users:
143
+
144
+ ```text
145
+ Cognito signup + email confirmation -> Post Confirmation Lambda -> Users DynamoDB table
146
+ ```
147
+
148
+ If deploying with an existing Cognito User Pool, deploy with `createUsersTable=true`, then attach the trigger:
149
+
150
+ ```bash
151
+ make attach-post-confirmation
152
+ ```
153
+
154
+ Configure these in `.env` first:
155
+
156
+ ```text
157
+ COGNITO_USER_POOL_ID=<existing-user-pool-id>
158
+ POST_CONFIRMATION_LAMBDA_ARN=<output-from-cdk>
159
+ AWS_REGION=<region>
160
+ ```
161
+
162
+ See `docs/cognito-post-confirmation.md` and `docs/users-table.md` for details.
@@ -0,0 +1,5 @@
1
+ from platform_auth.models import AuthContext
2
+
3
+ __version__ = '0.2.0'
4
+
5
+ __all__ = ['AuthContext']
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def env(name: str, default: str | None = None) -> str:
7
+ value = os.getenv(name, default)
8
+ if value is None:
9
+ raise RuntimeError(f"Missing required environment variable: {name}")
10
+ return value
11
+
12
+
13
+ def optional_env(name: str) -> str | None:
14
+ return os.getenv(name)
15
+
16
+
17
+ def local_dev_mode() -> bool:
18
+ return os.getenv("AUTH_LOCAL_DEV_MODE", "false").lower() == "true"
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from functools import lru_cache
5
+
6
+ import boto3
7
+ from boto3.resources.base import ServiceResource
8
+ from botocore.client import BaseClient
9
+
10
+
11
+ def _common_kwargs() -> dict:
12
+ endpoint_url = os.getenv("AWS_ENDPOINT_URL")
13
+ kwargs = {
14
+ "region_name": os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1")),
15
+ "endpoint_url": endpoint_url,
16
+ }
17
+ if endpoint_url:
18
+ kwargs.update(
19
+ aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID", "test"),
20
+ aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY", "test"),
21
+ aws_session_token=os.getenv("AWS_SESSION_TOKEN", "test"),
22
+ )
23
+ return kwargs
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def dynamodb_resource() -> ServiceResource:
28
+ return boto3.resource("dynamodb", **_common_kwargs())
29
+
30
+
31
+ @lru_cache(maxsize=1)
32
+ def dynamodb_client() -> BaseClient:
33
+ return boto3.client("dynamodb", **_common_kwargs())
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class PlatformAuthError(Exception):
9
+ code: str
10
+ message: str
11
+ status_code: int = 403
12
+ details: dict[str, Any] | None = None
13
+
14
+ def to_response(self) -> dict[str, Any]:
15
+ payload: dict[str, Any] = {'error': self.code, 'message': self.message}
16
+ if self.details:
17
+ payload['details'] = self.details
18
+ return payload
19
+
20
+
21
+ class UnauthorizedError(PlatformAuthError):
22
+ def __init__(self, message: str = 'Authentication required') -> None:
23
+ super().__init__('UNAUTHORIZED', message, 401)
24
+
25
+
26
+ class TenantRequiredError(PlatformAuthError):
27
+ def __init__(self) -> None:
28
+ super().__init__('TENANT_REQUIRED', 'Tenant context is required', 400)
29
+
30
+
31
+ class TenantForbiddenError(PlatformAuthError):
32
+ def __init__(self, message: str = 'Tenant access is forbidden') -> None:
33
+ super().__init__('TENANT_FORBIDDEN', message, 403)
34
+
35
+
36
+ class PermissionDeniedError(PlatformAuthError):
37
+ def __init__(self, permission: str) -> None:
38
+ super().__init__(
39
+ 'PERMISSION_DENIED',
40
+ 'User does not have the required permission',
41
+ 403,
42
+ {'permission': permission},
43
+ )
44
+
45
+
46
+ class FeatureNotIncludedError(PlatformAuthError):
47
+ def __init__(self, feature: str) -> None:
48
+ super().__init__(
49
+ 'FEATURE_NOT_INCLUDED_IN_PLAN',
50
+ 'Feature is not included in the tenant plan',
51
+ 403,
52
+ {'feature': feature},
53
+ )
54
+
55
+
56
+ class SubscriptionInactiveError(PlatformAuthError):
57
+ def __init__(self, subscription_status: str) -> None:
58
+ super().__init__(
59
+ 'SUBSCRIPTION_INACTIVE',
60
+ 'Tenant subscription is not active',
61
+ 403,
62
+ {'subscriptionStatus': subscription_status},
63
+ )
64
+
65
+
66
+ class QuotaExceededError(PlatformAuthError):
67
+ def __init__(self, metric_key: str, current: int, limit: int) -> None:
68
+ super().__init__(
69
+ 'QUOTA_EXCEEDED',
70
+ 'Quota exceeded',
71
+ 429,
72
+ {'metric': metric_key, 'current': current, 'limit': limit},
73
+ )
74
+
75
+
76
+ class ValidationError(PlatformAuthError):
77
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
78
+ super().__init__(code, message, 400, details)
79
+
80
+
81
+ class AuthConfigurationError(PlatformAuthError):
82
+ def __init__(self, message: str) -> None:
83
+ super().__init__('AUTH_CONFIGURATION_ERROR', message, 500)
File without changes
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from fastapi import Depends, Header, HTTPException, Request
7
+
8
+ from platform_auth.config import local_dev_mode
9
+ from platform_auth.errors import PlatformAuthError, UnauthorizedError
10
+ from platform_auth.models import AuthContext
11
+ from platform_auth.services.factory import access_service
12
+
13
+
14
+ def _claims_from_request(request: Request) -> dict[str, Any]:
15
+ event = request.scope.get("aws.event") or {}
16
+ claims = (
17
+ event.get("requestContext", {})
18
+ .get("authorizer", {})
19
+ .get("jwt", {})
20
+ .get("claims", {})
21
+ )
22
+ if claims:
23
+ return dict(claims)
24
+
25
+ if local_dev_mode():
26
+ user_id = request.headers.get("x-user-id")
27
+ if user_id:
28
+ return {
29
+ "sub": user_id,
30
+ "userId": user_id,
31
+ "email": request.headers.get("x-user-email", f"{user_id}@local.test"),
32
+ "platformRole": request.headers.get("x-platform-role"),
33
+ "userStatus": request.headers.get("x-user-status", "active"),
34
+ }
35
+
36
+ raise UnauthorizedError()
37
+
38
+
39
+ def require_access(
40
+ *,
41
+ permission: str | None = None,
42
+ feature: str | None = None,
43
+ quota: str | None = None,
44
+ quota_amount: int = 1,
45
+ platform_admin_required: bool = False,
46
+ ) -> Callable[..., AuthContext]:
47
+ def dependency(request: Request, x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id")) -> AuthContext:
48
+ try:
49
+ claims = _claims_from_request(request)
50
+ return access_service().require_access(
51
+ claims=claims,
52
+ tenant_id=x_tenant_id,
53
+ permission=permission,
54
+ feature=feature,
55
+ quota=quota,
56
+ quota_amount=quota_amount,
57
+ platform_admin_required=platform_admin_required,
58
+ )
59
+ except PlatformAuthError as exc:
60
+ raise HTTPException(status_code=exc.status_code, detail=exc.to_response()) from exc
61
+ return dependency
62
+
63
+
64
+ def require_authenticated_user(request: Request) -> AuthContext:
65
+ try:
66
+ claims = _claims_from_request(request)
67
+ return access_service().require_authenticated_user(claims=claims)
68
+ except PlatformAuthError as exc:
69
+ raise HTTPException(status_code=exc.status_code, detail=exc.to_response()) from exc
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Literal
6
+
7
+ TenantStatus = Literal["pending", "active", "suspended", "deleted"]
8
+ SubscriptionStatus = Literal["trialing", "active", "past_due", "suspended", "canceled", "expired"]
9
+ MembershipStatus = Literal["active", "inactive", "invited"]
10
+ PlanStatus = Literal["draft", "active", "deprecated", "retired", "disabled"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AuthContext:
15
+ user_id: str
16
+ tenant_id: str | None
17
+ role: str | None = None
18
+ platform_role: str | None = None
19
+ plan_id: str | None = None
20
+ subscription_status: str | None = None
21
+ permissions: list[str] = field(default_factory=list)
22
+ request_id: str | None = None
23
+ claims: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Tenant:
28
+ tenant_id: str
29
+ name: str
30
+ status: str
31
+ plan_id: str
32
+ subscription_status: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Membership:
37
+ tenant_id: str
38
+ user_id: str
39
+ role: str
40
+ status: str
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Plan:
45
+ plan_id: str
46
+ name: str
47
+ status: str
48
+ visibility: str = "public"
49
+ billing_mode: str = "manual"
50
+ currency: str = "USD"
51
+ monthly_price: int | None = None
52
+ annual_price: int | None = None
53
+ trial_days: int | None = None
54
+ description: str | None = None
55
+ created_at: str | None = None
56
+ updated_at: str | None = None
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class EntitlementSet:
61
+ plan_id: str
62
+ features: dict[str, bool]
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class QuotaSet:
67
+ plan_id: str
68
+ limits: dict[str, int]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class TenantOverride:
73
+ tenant_id: str
74
+ feature_overrides: dict[str, bool] = field(default_factory=dict)
75
+ quota_overrides: dict[str, int] = field(default_factory=dict)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class UsageCounter:
80
+ tenant_id: str
81
+ period: str
82
+ metric_key: str
83
+ value: int
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class TenantSubscription:
88
+ tenant_id: str
89
+ plan_id: str
90
+ status: str
91
+ billing_mode: str = "manual"
92
+ billing_cycle: str = "monthly"
93
+ currency: str = "USD"
94
+ price: int | None = None
95
+ current_period_start: str | None = None
96
+ current_period_end: str | None = None
97
+ trial_ends_at: str | None = None
98
+ payment_status: str = "pending"
99
+ last_payment_at: str | None = None
100
+ next_payment_due_at: str | None = None
101
+ updated_at: str | None = None
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class PaymentRecord:
106
+ payment_id: str
107
+ tenant_id: str
108
+ amount: int
109
+ currency: str
110
+ method: str
111
+ status: str
112
+ reference: str | None = None
113
+ paid_at: str | None = None
114
+ notes: str | None = None
115
+ created_by: str | None = None
116
+ created_at: str | None = None
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class UsageAdjustment:
121
+ adjustment_id: str
122
+ tenant_id: str
123
+ period: str
124
+ metric_key: str
125
+ delta: int
126
+ reason: str
127
+ created_by: str
128
+ created_at: str
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class AuditEvent:
133
+ tenant_id: str | None
134
+ user_id: str | None
135
+ action: str
136
+ resource_type: str | None = None
137
+ resource_id: str | None = None
138
+ metadata: dict[str, Any] = field(default_factory=dict)
139
+ created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ ROLE_PERMISSIONS: dict[str, set[str]] = {
4
+ 'owner': {'*'},
5
+ 'admin': {
6
+ 'tenant:read',
7
+ 'tenant:update',
8
+ 'tenant.settings:read',
9
+ 'tenant.settings:update',
10
+ 'tenant.members:read',
11
+ 'tenant.members:manage',
12
+ 'tenant.invitations:read',
13
+ 'tenant.invitations:manage',
14
+ 'tenant.usage:read',
15
+ 'tenant.billing:read',
16
+ 'tenant.audit:read',
17
+ },
18
+ 'member': {
19
+ 'tenant:read',
20
+ 'tenant.settings:read',
21
+ 'tenant.usage:read',
22
+ },
23
+ 'viewer': {
24
+ 'tenant:read',
25
+ 'tenant.settings:read',
26
+ 'tenant.usage:read',
27
+ },
28
+ }
29
+
30
+ PLATFORM_ROLE_PERMISSIONS: dict[str, set[str]] = {
31
+ 'support': {'admin.tenants:read', 'admin.plans:read', 'admin.audit:read'},
32
+ 'operator': {
33
+ 'admin.tenants:read',
34
+ 'admin.tenants:create',
35
+ 'admin.tenants:update',
36
+ 'admin.tenants:manage',
37
+ 'admin.plans:read',
38
+ 'admin.plans:create',
39
+ 'admin.plans:update',
40
+ 'admin.plans:manage',
41
+ 'admin.audit:read',
42
+ },
43
+ 'super_admin': {'*'},
44
+ }
45
+
46
+
47
+ def has_permission(role: str | None, permission: str, *, platform_role: str | None = None) -> bool:
48
+ if platform_role:
49
+ platform_permissions = PLATFORM_ROLE_PERMISSIONS.get(platform_role, set())
50
+ if '*' in platform_permissions or permission in platform_permissions:
51
+ return True
52
+
53
+ if not role:
54
+ return False
55
+
56
+ permissions = ROLE_PERMISSIONS.get(role, set())
57
+ return '*' in permissions or permission in permissions
58
+
59
+
60
+ def permissions_for_role(role: str | None, *, platform_role: str | None = None) -> list[str]:
61
+ result: set[str] = set()
62
+ if role:
63
+ result.update(ROLE_PERMISSIONS.get(role, set()))
64
+ if platform_role:
65
+ result.update(PLATFORM_ROLE_PERMISSIONS.get(platform_role, set()))
66
+ return sorted(result)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from uuid import uuid4
5
+
6
+ from boto3.dynamodb.conditions import Key
7
+
8
+ from platform_auth.models import AuditEvent
9
+ from platform_auth.repositories.base import DynamoRepository
10
+
11
+
12
+ class AuditRepository(DynamoRepository):
13
+ def __init__(self, table_name: str | None = None) -> None:
14
+ super().__init__(table_name or os.environ['AUDIT_LOG_TABLE_NAME'])
15
+
16
+ def write(self, event: AuditEvent) -> None:
17
+ event_id = str(uuid4())
18
+ pk = f'TENANT#{event.tenant_id}' if event.tenant_id else 'PLATFORM'
19
+ self.table.put_item(
20
+ Item={
21
+ 'PK': pk,
22
+ 'SK': f'AUDIT#{event.created_at}#{event_id}',
23
+ 'tenantId': event.tenant_id,
24
+ 'userId': event.user_id,
25
+ 'action': event.action,
26
+ 'resourceType': event.resource_type,
27
+ 'resourceId': event.resource_id,
28
+ 'metadata': event.metadata,
29
+ 'createdAt': event.created_at,
30
+ }
31
+ )
32
+
33
+ def list_for_tenant(self, tenant_id: str, limit: int = 50) -> list[dict]:
34
+ res = self.table.query(
35
+ KeyConditionExpression=Key('PK').eq(f'TENANT#{tenant_id}'),
36
+ ScanIndexForward=False,
37
+ Limit=limit,
38
+ )
39
+ return list(res.get('Items', []))
40
+
41
+ def list_platform(self, limit: int = 50) -> list[dict]:
42
+ res = self.table.query(
43
+ KeyConditionExpression=Key('PK').eq('PLATFORM'),
44
+ ScanIndexForward=False,
45
+ Limit=limit,
46
+ )
47
+ return list(res.get('Items', []))
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from platform_auth.dynamodb import dynamodb_resource
4
+
5
+
6
+ class DynamoRepository:
7
+ def __init__(self, table_name: str) -> None:
8
+ self.table = dynamodb_resource().Table(table_name)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from datetime import UTC, datetime
5
+
6
+ from platform_auth.models import EntitlementSet
7
+ from platform_auth.repositories.base import DynamoRepository
8
+
9
+
10
+ def _now() -> str:
11
+ return datetime.now(UTC).isoformat().replace('+00:00', 'Z')
12
+
13
+
14
+ class EntitlementRepository(DynamoRepository):
15
+ def __init__(self, table_name: str | None = None) -> None:
16
+ super().__init__(table_name or os.environ["ENTITLEMENTS_TABLE_NAME"])
17
+
18
+ def get(self, plan_id: str) -> EntitlementSet:
19
+ res = self.table.get_item(Key={"PK": f"PLAN#{plan_id}", "SK": "ENTITLEMENTS"})
20
+ item = res.get("Item") or {"features": {}}
21
+ return EntitlementSet(plan_id=plan_id, features=dict(item.get("features", {})))
22
+
23
+ def put(self, plan_id: str, features: dict[str, bool]) -> None:
24
+ self.table.put_item(
25
+ Item={
26
+ "PK": f"PLAN#{plan_id}",
27
+ "SK": "ENTITLEMENTS",
28
+ "planId": plan_id,
29
+ "features": features,
30
+ "updatedAt": _now(),
31
+ }
32
+ )
33
+
34
+ def patch(self, plan_id: str, features: dict[str, bool]) -> EntitlementSet:
35
+ current = self.get(plan_id).features
36
+ current.update(features)
37
+ self.put(plan_id, current)
38
+ return EntitlementSet(plan_id=plan_id, features=current)