terp-cap-users 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-users
3
+ Version: 0.1.0
4
+ Summary: Terp users capability — admin user management over the identity store.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: terp-cap-auth==0.1.0
8
+ Requires-Dist: terp-cap-identity==0.1.0
9
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-users"
7
+ version = "0.1.0"
8
+ description = "Terp users capability — admin user management over the identity store."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ "terp-cap-identity==0.1.0",
14
+ "terp-cap-auth==0.1.0",
15
+ ]
16
+
17
+ # Self-registering: the kernel discovers this ModuleSpec via the entry point,
18
+ # mounting the admin `users` management router at /api/v1/users without any edit
19
+ # to a composition root. It owns the user-administration surface over the
20
+ # identity store (which keeps the User model + authenticate).
21
+ [project.entry-points."terp.capabilities"]
22
+ users = "terp.capabilities.users:module"
23
+
24
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.users`.
25
+ [tool.hatch.build.targets.wheel]
26
+ sources = ["src"]
27
+ only-include = ["src/terp/capabilities/users"]
@@ -0,0 +1,33 @@
1
+ """terp.capabilities.users — admin user management over the identity store.
2
+
3
+ Provides a :class:`UsersService` and a **self-registering** admin ``users`` router
4
+ (list / get / provision / edit / deactivate / reactivate / reset password). The
5
+ kernel discovers ``module`` via the ``terp.capabilities`` entry point and mounts
6
+ it at ``/api/v1/users`` with no composition-root edit.
7
+
8
+ The persisted ``User`` (and ``authenticate``) live in ``terp-cap-identity``; this
9
+ capability is the **administration surface** over that store, so there is a single
10
+ user table shared with the login path. Every write is audited (it routes through
11
+ the ``BaseService`` chokepoint) and passwords are hashed via ``terp-cap-auth``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from terp.capabilities.users.router import module, router
17
+ from terp.capabilities.users.schemas import (
18
+ UserAdminUpdate,
19
+ UserPasswordReset,
20
+ UserProvision,
21
+ UserRead,
22
+ )
23
+ from terp.capabilities.users.service import UsersService
24
+
25
+ __all__ = [
26
+ "UserAdminUpdate",
27
+ "UserPasswordReset",
28
+ "UserProvision",
29
+ "UserRead",
30
+ "UsersService",
31
+ "module",
32
+ "router",
33
+ ]
@@ -0,0 +1,128 @@
1
+ """Admin user-management router + the discoverable ``ModuleSpec``.
2
+
3
+ Admin-only (``Policy`` requires ``ADMIN`` for both read and write). Mounted at
4
+ ``/api/v1/users`` purely via entry-point discovery — it owns the
5
+ user-administration surface over the identity store: list / get / provision /
6
+ edit / deactivate / reactivate / reset password. Deactivation is preferred over
7
+ deletion, so a user is never hard-removed here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import uuid
13
+
14
+ from fastapi import APIRouter, Depends, Query
15
+
16
+ from terp.core import (
17
+ ModuleSpec,
18
+ Page,
19
+ PaginationDep,
20
+ Policy,
21
+ Principal,
22
+ Roles,
23
+ SessionDep,
24
+ get_principal,
25
+ )
26
+
27
+ from terp.capabilities.identity import RefreshTokenService
28
+ from terp.capabilities.users.schemas import (
29
+ UserAdminUpdate,
30
+ UserPasswordReset,
31
+ UserProvision,
32
+ UserRead,
33
+ )
34
+ from terp.capabilities.users.service import UsersService
35
+
36
+ router = APIRouter(tags=["users"])
37
+ # Admin security actions (deactivate / demote / reset-password) also revoke the target's
38
+ # refresh-token families (ADR 0054), so a reset after a compromise kills the refresh cookie
39
+ # too — not just the short-lived access token. For an app that issues no refresh tokens this
40
+ # is a harmless no-op (the family query returns nothing).
41
+ _service = UsersService(refresh_revoker=RefreshTokenService().revoke_all_for_user)
42
+
43
+
44
+ @router.get("/", response_model=Page[UserRead])
45
+ def list_users(
46
+ session: SessionDep,
47
+ pagination: PaginationDep,
48
+ email: str | None = Query(
49
+ None,
50
+ max_length=254,
51
+ description="Filter to users whose email contains this text (case-insensitive).",
52
+ ),
53
+ ) -> Page[UserRead]:
54
+ if email:
55
+ rows, total = _service.list_matching(
56
+ session, email=email, skip=pagination.skip, limit=pagination.limit
57
+ )
58
+ else:
59
+ rows, total = _service.list(
60
+ session, skip=pagination.skip, limit=pagination.limit
61
+ )
62
+ return Page[UserRead].of(
63
+ [UserRead.model_validate(row) for row in rows], total, pagination
64
+ )
65
+
66
+
67
+ @router.post("/", response_model=UserRead, status_code=201)
68
+ def provision_user(payload: UserProvision, session: SessionDep) -> UserRead:
69
+ return UserRead.model_validate(_service.create(session, payload))
70
+
71
+
72
+ @router.get("/{user_id}", response_model=UserRead)
73
+ def get_user(user_id: uuid.UUID, session: SessionDep) -> UserRead:
74
+ return UserRead.model_validate(_service.get(session, user_id))
75
+
76
+
77
+ @router.patch("/{user_id}", response_model=UserRead)
78
+ def update_user(
79
+ user_id: uuid.UUID,
80
+ payload: UserAdminUpdate,
81
+ session: SessionDep,
82
+ principal: Principal | None = Depends(get_principal),
83
+ ) -> UserRead:
84
+ return UserRead.model_validate(
85
+ _service.update(
86
+ session,
87
+ user_id,
88
+ payload,
89
+ actor_id=principal.id if principal is not None else None,
90
+ )
91
+ )
92
+
93
+
94
+ @router.post("/{user_id}/deactivate", response_model=UserRead)
95
+ def deactivate_user(
96
+ user_id: uuid.UUID,
97
+ session: SessionDep,
98
+ principal: Principal | None = Depends(get_principal),
99
+ ) -> UserRead:
100
+ return UserRead.model_validate(
101
+ _service.set_active(
102
+ session,
103
+ user_id,
104
+ active=False,
105
+ actor_id=principal.id if principal is not None else None,
106
+ )
107
+ )
108
+
109
+
110
+ @router.post("/{user_id}/reactivate", response_model=UserRead)
111
+ def reactivate_user(user_id: uuid.UUID, session: SessionDep) -> UserRead:
112
+ return UserRead.model_validate(_service.set_active(session, user_id, active=True))
113
+
114
+
115
+ @router.post("/{user_id}/reset-password", response_model=UserRead)
116
+ def reset_user_password(
117
+ user_id: uuid.UUID, payload: UserPasswordReset, session: SessionDep
118
+ ) -> UserRead:
119
+ return UserRead.model_validate(
120
+ _service.reset_password(session, user_id, payload.password)
121
+ )
122
+
123
+
124
+ module = ModuleSpec(
125
+ name="users",
126
+ router=router,
127
+ policy=Policy(read_role=Roles.ADMIN, write_role=Roles.ADMIN),
128
+ )
@@ -0,0 +1,56 @@
1
+ """Users capability DTOs — admin management over the identity ``User`` store.
2
+
3
+ The read shape reuses identity's ``UserRead`` (which never exposes
4
+ ``hashed_password``); this module adds the admin write DTOs. ``UserAdminUpdate``
5
+ requires the optimistic-concurrency ``version`` like every other update schema.
6
+
7
+ The ``password`` ``max_length`` is the DoS cap; *strength* is enforced one layer in,
8
+ at the ``UsersService`` write chokepoint (ADR 0032), so a rejection is the uniform
9
+ typed ``WeakPasswordError`` envelope rather than a raw pydantic 422.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from sqlmodel import Field
15
+
16
+ from terp.core import BaseSchema, BaseUpdateSchema, Roles
17
+
18
+ from terp.capabilities.identity import UserRead
19
+
20
+ __all__ = [
21
+ "UserAdminUpdate",
22
+ "UserPasswordReset",
23
+ "UserProvision",
24
+ "UserRead",
25
+ ]
26
+
27
+
28
+ class UserProvision(BaseSchema):
29
+ """Admin-provisions a new user with an initial password and a role *rank*.
30
+
31
+ ``role`` is an integer rank resolved against the app's ``PermissionModel`` at
32
+ login (ADR 0022), so an admin can provision any role the app's ladder defines —
33
+ not only the default three tiers; an unmodeled rank simply fails closed at that
34
+ user's next login. The default is the viewer rank.
35
+ """
36
+
37
+ email: str = Field(max_length=320)
38
+ password: str = Field(max_length=256)
39
+ role: int = Field(default=int(Roles.VIEWER), ge=0)
40
+
41
+
42
+ class UserAdminUpdate(BaseUpdateSchema):
43
+ """Admin edits a user's email / role rank (OCC ``version`` required).
44
+
45
+ Activation state is changed through the dedicated ``deactivate`` /
46
+ ``reactivate`` actions, not this generic patch.
47
+ """
48
+
49
+ email: str | None = Field(default=None, max_length=320)
50
+ role: int | None = Field(default=None, ge=0)
51
+
52
+
53
+ class UserPasswordReset(BaseSchema):
54
+ """Admin sets a new password for a user."""
55
+
56
+ password: str = Field(max_length=256)
@@ -0,0 +1,242 @@
1
+ """Users service — admin management over the identity ``User`` store.
2
+
3
+ Builds on identity's persisted ``User``: every write routes through the audited
4
+ ``BaseService`` chokepoint, so admin provisioning, role/email edits, deactivation,
5
+ and password resets each land an audit record (and a soft-delete-style
6
+ deactivation is preferred over a hard delete). Passwords are hashed via the auth
7
+ capability. Reads (``list`` / ``get``) are inherited unchanged.
8
+
9
+ A safety invariant guards the admin surface itself: an action that would leave the
10
+ system with **no active administrator** — deactivating or demoting the last active
11
+ admin — is refused (fail-closed), so an admin can never lock every administrator
12
+ out of the admin-only routes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import uuid
18
+ from collections.abc import Callable
19
+ from threading import RLock
20
+ from typing import ClassVar
21
+
22
+ from sqlmodel import Session, col, select
23
+
24
+ from terp.capabilities.auth import hash_password
25
+ from terp.core import AppError, AuditAction, BaseService, Roles, validate_password
26
+
27
+ from terp.capabilities.identity import User
28
+ from terp.capabilities.users.schemas import UserAdminUpdate, UserProvision
29
+
30
+ _ADMIN_RANK = int(Roles.ADMIN)
31
+
32
+
33
+ class LastAdminError(AppError):
34
+ """409 — the action would leave the system with no active administrator."""
35
+
36
+ status_code = 409
37
+ code = "last_admin_protected"
38
+ default_message = (
39
+ "This action would remove the last active administrator and is refused; "
40
+ "promote or activate another administrator first."
41
+ )
42
+
43
+
44
+ class SelfAdminActionError(AppError):
45
+ """409 — an administrator may not deactivate or demote their own account."""
46
+
47
+ status_code = 409
48
+ code = "self_admin_action_protected"
49
+ default_message = (
50
+ "Administrators cannot deactivate or demote their own administrator account; "
51
+ "ask another active administrator to perform this action."
52
+ )
53
+
54
+
55
+ class UsersService(BaseService[User, UserProvision, UserAdminUpdate]):
56
+ model = User
57
+ _admin_invariant_lock: ClassVar[RLock] = RLock()
58
+
59
+ def __init__(
60
+ self, refresh_revoker: Callable[[Session, uuid.UUID], None] | None = None
61
+ ) -> None:
62
+ """Optionally wire a *refresh_revoker* (ADR 0054).
63
+
64
+ When supplied, every audited user security update also revokes the user's
65
+ refresh-token families in ``_after_write``, so a logout / deactivate / demote /
66
+ password-reset kills *both* the access-token epoch and the refresh cookie in the
67
+ same transaction. Unwired (the default), revocation is ADR 0031's epoch-only
68
+ behaviour, unchanged.
69
+ """
70
+ self._refresh_revoker = refresh_revoker
71
+
72
+ def create(self, session: Session, data: UserProvision) -> User:
73
+ """Provision a new user (password hashed), audited via the write chokepoint.
74
+
75
+ The credential boundary enforces the app's ``PasswordPolicy`` (ADR 0032): a weak
76
+ password is refused with the uniform ``WeakPasswordError`` before it is hashed; the
77
+ DTO's ``max_length`` stays the separate DoS cap.
78
+ """
79
+ validate_password(data.password)
80
+ user = User(
81
+ email=data.email,
82
+ hashed_password=hash_password(data.password),
83
+ role=int(data.role),
84
+ )
85
+ return self._save(session, user, AuditAction.CREATED)
86
+
87
+ def get_by_email(self, session: Session, email: str) -> User | None:
88
+ """Find a user by email, or ``None`` — the lookup the bootstrap / seed paths share."""
89
+ return session.exec(select(User).where(User.email == email)).first()
90
+
91
+ def list_matching(
92
+ self, session: Session, *, email: str, skip: int, limit: int
93
+ ) -> tuple[list[User], int]:
94
+ """Paginated users whose email contains *email* (case-insensitive).
95
+
96
+ The directory-lookup primitive behind the admin surface's ``?email=``
97
+ filter: member pickers and admin searches resolve an account by typing
98
+ part of its address instead of paging through the whole directory.
99
+ Builds on ``base_query`` like every read.
100
+ """
101
+ return self._paginate(
102
+ session,
103
+ self.base_query().where(col(User.email).icontains(email)),
104
+ skip=skip,
105
+ limit=limit,
106
+ )
107
+
108
+ def ensure_user(self, session: Session, data: UserProvision) -> User:
109
+ """Idempotent provisioning: return the existing user for ``data.email``, else create one.
110
+
111
+ The create-if-absent primitive for bootstrap and seed paths (``terp user create`` /
112
+ ``terp seed``): the lookup + audited create live here once, so a caller never
113
+ re-implements the query. An already-present user is returned untouched (no password
114
+ reset, no role change), so re-running a seed is a safe no-op.
115
+ """
116
+ existing = self.get_by_email(session, data.email)
117
+ if existing is not None:
118
+ return existing
119
+ return self.create(session, data)
120
+
121
+ def update(
122
+ self,
123
+ session: Session,
124
+ entity_id: uuid.UUID,
125
+ data: UserAdminUpdate,
126
+ *,
127
+ actor_id: uuid.UUID | None = None,
128
+ ) -> User:
129
+ """Admin edit (email / role) with OCC — refusing to demote the last admin.
130
+
131
+ A role or email change is security-relevant (a demotion, or an email change that
132
+ re-tenants the user), so it bumps the token epoch in the **same** write — revoking
133
+ the user's outstanding tokens at once (ADR 0031) instead of leaving the old rank /
134
+ tenant live for the access-token lifetime.
135
+ """
136
+ with self._admin_invariant_lock:
137
+ user = self.get(session, entity_id)
138
+ if data.role is not None and int(data.role) < _ADMIN_RANK:
139
+ if actor_id is not None and user.id == actor_id:
140
+ raise SelfAdminActionError()
141
+ if self._is_last_active_admin(session, user):
142
+ raise LastAdminError()
143
+ # Do the OCC check before bumping the token epoch. Bumping first leaves a
144
+ # dirty object behind if the stale-version check raises; a later commit on the
145
+ # same session could otherwise revoke sessions even though the update failed.
146
+ if user.version != data.version:
147
+ from terp.core import StaleDataError
148
+
149
+ raise StaleDataError()
150
+ patch = self._without_managed_columns(data.model_dump(exclude_unset=True))
151
+ for key, value in patch.items():
152
+ setattr(user, key, value)
153
+ self._bump_token_version(user)
154
+ return self._save(session, user, AuditAction.UPDATED)
155
+
156
+ def set_active(
157
+ self,
158
+ session: Session,
159
+ user_id: uuid.UUID,
160
+ *,
161
+ active: bool,
162
+ actor_id: uuid.UUID | None = None,
163
+ ) -> User:
164
+ """Deactivate / reactivate a user (audited) — preferred over a hard delete.
165
+
166
+ Deactivating the last active administrator is refused, so the admin-only
167
+ surface can never be locked out for everyone. A deactivation also bumps the
168
+ token epoch, so the user's outstanding tokens stop working at once — defense in
169
+ depth atop the principal seam's mid-session ``is_active`` re-check (ADR 0031).
170
+ """
171
+ with self._admin_invariant_lock:
172
+ user = self.get(session, user_id)
173
+ if not active:
174
+ if actor_id is not None and user.id == actor_id:
175
+ raise SelfAdminActionError()
176
+ if self._is_last_active_admin(session, user):
177
+ raise LastAdminError()
178
+ self._bump_token_version(user)
179
+ user.is_active = active
180
+ return self._save(session, user, AuditAction.UPDATED)
181
+
182
+ def reset_password(
183
+ self, session: Session, user_id: uuid.UUID, new_password: str
184
+ ) -> User:
185
+ """Set a new password for a user (hashed, audited).
186
+
187
+ Resetting the password revokes the user's outstanding tokens (the epoch bump),
188
+ so a live session on the old credential cannot survive the reset (ADR 0031).
189
+ """
190
+ validate_password(new_password)
191
+ user = self.get(session, user_id)
192
+ user.hashed_password = hash_password(new_password)
193
+ self._bump_token_version(user)
194
+ return self._save(session, user, AuditAction.UPDATED)
195
+
196
+ def revoke_sessions(self, session: Session, user_id: uuid.UUID) -> None:
197
+ """Invalidate a user's outstanding tokens — the logout / forced-logout write.
198
+
199
+ Bumps the token epoch through the audited chokepoint, so every token minted
200
+ before this is rejected at its next request (ADR 0031). The auth capability's
201
+ ``/logout`` route wires this as its ``revoke_sessions`` seam (auth does not own
202
+ the store it must write).
203
+ """
204
+ user = self.get(session, user_id)
205
+ self._bump_token_version(user)
206
+ self._save(session, user, AuditAction.UPDATED)
207
+
208
+ def _after_write(self, session: Session, entity: User, action: AuditAction) -> None:
209
+ """Join refresh-token revocation to the audited user write (ADR 0054).
210
+
211
+ ``BaseService._save`` calls this after staging the ``identity_user`` update + audit
212
+ record and before the single outer commit. That makes the access-token epoch bump,
213
+ audit trail, and refresh-family revocation one atomic write unit; if any part raises,
214
+ none commits.
215
+ """
216
+ super()._after_write(session, entity, action)
217
+ if action is AuditAction.UPDATED and self._refresh_revoker is not None:
218
+ self._refresh_revoker(session, entity.id)
219
+
220
+ @staticmethod
221
+ def _bump_token_version(user: User) -> None:
222
+ """Advance the token epoch (ADR 0031); refresh families revoke in ``_after_write``.
223
+
224
+ The refresh-token revoker (when wired) runs from :meth:`_after_write`, inside the
225
+ same ``BaseService._save`` transaction as the audited user update.
226
+ """
227
+ user.token_version += 1
228
+
229
+ def _is_last_active_admin(self, session: Session, user: User) -> bool:
230
+ """True when *user* is an active admin and no other active admin remains."""
231
+ if not (user.is_active and user.role >= _ADMIN_RANK):
232
+ return False
233
+ return self._active_admin_count(session) <= 1
234
+
235
+ def _active_admin_count(self, session: Session) -> int:
236
+ """Count active admins, locking those rows where the database supports it."""
237
+ admins = session.exec(
238
+ select(User)
239
+ .where(User.is_active, User.role >= _ADMIN_RANK) # type: ignore[arg-type]
240
+ .with_for_update()
241
+ ).all()
242
+ return len(admins)