dirigent-core 0.9.0__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.
- dirigent_core/__init__.py +26 -0
- dirigent_core/alembic/env.py +71 -0
- dirigent_core/alembic/script.py.mako +26 -0
- dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
- dirigent_core/alerting.py +805 -0
- dirigent_core/artifacts.py +73 -0
- dirigent_core/auth.py +529 -0
- dirigent_core/blockdocs.py +223 -0
- dirigent_core/config.py +421 -0
- dirigent_core/configdocs.py +134 -0
- dirigent_core/database.py +177 -0
- dirigent_core/directory.py +339 -0
- dirigent_core/documents.py +878 -0
- dirigent_core/documentschema.py +92 -0
- dirigent_core/engine/__init__.py +120 -0
- dirigent_core/engine/claim.py +156 -0
- dirigent_core/engine/context.py +420 -0
- dirigent_core/engine/definition.py +543 -0
- dirigent_core/engine/executor.py +1029 -0
- dirigent_core/engine/failure.py +71 -0
- dirigent_core/engine/recovery.py +137 -0
- dirigent_core/engine/references.py +239 -0
- dirigent_core/engine/runs.py +865 -0
- dirigent_core/engine/services.py +74 -0
- dirigent_core/engine/state.py +434 -0
- dirigent_core/ids.py +39 -0
- dirigent_core/logging.py +310 -0
- dirigent_core/migrations.py +98 -0
- dirigent_core/models.py +626 -0
- dirigent_core/pipelines.py +494 -0
- dirigent_core/plugins.py +255 -0
- dirigent_core/protocol.py +276 -0
- dirigent_core/py.typed +0 -0
- dirigent_core/ratelimit.py +42 -0
- dirigent_core/registry.py +32 -0
- dirigent_core/retention.py +293 -0
- dirigent_core/scheduler.py +491 -0
- dirigent_core/schemas.py +147 -0
- dirigent_core/secrets.py +164 -0
- dirigent_core/storage.py +349 -0
- dirigent_core/telemetry.py +402 -0
- dirigent_core/trigger_documents.py +193 -0
- dirigent_core/triggers/__init__.py +117 -0
- dirigent_core/triggers/backfill.py +131 -0
- dirigent_core/triggers/materialize.py +218 -0
- dirigent_core/triggers/schedules.py +531 -0
- dirigent_core/triggers/webhooks.py +586 -0
- dirigent_core/types.py +59 -0
- dirigent_core/worker.py +350 -0
- dirigent_core-0.9.0.dist-info/METADATA +29 -0
- dirigent_core-0.9.0.dist-info/RECORD +53 -0
- dirigent_core-0.9.0.dist-info/WHEEL +4 -0
- dirigent_core-0.9.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Persisting a step's output as an artifact reference: inline when small, a URI when not."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from dirigent_common import JsonMap
|
|
10
|
+
from dirigent_core.models import ArtifactRef, StepAttempt
|
|
11
|
+
from dirigent_core.storage import Storage, join_uri, parse_uri
|
|
12
|
+
|
|
13
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_json(value: object) -> bytes:
|
|
17
|
+
"""Serialize a value the one way the engine hashes and stores it."""
|
|
18
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def digest_of(payload: bytes) -> str:
|
|
22
|
+
"""Hash a payload the way every digest column in the schema is written."""
|
|
23
|
+
return f"sha256:{hashlib.sha256(payload).hexdigest()}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def persist_output(
|
|
27
|
+
session: AsyncSession,
|
|
28
|
+
storage: Storage,
|
|
29
|
+
attempt: StepAttempt,
|
|
30
|
+
output: JsonMap,
|
|
31
|
+
*,
|
|
32
|
+
inline_max_bytes: int,
|
|
33
|
+
) -> ArtifactRef:
|
|
34
|
+
"""Record a step output, inline or as a stored object, and return its reference.
|
|
35
|
+
|
|
36
|
+
The attempt keeps the structured value either way, so reference resolution reads it
|
|
37
|
+
straight off the row.
|
|
38
|
+
"""
|
|
39
|
+
payload = canonical_json(output)
|
|
40
|
+
reference = ArtifactRef(
|
|
41
|
+
run_id=attempt.run_id,
|
|
42
|
+
step_attempt_id=attempt.id,
|
|
43
|
+
step_name=attempt.step_name,
|
|
44
|
+
content_type=JSON_CONTENT_TYPE,
|
|
45
|
+
size_bytes=len(payload),
|
|
46
|
+
digest=digest_of(payload),
|
|
47
|
+
)
|
|
48
|
+
if len(payload) <= inline_max_bytes:
|
|
49
|
+
reference.inline_value = output
|
|
50
|
+
else:
|
|
51
|
+
uri = join_uri(storage.scratch_for(attempt.run_id), "outputs", f"{attempt.id}.json")
|
|
52
|
+
await storage.write_bytes(uri, payload)
|
|
53
|
+
reference.uri = uri
|
|
54
|
+
reference.scheme = parse_uri(uri)[0]
|
|
55
|
+
session.add(reference)
|
|
56
|
+
await session.flush()
|
|
57
|
+
attempt.output = output
|
|
58
|
+
attempt.output_artifact_id = reference.id
|
|
59
|
+
return reference
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UUID) -> JsonMap | None:
|
|
63
|
+
"""Read an artifact back, from the row when it inlined and from storage when it did not."""
|
|
64
|
+
reference = await session.get(ArtifactRef, artifact_id)
|
|
65
|
+
if reference is None:
|
|
66
|
+
return None
|
|
67
|
+
if reference.inline_value is not None:
|
|
68
|
+
return reference.inline_value
|
|
69
|
+
if reference.uri is None:
|
|
70
|
+
return None
|
|
71
|
+
payload = await storage.read_bytes(reference.uri)
|
|
72
|
+
loaded: JsonMap = json.loads(payload)
|
|
73
|
+
return loaded
|
dirigent_core/auth.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Accounts, passwords, and bearer credentials.
|
|
2
|
+
|
|
3
|
+
No secret is stored: passwords are Argon2id hashes and tokens are stored as a SHA-256 of the
|
|
4
|
+
presented value.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import secrets as secrets_module
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
from argon2 import PasswordHasher
|
|
15
|
+
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, SecretStr
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from dirigent_client.enums import TokenKind, TriggerKind, UserRole
|
|
20
|
+
from dirigent_core.logging import get_logger
|
|
21
|
+
from dirigent_core.models import ApiToken, User, utcnow
|
|
22
|
+
|
|
23
|
+
SESSION_LIFETIME = timedelta(days=14)
|
|
24
|
+
|
|
25
|
+
TOKEN_BYTES = 32
|
|
26
|
+
|
|
27
|
+
#: How much of a token is stored in the clear beside its hash.
|
|
28
|
+
PREFIX_LENGTH = 8
|
|
29
|
+
|
|
30
|
+
MIN_PASSWORD_LENGTH = 8
|
|
31
|
+
|
|
32
|
+
BOOTSTRAP_PASSWORD_ENV = "DIRIGENT_BOOTSTRAP_ADMIN_PASSWORD"
|
|
33
|
+
|
|
34
|
+
DEFAULT_ADMIN = "admin"
|
|
35
|
+
|
|
36
|
+
#: Names the advisory lock every account change that could lock this instance out takes.
|
|
37
|
+
ACCOUNT_LOCK_KEY = 0x64_69_72_67_61_63_63_74
|
|
38
|
+
|
|
39
|
+
#: How stale a token's ``last_used_at`` may be before the next resolution rewrites it.
|
|
40
|
+
LAST_USED_RESOLUTION = timedelta(seconds=60)
|
|
41
|
+
|
|
42
|
+
_hasher = PasswordHasher()
|
|
43
|
+
_logger = get_logger("auth")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthError(Exception):
|
|
47
|
+
"""Any refusal from the authentication layer."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WeakPassword(AuthError):
|
|
51
|
+
"""A password was too short to be worth hashing."""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
"""Build a message stating the minimum length."""
|
|
55
|
+
super().__init__(f"a password must be at least {MIN_PASSWORD_LENGTH} characters")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DuplicateUser(AuthError):
|
|
59
|
+
"""An account already holds the requested username."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, username: str) -> None:
|
|
62
|
+
"""Name the account already holding the username."""
|
|
63
|
+
super().__init__(f"a user named {username!r} already exists")
|
|
64
|
+
self.username = username
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DuplicateEmail(AuthError):
|
|
68
|
+
"""An account already holds the requested email address."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, email: str) -> None:
|
|
71
|
+
"""Name the address already taken."""
|
|
72
|
+
super().__init__(f"a user with the email {email!r} already exists")
|
|
73
|
+
self.email = email
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LastAdmin(AuthError):
|
|
77
|
+
"""A change would have left the instance with no active admin."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, username: str) -> None:
|
|
80
|
+
"""Name the account that would have been the last one able to manage this instance."""
|
|
81
|
+
super().__init__(f"{username!r} is the only active admin; promote another account before changing this one")
|
|
82
|
+
self.username = username
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WrongPassword(AuthError):
|
|
86
|
+
"""A self-service password change presented the wrong current password."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
"""State what did not match, without naming the account."""
|
|
90
|
+
super().__init__("the current password is not correct")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Principal(BaseModel):
|
|
94
|
+
"""Who a request is acting as, and which credential said so."""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(frozen=True)
|
|
97
|
+
|
|
98
|
+
user_id: UUID
|
|
99
|
+
username: str
|
|
100
|
+
role: UserRole
|
|
101
|
+
token_id: UUID | None = None
|
|
102
|
+
token_name: str | None = None
|
|
103
|
+
via: TokenKind | None = None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_admin(self) -> bool:
|
|
107
|
+
"""Report whether this principal may manage accounts and definitions."""
|
|
108
|
+
return self.role is UserRole.ADMIN
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def may_operate(self) -> bool:
|
|
112
|
+
"""Report whether this principal may change anything: define, apply, run, and schedule."""
|
|
113
|
+
return self.role in {UserRole.ADMIN, UserRole.OPERATOR}
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def label(self) -> str:
|
|
117
|
+
"""Render who this is for a log line or a run's attribution label."""
|
|
118
|
+
if self.token_name and self.via is TokenKind.API:
|
|
119
|
+
return f"{self.username} (token {self.token_name})"
|
|
120
|
+
return self.username
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def trigger_kind(self) -> TriggerKind:
|
|
124
|
+
"""Say whether a run this principal starts was started by a person or by automation."""
|
|
125
|
+
return TriggerKind.API_TOKEN if self.via is TokenKind.API else TriggerKind.USER
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class IssuedToken(BaseModel):
|
|
129
|
+
"""A freshly minted credential, carrying the only copy of its secret."""
|
|
130
|
+
|
|
131
|
+
model_config = ConfigDict(frozen=True)
|
|
132
|
+
|
|
133
|
+
id: UUID
|
|
134
|
+
name: str
|
|
135
|
+
username: str
|
|
136
|
+
kind: TokenKind
|
|
137
|
+
secret: SecretStr
|
|
138
|
+
prefix: str
|
|
139
|
+
expires_at: datetime | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TokenRow(BaseModel):
|
|
143
|
+
"""An API token beside the account that holds it, as a listing renders it."""
|
|
144
|
+
|
|
145
|
+
model_config = ConfigDict(frozen=True)
|
|
146
|
+
|
|
147
|
+
id: UUID
|
|
148
|
+
username: str
|
|
149
|
+
name: str
|
|
150
|
+
prefix: str
|
|
151
|
+
created_at: datetime
|
|
152
|
+
last_used_at: datetime | None = None
|
|
153
|
+
expires_at: datetime | None = None
|
|
154
|
+
revoked_at: datetime | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def hash_password(password: str) -> str:
|
|
158
|
+
"""Hash a password with Argon2id, refusing one too short to be worth hashing."""
|
|
159
|
+
if len(password) < MIN_PASSWORD_LENGTH:
|
|
160
|
+
raise WeakPassword
|
|
161
|
+
return _hasher.hash(password)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def verify_password(password_hash: str, password: str) -> bool:
|
|
165
|
+
"""Check a password against its stored hash, returning rather than raising on mismatch."""
|
|
166
|
+
try:
|
|
167
|
+
return _hasher.verify(password_hash, password)
|
|
168
|
+
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def mint_secret() -> str:
|
|
173
|
+
"""Generate an unguessable token; this value is never stored, only its hash."""
|
|
174
|
+
return secrets_module.token_urlsafe(TOKEN_BYTES)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def hash_token(secret: str) -> str:
|
|
178
|
+
"""Hash a presented token the one way the lookup compares it.
|
|
179
|
+
|
|
180
|
+
A plain SHA-256 rather than a password hash: the token carries full entropy, so there is
|
|
181
|
+
nothing to brute-force.
|
|
182
|
+
"""
|
|
183
|
+
return hashlib.sha256(secret.encode()).hexdigest()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def find_user(session: AsyncSession, username: str) -> User | None:
|
|
187
|
+
"""Find an account by name."""
|
|
188
|
+
found = await session.execute(sa.select(User).where(User.username == username))
|
|
189
|
+
return found.scalar_one_or_none()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def find_user_by_email(session: AsyncSession, email: str) -> User | None:
|
|
193
|
+
"""Find an account by email address."""
|
|
194
|
+
found = await session.execute(sa.select(User).where(User.email == email))
|
|
195
|
+
return found.scalar_one_or_none()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def list_users(session: AsyncSession, *, after: str | None = None, limit: int | None = None) -> list[User]:
|
|
199
|
+
"""List accounts in name order."""
|
|
200
|
+
statement = sa.select(User).order_by(User.username)
|
|
201
|
+
if after is not None:
|
|
202
|
+
statement = statement.where(User.username > after)
|
|
203
|
+
if limit is not None:
|
|
204
|
+
statement = statement.limit(limit)
|
|
205
|
+
rows = await session.execute(statement)
|
|
206
|
+
return list(rows.scalars())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def count_users(session: AsyncSession) -> int:
|
|
210
|
+
"""Count accounts."""
|
|
211
|
+
found = await session.execute(sa.select(sa.func.count()).select_from(User))
|
|
212
|
+
return int(found.scalar_one())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def create_user(
|
|
216
|
+
session: AsyncSession,
|
|
217
|
+
username: str,
|
|
218
|
+
password: str,
|
|
219
|
+
*,
|
|
220
|
+
role: UserRole,
|
|
221
|
+
name: str | None = None,
|
|
222
|
+
email: str | None = None,
|
|
223
|
+
) -> User:
|
|
224
|
+
"""Create an account, refusing a duplicate name or address before hashing anything."""
|
|
225
|
+
if await find_user(session, username) is not None:
|
|
226
|
+
raise DuplicateUser(username)
|
|
227
|
+
if email is not None and await find_user_by_email(session, email) is not None:
|
|
228
|
+
raise DuplicateEmail(email)
|
|
229
|
+
user = User(
|
|
230
|
+
username=username,
|
|
231
|
+
name=name,
|
|
232
|
+
email=email,
|
|
233
|
+
password_hash=hash_password(password),
|
|
234
|
+
role=role,
|
|
235
|
+
)
|
|
236
|
+
session.add(user)
|
|
237
|
+
await session.flush()
|
|
238
|
+
_logger.info("user created", username=username, role=role.value)
|
|
239
|
+
return user
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def reset_password(session: AsyncSession, user: User, password: str) -> User:
|
|
243
|
+
"""Replace an account's password without its old one, ending every session it holds and no API token."""
|
|
244
|
+
user.password_hash = hash_password(password)
|
|
245
|
+
await revoke_sessions(session, user.id)
|
|
246
|
+
await session.flush()
|
|
247
|
+
_logger.info("password reset", username=user.username)
|
|
248
|
+
return user
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def set_email(session: AsyncSession, user: User, email: str | None) -> User:
|
|
252
|
+
"""Set or clear an account's email address, refusing one another account already holds."""
|
|
253
|
+
if email is not None:
|
|
254
|
+
existing = await find_user_by_email(session, email)
|
|
255
|
+
if existing is not None and existing.id != user.id:
|
|
256
|
+
raise DuplicateEmail(email)
|
|
257
|
+
user.email = email
|
|
258
|
+
await session.flush()
|
|
259
|
+
return user
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def count_active_admins(session: AsyncSession, *, excluding: UUID | None = None) -> int:
|
|
263
|
+
"""Count the accounts that can still manage this instance, optionally ignoring one row."""
|
|
264
|
+
statement = sa.select(sa.func.count()).select_from(User).where(User.role == UserRole.ADMIN, User.active.is_(True))
|
|
265
|
+
if excluding is not None:
|
|
266
|
+
statement = statement.where(User.id != excluding)
|
|
267
|
+
found = await session.execute(statement)
|
|
268
|
+
return int(found.scalar_one())
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def lock_accounts(session: AsyncSession) -> None:
|
|
272
|
+
"""Serialise the account changes that are guarded against locking everyone out.
|
|
273
|
+
|
|
274
|
+
The guard counts the other admins and then writes, and two of those running at once each
|
|
275
|
+
see the other account as the one that keeps the instance manageable. The lock is held
|
|
276
|
+
until the transaction ends. PostgreSQL only; SQLite serialises its writers itself.
|
|
277
|
+
"""
|
|
278
|
+
if session.get_bind().dialect.name != "postgresql":
|
|
279
|
+
return
|
|
280
|
+
await session.execute(sa.select(sa.func.pg_advisory_xact_lock(ACCOUNT_LOCK_KEY)))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def _refuse_lockout(session: AsyncSession, user: User) -> None:
|
|
284
|
+
"""Refuse a change to this row that would leave the instance with no active admin."""
|
|
285
|
+
await lock_accounts(session)
|
|
286
|
+
if user.role is not UserRole.ADMIN or not user.active:
|
|
287
|
+
return
|
|
288
|
+
if await count_active_admins(session, excluding=user.id) == 0:
|
|
289
|
+
raise LastAdmin(user.username)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def set_role(session: AsyncSession, user: User, role: UserRole) -> User:
|
|
293
|
+
"""Change what an account may do, refusing the change that locks everyone out."""
|
|
294
|
+
if role is not UserRole.ADMIN:
|
|
295
|
+
await _refuse_lockout(session, user)
|
|
296
|
+
user.role = role
|
|
297
|
+
await session.flush()
|
|
298
|
+
_logger.info("user role changed", username=user.username, role=role.value)
|
|
299
|
+
return user
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def revoke_sessions(session: AsyncSession, user_id: UUID, *, keep: UUID | None = None) -> int:
|
|
303
|
+
"""Revoke every live browser session of an account, reporting how many were ended."""
|
|
304
|
+
statement = sa.select(ApiToken).where(
|
|
305
|
+
ApiToken.user_id == user_id,
|
|
306
|
+
ApiToken.kind == TokenKind.SESSION,
|
|
307
|
+
ApiToken.revoked_at.is_(None),
|
|
308
|
+
)
|
|
309
|
+
if keep is not None:
|
|
310
|
+
statement = statement.where(ApiToken.id != keep)
|
|
311
|
+
rows = await session.execute(statement)
|
|
312
|
+
moment = utcnow()
|
|
313
|
+
revoked = 0
|
|
314
|
+
for row in rows.scalars():
|
|
315
|
+
row.revoked_at = moment
|
|
316
|
+
revoked += 1
|
|
317
|
+
await session.flush()
|
|
318
|
+
return revoked
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def deactivate_user(session: AsyncSession, user: User) -> User:
|
|
322
|
+
"""Bar an account from logging in and end the sessions it already holds.
|
|
323
|
+
|
|
324
|
+
The flag and the revocations land in one flush, so no window exists in which the account
|
|
325
|
+
is barred from logging in while a session it already had still resolves.
|
|
326
|
+
"""
|
|
327
|
+
await _refuse_lockout(session, user)
|
|
328
|
+
user.active = False
|
|
329
|
+
await revoke_sessions(session, user.id)
|
|
330
|
+
_logger.info("user deactivated", username=user.username)
|
|
331
|
+
return user
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def activate_user(session: AsyncSession, user: User) -> User:
|
|
335
|
+
"""Let an account log in again; the sessions it lost are not restored."""
|
|
336
|
+
user.active = True
|
|
337
|
+
await session.flush()
|
|
338
|
+
_logger.info("user activated", username=user.username)
|
|
339
|
+
return user
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def change_password(
|
|
343
|
+
session: AsyncSession,
|
|
344
|
+
user: User,
|
|
345
|
+
current: str,
|
|
346
|
+
new: str,
|
|
347
|
+
*,
|
|
348
|
+
keep: UUID | None = None,
|
|
349
|
+
) -> User:
|
|
350
|
+
"""Replace an account's own password, ending every session it holds except ``keep``.
|
|
351
|
+
|
|
352
|
+
Verifying the current password here is what makes this self-service rather than a reset:
|
|
353
|
+
holding the credential is not enough, the person has to know the password it stands for.
|
|
354
|
+
"""
|
|
355
|
+
if not verify_password(user.password_hash, current):
|
|
356
|
+
raise WrongPassword
|
|
357
|
+
user.password_hash = hash_password(new)
|
|
358
|
+
await revoke_sessions(session, user.id, keep=keep)
|
|
359
|
+
_logger.info("password changed", username=user.username)
|
|
360
|
+
return user
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@lru_cache(maxsize=1)
|
|
364
|
+
def absent_password_hash() -> str:
|
|
365
|
+
"""A real Argon2id hash of a value nothing can present, verified against on a miss.
|
|
366
|
+
|
|
367
|
+
The point is the *time*, not the result. Short-circuiting on a missing account would let
|
|
368
|
+
a login against an unknown username return in well under a millisecond while a real one
|
|
369
|
+
takes the ~28ms Argon2id costs, answering "does this account exist?" to anyone with a
|
|
370
|
+
stopwatch -- exactly what the identical error message is there to prevent. Hashing a
|
|
371
|
+
random secret costs one call, once per process.
|
|
372
|
+
"""
|
|
373
|
+
return _hasher.hash(secrets_module.token_urlsafe(TOKEN_BYTES))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def authenticate(session: AsyncSession, username: str, password: str) -> User | None:
|
|
377
|
+
"""Verify a username and password, returning the account or nothing at all.
|
|
378
|
+
|
|
379
|
+
A missing account and a wrong password are the same answer on purpose, so the endpoint
|
|
380
|
+
above cannot become a way to enumerate who exists -- and they take the same time, which
|
|
381
|
+
is the half that a short-circuit would give away.
|
|
382
|
+
"""
|
|
383
|
+
user = await find_user(session, username)
|
|
384
|
+
if user is None or not user.active:
|
|
385
|
+
verify_password(absent_password_hash(), password)
|
|
386
|
+
return None
|
|
387
|
+
if not verify_password(user.password_hash, password):
|
|
388
|
+
return None
|
|
389
|
+
user.last_login_at = utcnow()
|
|
390
|
+
await session.flush()
|
|
391
|
+
return user
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def issue_token(
|
|
395
|
+
session: AsyncSession,
|
|
396
|
+
user: User,
|
|
397
|
+
*,
|
|
398
|
+
name: str,
|
|
399
|
+
kind: TokenKind = TokenKind.API,
|
|
400
|
+
lifetime: timedelta | None = None,
|
|
401
|
+
) -> IssuedToken:
|
|
402
|
+
"""Mint a credential for an account and store only its hash."""
|
|
403
|
+
secret = mint_secret()
|
|
404
|
+
expires_at = utcnow() + lifetime if lifetime else None
|
|
405
|
+
row = ApiToken(
|
|
406
|
+
user_id=user.id,
|
|
407
|
+
name=name,
|
|
408
|
+
kind=kind,
|
|
409
|
+
token_hash=hash_token(secret),
|
|
410
|
+
prefix=secret[:PREFIX_LENGTH],
|
|
411
|
+
expires_at=expires_at,
|
|
412
|
+
)
|
|
413
|
+
session.add(row)
|
|
414
|
+
await session.flush()
|
|
415
|
+
_logger.info("token issued", username=user.username, name=name, kind=kind.value)
|
|
416
|
+
return IssuedToken(
|
|
417
|
+
id=row.id,
|
|
418
|
+
name=name,
|
|
419
|
+
username=user.username,
|
|
420
|
+
kind=kind,
|
|
421
|
+
secret=SecretStr(secret),
|
|
422
|
+
prefix=row.prefix,
|
|
423
|
+
expires_at=expires_at,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _should_record_use(token: ApiToken, moment: datetime) -> bool:
|
|
428
|
+
"""Say whether this resolution is the one that writes ``last_used_at``."""
|
|
429
|
+
if token.last_used_at is None:
|
|
430
|
+
return True
|
|
431
|
+
return moment - token.last_used_at >= LAST_USED_RESOLUTION
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def resolve_token(session: AsyncSession, secret: str, *, now: datetime | None = None) -> Principal | None:
|
|
435
|
+
"""Turn a presented secret into the principal it authenticates, or nothing."""
|
|
436
|
+
moment = now or utcnow()
|
|
437
|
+
found = await session.execute(sa.select(ApiToken).where(ApiToken.token_hash == hash_token(secret)))
|
|
438
|
+
token = found.scalar_one_or_none()
|
|
439
|
+
if token is None or token.revoked_at is not None:
|
|
440
|
+
return None
|
|
441
|
+
user = await session.get(User, token.user_id)
|
|
442
|
+
if user is None or not user.active:
|
|
443
|
+
return None
|
|
444
|
+
if token.expires_at is not None and token.expires_at <= moment:
|
|
445
|
+
return None
|
|
446
|
+
if _should_record_use(token, moment):
|
|
447
|
+
token.last_used_at = moment
|
|
448
|
+
return Principal(
|
|
449
|
+
user_id=user.id,
|
|
450
|
+
username=user.username,
|
|
451
|
+
role=user.role,
|
|
452
|
+
token_id=token.id,
|
|
453
|
+
token_name=token.name,
|
|
454
|
+
via=token.kind,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def list_tokens(
|
|
459
|
+
session: AsyncSession,
|
|
460
|
+
*,
|
|
461
|
+
user_id: UUID | None = None,
|
|
462
|
+
after: UUID | None = None,
|
|
463
|
+
limit: int | None = None,
|
|
464
|
+
) -> list[TokenRow]:
|
|
465
|
+
"""List API tokens beside the account that holds each, in id order, which is oldest first."""
|
|
466
|
+
statement = (
|
|
467
|
+
sa.select(ApiToken, User.username)
|
|
468
|
+
.join(User, User.id == ApiToken.user_id)
|
|
469
|
+
.where(ApiToken.kind == TokenKind.API)
|
|
470
|
+
.order_by(ApiToken.id)
|
|
471
|
+
)
|
|
472
|
+
if user_id is not None:
|
|
473
|
+
statement = statement.where(ApiToken.user_id == user_id)
|
|
474
|
+
if after is not None:
|
|
475
|
+
statement = statement.where(ApiToken.id > after)
|
|
476
|
+
if limit is not None:
|
|
477
|
+
statement = statement.limit(limit)
|
|
478
|
+
rows = await session.execute(statement)
|
|
479
|
+
return [
|
|
480
|
+
TokenRow(
|
|
481
|
+
id=token.id,
|
|
482
|
+
username=username,
|
|
483
|
+
name=token.name,
|
|
484
|
+
prefix=token.prefix,
|
|
485
|
+
created_at=token.created_at,
|
|
486
|
+
last_used_at=token.last_used_at,
|
|
487
|
+
expires_at=token.expires_at,
|
|
488
|
+
revoked_at=token.revoked_at,
|
|
489
|
+
)
|
|
490
|
+
for token, username in rows
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def revoke_token(session: AsyncSession, *, user_id: UUID, name: str) -> bool:
|
|
495
|
+
"""Revoke one account's live tokens of a given name, reporting whether anything was revoked.
|
|
496
|
+
|
|
497
|
+
A name is unique only within an account, so the owner is part of what is revoked.
|
|
498
|
+
"""
|
|
499
|
+
statement = sa.select(ApiToken).where(
|
|
500
|
+
ApiToken.revoked_at.is_(None),
|
|
501
|
+
ApiToken.user_id == user_id,
|
|
502
|
+
ApiToken.name == name,
|
|
503
|
+
)
|
|
504
|
+
rows = await session.execute(statement)
|
|
505
|
+
revoked = 0
|
|
506
|
+
for token in rows.scalars():
|
|
507
|
+
token.revoked_at = utcnow()
|
|
508
|
+
revoked += 1
|
|
509
|
+
await session.flush()
|
|
510
|
+
return revoked > 0
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def revoke_session(session: AsyncSession, secret: str) -> None:
|
|
514
|
+
"""Revoke the session a cookie carries."""
|
|
515
|
+
found = await session.execute(sa.select(ApiToken).where(ApiToken.token_hash == hash_token(secret)))
|
|
516
|
+
row = found.scalar_one_or_none()
|
|
517
|
+
if row is not None and row.revoked_at is None:
|
|
518
|
+
row.revoked_at = utcnow()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
async def bootstrap_admin(session: AsyncSession, password: str, *, username: str = DEFAULT_ADMIN) -> User | None:
|
|
522
|
+
"""Create the first admin if this instance has none.
|
|
523
|
+
|
|
524
|
+
Returns None when accounts already exist, so setting the environment variable on every
|
|
525
|
+
deploy cannot reset the password of a live instance.
|
|
526
|
+
"""
|
|
527
|
+
if await count_users(session) > 0:
|
|
528
|
+
return None
|
|
529
|
+
return await create_user(session, username, password, role=UserRole.ADMIN, name="Bootstrap admin")
|