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.
- terp_cap_users-0.1.0/.gitignore +47 -0
- terp_cap_users-0.1.0/PKG-INFO +9 -0
- terp_cap_users-0.1.0/pyproject.toml +27 -0
- terp_cap_users-0.1.0/src/terp/capabilities/users/__init__.py +33 -0
- terp_cap_users-0.1.0/src/terp/capabilities/users/py.typed +0 -0
- terp_cap_users-0.1.0/src/terp/capabilities/users/router.py +128 -0
- terp_cap_users-0.1.0/src/terp/capabilities/users/schemas.py +56 -0
- terp_cap_users-0.1.0/src/terp/capabilities/users/service.py +242 -0
|
@@ -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
|
+
]
|
|
File without changes
|
|
@@ -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)
|