auth-guide 0.5.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.
- auth_guide/__init__.py +5 -0
- auth_guide/__main__.py +6 -0
- auth_guide/accounts/__init__.py +322 -0
- auth_guide/accounts/migrations/0001_initial.py +63 -0
- auth_guide/accounts/migrations/__init__.py +1 -0
- auth_guide/accounts/models.py +206 -0
- auth_guide/accounts/passwords.py +172 -0
- auth_guide/accounts/tokens.py +41 -0
- auth_guide/bootstrap.py +76 -0
- auth_guide/certificate_operations.py +129 -0
- auth_guide/cli/__init__.py +898 -0
- auth_guide/cli/client.py +83 -0
- auth_guide/cli/config.py +157 -0
- auth_guide/cli/logging.py +365 -0
- auth_guide/cli/redaction.py +11 -0
- auth_guide/config_paths.py +28 -0
- auth_guide/management_api.py +591 -0
- auth_guide/platform_trust/__init__.py +1 -0
- auth_guide/platform_trust/common.py +85 -0
- auth_guide/platform_trust/debian.py +50 -0
- auth_guide/platform_trust/macos.py +83 -0
- auth_guide/platform_trust/redhat.py +50 -0
- auth_guide/secrets.py +286 -0
- auth_guide/server/__init__.py +5 -0
- auth_guide/server/lifecycle.py +209 -0
- auth_guide/server/runtime.py +235 -0
- auth_guide/system_trust.py +145 -0
- auth_guide/tls.py +835 -0
- auth_guide-0.5.0.dist-info/METADATA +146 -0
- auth_guide-0.5.0.dist-info/RECORD +33 -0
- auth_guide-0.5.0.dist-info/WHEEL +4 -0
- auth_guide-0.5.0.dist-info/entry_points.txt +3 -0
- auth_guide-0.5.0.dist-info/licenses/LICENSE +21 -0
auth_guide/__init__.py
ADDED
auth_guide/__main__.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Encrypted asynchronous provider account persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import secrets
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from anyio import Path as AsyncPath
|
|
12
|
+
from tortoise import Tortoise
|
|
13
|
+
from tortoise.connection import get_connection
|
|
14
|
+
from tortoise.migrations.api import migrate
|
|
15
|
+
from tortoise.migrations.executor import MigrationExecutor
|
|
16
|
+
|
|
17
|
+
from auth_guide.accounts.models import AccountRecord, AccountTokenRecord, Grant
|
|
18
|
+
from auth_guide.accounts.passwords import PasswordOperations
|
|
19
|
+
from auth_guide.accounts.tokens import (
|
|
20
|
+
NewAccountToken,
|
|
21
|
+
generate_account_token,
|
|
22
|
+
resolve_account_token,
|
|
23
|
+
verify_account_token,
|
|
24
|
+
)
|
|
25
|
+
from auth_guide.secrets import (
|
|
26
|
+
MissingSecretError,
|
|
27
|
+
PlatformSecretProvider,
|
|
28
|
+
SecretAlreadyExistsError,
|
|
29
|
+
SecretReference,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DATABASE_KEY_REFERENCE = SecretReference("auth-guide-database-key")
|
|
33
|
+
DATABASE_FILENAME = "auth-guide.sqlite3"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AccountRecord",
|
|
37
|
+
"AccountTokenRecord",
|
|
38
|
+
"AccountStore",
|
|
39
|
+
"AccountStoreError",
|
|
40
|
+
"DATABASE_FILENAME",
|
|
41
|
+
"Grant",
|
|
42
|
+
"NewAccountToken",
|
|
43
|
+
"generate_account_token",
|
|
44
|
+
"resolve_account_token",
|
|
45
|
+
"verify_account_token",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AccountStoreError(RuntimeError):
|
|
50
|
+
"""Base error for account-store lifecycle and persistence failures."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AccountStore:
|
|
54
|
+
"""Own the encrypted Tortoise connection and account-password operations."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, config_dir: str | Path | AsyncPath, secrets_provider: PlatformSecretProvider) -> None:
|
|
57
|
+
self._config_dir = AsyncPath(config_dir)
|
|
58
|
+
self._secrets = secrets_provider
|
|
59
|
+
self._passwords = PasswordOperations()
|
|
60
|
+
self._database_key: bytes | None = None
|
|
61
|
+
self._started = False
|
|
62
|
+
self._migrated = False
|
|
63
|
+
self._initialised_here = False
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> "AccountStore":
|
|
66
|
+
await self.start()
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(self, *_: object) -> None:
|
|
70
|
+
await self.aclose()
|
|
71
|
+
|
|
72
|
+
async def start(self) -> None:
|
|
73
|
+
"""Configure encrypted storage without applying database migrations."""
|
|
74
|
+
if self._started:
|
|
75
|
+
return
|
|
76
|
+
await self._config_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
self._database_key = await self._resolve_database_key()
|
|
78
|
+
await self._passwords.start()
|
|
79
|
+
try:
|
|
80
|
+
await Tortoise.init(
|
|
81
|
+
config=self._tortoise_config(),
|
|
82
|
+
init_connections=False,
|
|
83
|
+
_enable_global_fallback=True,
|
|
84
|
+
)
|
|
85
|
+
self._started = True
|
|
86
|
+
except BaseException:
|
|
87
|
+
await self._passwords.aclose()
|
|
88
|
+
self._database_key = None
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
async def initialise(self) -> None:
|
|
92
|
+
"""Explicitly create a new encrypted account database from user migrations."""
|
|
93
|
+
if self._started:
|
|
94
|
+
raise AccountStoreError("Account store is already started")
|
|
95
|
+
await self._config_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
if await self._database_path.exists():
|
|
97
|
+
raise AccountStoreError("Account database already exists")
|
|
98
|
+
if not await self._has_user_migrations():
|
|
99
|
+
raise AccountStoreError("Account store migration is required")
|
|
100
|
+
key = secrets.token_bytes(32)
|
|
101
|
+
try:
|
|
102
|
+
creation_cancelled = await self._create_database_key(key)
|
|
103
|
+
except SecretAlreadyExistsError as error:
|
|
104
|
+
raise AccountStoreError("Account database key already exists") from error
|
|
105
|
+
try:
|
|
106
|
+
created_database_key = True
|
|
107
|
+
if creation_cancelled:
|
|
108
|
+
raise asyncio.CancelledError
|
|
109
|
+
self._database_key = key
|
|
110
|
+
await self._passwords.start()
|
|
111
|
+
await Tortoise.init(config=self._tortoise_config(), init_connections=False)
|
|
112
|
+
self._started = True
|
|
113
|
+
await migrate(config=self._tortoise_config())
|
|
114
|
+
await Tortoise.init(config=self._tortoise_config(), _enable_global_fallback=True)
|
|
115
|
+
self._migrated = True
|
|
116
|
+
self._initialised_here = True
|
|
117
|
+
except BaseException:
|
|
118
|
+
await self._cleanup_failed_initialisation(created_database_key)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
async def remove_orphan_database_key(self) -> None:
|
|
122
|
+
"""Remove a present database key only when no database exists."""
|
|
123
|
+
if await self._database_path.exists():
|
|
124
|
+
raise AccountStoreError("Account database already exists")
|
|
125
|
+
if self._started:
|
|
126
|
+
await self.aclose()
|
|
127
|
+
self._database_key = None
|
|
128
|
+
self._passwords = PasswordOperations()
|
|
129
|
+
try:
|
|
130
|
+
await self._secrets.resolve(DATABASE_KEY_REFERENCE)
|
|
131
|
+
except MissingSecretError as error:
|
|
132
|
+
raise AccountStoreError("Account database key does not exist") from error
|
|
133
|
+
await self._secrets.delete(DATABASE_KEY_REFERENCE)
|
|
134
|
+
|
|
135
|
+
async def rollback_initialisation(self) -> None:
|
|
136
|
+
"""Remove only database state created by this store's initialisation."""
|
|
137
|
+
if not self._initialised_here:
|
|
138
|
+
raise AccountStoreError("Account store has no initialisation to roll back")
|
|
139
|
+
await self._cleanup_failed_initialisation(created_database_key=True)
|
|
140
|
+
|
|
141
|
+
async def aclose(self) -> None:
|
|
142
|
+
"""Close database connections owned by this store."""
|
|
143
|
+
if self._started:
|
|
144
|
+
await Tortoise.close_connections()
|
|
145
|
+
await self._passwords.aclose()
|
|
146
|
+
self._started = False
|
|
147
|
+
self._migrated = False
|
|
148
|
+
|
|
149
|
+
async def apply_migrations(self) -> None:
|
|
150
|
+
"""Explicitly apply the deploying user's native Tortoise migrations."""
|
|
151
|
+
await self._require_started()
|
|
152
|
+
if not await self._database_path.exists():
|
|
153
|
+
raise AccountStoreError("Account database is not initialised")
|
|
154
|
+
if not await self._has_user_migrations():
|
|
155
|
+
raise AccountStoreError("Account store migration is required")
|
|
156
|
+
await migrate(config=self._tortoise_config())
|
|
157
|
+
await Tortoise.init(config=self._tortoise_config())
|
|
158
|
+
self._migrated = True
|
|
159
|
+
|
|
160
|
+
async def migration_required(self) -> bool:
|
|
161
|
+
"""Report whether native migrations are pending without applying them."""
|
|
162
|
+
await self.pending_migration_plan()
|
|
163
|
+
return not self._migrated
|
|
164
|
+
|
|
165
|
+
async def pending_migration_plan(self) -> tuple[str, ...]:
|
|
166
|
+
"""Return a silent, display-ready plan of unapplied native migrations."""
|
|
167
|
+
await self._require_started()
|
|
168
|
+
if not await self._has_user_migrations():
|
|
169
|
+
self._migrated = False
|
|
170
|
+
return ()
|
|
171
|
+
if not await self._database_path.exists():
|
|
172
|
+
self._migrated = False
|
|
173
|
+
return ()
|
|
174
|
+
config = self._tortoise_config()
|
|
175
|
+
connection = get_connection("default")
|
|
176
|
+
await connection.create_connection(with_db=True)
|
|
177
|
+
executor = MigrationExecutor(connection, config["apps"])
|
|
178
|
+
steps = await executor.plan()
|
|
179
|
+
migration_plan = tuple(
|
|
180
|
+
f"{'-' if step.backward else '+'} {step.migration.app_label}.{step.migration.name}" for step in steps
|
|
181
|
+
)
|
|
182
|
+
self._migrated = not migration_plan
|
|
183
|
+
return migration_plan
|
|
184
|
+
|
|
185
|
+
async def list_accounts(self, *, include_inactive: bool = False) -> list[AccountRecord]:
|
|
186
|
+
"""Return complete account records, excluding inactive accounts by default."""
|
|
187
|
+
await self._require_migrated()
|
|
188
|
+
accounts = AccountRecord.including_inactive() if include_inactive else AccountRecord.all()
|
|
189
|
+
return await accounts.order_by("email_comparison")
|
|
190
|
+
|
|
191
|
+
async def set_password(self, account: AccountRecord, password: str) -> None:
|
|
192
|
+
"""Persist a fixed-policy Argon2id verifier on an account record."""
|
|
193
|
+
await self._require_migrated()
|
|
194
|
+
account.password_hash = await self._passwords.hash(password)
|
|
195
|
+
account.must_change_password = False
|
|
196
|
+
await account.save(update_fields=["password_hash", "must_change_password"])
|
|
197
|
+
|
|
198
|
+
async def create_account(self, account: AccountRecord, password: str) -> None:
|
|
199
|
+
"""Persist a newly provisioned account and its initial password together."""
|
|
200
|
+
await self._require_migrated()
|
|
201
|
+
account.password_hash = await self._passwords.hash(password)
|
|
202
|
+
account.must_change_password = True
|
|
203
|
+
await account.save()
|
|
204
|
+
|
|
205
|
+
async def reset_password(self, account: AccountRecord, password: str) -> None:
|
|
206
|
+
"""Replace a managed account password and require its next change."""
|
|
207
|
+
await self._require_migrated()
|
|
208
|
+
account.password_hash = await self._passwords.hash(password)
|
|
209
|
+
account.must_change_password = True
|
|
210
|
+
await account.save(update_fields=["password_hash", "must_change_password"])
|
|
211
|
+
|
|
212
|
+
async def verify_password(self, account: AccountRecord | None, password: str) -> bool:
|
|
213
|
+
"""Verify an active account password against its fixed Argon2id policy."""
|
|
214
|
+
await self._require_migrated()
|
|
215
|
+
if account is None or account.password_hash is None:
|
|
216
|
+
await self._passwords.verify_unknown(password)
|
|
217
|
+
return False
|
|
218
|
+
if not account.is_active:
|
|
219
|
+
await self._passwords.verify_unknown(password)
|
|
220
|
+
return False
|
|
221
|
+
if not await self._passwords.verify(account.password_hash, password):
|
|
222
|
+
return False
|
|
223
|
+
current_account = await AccountRecord.get_or_none(id=account.id)
|
|
224
|
+
return (
|
|
225
|
+
current_account is not None
|
|
226
|
+
and current_account.is_active
|
|
227
|
+
and current_account.password_hash == account.password_hash
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def _cleanup_failed_initialisation(self, created_database_key: bool) -> None:
|
|
231
|
+
"""Close partial state and remove a key created by this failed invocation."""
|
|
232
|
+
try:
|
|
233
|
+
if self._started:
|
|
234
|
+
await self.aclose()
|
|
235
|
+
else:
|
|
236
|
+
await self._passwords.aclose()
|
|
237
|
+
finally:
|
|
238
|
+
self._started = False
|
|
239
|
+
self._migrated = False
|
|
240
|
+
self._initialised_here = False
|
|
241
|
+
self._database_key = None
|
|
242
|
+
for database_path in self._database_files:
|
|
243
|
+
if await database_path.exists():
|
|
244
|
+
await database_path.unlink()
|
|
245
|
+
if created_database_key:
|
|
246
|
+
await self._secrets.delete(DATABASE_KEY_REFERENCE)
|
|
247
|
+
|
|
248
|
+
async def _create_database_key(self, key: bytes) -> bool:
|
|
249
|
+
"""Create a key to completion and report whether the caller cancelled."""
|
|
250
|
+
creation = asyncio.create_task(
|
|
251
|
+
self._secrets.create(DATABASE_KEY_REFERENCE, base64.b64encode(key).decode("ascii"))
|
|
252
|
+
)
|
|
253
|
+
cancelled = False
|
|
254
|
+
while not creation.done():
|
|
255
|
+
try:
|
|
256
|
+
await asyncio.shield(creation)
|
|
257
|
+
except asyncio.CancelledError:
|
|
258
|
+
cancelled = True
|
|
259
|
+
await creation
|
|
260
|
+
return cancelled
|
|
261
|
+
|
|
262
|
+
async def _resolve_database_key(self) -> bytes:
|
|
263
|
+
encoded_key = await self._secrets.resolve(DATABASE_KEY_REFERENCE)
|
|
264
|
+
try:
|
|
265
|
+
key = base64.b64decode(encoded_key, validate=True)
|
|
266
|
+
except ValueError as error:
|
|
267
|
+
raise AccountStoreError("Stored database key is invalid") from error
|
|
268
|
+
if len(key) != 32:
|
|
269
|
+
raise AccountStoreError("Stored database key is invalid")
|
|
270
|
+
return key
|
|
271
|
+
|
|
272
|
+
async def _require_started(self) -> None:
|
|
273
|
+
if not self._started:
|
|
274
|
+
raise AccountStoreError("Account store is not started")
|
|
275
|
+
|
|
276
|
+
async def _require_migrated(self) -> None:
|
|
277
|
+
await self._require_started()
|
|
278
|
+
if not self._migrated:
|
|
279
|
+
raise AccountStoreError("Account store migration is required")
|
|
280
|
+
|
|
281
|
+
async def _has_user_migrations(self) -> bool:
|
|
282
|
+
"""Report whether the deploying user has supplied a native migration."""
|
|
283
|
+
migrations_path = AsyncPath(__file__).parent / "migrations"
|
|
284
|
+
async for path in migrations_path.iterdir():
|
|
285
|
+
if path.suffix == ".py" and path.name != "__init__.py" and not path.name.startswith("_"):
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def _database_path(self) -> AsyncPath:
|
|
291
|
+
"""Return the configured encrypted database path."""
|
|
292
|
+
return self._config_dir / DATABASE_FILENAME
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def _database_files(self) -> tuple[AsyncPath, AsyncPath, AsyncPath]:
|
|
296
|
+
"""Return the database file and SQLite write-ahead-log sidecars."""
|
|
297
|
+
database_path = self._database_path
|
|
298
|
+
return database_path, AsyncPath(f"{database_path}-wal"), AsyncPath(f"{database_path}-shm")
|
|
299
|
+
|
|
300
|
+
def _tortoise_config(self) -> dict[str, Any]:
|
|
301
|
+
key = self._database_key
|
|
302
|
+
if key is None:
|
|
303
|
+
raise AccountStoreError("Account store database key is unavailable")
|
|
304
|
+
return {
|
|
305
|
+
"use_tz": True,
|
|
306
|
+
"timezone": "UTC",
|
|
307
|
+
"connections": {
|
|
308
|
+
"default": {
|
|
309
|
+
"engine": "tortoise_sqlcipher.sqlite_sqlcipher",
|
|
310
|
+
"credentials": {
|
|
311
|
+
"file_path": str(self._database_path),
|
|
312
|
+
"encryption_key": key,
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
"apps": {
|
|
317
|
+
"accounts": {
|
|
318
|
+
"models": ["auth_guide.accounts.models"],
|
|
319
|
+
"migrations": "auth_guide.accounts.migrations",
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from json import dumps, loads
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from tortoise import fields, migrations
|
|
6
|
+
from tortoise.fields.base import OnDelete
|
|
7
|
+
from tortoise.migrations import operations as ops
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Migration(migrations.Migration):
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
ops.CreateModel(
|
|
15
|
+
name="AccountRecord",
|
|
16
|
+
fields=[
|
|
17
|
+
("id", fields.UUIDField(primary_key=True, default=uuid4, unique=True, db_index=True)),
|
|
18
|
+
("email", fields.CharField(max_length=254)),
|
|
19
|
+
("email_comparison", fields.CharField(unique=True, max_length=254)),
|
|
20
|
+
("grants", fields.JSONField(encoder=functools.partial(dumps, separators=(",", ":")), decoder=loads)),
|
|
21
|
+
("password_hash", fields.TextField(unique=False)),
|
|
22
|
+
("created_at", fields.DatetimeField(auto_now=False, auto_now_add=True)),
|
|
23
|
+
("expires_at", fields.DatetimeField(null=True, auto_now=False, auto_now_add=False)),
|
|
24
|
+
("full_name", fields.TextField(null=True, unique=False)),
|
|
25
|
+
("must_change_password", fields.BooleanField(default=False)),
|
|
26
|
+
],
|
|
27
|
+
options={
|
|
28
|
+
"table": "accounts",
|
|
29
|
+
"app": "accounts",
|
|
30
|
+
"pk_attr": "id",
|
|
31
|
+
"table_description": "The persisted representation of one provider account.",
|
|
32
|
+
},
|
|
33
|
+
bases=["Model"],
|
|
34
|
+
),
|
|
35
|
+
ops.CreateModel(
|
|
36
|
+
name="AccountTokenRecord",
|
|
37
|
+
fields=[
|
|
38
|
+
("id", fields.UUIDField(primary_key=True, default=uuid4, unique=True, db_index=True)),
|
|
39
|
+
(
|
|
40
|
+
"account",
|
|
41
|
+
fields.ForeignKeyField(
|
|
42
|
+
"accounts.AccountRecord",
|
|
43
|
+
source_field="account_id",
|
|
44
|
+
db_constraint=True,
|
|
45
|
+
to_field="id",
|
|
46
|
+
related_name="tokens",
|
|
47
|
+
on_delete=OnDelete.CASCADE,
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
("label", fields.TextField(unique=False)),
|
|
51
|
+
("verifier", fields.CharField(unique=True, max_length=64)),
|
|
52
|
+
("scopes", fields.JSONField(encoder=functools.partial(dumps, separators=(",", ":")), decoder=loads)),
|
|
53
|
+
("created_at", fields.DatetimeField(auto_now=False, auto_now_add=True)),
|
|
54
|
+
],
|
|
55
|
+
options={
|
|
56
|
+
"table": "account_tokens",
|
|
57
|
+
"app": "accounts",
|
|
58
|
+
"pk_attr": "id",
|
|
59
|
+
"table_description": "A revocable account token represented only by its verifier and metadata.",
|
|
60
|
+
},
|
|
61
|
+
bases=["Model"],
|
|
62
|
+
),
|
|
63
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Native Tortoise migrations for account persistence."""
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tortoise models for encrypted account persistence."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from email_validator import EmailNotValidError, validate_email
|
|
10
|
+
from tortoise import fields
|
|
11
|
+
from tortoise.backends.base.client import BaseDBAsyncClient
|
|
12
|
+
from tortoise.expressions import Q
|
|
13
|
+
from tortoise.manager import Manager
|
|
14
|
+
from tortoise.models import Model
|
|
15
|
+
from tortoise.queryset import QuerySet
|
|
16
|
+
|
|
17
|
+
from auth_guide.accounts.passwords import PasswordOperations
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Grant(StrEnum):
|
|
23
|
+
"""Recognised capability grants persisted for provider accounts."""
|
|
24
|
+
|
|
25
|
+
GUIDE_ADMIN = "guide:admin"
|
|
26
|
+
ACCOUNTS_READ = "accounts:read"
|
|
27
|
+
ACCOUNTS_MANAGE = "accounts:manage"
|
|
28
|
+
TOKENS_MANAGE = "tokens:manage"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AccountQuerySet(QuerySet):
|
|
32
|
+
"""Warn when native bulk writes bypass account-model validation."""
|
|
33
|
+
|
|
34
|
+
def update(self, **kwargs: object):
|
|
35
|
+
logger.warning("Bulk account update bypasses AccountRecord.save validation")
|
|
36
|
+
return super().update(**kwargs)
|
|
37
|
+
|
|
38
|
+
def bulk_create(
|
|
39
|
+
self,
|
|
40
|
+
objects: Iterable[Any],
|
|
41
|
+
batch_size: int | None = None,
|
|
42
|
+
ignore_conflicts: bool = False,
|
|
43
|
+
update_fields: Iterable[str] | None = None,
|
|
44
|
+
on_conflict: Iterable[str] | None = None,
|
|
45
|
+
) -> Any:
|
|
46
|
+
logger.warning("Bulk account creation bypasses AccountRecord.save validation")
|
|
47
|
+
return super().bulk_create(objects, batch_size, ignore_conflicts, update_fields, on_conflict)
|
|
48
|
+
|
|
49
|
+
def bulk_update(self, objects: Iterable[Any], fields: Iterable[str], batch_size: int | None = None) -> Any:
|
|
50
|
+
logger.warning("Bulk account update bypasses AccountRecord.save validation")
|
|
51
|
+
return super().bulk_update(objects, fields, batch_size)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ActiveAccountManager(Manager):
|
|
55
|
+
"""Provide active accounts through the normal model query interface."""
|
|
56
|
+
|
|
57
|
+
def get_queryset(self) -> QuerySet:
|
|
58
|
+
"""Return accounts whose optional expiry permits access now."""
|
|
59
|
+
if self._model is None:
|
|
60
|
+
raise RuntimeError("Account manager has no model")
|
|
61
|
+
return AccountQuerySet(self._model).filter(Q(expires_at=None) | Q(expires_at__gt=datetime.now(timezone.utc)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AccountRecord(Model):
|
|
65
|
+
"""The persisted representation of one provider account."""
|
|
66
|
+
|
|
67
|
+
id = fields.UUIDField(primary_key=True)
|
|
68
|
+
email = fields.CharField(max_length=254)
|
|
69
|
+
email_comparison = fields.CharField(max_length=254, unique=True)
|
|
70
|
+
grants = fields.JSONField()
|
|
71
|
+
password_hash = fields.TextField()
|
|
72
|
+
created_at = fields.DatetimeField(auto_now_add=True)
|
|
73
|
+
expires_at = fields.DatetimeField(null=True)
|
|
74
|
+
full_name = fields.TextField(null=True)
|
|
75
|
+
must_change_password = fields.BooleanField(default=False)
|
|
76
|
+
|
|
77
|
+
class Meta: # ty: ignore[invalid-attribute-override]
|
|
78
|
+
table = "accounts"
|
|
79
|
+
manager = ActiveAccountManager()
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def including_inactive(cls) -> QuerySet:
|
|
83
|
+
"""Return an unfiltered account query for explicit administration work."""
|
|
84
|
+
return AccountQuerySet(cls)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_active(self) -> bool:
|
|
88
|
+
"""Report whether the account's optional expiry permits access."""
|
|
89
|
+
return self.expires_at is None or self.expires_at > datetime.now(timezone.utc)
|
|
90
|
+
|
|
91
|
+
def has_grant(self, requested_grant: Grant) -> bool:
|
|
92
|
+
"""Report whether an active account holds the requested grant."""
|
|
93
|
+
return self.is_active and requested_grant in self.grants
|
|
94
|
+
|
|
95
|
+
async def save(
|
|
96
|
+
self,
|
|
97
|
+
using_db: BaseDBAsyncClient | None = None,
|
|
98
|
+
update_fields: Iterable[str] | None = None,
|
|
99
|
+
force_create: bool = False,
|
|
100
|
+
force_update: bool = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Validate account identity and lifecycle fields before persistence."""
|
|
103
|
+
fields_to_update = set(update_fields) if update_fields is not None else None
|
|
104
|
+
if fields_to_update is None or "email" in fields_to_update:
|
|
105
|
+
self.update_from_dict(await normalise_account_email(self.email))
|
|
106
|
+
if fields_to_update is not None:
|
|
107
|
+
fields_to_update.add("email_comparison")
|
|
108
|
+
if fields_to_update is None or "expires_at" in fields_to_update:
|
|
109
|
+
self.update_from_dict({"expires_at": await normalise_account_expiry(self.expires_at)})
|
|
110
|
+
if fields_to_update is None or "grants" in fields_to_update:
|
|
111
|
+
self.grants = await normalise_grants(self.grants)
|
|
112
|
+
if fields_to_update is None or "password_hash" in fields_to_update:
|
|
113
|
+
if self.password_hash is not None and not PasswordOperations.is_current_verifier(self.password_hash):
|
|
114
|
+
raise ValueError("Account password verifier is invalid")
|
|
115
|
+
await super().save(
|
|
116
|
+
using_db=using_db,
|
|
117
|
+
update_fields=fields_to_update,
|
|
118
|
+
force_create=force_create,
|
|
119
|
+
force_update=force_update,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AccountTokenRecord(Model):
|
|
124
|
+
"""A revocable account token represented only by its verifier and metadata."""
|
|
125
|
+
|
|
126
|
+
id = fields.UUIDField(primary_key=True)
|
|
127
|
+
account = fields.ForeignKeyField("accounts.AccountRecord", related_name="tokens", on_delete=fields.CASCADE)
|
|
128
|
+
label = fields.TextField()
|
|
129
|
+
verifier = fields.CharField(max_length=64, unique=True)
|
|
130
|
+
scopes = fields.JSONField()
|
|
131
|
+
created_at = fields.DatetimeField(auto_now_add=True)
|
|
132
|
+
|
|
133
|
+
class Meta: # ty: ignore[invalid-attribute-override]
|
|
134
|
+
table = "account_tokens"
|
|
135
|
+
|
|
136
|
+
async def save(
|
|
137
|
+
self,
|
|
138
|
+
using_db: BaseDBAsyncClient | None = None,
|
|
139
|
+
update_fields: Iterable[str] | None = None,
|
|
140
|
+
force_create: bool = False,
|
|
141
|
+
force_update: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Validate immutable token properties and its mutable label."""
|
|
144
|
+
fields_to_update = set(update_fields) if update_fields is not None else None
|
|
145
|
+
if self._saved_in_db and (fields_to_update is None or fields_to_update - {"label"}):
|
|
146
|
+
raise ValueError("Only an account token label may be updated")
|
|
147
|
+
if fields_to_update is None or "label" in fields_to_update:
|
|
148
|
+
if not self.label:
|
|
149
|
+
raise ValueError("Account token label is required")
|
|
150
|
+
if fields_to_update is None or "verifier" in fields_to_update:
|
|
151
|
+
if len(self.verifier) != 64 or any(character not in "0123456789abcdef" for character in self.verifier):
|
|
152
|
+
raise ValueError("Account token verifier is invalid")
|
|
153
|
+
if fields_to_update is None or "scopes" in fields_to_update:
|
|
154
|
+
self.scopes = await normalise_grants(self.scopes)
|
|
155
|
+
await super().save(
|
|
156
|
+
using_db=using_db,
|
|
157
|
+
update_fields=fields_to_update,
|
|
158
|
+
force_create=force_create,
|
|
159
|
+
force_update=force_update,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_account_email(email: str) -> str:
|
|
164
|
+
"""Validate and canonicalise an email identifier without deliverability checks."""
|
|
165
|
+
try:
|
|
166
|
+
validated = validate_email(email, check_deliverability=False)
|
|
167
|
+
except EmailNotValidError as error:
|
|
168
|
+
raise ValueError("Account email identifier is invalid") from error
|
|
169
|
+
local_part, _, _ = validated.original.rpartition("@")
|
|
170
|
+
canonical_email = f"{local_part}@{validated.domain.lower()}"
|
|
171
|
+
if len(canonical_email.encode("utf-8")) > 254:
|
|
172
|
+
raise ValueError("Account email identifier is too long")
|
|
173
|
+
return canonical_email
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AccountExpiryTimezoneError(ValueError):
|
|
177
|
+
"""Raised when an account expiry omits its required timezone."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def normalise_account_email(email: str) -> dict[str, str]:
|
|
181
|
+
"""Validate and normalise an email identifier for asynchronous model use."""
|
|
182
|
+
canonical_email = validate_account_email(email)
|
|
183
|
+
return {"email": canonical_email, "email_comparison": canonical_email.casefold()}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def normalise_account_expiry(expires_at: datetime | None) -> datetime | None:
|
|
187
|
+
"""Validate and convert an optional account expiry to UTC."""
|
|
188
|
+
if expires_at is None:
|
|
189
|
+
return None
|
|
190
|
+
if expires_at.tzinfo is None or expires_at.utcoffset() is None:
|
|
191
|
+
raise AccountExpiryTimezoneError("Account expiry must include a timezone")
|
|
192
|
+
return expires_at.astimezone(timezone.utc)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def normalise_grants(grants: Iterable[Grant | str]) -> list[str]:
|
|
196
|
+
"""Validate a complete account grant or token scope set."""
|
|
197
|
+
if isinstance(grants, str):
|
|
198
|
+
raise ValueError("Grant set must be an array")
|
|
199
|
+
values = list(grants)
|
|
200
|
+
try:
|
|
201
|
+
normalised = [str(Grant(value)) for value in values]
|
|
202
|
+
except ValueError as error:
|
|
203
|
+
raise ValueError("Grant set contains an unknown grant") from error
|
|
204
|
+
if len(set(normalised)) != len(normalised):
|
|
205
|
+
raise ValueError("Grant set must not contain duplicates")
|
|
206
|
+
return normalised
|