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.
- platform_auth-0.4.0/PKG-INFO +9 -0
- platform_auth-0.4.0/README.md +162 -0
- platform_auth-0.4.0/platform_auth/__init__.py +5 -0
- platform_auth-0.4.0/platform_auth/config.py +18 -0
- platform_auth-0.4.0/platform_auth/dynamodb.py +33 -0
- platform_auth-0.4.0/platform_auth/errors.py +83 -0
- platform_auth-0.4.0/platform_auth/fastapi/__init__.py +0 -0
- platform_auth-0.4.0/platform_auth/fastapi/dependencies.py +69 -0
- platform_auth-0.4.0/platform_auth/models.py +139 -0
- platform_auth-0.4.0/platform_auth/permissions.py +66 -0
- platform_auth-0.4.0/platform_auth/repositories/__init__.py +0 -0
- platform_auth-0.4.0/platform_auth/repositories/audit.py +47 -0
- platform_auth-0.4.0/platform_auth/repositories/base.py +8 -0
- platform_auth-0.4.0/platform_auth/repositories/entitlements.py +38 -0
- platform_auth-0.4.0/platform_auth/repositories/invitations.py +96 -0
- platform_auth-0.4.0/platform_auth/repositories/memberships.py +88 -0
- platform_auth-0.4.0/platform_auth/repositories/overrides.py +43 -0
- platform_auth-0.4.0/platform_auth/repositories/payments.py +77 -0
- platform_auth-0.4.0/platform_auth/repositories/plans.py +116 -0
- platform_auth-0.4.0/platform_auth/repositories/quotas.py +39 -0
- platform_auth-0.4.0/platform_auth/repositories/settings.py +82 -0
- platform_auth-0.4.0/platform_auth/repositories/subscriptions.py +110 -0
- platform_auth-0.4.0/platform_auth/repositories/tenants.py +129 -0
- platform_auth-0.4.0/platform_auth/repositories/usage.py +134 -0
- platform_auth-0.4.0/platform_auth/repositories/usage_adjustments.py +62 -0
- platform_auth-0.4.0/platform_auth/repositories/users.py +147 -0
- platform_auth-0.4.0/platform_auth/services/__init__.py +0 -0
- platform_auth-0.4.0/platform_auth/services/access_service.py +177 -0
- platform_auth-0.4.0/platform_auth/services/factory.py +34 -0
- platform_auth-0.4.0/platform_auth/services/quota_service.py +86 -0
- platform_auth-0.4.0/platform_auth/services/session_service.py +44 -0
- platform_auth-0.4.0/platform_auth/services/user_provisioning_service.py +33 -0
- platform_auth-0.4.0/platform_auth.egg-info/PKG-INFO +9 -0
- platform_auth-0.4.0/platform_auth.egg-info/SOURCES.txt +37 -0
- platform_auth-0.4.0/platform_auth.egg-info/dependency_links.txt +1 -0
- platform_auth-0.4.0/platform_auth.egg-info/requires.txt +4 -0
- platform_auth-0.4.0/platform_auth.egg-info/top_level.txt +1 -0
- platform_auth-0.4.0/pyproject.toml +32 -0
- 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,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)
|
|
File without changes
|
|
@@ -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,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)
|