tba-app-backend-platform-administration 0.4.0.dev2__py3-none-any.whl

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,33 @@
1
+ """Platform admin backend application package for TBA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import PRODUCT_SURFACE, app, build_app
6
+ from .contract import (
7
+ PLATFORM_ADMIN_BACKEND_APP_CONTRACT,
8
+ PlatformAdminBackendAppContract,
9
+ )
10
+ from .identities import (
11
+ AdminIdentityOut,
12
+ AdminIdentityProvisionIn,
13
+ AdminIdentityUpdateIn,
14
+ )
15
+ from .realms import AdminRealmOut, AdminRealmProvisionIn, AdminRealmUpdateIn
16
+ from .tenants import AdminTenantOut, AdminTenantProvisionIn, AdminTenantUpdateIn
17
+
18
+ __all__ = [
19
+ "AdminIdentityOut",
20
+ "AdminIdentityProvisionIn",
21
+ "AdminIdentityUpdateIn",
22
+ "AdminRealmOut",
23
+ "AdminRealmProvisionIn",
24
+ "AdminRealmUpdateIn",
25
+ "AdminTenantOut",
26
+ "AdminTenantProvisionIn",
27
+ "AdminTenantUpdateIn",
28
+ "PRODUCT_SURFACE",
29
+ "PLATFORM_ADMIN_BACKEND_APP_CONTRACT",
30
+ "PlatformAdminBackendAppContract",
31
+ "app",
32
+ "build_app",
33
+ ]
@@ -0,0 +1,138 @@
1
+ """ASGI entrypoint for the platform admin TBA backend application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
7
+
8
+ from tba_app_backend_core import build_product_app
9
+ from tba_app_backend_platform_administration.contract import (
10
+ PLATFORM_ADMIN_BACKEND_APP_CONTRACT,
11
+ )
12
+ from tba_auth.runtime import LazyASGIApplication
13
+ from tba_app_backend_platform_administration.identities import router as identity_router
14
+ from tba_app_backend_platform_administration.realms import router as realm_router
15
+ from tba_app_backend_platform_administration.tenants import router as tenant_router
16
+
17
+ if TYPE_CHECKING:
18
+ from tigrbl import TigrblApp
19
+
20
+ PRODUCT_SURFACE = "platform-admin-app"
21
+ DEFAULT_CORS_ORIGINS = ("http://localhost:3011", "http://127.0.0.1:3011")
22
+
23
+
24
+ class PlatformAdminCors:
25
+ """Small ASGI CORS wrapper for the standalone platform admin UIX demo."""
26
+
27
+ def __init__(
28
+ self, app: Any, origins: tuple[str, ...] = DEFAULT_CORS_ORIGINS
29
+ ) -> None:
30
+ self.app = app
31
+ self.origins = origins
32
+
33
+ def __getattr__(self, name: str) -> Any:
34
+ return getattr(self.app, name)
35
+
36
+ async def __call__(
37
+ self,
38
+ scope: dict[str, Any],
39
+ receive: Callable[[], Awaitable[dict[str, Any]]],
40
+ send: Callable[[dict[str, Any]], Awaitable[None]],
41
+ ) -> None:
42
+ if scope.get("type") != "http":
43
+ await self.app(scope, receive, send)
44
+ return
45
+
46
+ headers = {
47
+ key.decode("latin-1").lower(): value.decode("latin-1")
48
+ for key, value in scope.get("headers", [])
49
+ }
50
+ origin = headers.get("origin")
51
+ allowed_origin = origin if origin in self.origins else None
52
+
53
+ if str(scope.get("method", "")).upper() == "OPTIONS" and allowed_origin:
54
+ await send(
55
+ {
56
+ "type": "http.response.start",
57
+ "status": 204,
58
+ "headers": self._cors_headers(allowed_origin)
59
+ + [
60
+ (
61
+ b"access-control-allow-methods",
62
+ b"GET,POST,PATCH,DELETE,OPTIONS",
63
+ ),
64
+ (
65
+ b"access-control-allow-headers",
66
+ headers.get(
67
+ "access-control-request-headers",
68
+ "content-type,authorization,x-api-key",
69
+ ).encode("latin-1"),
70
+ ),
71
+ (b"content-length", b"0"),
72
+ ],
73
+ }
74
+ )
75
+ await send({"type": "http.response.body", "body": b""})
76
+ return
77
+
78
+ async def cors_send(message: dict[str, Any]) -> None:
79
+ if message.get("type") == "http.response.start" and allowed_origin:
80
+ message = dict(message)
81
+ response_headers = list(message.get("headers", []))
82
+ response_headers.extend(self._cors_headers(allowed_origin))
83
+ message["headers"] = response_headers
84
+ await send(message)
85
+
86
+ await self.app(scope, receive, cors_send)
87
+
88
+ @staticmethod
89
+ def _cors_headers(origin: str) -> list[tuple[bytes, bytes]]:
90
+ return [
91
+ (b"access-control-allow-origin", origin.encode("latin-1")),
92
+ (b"access-control-allow-credentials", b"true"),
93
+ (b"vary", b"Origin"),
94
+ ]
95
+
96
+
97
+ def _default_settings() -> object:
98
+ from tba_auth.config.settings import Settings
99
+
100
+ settings = Settings()
101
+ env_overrides = {
102
+ "issuer": os.environ.get("AUTHN_ISSUER"),
103
+ "protected_resource_identifier": os.environ.get(
104
+ "TBA_AUTH_PROTECTED_RESOURCE_IDENTIFIER"
105
+ ),
106
+ "deployment_profile": os.environ.get("TBA_AUTH_PROFILE"),
107
+ "require_tls": os.environ.get("TBA_AUTH_REQUIRE_TLS"),
108
+ "admin_api_key": os.environ.get("TBA_AUTH_ADMIN_API_KEY"),
109
+ "admin_api_key_dir": os.environ.get("TBA_AUTH_ADMIN_API_KEY_DIR"),
110
+ }
111
+ for name, value in env_overrides.items():
112
+ if value in {None, ""}:
113
+ continue
114
+ current = getattr(settings, name, None)
115
+ if isinstance(current, bool):
116
+ value = str(value).lower() in {"1", "true", "yes", "on"}
117
+ setattr(settings, name, value)
118
+ return settings
119
+
120
+
121
+ def build_app(settings_obj: object | None = None) -> "TigrblApp":
122
+ if settings_obj is None:
123
+ settings_obj = _default_settings()
124
+ app = build_product_app(
125
+ PRODUCT_SURFACE, settings_obj, contract=PLATFORM_ADMIN_BACKEND_APP_CONTRACT
126
+ )
127
+ app.include_router(tenant_router)
128
+ app.include_router(realm_router)
129
+ app.include_router(identity_router)
130
+ app.admin_path_prefixes = tuple(
131
+ dict.fromkeys((*app.admin_path_prefixes, "/admin/identities"))
132
+ )
133
+ return PlatformAdminCors(app)
134
+
135
+
136
+ app = LazyASGIApplication(build_app)
137
+
138
+ __all__ = ["PRODUCT_SURFACE", "app", "build_app"]
@@ -0,0 +1,82 @@
1
+ """Platform-admin backend-app contract for TBA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class PlatformAdminBackendAppContract:
10
+ """Machine-readable boundary for the platform control-plane backend app."""
11
+
12
+ product_surface: str
13
+ intended_uix: str
14
+ admin_resources: tuple[str, ...]
15
+ admin_rest_groups: tuple[str, ...]
16
+ forbidden_route_prefixes: tuple[str, ...]
17
+ forbidden_exact_routes: tuple[str, ...]
18
+ consumed_packages: tuple[str, ...]
19
+ mounted_router_packages: tuple[str, ...]
20
+ app_local_route_groups: tuple[str, ...]
21
+
22
+
23
+ PLATFORM_ADMIN_BACKEND_APP_CONTRACT = PlatformAdminBackendAppContract(
24
+ product_surface="platform-admin-app",
25
+ intended_uix="@tba/ui-platform-administration",
26
+ admin_resources=(
27
+ "Realm",
28
+ "Tenant",
29
+ "User",
30
+ "AuditEvent",
31
+ "CryptoKey",
32
+ "CryptoKeyVersion",
33
+ "PrincipalKeyBinding",
34
+ "KeyEnvelope",
35
+ "KeyAttestationEvidence",
36
+ "KeyRotationEvent",
37
+ ),
38
+ admin_rest_groups=("admin_auth", "admin_realms", "admin_identities"),
39
+ forbidden_route_prefixes=(
40
+ "/client",
41
+ "/clientregistration",
42
+ "/consent",
43
+ "/serviceidentity",
44
+ "/credentialservicekey",
45
+ "/credentialapikey",
46
+ "/tokenrecord",
47
+ "/revokedtoken",
48
+ "/authsession",
49
+ "/tenant",
50
+ "/user",
51
+ ),
52
+ forbidden_exact_routes=(
53
+ "/login",
54
+ "/authorize",
55
+ "/token",
56
+ "/register",
57
+ "/logout",
58
+ "/revoke",
59
+ "/userinfo",
60
+ "/introspect",
61
+ "/rpc",
62
+ "/openrpc.json",
63
+ "/.well-known/jwks.json",
64
+ "/.well-known/openid-configuration",
65
+ ),
66
+ consumed_packages=(
67
+ "tba-auth",
68
+ "tba-server-identity",
69
+ "tba-runtime-identity",
70
+ "tba-runtime-identity-administration",
71
+ "tba-concrete-identity-principals",
72
+ "tba-runtime-authorization-policy",
73
+ ),
74
+ mounted_router_packages=("tba-router-administration-gate",),
75
+ app_local_route_groups=(
76
+ "realms",
77
+ "tenants",
78
+ "identities",
79
+ ),
80
+ )
81
+
82
+ __all__ = ["PLATFORM_ADMIN_BACKEND_APP_CONTRACT", "PlatformAdminBackendAppContract"]
@@ -0,0 +1,213 @@
1
+ """Platform identity-administration HTTP routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field, constr
8
+ from tigrbl import TigrblRouter
9
+ from tigrbl.requests import Request
10
+ from tigrbl.runtime.status import HTTPException
11
+ from tigrbl.security import Depends
12
+ from tba_contracts_identity.admin_identities import (
13
+ AdminIdentity,
14
+ AdminIdentityCreate,
15
+ AdminIdentityUpdate,
16
+ IdentityAdministrationConflictError,
17
+ IdentityAdministrationNotFoundError,
18
+ IdentityAdministrationPolicyError,
19
+ IdentityAdministrationValidationError,
20
+ )
21
+ from tba_contracts_identity.admin_tenants import (
22
+ TenantAdministratorAuthenticationError,
23
+ )
24
+ from tba_server_identity.platform_identity_administration import (
25
+ get_db,
26
+ identity_administration_for_request,
27
+ require_identity_administrator,
28
+ )
29
+ from tba_router_administration_gate import ADMIN_OPENAPI_SECURITY_DEPENDENCIES
30
+
31
+
32
+ _email = constr(strip_whitespace=True, min_length=3, max_length=120)
33
+ _password = constr(min_length=8, max_length=256)
34
+ _username = constr(strip_whitespace=True, min_length=3, max_length=80)
35
+
36
+
37
+ class AdminIdentityOut(BaseModel):
38
+ id: str
39
+ tenant_id: str
40
+ username: str
41
+ email: str
42
+ is_active: bool = True
43
+ is_admin: bool = False
44
+ is_superuser: bool = False
45
+ must_change_password: bool = False
46
+ roles: list[str] = Field(default_factory=list)
47
+ created_at: str | None = None
48
+ updated_at: str | None = None
49
+
50
+
51
+ class AdminIdentityProvisionIn(BaseModel):
52
+ tenant_id: str
53
+ username: _username
54
+ email: _email
55
+ password: _password
56
+ is_admin: bool = False
57
+ is_superuser: bool = False
58
+ must_change_password: bool = True
59
+
60
+
61
+ class AdminIdentityUpdateIn(BaseModel):
62
+ username: _username | None = None
63
+ email: _email | None = None
64
+ password: _password | None = None
65
+ is_active: bool | None = None
66
+ is_admin: bool | None = None
67
+ is_superuser: bool | None = None
68
+ must_change_password: bool | None = None
69
+
70
+
71
+ def _identity_payload(row: AdminIdentity) -> AdminIdentityOut:
72
+ return AdminIdentityOut(
73
+ id=row.identity_id,
74
+ tenant_id=row.tenant_id,
75
+ username=row.username,
76
+ email=row.email,
77
+ is_active=row.is_active,
78
+ is_admin=row.is_admin,
79
+ is_superuser=row.is_superuser,
80
+ must_change_password=row.must_change_password,
81
+ roles=list(row.roles),
82
+ created_at=row.created_at,
83
+ updated_at=row.updated_at,
84
+ )
85
+
86
+
87
+ async def _invoke(
88
+ request: Request,
89
+ db: Any,
90
+ operation: str,
91
+ *args: object,
92
+ ) -> object:
93
+ try:
94
+ actor = await require_identity_administrator(request, db)
95
+ capability = identity_administration_for_request(request, db)
96
+ return (await capability.call(operation, actor, *args)).value
97
+ except TenantAdministratorAuthenticationError as exc:
98
+ raise HTTPException(401, str(exc)) from exc
99
+ except IdentityAdministrationPolicyError as exc:
100
+ raise HTTPException(403, str(exc)) from exc
101
+ except IdentityAdministrationNotFoundError as exc:
102
+ raise HTTPException(404, str(exc)) from exc
103
+ except IdentityAdministrationConflictError as exc:
104
+ raise HTTPException(409, str(exc)) from exc
105
+ except IdentityAdministrationValidationError as exc:
106
+ raise HTTPException(400, str(exc)) from exc
107
+
108
+
109
+ router = TigrblRouter()
110
+
111
+
112
+ @router.route(
113
+ "/admin/identities",
114
+ methods=["GET"],
115
+ response_model=list[AdminIdentityOut],
116
+ dependencies=list(ADMIN_OPENAPI_SECURITY_DEPENDENCIES),
117
+ )
118
+ async def admin_list_identities(
119
+ request: Request,
120
+ tenant_id: str | None = None,
121
+ db: Any = Depends(get_db),
122
+ ) -> list[AdminIdentityOut]:
123
+ rows = await _invoke(request, db, "list_identities", tenant_id)
124
+ return [_identity_payload(row) for row in rows]
125
+
126
+
127
+ @router.route(
128
+ "/admin/identities",
129
+ methods=["POST"],
130
+ response_model=AdminIdentityOut,
131
+ dependencies=list(ADMIN_OPENAPI_SECURITY_DEPENDENCIES),
132
+ )
133
+ async def admin_create_identity(
134
+ request: Request,
135
+ payload: AdminIdentityProvisionIn | None = None,
136
+ db: Any = Depends(get_db),
137
+ ) -> AdminIdentityOut:
138
+ if payload is None:
139
+ payload = AdminIdentityProvisionIn.model_validate(await request.json() or {})
140
+ row = await _invoke(
141
+ request,
142
+ db,
143
+ "create_identity",
144
+ AdminIdentityCreate(
145
+ tenant_id=payload.tenant_id,
146
+ username=payload.username,
147
+ email=str(payload.email),
148
+ password=payload.password,
149
+ is_admin=payload.is_admin,
150
+ is_superuser=payload.is_superuser,
151
+ must_change_password=payload.must_change_password,
152
+ ),
153
+ )
154
+ return _identity_payload(row)
155
+
156
+
157
+ @router.route(
158
+ "/admin/identities/{user_id}",
159
+ methods=["PATCH"],
160
+ response_model=AdminIdentityOut,
161
+ dependencies=list(ADMIN_OPENAPI_SECURITY_DEPENDENCIES),
162
+ )
163
+ async def admin_update_identity(
164
+ request: Request,
165
+ user_id: str,
166
+ payload: AdminIdentityUpdateIn | None = None,
167
+ db: Any = Depends(get_db),
168
+ ) -> AdminIdentityOut:
169
+ if payload is None:
170
+ payload = AdminIdentityUpdateIn.model_validate(await request.json() or {})
171
+ row = await _invoke(
172
+ request,
173
+ db,
174
+ "update_identity",
175
+ user_id,
176
+ AdminIdentityUpdate(
177
+ username=payload.username,
178
+ email=str(payload.email) if payload.email is not None else None,
179
+ password=payload.password,
180
+ is_active=payload.is_active,
181
+ is_admin=payload.is_admin,
182
+ is_superuser=payload.is_superuser,
183
+ must_change_password=payload.must_change_password,
184
+ ),
185
+ )
186
+ return _identity_payload(row)
187
+
188
+
189
+ @router.route(
190
+ "/admin/identities/{user_id}",
191
+ methods=["DELETE"],
192
+ response_model=AdminIdentityOut,
193
+ dependencies=list(ADMIN_OPENAPI_SECURITY_DEPENDENCIES),
194
+ )
195
+ async def admin_delete_identity(
196
+ request: Request,
197
+ user_id: str,
198
+ db: Any = Depends(get_db),
199
+ ) -> AdminIdentityOut:
200
+ row = await _invoke(request, db, "delete_identity", user_id)
201
+ return _identity_payload(row)
202
+
203
+
204
+ __all__ = [
205
+ "AdminIdentityOut",
206
+ "AdminIdentityProvisionIn",
207
+ "AdminIdentityUpdateIn",
208
+ "admin_create_identity",
209
+ "admin_delete_identity",
210
+ "admin_list_identities",
211
+ "admin_update_identity",
212
+ "router",
213
+ ]
File without changes
@@ -0,0 +1,267 @@
1
+ """Platform realm-administration HTTP routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, constr
8
+ from tigrbl import TigrblRouter
9
+ from tigrbl.requests import Request
10
+ from tigrbl.runtime.status import HTTPException
11
+ from tigrbl.security import Depends
12
+ from tba_router_administration_gate import ADMIN_OPENAPI_SECURITY_DEPENDENCIES
13
+ from tba_contracts_identity.admin_realms import (
14
+ AdminRealm,
15
+ AdminRealmCreate,
16
+ AdminRealmUpdate,
17
+ RealmAdministrationConflictError,
18
+ RealmAdministrationNotFoundError,
19
+ RealmAdministrationValidationError,
20
+ )
21
+ from tba_contracts_identity.admin_tenants import (
22
+ AdminTenantCreate,
23
+ TenantAdministrationConflictError,
24
+ TenantAdministrationNotFoundError,
25
+ TenantAdministrationPolicyError,
26
+ TenantAdministrationValidationError,
27
+ TenantAdministratorAuthenticationError,
28
+ )
29
+ from tba_server_identity.platform_realm_administration import (
30
+ get_db,
31
+ realm_administration_for_request,
32
+ require_realm_administrator,
33
+ )
34
+
35
+ from .tenants import AdminTenantOut, AdminTenantProvisionIn, tenant_payload
36
+
37
+
38
+ _description = constr(strip_whitespace=True, max_length=255)
39
+ _issuer_path_value = constr(strip_whitespace=True, max_length=255)
40
+ _name = constr(strip_whitespace=True, min_length=1, max_length=120)
41
+ _slug = constr(strip_whitespace=True, min_length=3, max_length=120)
42
+ _SECURITY = list(ADMIN_OPENAPI_SECURITY_DEPENDENCIES)
43
+
44
+
45
+ class AdminRealmOut(BaseModel):
46
+ id: str
47
+ slug: str
48
+ name: str
49
+ issuer_path: str = ""
50
+ description: str | None = None
51
+ created_at: str | None = None
52
+ updated_at: str | None = None
53
+
54
+
55
+ class AdminRealmProvisionIn(BaseModel):
56
+ slug: _slug
57
+ name: _name
58
+ issuer_path: _issuer_path_value | None = None
59
+ description: _description | None = None
60
+
61
+
62
+ class AdminRealmUpdateIn(BaseModel):
63
+ slug: _slug | None = None
64
+ name: _name | None = None
65
+ issuer_path: _issuer_path_value | None = None
66
+ description: _description | None = None
67
+
68
+
69
+ def _realm_payload(row: AdminRealm) -> AdminRealmOut:
70
+ return AdminRealmOut(
71
+ id=row.realm_id,
72
+ slug=row.slug,
73
+ name=row.name,
74
+ issuer_path=row.issuer_path,
75
+ description=row.description,
76
+ created_at=row.created_at,
77
+ updated_at=row.updated_at,
78
+ )
79
+
80
+
81
+ async def _invoke(
82
+ request: Request,
83
+ db: Any,
84
+ operation: str,
85
+ *args: object,
86
+ ) -> object:
87
+ try:
88
+ actor = await require_realm_administrator(request, db)
89
+ capability = realm_administration_for_request(request, db)
90
+ return (await capability.call(operation, actor, *args)).value
91
+ except TenantAdministratorAuthenticationError as exc:
92
+ raise HTTPException(401, str(exc)) from exc
93
+ except TenantAdministrationPolicyError as exc:
94
+ raise HTTPException(403, str(exc)) from exc
95
+ except (RealmAdministrationNotFoundError, TenantAdministrationNotFoundError) as exc:
96
+ raise HTTPException(404, str(exc)) from exc
97
+ except (RealmAdministrationConflictError, TenantAdministrationConflictError) as exc:
98
+ raise HTTPException(409, str(exc)) from exc
99
+ except (
100
+ RealmAdministrationValidationError,
101
+ TenantAdministrationValidationError,
102
+ ) as exc:
103
+ raise HTTPException(400, str(exc)) from exc
104
+
105
+
106
+ router = TigrblRouter()
107
+
108
+
109
+ @router.route(
110
+ "/admin/realm",
111
+ methods=["GET"],
112
+ response_model=list[AdminRealmOut],
113
+ dependencies=_SECURITY,
114
+ )
115
+ async def admin_list_realms(
116
+ request: Request,
117
+ db: Any = Depends(get_db),
118
+ ) -> list[AdminRealmOut]:
119
+ rows = await _invoke(request, db, "list_realms")
120
+ return [_realm_payload(row) for row in rows]
121
+
122
+
123
+ @router.route(
124
+ "/admin/realm",
125
+ methods=["POST"],
126
+ response_model=AdminRealmOut,
127
+ dependencies=_SECURITY,
128
+ )
129
+ async def admin_create_realm(
130
+ request: Request,
131
+ payload: AdminRealmProvisionIn | None = None,
132
+ db: Any = Depends(get_db),
133
+ ) -> AdminRealmOut:
134
+ if payload is None:
135
+ payload = AdminRealmProvisionIn.model_validate(await request.json() or {})
136
+ row = await _invoke(
137
+ request,
138
+ db,
139
+ "create_realm",
140
+ AdminRealmCreate(
141
+ slug=payload.slug,
142
+ name=payload.name,
143
+ issuer_path=payload.issuer_path,
144
+ description=payload.description,
145
+ ),
146
+ )
147
+ return _realm_payload(row)
148
+
149
+
150
+ @router.route(
151
+ "/admin/realm/{realm_id}",
152
+ methods=["GET"],
153
+ response_model=AdminRealmOut,
154
+ dependencies=_SECURITY,
155
+ )
156
+ async def admin_get_realm(
157
+ request: Request,
158
+ realm_id: str,
159
+ db: Any = Depends(get_db),
160
+ ) -> AdminRealmOut:
161
+ row = await _invoke(request, db, "read_realm", realm_id)
162
+ if row is None:
163
+ raise HTTPException(404, "realm not found")
164
+ return _realm_payload(row)
165
+
166
+
167
+ @router.route(
168
+ "/admin/realm/{realm_id}",
169
+ methods=["PATCH"],
170
+ response_model=AdminRealmOut,
171
+ dependencies=_SECURITY,
172
+ )
173
+ async def admin_update_realm(
174
+ request: Request,
175
+ realm_id: str,
176
+ payload: AdminRealmUpdateIn | None = None,
177
+ db: Any = Depends(get_db),
178
+ ) -> AdminRealmOut:
179
+ if payload is None:
180
+ payload = AdminRealmUpdateIn.model_validate(await request.json() or {})
181
+ row = await _invoke(
182
+ request,
183
+ db,
184
+ "update_realm",
185
+ realm_id,
186
+ AdminRealmUpdate(
187
+ slug=payload.slug,
188
+ name=payload.name,
189
+ issuer_path=payload.issuer_path,
190
+ description=payload.description,
191
+ ),
192
+ )
193
+ return _realm_payload(row)
194
+
195
+
196
+ @router.route(
197
+ "/admin/realm/{realm_id}",
198
+ methods=["DELETE"],
199
+ response_model=AdminRealmOut,
200
+ dependencies=_SECURITY,
201
+ )
202
+ async def admin_delete_realm(
203
+ request: Request,
204
+ realm_id: str,
205
+ db: Any = Depends(get_db),
206
+ ) -> AdminRealmOut:
207
+ row = await _invoke(request, db, "delete_realm", realm_id)
208
+ return _realm_payload(row)
209
+
210
+
211
+ @router.route(
212
+ "/admin/realm/{realm_id}/tenant",
213
+ methods=["GET"],
214
+ response_model=list[AdminTenantOut],
215
+ dependencies=_SECURITY,
216
+ )
217
+ async def admin_list_realm_tenants(
218
+ request: Request,
219
+ realm_id: str,
220
+ db: Any = Depends(get_db),
221
+ ) -> list[AdminTenantOut]:
222
+ rows = await _invoke(request, db, "list_realm_tenants", realm_id)
223
+ return [tenant_payload(row) for row in rows]
224
+
225
+
226
+ @router.route(
227
+ "/admin/realm/{realm_id}/tenant",
228
+ methods=["POST"],
229
+ response_model=AdminTenantOut,
230
+ dependencies=_SECURITY,
231
+ )
232
+ async def admin_create_realm_tenant(
233
+ request: Request,
234
+ realm_id: str,
235
+ payload: AdminTenantProvisionIn | None = None,
236
+ db: Any = Depends(get_db),
237
+ ) -> AdminTenantOut:
238
+ if payload is None:
239
+ payload = AdminTenantProvisionIn.model_validate(await request.json() or {})
240
+ row = await _invoke(
241
+ request,
242
+ db,
243
+ "create_realm_tenant",
244
+ realm_id,
245
+ AdminTenantCreate(
246
+ realm_id=realm_id,
247
+ slug=payload.slug,
248
+ name=payload.name,
249
+ email=payload.email,
250
+ ),
251
+ )
252
+ return tenant_payload(row)
253
+
254
+
255
+ __all__ = [
256
+ "AdminRealmOut",
257
+ "AdminRealmProvisionIn",
258
+ "AdminRealmUpdateIn",
259
+ "admin_create_realm",
260
+ "admin_create_realm_tenant",
261
+ "admin_delete_realm",
262
+ "admin_get_realm",
263
+ "admin_list_realm_tenants",
264
+ "admin_list_realms",
265
+ "admin_update_realm",
266
+ "router",
267
+ ]
@@ -0,0 +1,195 @@
1
+ """Platform tenant-administration HTTP routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, constr
8
+ from tigrbl import TigrblRouter
9
+ from tigrbl.requests import Request
10
+ from tigrbl.runtime.status import HTTPException
11
+ from tigrbl.security import Depends
12
+ from tba_router_administration_gate import ADMIN_OPENAPI_SECURITY_DEPENDENCIES
13
+ from tba_contracts_identity.admin_tenants import (
14
+ AdminTenant,
15
+ AdminTenantCreate,
16
+ AdminTenantUpdate,
17
+ TenantAdministrationConflictError,
18
+ TenantAdministrationNotFoundError,
19
+ TenantAdministrationPolicyError,
20
+ TenantAdministrationValidationError,
21
+ TenantAdministratorAuthenticationError,
22
+ )
23
+ from tba_server_identity.platform_tenant_administration import (
24
+ get_db,
25
+ require_tenant_administrator,
26
+ tenant_administration_for_request,
27
+ )
28
+
29
+
30
+ _email = constr(strip_whitespace=True, min_length=3, max_length=120)
31
+ _name = constr(strip_whitespace=True, min_length=1, max_length=120)
32
+ _slug = constr(strip_whitespace=True, min_length=3, max_length=120)
33
+ _SECURITY = list(ADMIN_OPENAPI_SECURITY_DEPENDENCIES)
34
+
35
+
36
+ class AdminTenantOut(BaseModel):
37
+ id: str
38
+ realm_id: str | None = None
39
+ slug: str
40
+ name: str
41
+ email: str
42
+ created_at: str | None = None
43
+ updated_at: str | None = None
44
+
45
+
46
+ class AdminTenantProvisionIn(BaseModel):
47
+ realm_id: str | None = None
48
+ slug: _slug
49
+ name: _name
50
+ email: _email
51
+
52
+
53
+ class AdminTenantUpdateIn(BaseModel):
54
+ realm_id: str | None = None
55
+ slug: _slug | None = None
56
+ name: _name | None = None
57
+ email: _email | None = None
58
+ is_active: bool | None = None
59
+
60
+
61
+ def tenant_payload(row: AdminTenant) -> AdminTenantOut:
62
+ return AdminTenantOut(
63
+ id=row.tenant_id,
64
+ realm_id=row.realm_id,
65
+ slug=row.slug,
66
+ name=row.name,
67
+ email=row.email,
68
+ created_at=row.created_at,
69
+ updated_at=row.updated_at,
70
+ )
71
+
72
+
73
+ async def _invoke(
74
+ request: Request,
75
+ db: Any,
76
+ operation: str,
77
+ *args: object,
78
+ ) -> object:
79
+ try:
80
+ actor = await require_tenant_administrator(request, db)
81
+ capability = tenant_administration_for_request(request, db)
82
+ return (await capability.call(operation, actor, *args)).value
83
+ except TenantAdministratorAuthenticationError as exc:
84
+ raise HTTPException(401, str(exc)) from exc
85
+ except TenantAdministrationPolicyError as exc:
86
+ raise HTTPException(403, str(exc)) from exc
87
+ except TenantAdministrationNotFoundError as exc:
88
+ raise HTTPException(404, str(exc)) from exc
89
+ except TenantAdministrationConflictError as exc:
90
+ raise HTTPException(409, str(exc)) from exc
91
+ except TenantAdministrationValidationError as exc:
92
+ raise HTTPException(400, str(exc)) from exc
93
+
94
+
95
+ admin_router = router = TigrblRouter()
96
+
97
+
98
+ @router.route(
99
+ "/admin/tenant",
100
+ methods=["GET"],
101
+ response_model=list[AdminTenantOut],
102
+ dependencies=_SECURITY,
103
+ )
104
+ async def admin_list_tenants(
105
+ request: Request,
106
+ db: Any = Depends(get_db),
107
+ ) -> list[AdminTenantOut]:
108
+ rows = await _invoke(request, db, "list_tenants")
109
+ return [tenant_payload(row) for row in rows]
110
+
111
+
112
+ @router.route(
113
+ "/admin/tenant",
114
+ methods=["POST"],
115
+ response_model=AdminTenantOut,
116
+ dependencies=_SECURITY,
117
+ )
118
+ async def admin_create_tenant(
119
+ request: Request,
120
+ payload: AdminTenantProvisionIn | None = None,
121
+ db: Any = Depends(get_db),
122
+ ) -> AdminTenantOut:
123
+ if payload is None:
124
+ payload = AdminTenantProvisionIn.model_validate(await request.json() or {})
125
+ row = await _invoke(
126
+ request,
127
+ db,
128
+ "create_tenant",
129
+ AdminTenantCreate(
130
+ realm_id=payload.realm_id,
131
+ slug=payload.slug,
132
+ name=payload.name,
133
+ email=payload.email,
134
+ ),
135
+ )
136
+ return tenant_payload(row)
137
+
138
+
139
+ @router.route(
140
+ "/admin/tenant/{tenant_id}",
141
+ methods=["DELETE"],
142
+ response_model=AdminTenantOut,
143
+ dependencies=_SECURITY,
144
+ )
145
+ async def admin_delete_tenant(
146
+ request: Request,
147
+ tenant_id: str,
148
+ db: Any = Depends(get_db),
149
+ ) -> AdminTenantOut:
150
+ row = await _invoke(request, db, "delete_tenant", tenant_id)
151
+ return tenant_payload(row)
152
+
153
+
154
+ @router.route(
155
+ "/admin/tenant/{tenant_id}",
156
+ methods=["PATCH"],
157
+ response_model=AdminTenantOut,
158
+ dependencies=_SECURITY,
159
+ )
160
+ async def admin_update_tenant(
161
+ request: Request,
162
+ tenant_id: str,
163
+ payload: AdminTenantUpdateIn | None = None,
164
+ db: Any = Depends(get_db),
165
+ ) -> AdminTenantOut:
166
+ if payload is None:
167
+ payload = AdminTenantUpdateIn.model_validate(await request.json() or {})
168
+ row = await _invoke(
169
+ request,
170
+ db,
171
+ "update_tenant",
172
+ tenant_id,
173
+ AdminTenantUpdate(
174
+ realm_id=payload.realm_id,
175
+ slug=payload.slug,
176
+ name=payload.name,
177
+ email=payload.email,
178
+ is_active=payload.is_active,
179
+ ),
180
+ )
181
+ return tenant_payload(row)
182
+
183
+
184
+ __all__ = [
185
+ "AdminTenantOut",
186
+ "AdminTenantProvisionIn",
187
+ "AdminTenantUpdateIn",
188
+ "admin_create_tenant",
189
+ "admin_delete_tenant",
190
+ "admin_list_tenants",
191
+ "admin_router",
192
+ "admin_update_tenant",
193
+ "router",
194
+ "tenant_payload",
195
+ ]
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: tba-app-backend-platform-administration
3
+ Version: 0.4.0.dev2
4
+ Summary: Platform owner and operator control-plane backend application for TBA.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10,<3.15
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: pydantic (==2.12.5)
14
+ Requires-Dist: tba-app-backend-core (==0.4.0.dev2)
15
+ Requires-Dist: tba-auth (==0.4.0.dev2)
16
+ Requires-Dist: tba-contracts-identity (==0.4.0.dev2)
17
+ Requires-Dist: tba-router-administration-gate (==0.4.0.dev2)
18
+ Requires-Dist: tba-server-identity (==0.4.0.dev2)
19
+ Requires-Dist: tigrbl (==0.4.5.dev8)
20
+ Project-URL: Homepage, https://github.com/tigrbl/tigrbl_auth
21
+ Project-URL: Repository, https://github.com/tigrbl/tigrbl_auth
22
+ Project-URL: Source, https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-platform-administration
23
+ Description-Content-Type: text/markdown
24
+
25
+ # tba-app-backend-platform-administration
26
+
27
+ Platform-scoped tenant lifecycle and authority-assignment API front door.
28
+
29
+ ## Surface
30
+
31
+ | Property | Value |
32
+ | --- | --- |
33
+ | Product surface | `platform-admin-app` |
34
+
35
+ The backend app owns HTTP schemas and routes only. Tenant, realm, and identity
36
+ administration are invoked through request-scoped layer-40 capabilities composed
37
+ by `tba-server-identity`; durable table operations and secret hashing remain
38
+ in the layer-60 runtime adapters and their lower-layer providers.
39
+ | Intended UIX | `@tba/ui-platform-administration` |
40
+ | Exposes | Tenant lifecycle, platform authority assignment, admin identity lookup, audit/session/profile RPC |
41
+ | Excludes | Public login/consent pages, tenant self-service-only workflows, developer app registration surface |
42
+
43
+ ## Entrypoint
44
+
45
+ ```python
46
+ from tba_app_backend_platform_administration import app, build_app
47
+ ```
48
+
49
+ ## Local dev deployment
50
+
51
+ Run the isolated platform control-plane front door on port `8015`:
52
+
53
+ ```bash
54
+ docker compose -f docker/docker-compose.platform-admin-app.yml up -d --build
55
+ ```
56
+
57
+ The dev API key defaults to `dev-platform-admin-key`.
58
+
59
+ Useful endpoints:
60
+
61
+ | Endpoint | Purpose |
62
+ | --- | --- |
63
+ | `/openapi.json` | Platform-admin REST/control-plane contract. |
64
+ | `/openrpc.json` | Platform-admin JSON-RPC method contract. |
65
+ | `/rpc` | API-key-protected JSON-RPC dispatch. |
66
+ | `/tenant` | API-key-protected platform tenant lifecycle resource. |
67
+
@@ -0,0 +1,10 @@
1
+ tba_app_backend_platform_administration/__init__.py,sha256=LVh0G3ZEnGj6EK2ZwmvPOxarm19BZOKPsKOw5-c4oks,901
2
+ tba_app_backend_platform_administration/app.py,sha256=Q4ASAo5UoDSVSlibwFMOWuitnZgZ6EIS2EpvUq7Z-nw,4985
3
+ tba_app_backend_platform_administration/contract.py,sha256=YoY74WSm-XTu50pBkISu9qX1ls20_h0JeDuDwjwPn9M,2250
4
+ tba_app_backend_platform_administration/identities.py,sha256=QLoxIEDcuJUxvy4w8MCr3v6pkXOvEPd8-oY29fDT47o,6309
5
+ tba_app_backend_platform_administration/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ tba_app_backend_platform_administration/realms.py,sha256=PZKT3uvae5Al7qPHYMNr71odvoRzJIryOtmg66BAbQQ,7259
7
+ tba_app_backend_platform_administration/tenants.py,sha256=SERHGsMeofZijBXYsBea0AL7fcb_z8FEm9Hsy4P6aKU,5204
8
+ tba_app_backend_platform_administration-0.4.0.dev2.dist-info/METADATA,sha256=t9bIt8Oa_BmkPVQcmZTDi-kJFTNpvMpMRz147e2BLVc,2588
9
+ tba_app_backend_platform_administration-0.4.0.dev2.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
10
+ tba_app_backend_platform_administration-0.4.0.dev2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any