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.
Files changed (53) hide show
  1. dirigent_core/__init__.py +26 -0
  2. dirigent_core/alembic/env.py +71 -0
  3. dirigent_core/alembic/script.py.mako +26 -0
  4. dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
  5. dirigent_core/alerting.py +805 -0
  6. dirigent_core/artifacts.py +73 -0
  7. dirigent_core/auth.py +529 -0
  8. dirigent_core/blockdocs.py +223 -0
  9. dirigent_core/config.py +421 -0
  10. dirigent_core/configdocs.py +134 -0
  11. dirigent_core/database.py +177 -0
  12. dirigent_core/directory.py +339 -0
  13. dirigent_core/documents.py +878 -0
  14. dirigent_core/documentschema.py +92 -0
  15. dirigent_core/engine/__init__.py +120 -0
  16. dirigent_core/engine/claim.py +156 -0
  17. dirigent_core/engine/context.py +420 -0
  18. dirigent_core/engine/definition.py +543 -0
  19. dirigent_core/engine/executor.py +1029 -0
  20. dirigent_core/engine/failure.py +71 -0
  21. dirigent_core/engine/recovery.py +137 -0
  22. dirigent_core/engine/references.py +239 -0
  23. dirigent_core/engine/runs.py +865 -0
  24. dirigent_core/engine/services.py +74 -0
  25. dirigent_core/engine/state.py +434 -0
  26. dirigent_core/ids.py +39 -0
  27. dirigent_core/logging.py +310 -0
  28. dirigent_core/migrations.py +98 -0
  29. dirigent_core/models.py +626 -0
  30. dirigent_core/pipelines.py +494 -0
  31. dirigent_core/plugins.py +255 -0
  32. dirigent_core/protocol.py +276 -0
  33. dirigent_core/py.typed +0 -0
  34. dirigent_core/ratelimit.py +42 -0
  35. dirigent_core/registry.py +32 -0
  36. dirigent_core/retention.py +293 -0
  37. dirigent_core/scheduler.py +491 -0
  38. dirigent_core/schemas.py +147 -0
  39. dirigent_core/secrets.py +164 -0
  40. dirigent_core/storage.py +349 -0
  41. dirigent_core/telemetry.py +402 -0
  42. dirigent_core/trigger_documents.py +193 -0
  43. dirigent_core/triggers/__init__.py +117 -0
  44. dirigent_core/triggers/backfill.py +131 -0
  45. dirigent_core/triggers/materialize.py +218 -0
  46. dirigent_core/triggers/schedules.py +531 -0
  47. dirigent_core/triggers/webhooks.py +586 -0
  48. dirigent_core/types.py +59 -0
  49. dirigent_core/worker.py +350 -0
  50. dirigent_core-0.9.0.dist-info/METADATA +29 -0
  51. dirigent_core-0.9.0.dist-info/RECORD +53 -0
  52. dirigent_core-0.9.0.dist-info/WHEEL +4 -0
  53. 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")