tba-app-backend-my-account 0.4.0.dev2__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,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: tba-app-backend-my-account
3
+ Version: 0.4.0.dev2
4
+ Summary: End-user account self-service 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-capability-account-self-service (==0.4.0.dev2)
17
+ Requires-Dist: tba-contracts-identity (==0.4.0.dev2)
18
+ Requires-Dist: tba-router-oidc-discovery (==0.4.0.dev2)
19
+ Requires-Dist: tba-server-identity (==0.4.0.dev2)
20
+ Requires-Dist: tigrbl (==0.4.5.dev8)
21
+ Project-URL: Homepage, https://github.com/tigrbl/tigrbl_auth
22
+ Project-URL: Repository, https://github.com/tigrbl/tigrbl_auth
23
+ Project-URL: Source, https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-my-account
24
+ Description-Content-Type: text/markdown
25
+
26
+ # tba-app-backend-my-account
27
+
28
+ End-user self-service account API front door for TBA.
29
+
30
+ This package exposes the authenticated current subject's account surface:
31
+
32
+ - `GET /account/profile`
33
+ - `PATCH /account/profile`
34
+ - `GET /account/sessions`
35
+ - `DELETE /account/sessions/{session_id}`
36
+ - `GET /account/authorized-apps`
37
+ - `DELETE /account/authorized-apps/{client_id}`
38
+ - `GET /account/consents`
39
+ - `DELETE /account/consents/{consent_id}`
40
+ - `POST /account/password/change`
41
+
42
+ It does not expose platform administration, tenant-wide user management, RPC,
43
+ or table CRUD. All account operations are scoped to the authenticated end user.
44
+
@@ -0,0 +1,18 @@
1
+ # tba-app-backend-my-account
2
+
3
+ End-user self-service account API front door for TBA.
4
+
5
+ This package exposes the authenticated current subject's account surface:
6
+
7
+ - `GET /account/profile`
8
+ - `PATCH /account/profile`
9
+ - `GET /account/sessions`
10
+ - `DELETE /account/sessions/{session_id}`
11
+ - `GET /account/authorized-apps`
12
+ - `DELETE /account/authorized-apps/{client_id}`
13
+ - `GET /account/consents`
14
+ - `DELETE /account/consents/{consent_id}`
15
+ - `POST /account/password/change`
16
+
17
+ It does not expose platform administration, tenant-wide user management, RPC,
18
+ or table CRUD. All account operations are scoped to the authenticated end user.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "tba-app-backend-my-account"
3
+ version = "0.4.0.dev2"
4
+ description = "End-user account self-service backend application for TBA."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.15"
7
+ license = "Apache-2.0"
8
+ dependencies = [
9
+ "pydantic==2.12.5",
10
+ "tba-auth==0.4.0.dev2",
11
+ "tba-app-backend-core==0.4.0.dev2",
12
+ "tigrbl==0.4.5.dev8",
13
+ "tba-capability-account-self-service==0.4.0.dev2",
14
+ "tba-contracts-identity==0.4.0.dev2",
15
+ "tba-server-identity==0.4.0.dev2",
16
+ "tba-router-oidc-discovery==0.4.0.dev2",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/tigrbl/tigrbl_auth"
21
+ Repository = "https://github.com/tigrbl/tigrbl_auth"
22
+ Source = "https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-my-account"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core>=1.0.0"]
26
+ build-backend = "poetry.core.masonry.api"
27
+
28
+ [tool.poetry]
29
+ packages = [{ include = "tba_app_backend_my_account", from = "src" }]
@@ -0,0 +1,35 @@
1
+ """My Account backend application package for TBA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .common import MyAccountMutationOut
6
+ from .contract import MY_ACCOUNT_BACKEND_APP_CONTRACT, MyAccountBackendAppContract
7
+ from .profiles import (
8
+ MyAccountPasswordChangeIn,
9
+ MyAccountProfileOut,
10
+ MyAccountProfileUpdateIn,
11
+ )
12
+ from .sessions import MyAccountSessionOut
13
+
14
+ PRODUCT_SURFACE = "my-account-app"
15
+
16
+
17
+ def __getattr__(name: str):
18
+ if name in {"app", "build_app"}:
19
+ from .app import app, build_app
20
+
21
+ return app if name == "app" else build_app
22
+ raise AttributeError(name)
23
+
24
+ __all__ = [
25
+ "MY_ACCOUNT_BACKEND_APP_CONTRACT",
26
+ "MyAccountMutationOut",
27
+ "MyAccountPasswordChangeIn",
28
+ "MyAccountBackendAppContract",
29
+ "MyAccountProfileOut",
30
+ "MyAccountProfileUpdateIn",
31
+ "MyAccountSessionOut",
32
+ "PRODUCT_SURFACE",
33
+ "app",
34
+ "build_app",
35
+ ]
@@ -0,0 +1,132 @@
1
+ """ASGI entrypoint for the My Account 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_my_account.contract import MY_ACCOUNT_BACKEND_APP_CONTRACT
10
+ from tba_auth.runtime import LazyASGIApplication
11
+ from tba_app_backend_my_account.openapi import patch_my_account_openapi
12
+ from tba_app_backend_my_account.routes import router as account_router
13
+
14
+ if TYPE_CHECKING:
15
+ from tigrbl import TigrblApp
16
+
17
+ PRODUCT_SURFACE = "my-account-app"
18
+ DEFAULT_CORS_ORIGINS = ("http://localhost:3019", "http://127.0.0.1:3019")
19
+
20
+
21
+ class MyAccountCors:
22
+ """Small ASGI CORS wrapper for the standalone My Account/UIX demo surface."""
23
+
24
+ def __init__(
25
+ self, app: Any, origins: tuple[str, ...] = DEFAULT_CORS_ORIGINS
26
+ ) -> None:
27
+ self.app = app
28
+ self.origins = origins
29
+
30
+ def __getattr__(self, name: str) -> Any:
31
+ return getattr(self.app, name)
32
+
33
+ async def __call__(
34
+ self,
35
+ scope: dict[str, Any],
36
+ receive: Callable[[], Awaitable[dict[str, Any]]],
37
+ send: Callable[[dict[str, Any]], Awaitable[None]],
38
+ ) -> None:
39
+ if scope.get("type") != "http":
40
+ await self.app(scope, receive, send)
41
+ return
42
+
43
+ headers = {
44
+ key.decode("latin-1").lower(): value.decode("latin-1")
45
+ for key, value in scope.get("headers", [])
46
+ }
47
+ origin = headers.get("origin")
48
+ allowed_origin = origin if origin in self.origins else None
49
+
50
+ if str(scope.get("method", "")).upper() == "OPTIONS" and allowed_origin:
51
+ await send(
52
+ {
53
+ "type": "http.response.start",
54
+ "status": 204,
55
+ "headers": self._cors_headers(allowed_origin)
56
+ + [
57
+ (
58
+ b"access-control-allow-methods",
59
+ b"GET,PATCH,POST,DELETE,OPTIONS",
60
+ ),
61
+ (
62
+ b"access-control-allow-headers",
63
+ headers.get(
64
+ "access-control-request-headers",
65
+ "content-type,authorization",
66
+ ).encode("latin-1"),
67
+ ),
68
+ (b"content-length", b"0"),
69
+ ],
70
+ }
71
+ )
72
+ await send({"type": "http.response.body", "body": b""})
73
+ return
74
+
75
+ async def cors_send(message: dict[str, Any]) -> None:
76
+ if message.get("type") == "http.response.start" and allowed_origin:
77
+ message = dict(message)
78
+ response_headers = list(message.get("headers", []))
79
+ response_headers.extend(self._cors_headers(allowed_origin))
80
+ message["headers"] = response_headers
81
+ await send(message)
82
+
83
+ await self.app(scope, receive, cors_send)
84
+
85
+ @staticmethod
86
+ def _cors_headers(origin: str) -> list[tuple[bytes, bytes]]:
87
+ return [
88
+ (b"access-control-allow-origin", origin.encode("latin-1")),
89
+ (b"access-control-allow-credentials", b"true"),
90
+ (b"vary", b"Origin"),
91
+ ]
92
+
93
+
94
+ def _default_settings() -> object:
95
+ from tba_auth.config.settings import Settings
96
+
97
+ settings = Settings()
98
+ env_overrides = {
99
+ "issuer": os.environ.get("AUTHN_ISSUER"),
100
+ "protected_resource_identifier": os.environ.get(
101
+ "TBA_AUTH_PROTECTED_RESOURCE_IDENTIFIER"
102
+ ),
103
+ "deployment_profile": os.environ.get("TBA_AUTH_PROFILE"),
104
+ "require_tls": os.environ.get("TBA_AUTH_REQUIRE_TLS"),
105
+ "session_cookie_force_secure": os.environ.get(
106
+ "TBA_AUTH_SESSION_COOKIE_FORCE_SECURE"
107
+ ),
108
+ }
109
+ for name, value in env_overrides.items():
110
+ if value in {None, ""}:
111
+ continue
112
+ current = getattr(settings, name, None)
113
+ if isinstance(current, bool):
114
+ value = str(value).lower() in {"1", "true", "yes", "on"}
115
+ setattr(settings, name, value)
116
+ return settings
117
+
118
+
119
+ def build_app(settings_obj: object | None = None) -> "TigrblApp":
120
+ if settings_obj is None:
121
+ settings_obj = _default_settings()
122
+ app = build_product_app(
123
+ PRODUCT_SURFACE, settings_obj, contract=MY_ACCOUNT_BACKEND_APP_CONTRACT
124
+ )
125
+ app.include_router(account_router)
126
+ app = patch_my_account_openapi(app)
127
+ return MyAccountCors(app)
128
+
129
+
130
+ app = LazyASGIApplication(build_app)
131
+
132
+ __all__ = ["PRODUCT_SURFACE", "app", "build_app"]
@@ -0,0 +1,58 @@
1
+ """Shared HTTP concerns for the My Account API product."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+ from tigrbl.requests import Request
10
+ from tigrbl.security import Depends
11
+ from tba_contracts_identity.account_self_service import AccountPrincipal
12
+ from tba_server_identity.account_self_service import (
13
+ current_account_principal,
14
+ get_db,
15
+ )
16
+
17
+
18
+ class MyAccountMutationOut(BaseModel):
19
+ status: str
20
+ id: str | None = None
21
+
22
+
23
+ async def current_principal_dependency(
24
+ request: Request,
25
+ authorization: str | None = None,
26
+ api_key: str | None = None,
27
+ dpop: str | None = None,
28
+ db: Any = Depends(get_db),
29
+ ) -> AccountPrincipal:
30
+ app_state = getattr(getattr(request, "app", None), "state", None)
31
+ provider = getattr(app_state, "tba_auth_account_principal_provider", None)
32
+ if callable(provider):
33
+ provided = provider(request)
34
+ principal = await provided if inspect.isawaitable(provided) else provided
35
+ if not isinstance(principal, AccountPrincipal):
36
+ raise TypeError("account principal provider returned an invalid principal")
37
+ return principal
38
+ headers = getattr(request, "headers", {})
39
+ authorization = (
40
+ authorization
41
+ or headers.get("Authorization", "")
42
+ or headers.get("authorization", "")
43
+ )
44
+ api_key = api_key or headers.get("x-api-key")
45
+ dpop = dpop or headers.get("DPoP") or headers.get("dpop")
46
+ return await current_account_principal(
47
+ request,
48
+ authorization=authorization,
49
+ api_key=api_key,
50
+ dpop=dpop,
51
+ db=db,
52
+ )
53
+
54
+
55
+ __all__ = [
56
+ "MyAccountMutationOut",
57
+ "current_principal_dependency",
58
+ ]
@@ -0,0 +1,164 @@
1
+ """Current-subject consent and authorized-application HTTP routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+ from uuid import UUID
8
+
9
+ from pydantic import BaseModel
10
+ from tigrbl import TigrblRouter
11
+ from tigrbl.requests import Request
12
+ from tigrbl.runtime.status import HTTPException
13
+ from tigrbl.security import Depends
14
+ from tba_contracts_identity.account_self_service import (
15
+ AccountConsent,
16
+ AccountNotFoundError,
17
+ AccountPrincipal,
18
+ )
19
+ from tba_server_identity.account_self_service import (
20
+ account_principal,
21
+ account_self_service_for_request,
22
+ get_db,
23
+ )
24
+
25
+ from .common import current_principal_dependency
26
+
27
+
28
+ router = TigrblRouter()
29
+ MY_ACCOUNT_TAGS = ["My Account"]
30
+
31
+
32
+ class MyAccountConsentOut(BaseModel):
33
+ id: UUID
34
+ tenant_id: UUID
35
+ user_id: UUID
36
+ client_id: UUID
37
+ scope: str
38
+ claims: dict[str, Any] | None = None
39
+ state: str
40
+ granted_at: datetime | None = None
41
+ expires_at: datetime | None = None
42
+ revoked_at: datetime | None = None
43
+
44
+
45
+ class MyAccountAuthorizedAppOut(BaseModel):
46
+ client_id: UUID
47
+ tenant_id: UUID
48
+ scope: str
49
+ consent_state: str
50
+ granted_at: datetime | None = None
51
+ revoked_at: datetime | None = None
52
+
53
+
54
+ def _consent_payload(record: AccountConsent) -> MyAccountConsentOut:
55
+ return MyAccountConsentOut(
56
+ id=record.consent_id,
57
+ tenant_id=record.tenant_id,
58
+ user_id=record.identity_id,
59
+ client_id=record.client_id,
60
+ scope=record.scope,
61
+ claims=dict(record.claims) if record.claims is not None else None,
62
+ state=record.state,
63
+ granted_at=record.granted_at,
64
+ expires_at=record.expires_at,
65
+ revoked_at=record.revoked_at,
66
+ )
67
+
68
+
69
+ def _authorized_app_payload(record: AccountConsent) -> MyAccountAuthorizedAppOut:
70
+ return MyAccountAuthorizedAppOut(
71
+ client_id=record.client_id,
72
+ tenant_id=record.tenant_id,
73
+ scope=record.scope,
74
+ consent_state=record.state,
75
+ granted_at=record.granted_at,
76
+ revoked_at=record.revoked_at,
77
+ )
78
+
79
+
80
+ @router.route(
81
+ "/account/consents",
82
+ methods=["GET"],
83
+ response_model=list[MyAccountConsentOut],
84
+ tags=MY_ACCOUNT_TAGS,
85
+ )
86
+ async def list_account_consents(
87
+ request: Request | None = None,
88
+ current_user: AccountPrincipal | object = Depends(current_principal_dependency),
89
+ db: Any = Depends(get_db),
90
+ ) -> list[MyAccountConsentOut]:
91
+ records = await account_self_service_for_request(
92
+ request or object(), db
93
+ ).list_consents(account_principal(current_user))
94
+ return [_consent_payload(record) for record in records]
95
+
96
+
97
+ @router.route(
98
+ "/account/consents/{consent_id}",
99
+ methods=["DELETE"],
100
+ response_model=MyAccountConsentOut,
101
+ tags=MY_ACCOUNT_TAGS,
102
+ )
103
+ async def revoke_account_consent(
104
+ consent_id: str,
105
+ request: Request | None = None,
106
+ current_user: AccountPrincipal | object = Depends(current_principal_dependency),
107
+ db: Any = Depends(get_db),
108
+ ) -> MyAccountConsentOut:
109
+ try:
110
+ record = await account_self_service_for_request(
111
+ request or object(), db
112
+ ).revoke_consent(account_principal(current_user), consent_id)
113
+ except AccountNotFoundError as exc:
114
+ raise HTTPException(404, str(exc)) from exc
115
+ return _consent_payload(record)
116
+
117
+
118
+ @router.route(
119
+ "/account/authorized-apps",
120
+ methods=["GET"],
121
+ response_model=list[MyAccountAuthorizedAppOut],
122
+ tags=MY_ACCOUNT_TAGS,
123
+ )
124
+ async def list_account_authorized_apps(
125
+ request: Request | None = None,
126
+ current_user: AccountPrincipal | object = Depends(current_principal_dependency),
127
+ db: Any = Depends(get_db),
128
+ ) -> list[MyAccountAuthorizedAppOut]:
129
+ records = await account_self_service_for_request(
130
+ request or object(), db
131
+ ).list_authorized_apps(account_principal(current_user))
132
+ return [_authorized_app_payload(record) for record in records]
133
+
134
+
135
+ @router.route(
136
+ "/account/authorized-apps/{client_id}",
137
+ methods=["DELETE"],
138
+ response_model=list[MyAccountConsentOut],
139
+ tags=MY_ACCOUNT_TAGS,
140
+ )
141
+ async def revoke_account_authorized_app(
142
+ client_id: str,
143
+ request: Request | None = None,
144
+ current_user: AccountPrincipal | object = Depends(current_principal_dependency),
145
+ db: Any = Depends(get_db),
146
+ ) -> list[MyAccountConsentOut]:
147
+ try:
148
+ records = await account_self_service_for_request(
149
+ request or object(), db
150
+ ).revoke_authorized_app(account_principal(current_user), client_id)
151
+ except AccountNotFoundError as exc:
152
+ raise HTTPException(404, str(exc)) from exc
153
+ return [_consent_payload(record) for record in records]
154
+
155
+
156
+ __all__ = [
157
+ "MyAccountAuthorizedAppOut",
158
+ "MyAccountConsentOut",
159
+ "list_account_authorized_apps",
160
+ "list_account_consents",
161
+ "revoke_account_authorized_app",
162
+ "revoke_account_consent",
163
+ "router",
164
+ ]
@@ -0,0 +1,82 @@
1
+ """My Account 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 MyAccountBackendAppContract:
10
+ """Machine-readable boundary for the end-user account backend app."""
11
+
12
+ product_surface: str
13
+ intended_uix: str
14
+ baseline_capabilities: tuple[str, ...]
15
+ forbidden_route_prefixes: tuple[str, ...]
16
+ forbidden_exact_routes: tuple[str, ...]
17
+ consumed_packages: tuple[str, ...]
18
+ mounted_router_packages: tuple[str, ...]
19
+ app_local_route_groups: tuple[str, ...]
20
+
21
+
22
+ MY_ACCOUNT_BACKEND_APP_CONTRACT = MyAccountBackendAppContract(
23
+ product_surface="my-account-app",
24
+ intended_uix="@tba/ui-my-account",
25
+ baseline_capabilities=(
26
+ "account-profile",
27
+ "account-sessions",
28
+ "account-consents",
29
+ "account-credentials",
30
+ "rest-only",
31
+ "openid-configuration",
32
+ "tenant-openid-configuration",
33
+ "jwks",
34
+ "tenant-jwks",
35
+ ),
36
+ forbidden_route_prefixes=(
37
+ "/admin",
38
+ "/tenant",
39
+ "/client",
40
+ "/serviceidentity",
41
+ "/user",
42
+ "/credentialapikey",
43
+ "/credentialservicekey",
44
+ "/cryptokey",
45
+ "/cryptokeyversion",
46
+ "/principalkeybinding",
47
+ "/keyenvelope",
48
+ "/keyattestationevidence",
49
+ "/tokenrecord",
50
+ "/authsession",
51
+ "/auditevent",
52
+ ),
53
+ forbidden_exact_routes=(
54
+ "/login",
55
+ "/authorize",
56
+ "/token",
57
+ "/register",
58
+ "/logout",
59
+ "/revoke",
60
+ "/userinfo",
61
+ "/introspect",
62
+ ),
63
+ consumed_packages=(
64
+ "tba-auth",
65
+ "tba-server-identity",
66
+ "tba-runtime-identity",
67
+ "tba-concrete-identity-principals",
68
+ "tba-credentials",
69
+ "tba-runtime-authorization-policy",
70
+ "tba-protocol-oidc",
71
+ "tba-storage-core",
72
+ ),
73
+ mounted_router_packages=("tba-router-oidc-discovery",),
74
+ app_local_route_groups=(
75
+ "profiles",
76
+ "sessions",
77
+ "consents",
78
+ "credentials",
79
+ ),
80
+ )
81
+
82
+ __all__ = ["MY_ACCOUNT_BACKEND_APP_CONTRACT", "MyAccountBackendAppContract"]
@@ -0,0 +1,52 @@
1
+ """OpenAPI affordances for the My Account backend app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import Any, Callable
7
+
8
+
9
+ def patch_my_account_openapi(app: object) -> object:
10
+ """Patch generated OpenAPI metadata for the My Account surface."""
11
+ target = getattr(app, "app", app)
12
+ original_openapi: Callable[[], dict[str, Any]] = target.openapi
13
+
14
+ def my_account_openapi() -> dict[str, Any]:
15
+ cached = getattr(target, "openapi_schema", None)
16
+ if cached:
17
+ return cached
18
+ openapi = original_openapi()
19
+ openapi["title"] = "tba-app-backend-my-account"
20
+ for path, methods in openapi.get("paths", {}).items():
21
+ if path.startswith("/account/"):
22
+ for operation in methods.values():
23
+ operation["tags"] = ["My Account"]
24
+ setattr(target, "openapi_schema", openapi)
25
+ return openapi
26
+
27
+ target.openapi = my_account_openapi
28
+ if target is not app:
29
+ app.openapi = my_account_openapi
30
+
31
+ routes = getattr(target, "_routes", None)
32
+ if isinstance(routes, list):
33
+ from tigrbl_concrete._concrete._response import Response
34
+
35
+ def _openapi_handler(request: Any) -> Response:
36
+ return Response.json(my_account_openapi())
37
+
38
+ patched_routes = []
39
+ for route in routes:
40
+ path = str(
41
+ getattr(route, "path_template", None) or getattr(route, "path", "")
42
+ )
43
+ methods = set(getattr(route, "methods", ()) or ())
44
+ if path == "/openapi.json" and "GET" in methods:
45
+ patched_routes.append(replace(route, handler=_openapi_handler))
46
+ else:
47
+ patched_routes.append(route)
48
+ routes[:] = patched_routes
49
+ return app
50
+
51
+
52
+ __all__ = ["patch_my_account_openapi"]
@@ -0,0 +1,177 @@
1
+ """Current-subject profile and password 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.account_self_service import (
13
+ AccountAuthenticationError,
14
+ AccountPrincipal,
15
+ AccountProfile,
16
+ AccountProfileUpdate,
17
+ AccountValidationError,
18
+ )
19
+ from tba_server_identity.account_self_service import (
20
+ account_principal,
21
+ account_self_service_for_request,
22
+ get_db,
23
+ )
24
+
25
+ from .common import MyAccountMutationOut, current_principal_dependency
26
+
27
+
28
+ _email = constr(strip_whitespace=True, min_length=3, max_length=120)
29
+ _password = constr(min_length=8, max_length=256)
30
+ _username = constr(strip_whitespace=True, min_length=3, max_length=80)
31
+
32
+
33
+ class MyAccountProfileOut(BaseModel):
34
+ id: str
35
+ tenant_id: str
36
+ username: str
37
+ email: str
38
+ is_active: bool = True
39
+ must_change_password: bool = False
40
+ roles: list[str] = Field(default_factory=list)
41
+ created_at: str | None = None
42
+ updated_at: str | None = None
43
+
44
+
45
+ class MyAccountProfileUpdateIn(BaseModel):
46
+ username: _username | None = None
47
+ email: _email | None = None
48
+
49
+
50
+ class MyAccountPasswordChangeIn(BaseModel):
51
+ current_password: _password
52
+ new_password: _password
53
+
54
+
55
+ def _profile_payload(profile: AccountProfile) -> MyAccountProfileOut:
56
+ return MyAccountProfileOut(
57
+ id=profile.identity_id,
58
+ tenant_id=profile.tenant_id,
59
+ username=profile.username,
60
+ email=profile.email,
61
+ is_active=profile.is_active,
62
+ must_change_password=profile.must_change_password,
63
+ roles=list(profile.roles),
64
+ created_at=profile.created_at,
65
+ updated_at=profile.updated_at,
66
+ )
67
+
68
+
69
+ async def _principal(
70
+ request: Request | None, current_user: object | None, db: object
71
+ ) -> AccountPrincipal:
72
+ if current_user is not None:
73
+ return account_principal(current_user)
74
+ if request is None:
75
+ raise HTTPException(401, "authenticated account required")
76
+ return await current_principal_dependency(request, db=db)
77
+
78
+
79
+ def _capability(request: Request | None, db: object):
80
+ return (
81
+ account_self_service_for_request(request, db)
82
+ if request
83
+ else account_self_service_for_request(object(), db)
84
+ )
85
+
86
+
87
+ def _raise_http(exc: Exception) -> None:
88
+ if isinstance(exc, AccountAuthenticationError):
89
+ raise HTTPException(401, str(exc)) from exc
90
+ if isinstance(exc, AccountValidationError):
91
+ raise HTTPException(400, str(exc)) from exc
92
+ raise exc
93
+
94
+
95
+ router = TigrblRouter()
96
+ MY_ACCOUNT_TAGS = ["My Account"]
97
+
98
+
99
+ @router.route(
100
+ "/account/profile",
101
+ methods=["GET"],
102
+ response_model=MyAccountProfileOut,
103
+ tags=MY_ACCOUNT_TAGS,
104
+ )
105
+ async def get_account_profile(
106
+ request: Request | None = None,
107
+ current_user: object | None = None,
108
+ db: Any = Depends(get_db),
109
+ ) -> MyAccountProfileOut:
110
+ principal = await _principal(request, current_user, db)
111
+ try:
112
+ return _profile_payload(await _capability(request, db).get_profile(principal))
113
+ except (AccountAuthenticationError, AccountValidationError) as exc:
114
+ _raise_http(exc)
115
+
116
+
117
+ @router.route(
118
+ "/account/profile",
119
+ methods=["PATCH"],
120
+ response_model=MyAccountProfileOut,
121
+ tags=MY_ACCOUNT_TAGS,
122
+ )
123
+ async def update_account_profile(
124
+ request: Request | None = None,
125
+ payload: MyAccountProfileUpdateIn | None = None,
126
+ current_user: object | None = None,
127
+ db: Any = Depends(get_db),
128
+ ) -> MyAccountProfileOut:
129
+ principal = await _principal(request, current_user, db)
130
+ if payload is None:
131
+ if request is None:
132
+ raise HTTPException(400, "request payload required")
133
+ payload = MyAccountProfileUpdateIn.model_validate(await request.json() or {})
134
+ try:
135
+ profile = await _capability(request, db).update_profile(
136
+ principal, AccountProfileUpdate(payload.username, payload.email)
137
+ )
138
+ return _profile_payload(profile)
139
+ except (AccountAuthenticationError, AccountValidationError) as exc:
140
+ _raise_http(exc)
141
+
142
+
143
+ @router.route(
144
+ "/account/password/change",
145
+ methods=["POST"],
146
+ response_model=MyAccountMutationOut,
147
+ tags=MY_ACCOUNT_TAGS,
148
+ )
149
+ async def change_account_password(
150
+ request: Request | None = None,
151
+ payload: MyAccountPasswordChangeIn | None = None,
152
+ current_user: object | None = None,
153
+ db: Any = Depends(get_db),
154
+ ) -> MyAccountMutationOut:
155
+ principal = await _principal(request, current_user, db)
156
+ if payload is None:
157
+ if request is None:
158
+ raise HTTPException(400, "request payload required")
159
+ payload = MyAccountPasswordChangeIn.model_validate(await request.json() or {})
160
+ try:
161
+ result = await _capability(request, db).change_password(
162
+ principal, payload.current_password, payload.new_password
163
+ )
164
+ return MyAccountMutationOut(status=result.status, id=result.resource_id)
165
+ except (AccountAuthenticationError, AccountValidationError) as exc:
166
+ _raise_http(exc)
167
+
168
+
169
+ __all__ = [
170
+ "MyAccountPasswordChangeIn",
171
+ "MyAccountProfileOut",
172
+ "MyAccountProfileUpdateIn",
173
+ "change_account_password",
174
+ "get_account_profile",
175
+ "router",
176
+ "update_account_profile",
177
+ ]
@@ -0,0 +1,18 @@
1
+ """HTTP route composition for the My Account API product."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tigrbl import TigrblRouter
6
+
7
+ from .consents import router as consent_router
8
+ from .profiles import router as profile_router
9
+ from .sessions import router as session_router
10
+
11
+
12
+ router = TigrblRouter()
13
+ router.include_router(profile_router)
14
+ router.include_router(session_router)
15
+ router.include_router(consent_router)
16
+
17
+
18
+ __all__ = ["router"]
@@ -0,0 +1,102 @@
1
+ """Current-subject authentication-session HTTP routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
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.account_self_service import (
13
+ AccountNotFoundError,
14
+ AccountPrincipal,
15
+ AccountSession,
16
+ )
17
+ from tba_server_identity.account_self_service import (
18
+ account_principal,
19
+ account_self_service_for_request,
20
+ get_db,
21
+ )
22
+
23
+ from .common import MyAccountMutationOut, current_principal_dependency
24
+
25
+
26
+ class MyAccountSessionOut(BaseModel):
27
+ id: str
28
+ tenant_id: str
29
+ user_id: str
30
+ username: str
31
+ client_id: str | None = None
32
+ state: str = "active"
33
+ auth_time: str | None = None
34
+ last_seen_at: str | None = None
35
+ expires_at: str | None = None
36
+ ended_at: str | None = None
37
+
38
+
39
+ def _session_payload(session: AccountSession) -> MyAccountSessionOut:
40
+ return MyAccountSessionOut(
41
+ id=session.session_id,
42
+ tenant_id=session.tenant_id,
43
+ user_id=session.identity_id,
44
+ username=session.username,
45
+ client_id=session.client_id,
46
+ state=session.state,
47
+ auth_time=session.auth_time,
48
+ last_seen_at=session.last_seen_at,
49
+ expires_at=session.expires_at,
50
+ ended_at=session.ended_at,
51
+ )
52
+
53
+
54
+ router = TigrblRouter()
55
+ MY_ACCOUNT_TAGS = ["My Account"]
56
+
57
+
58
+ @router.route(
59
+ "/account/sessions",
60
+ methods=["GET"],
61
+ response_model=list[MyAccountSessionOut],
62
+ tags=MY_ACCOUNT_TAGS,
63
+ )
64
+ async def list_account_sessions(
65
+ request: Request | None = None,
66
+ current_user: AccountPrincipal | object = Depends(current_principal_dependency),
67
+ db: Any = Depends(get_db),
68
+ ) -> list[MyAccountSessionOut]:
69
+ principal = account_principal(current_user)
70
+ sessions = await account_self_service_for_request(
71
+ request or object(), db
72
+ ).list_sessions(principal)
73
+ return [_session_payload(session) for session in sessions]
74
+
75
+
76
+ @router.route(
77
+ "/account/sessions/{session_id}",
78
+ methods=["DELETE"],
79
+ response_model=MyAccountMutationOut,
80
+ tags=MY_ACCOUNT_TAGS,
81
+ )
82
+ async def revoke_account_session(
83
+ session_id: str,
84
+ request: Request | None = None,
85
+ current_user: AccountPrincipal | object = Depends(current_principal_dependency),
86
+ db: Any = Depends(get_db),
87
+ ) -> MyAccountMutationOut:
88
+ try:
89
+ result = await account_self_service_for_request(
90
+ request or object(), db
91
+ ).revoke_session(account_principal(current_user), session_id)
92
+ except AccountNotFoundError as exc:
93
+ raise HTTPException(404, str(exc)) from exc
94
+ return MyAccountMutationOut(status=result.status, id=result.resource_id)
95
+
96
+
97
+ __all__ = [
98
+ "MyAccountSessionOut",
99
+ "list_account_sessions",
100
+ "revoke_account_session",
101
+ "router",
102
+ ]