l3io-wordpress-database 0.3.5__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.
- l3io/wp/database/__init__.py +192 -0
- l3io/wp/database/__main__.py +10 -0
- l3io/wp/database/_client.py +318 -0
- l3io/wp/database/_observe.py +157 -0
- l3io/wp/database/_passphrase.py +237 -0
- l3io/wp/database/_sql.py +79 -0
- l3io/wp/database/_version.py +24 -0
- l3io/wp/database/_version_map.py +35 -0
- l3io/wp/database/auth.py +64 -0
- l3io/wp/database/cli.py +531 -0
- l3io/wp/database/connection.py +71 -0
- l3io/wp/database/credentials.py +196 -0
- l3io/wp/database/encryption.py +188 -0
- l3io/wp/database/errors.py +186 -0
- l3io/wp/database/facade.py +220 -0
- l3io/wp/database/identifiers.py +95 -0
- l3io/wp/database/inspection.py +254 -0
- l3io/wp/database/provision.py +751 -0
- l3io/wp/database/py.typed +0 -0
- l3io/wp/database/server.py +139 -0
- l3io/wp/database/state.py +82 -0
- l3io/wp/database/tls.py +211 -0
- l3io/wp/database/transfer.py +1165 -0
- l3io/wp/database/verdict.py +193 -0
- l3io/wp/database/versions.py +101 -0
- l3io/wp/database/wpconfig.py +145 -0
- l3io_wordpress_database-0.3.5.dist-info/METADATA +239 -0
- l3io_wordpress_database-0.3.5.dist-info/RECORD +31 -0
- l3io_wordpress_database-0.3.5.dist-info/WHEEL +4 -0
- l3io_wordpress_database-0.3.5.dist-info/entry_points.txt +2 -0
- l3io_wordpress_database-0.3.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""Provisioning operations, each checked before it runs.
|
|
2
|
+
|
|
3
|
+
Every operation's preflight produces a :class:`Verdict`. ``check_X`` returns it;
|
|
4
|
+
``X`` raises :class:`RefusedError` with it on a blocker, so the check is never
|
|
5
|
+
skipped and never differs from the run. ``ensure`` queries facts, plans with a
|
|
6
|
+
pure function, and executes only what the plan names, so a converged server
|
|
7
|
+
sees no writes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pymysql
|
|
16
|
+
|
|
17
|
+
from ._observe import VERDICT_KEY, operation
|
|
18
|
+
from ._sql import (
|
|
19
|
+
ER_CANNOT_USER,
|
|
20
|
+
ER_DBACCESS_DENIED,
|
|
21
|
+
ER_NONEXISTING_GRANT,
|
|
22
|
+
ER_TABLEACCESS_DENIED,
|
|
23
|
+
classify,
|
|
24
|
+
error_code,
|
|
25
|
+
literal,
|
|
26
|
+
open_connection,
|
|
27
|
+
)
|
|
28
|
+
from .connection import Connection
|
|
29
|
+
from .credentials import CredentialSource, Explicit, ResolvedCredentials
|
|
30
|
+
from .errors import (
|
|
31
|
+
AccessDeniedError,
|
|
32
|
+
ConfigurationError,
|
|
33
|
+
CredentialsAbsentError,
|
|
34
|
+
CredentialsUnobtainableError,
|
|
35
|
+
DatabaseError,
|
|
36
|
+
DatabaseUnreachableError,
|
|
37
|
+
InvalidIdentifierError,
|
|
38
|
+
MissingDependencyError,
|
|
39
|
+
ProvisioningError,
|
|
40
|
+
RefusedError,
|
|
41
|
+
)
|
|
42
|
+
from .identifiers import (
|
|
43
|
+
grant_pattern,
|
|
44
|
+
quote_grant_database,
|
|
45
|
+
quote_identifier,
|
|
46
|
+
validate_charset,
|
|
47
|
+
validate_user_name,
|
|
48
|
+
)
|
|
49
|
+
from .inspection import inspect
|
|
50
|
+
from .server import Privileges, ServerInfo, account_privileges, detect_server, grantee
|
|
51
|
+
from .state import ConnectionState
|
|
52
|
+
from .verdict import Code, Finding, Verdict
|
|
53
|
+
|
|
54
|
+
WORDPRESS_PRIVILEGES = frozenset(
|
|
55
|
+
{"SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX"}
|
|
56
|
+
)
|
|
57
|
+
#: GRANT ALL needs the grantor to hold what it grants, not only GRANT OPTION.
|
|
58
|
+
ENSURE_PRIVILEGES = (
|
|
59
|
+
"CREATE",
|
|
60
|
+
"CREATE USER",
|
|
61
|
+
"GRANT OPTION",
|
|
62
|
+
*sorted(WORDPRESS_PRIVILEGES - {"CREATE"}),
|
|
63
|
+
)
|
|
64
|
+
#: Reading ``mysql.user`` needs SELECT on ``*.*``; a database-level SELECT is not enough.
|
|
65
|
+
ENSURE_GLOBAL_PRIVILEGES = ("SELECT",)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class EnsureFacts:
|
|
70
|
+
"""What already exists, as the administrator sees it."""
|
|
71
|
+
|
|
72
|
+
database_exists: bool
|
|
73
|
+
user_exists: bool
|
|
74
|
+
password_works: bool
|
|
75
|
+
grant_present: bool
|
|
76
|
+
#: Whether this machine matches the account's host, so a login proves anything.
|
|
77
|
+
verifiable: bool = True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class EnsureResult:
|
|
82
|
+
"""What :func:`ensure` changed. Each flag is true only if it changed something."""
|
|
83
|
+
|
|
84
|
+
created_database: bool
|
|
85
|
+
created_user: bool
|
|
86
|
+
password_set: bool
|
|
87
|
+
granted: bool
|
|
88
|
+
verdict: Verdict
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def changed(self) -> bool:
|
|
92
|
+
return self.created_database or self.created_user or self.password_set or self.granted
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def plan_ensure(facts: EnsureFacts) -> tuple[str, ...]:
|
|
96
|
+
"""The steps that converge the server on the desired state, in order."""
|
|
97
|
+
steps: list[str] = []
|
|
98
|
+
if not facts.database_exists:
|
|
99
|
+
steps.append("create_database")
|
|
100
|
+
if not facts.user_exists:
|
|
101
|
+
steps.append("create_user")
|
|
102
|
+
elif not facts.password_works:
|
|
103
|
+
steps.append("set_password")
|
|
104
|
+
if not facts.grant_present:
|
|
105
|
+
steps.append("grant")
|
|
106
|
+
return tuple(steps)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _privilege_findings(
|
|
110
|
+
privileges: Privileges,
|
|
111
|
+
required: tuple[str, ...],
|
|
112
|
+
global_required: tuple[str, ...] = (),
|
|
113
|
+
) -> list[Finding]:
|
|
114
|
+
if privileges.unverified:
|
|
115
|
+
return [
|
|
116
|
+
Finding(
|
|
117
|
+
Code.PRIVILEGES_UNVERIFIED,
|
|
118
|
+
"the administrator has an active role; its privileges cannot be "
|
|
119
|
+
"checked in advance, so the operation will be attempted",
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
missing = [
|
|
123
|
+
Finding(
|
|
124
|
+
Code.MISSING_PRIVILEGE,
|
|
125
|
+
f"the administrator lacks {name}",
|
|
126
|
+
{"privilege": name},
|
|
127
|
+
)
|
|
128
|
+
for name in required
|
|
129
|
+
if not privileges.has(name)
|
|
130
|
+
]
|
|
131
|
+
missing += [
|
|
132
|
+
Finding(
|
|
133
|
+
Code.MISSING_PRIVILEGE,
|
|
134
|
+
f"the administrator lacks {name} on *.*",
|
|
135
|
+
{"privilege": name, "scope": "global"},
|
|
136
|
+
)
|
|
137
|
+
for name in global_required
|
|
138
|
+
if not privileges.has_global(name)
|
|
139
|
+
]
|
|
140
|
+
return missing
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _as_package_error(
|
|
144
|
+
exc: pymysql.err.MySQLError, fallback: type[DatabaseError], message: str | None = None
|
|
145
|
+
) -> DatabaseError:
|
|
146
|
+
"""Translate a server error raised mid-session into a package-owned one."""
|
|
147
|
+
if error_code(exc) in (ER_TABLEACCESS_DENIED, ER_DBACCESS_DENIED):
|
|
148
|
+
return AccessDeniedError(f"the administrator lacks a privilege this operation needs: {exc}")
|
|
149
|
+
return fallback(message or f"the server failed during the operation: {exc}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_or_finding(source: CredentialSource) -> ResolvedCredentials | Finding:
|
|
153
|
+
"""Resolve a source, or say why it could not be, as a finding.
|
|
154
|
+
|
|
155
|
+
Every failure a source can have is a verdict, never an exception: an absent
|
|
156
|
+
or failed source, a config file that cannot supply the pair, and a missing
|
|
157
|
+
extra.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
return source.resolve()
|
|
161
|
+
except MissingDependencyError as exc:
|
|
162
|
+
return Finding(Code.MISSING_DEPENDENCY, str(exc), {"extra": exc.extra})
|
|
163
|
+
except (CredentialsUnobtainableError, CredentialsAbsentError, ConfigurationError) as exc:
|
|
164
|
+
return Finding(Code.CREDENTIALS_UNOBTAINABLE, str(exc))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _preflight(
|
|
168
|
+
connection: Connection,
|
|
169
|
+
admin: CredentialSource,
|
|
170
|
+
operation_name: str,
|
|
171
|
+
required: tuple[str, ...],
|
|
172
|
+
global_required: tuple[str, ...] = (),
|
|
173
|
+
) -> tuple[Verdict, Any | None]:
|
|
174
|
+
"""Resolve the account, then :func:`_preflight_resolved`."""
|
|
175
|
+
resolved = _resolve_or_finding(admin)
|
|
176
|
+
if isinstance(resolved, Finding):
|
|
177
|
+
return Verdict(operation_name, (resolved,)), None
|
|
178
|
+
return _preflight_resolved(connection, resolved, operation_name, required, global_required)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _preflight_resolved(
|
|
182
|
+
connection: Connection,
|
|
183
|
+
resolved: ResolvedCredentials,
|
|
184
|
+
operation_name: str,
|
|
185
|
+
required: tuple[str, ...],
|
|
186
|
+
global_required: tuple[str, ...] = (),
|
|
187
|
+
) -> tuple[Verdict, Any | None]:
|
|
188
|
+
"""Connect as this account and check server and privileges.
|
|
189
|
+
|
|
190
|
+
Returns the verdict and, only when it holds no blocker, the open session.
|
|
191
|
+
"""
|
|
192
|
+
from .errors import AuthPluginRefusedError
|
|
193
|
+
|
|
194
|
+
verdict = Verdict(operation_name)
|
|
195
|
+
try:
|
|
196
|
+
session = open_connection(connection, resolved, database=None)
|
|
197
|
+
except AuthPluginRefusedError:
|
|
198
|
+
raise
|
|
199
|
+
except Exception as exc: # noqa: BLE001 - SSLError and driver errors both map here
|
|
200
|
+
state = classify(exc)
|
|
201
|
+
if state is ConnectionState.AUTHENTICATION_FAILED:
|
|
202
|
+
finding = Finding(
|
|
203
|
+
Code.AUTHENTICATION_FAILED,
|
|
204
|
+
f"the administrator account {resolved.username!r} was rejected",
|
|
205
|
+
)
|
|
206
|
+
elif state is ConnectionState.TLS_FAILED:
|
|
207
|
+
finding = Finding(
|
|
208
|
+
Code.UNREACHABLE,
|
|
209
|
+
f"{connection.host}:{connection.port} TLS failed",
|
|
210
|
+
{"host": connection.host, "port": connection.port, "cause": "tls"},
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
finding = Finding(
|
|
214
|
+
Code.UNREACHABLE,
|
|
215
|
+
f"{connection.host}:{connection.port} could not be reached",
|
|
216
|
+
{"host": connection.host, "port": connection.port},
|
|
217
|
+
)
|
|
218
|
+
return verdict.with_findings(finding), None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
with session.cursor() as cur:
|
|
222
|
+
findings: list[Finding] = _server_findings(detect_server(cur))
|
|
223
|
+
findings += _privilege_findings(
|
|
224
|
+
account_privileges(cur, connection.database), required, global_required
|
|
225
|
+
)
|
|
226
|
+
verdict = verdict.with_findings(*findings)
|
|
227
|
+
except pymysql.err.MySQLError as exc:
|
|
228
|
+
session.close()
|
|
229
|
+
raise _as_package_error(exc, DatabaseUnreachableError) from exc
|
|
230
|
+
except BaseException:
|
|
231
|
+
session.close()
|
|
232
|
+
raise
|
|
233
|
+
if not verdict.ok:
|
|
234
|
+
session.close()
|
|
235
|
+
return verdict, None
|
|
236
|
+
return verdict, session
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _server_findings(server: ServerInfo) -> list[Finding]:
|
|
240
|
+
"""A server below the floor is a blocker; one past upstream end of life, a warning."""
|
|
241
|
+
if not server.is_supported:
|
|
242
|
+
return [
|
|
243
|
+
Finding(
|
|
244
|
+
Code.SERVER_UNSUPPORTED,
|
|
245
|
+
f"{server.family} {server.version_text} is below the supported floor",
|
|
246
|
+
{"version": server.version_text},
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
if server.is_end_of_life:
|
|
250
|
+
return [
|
|
251
|
+
Finding(
|
|
252
|
+
Code.END_OF_LIFE_SERVER,
|
|
253
|
+
f"{server.family} {server.version_text} is past upstream end of life",
|
|
254
|
+
{"version": server.version_text},
|
|
255
|
+
)
|
|
256
|
+
]
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _ensure_facts(
|
|
261
|
+
cur: Any, connection: Connection, wordpress: ResolvedCredentials, user_host: str
|
|
262
|
+
) -> EnsureFacts:
|
|
263
|
+
database = connection.database
|
|
264
|
+
cur.execute(
|
|
265
|
+
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s", (database,)
|
|
266
|
+
)
|
|
267
|
+
database_exists = int(cur.fetchone()[0]) > 0
|
|
268
|
+
cur.execute(
|
|
269
|
+
"SELECT COUNT(*) FROM mysql.user WHERE User = %s AND Host = %s",
|
|
270
|
+
(wordpress.username, user_host),
|
|
271
|
+
)
|
|
272
|
+
user_exists = int(cur.fetchone()[0]) > 0
|
|
273
|
+
# A login from here only says something about the account if the server
|
|
274
|
+
# would match this machine to user_host; ask the server, which applies its
|
|
275
|
+
# own host-pattern rules.
|
|
276
|
+
cur.execute("SELECT SUBSTRING_INDEX(USER(), '@', -1) LIKE %s", (user_host,))
|
|
277
|
+
verifiable = bool(cur.fetchone()[0])
|
|
278
|
+
password_works = not verifiable # unverifiable: leave an existing password alone
|
|
279
|
+
if user_exists and verifiable:
|
|
280
|
+
try:
|
|
281
|
+
open_connection(connection, wordpress, database=None).close()
|
|
282
|
+
password_works = True
|
|
283
|
+
except pymysql.err.MySQLError as exc:
|
|
284
|
+
if classify(exc) is not ConnectionState.AUTHENTICATION_FAILED:
|
|
285
|
+
msg = f"could not test {wordpress.username!r}'s password"
|
|
286
|
+
raise ProvisioningError(msg) from exc
|
|
287
|
+
cur.execute(
|
|
288
|
+
"SELECT PRIVILEGE_TYPE FROM information_schema.SCHEMA_PRIVILEGES "
|
|
289
|
+
"WHERE GRANTEE = %s AND TABLE_SCHEMA = %s",
|
|
290
|
+
(grantee(wordpress.username, user_host), grant_pattern(database)),
|
|
291
|
+
)
|
|
292
|
+
held = frozenset(str(r[0]) for r in cur.fetchall())
|
|
293
|
+
return EnsureFacts(
|
|
294
|
+
database_exists, user_exists, password_works, held >= WORDPRESS_PRIVILEGES, verifiable
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def check_ensure(
|
|
299
|
+
connection: Connection,
|
|
300
|
+
admin: CredentialSource,
|
|
301
|
+
wordpress: CredentialSource,
|
|
302
|
+
*,
|
|
303
|
+
user_host: str = "%", # noqa: ARG001 - kept so check_ensure mirrors ensure's signature
|
|
304
|
+
) -> Verdict:
|
|
305
|
+
"""What :func:`ensure` would find, without changing anything."""
|
|
306
|
+
with operation("check_ensure", connection) as fields:
|
|
307
|
+
verdict, session = _preflight(
|
|
308
|
+
connection, admin, "ensure", ENSURE_PRIVILEGES, ENSURE_GLOBAL_PRIVILEGES
|
|
309
|
+
)
|
|
310
|
+
if session is not None:
|
|
311
|
+
session.close()
|
|
312
|
+
wp = _resolve_or_finding(wordpress)
|
|
313
|
+
if isinstance(wp, Finding):
|
|
314
|
+
return _note(fields, verdict.with_findings(wp))
|
|
315
|
+
validate_user_name(wp.username)
|
|
316
|
+
return _note(fields, verdict)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def ensure(
|
|
320
|
+
connection: Connection,
|
|
321
|
+
admin: CredentialSource,
|
|
322
|
+
wordpress: CredentialSource,
|
|
323
|
+
*,
|
|
324
|
+
user_host: str = "%",
|
|
325
|
+
charset: str = "utf8mb4",
|
|
326
|
+
collation: str = "utf8mb4_unicode_ci",
|
|
327
|
+
) -> EnsureResult:
|
|
328
|
+
"""Converge the server on: database exists, account exists with this password,
|
|
329
|
+
account holds ALL on this database only.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
RefusedError: the check found a blocker; nothing ran.
|
|
333
|
+
ProvisioningError: a statement failed, or the account still cannot use
|
|
334
|
+
the database afterwards; ``completed`` lists the steps that ran.
|
|
335
|
+
"""
|
|
336
|
+
validate_charset(charset, "charset")
|
|
337
|
+
validate_charset(collation, "collation")
|
|
338
|
+
with operation("ensure", connection) as fields:
|
|
339
|
+
verdict, session = _preflight(
|
|
340
|
+
connection, admin, "ensure", ENSURE_PRIVILEGES, ENSURE_GLOBAL_PRIVILEGES
|
|
341
|
+
)
|
|
342
|
+
wp = _resolve_or_finding(wordpress)
|
|
343
|
+
if isinstance(wp, Finding):
|
|
344
|
+
if session is not None:
|
|
345
|
+
session.close()
|
|
346
|
+
raise RefusedError(verdict.with_findings(wp))
|
|
347
|
+
try:
|
|
348
|
+
# Validated before the session check, exactly as check_ensure does,
|
|
349
|
+
# so both raise the same error for a bad name.
|
|
350
|
+
username = validate_user_name(wp.username)
|
|
351
|
+
except InvalidIdentifierError:
|
|
352
|
+
if session is not None:
|
|
353
|
+
session.close()
|
|
354
|
+
raise
|
|
355
|
+
if session is None:
|
|
356
|
+
raise RefusedError(verdict)
|
|
357
|
+
database = connection.database
|
|
358
|
+
statements = {
|
|
359
|
+
"create_database": (
|
|
360
|
+
f"CREATE DATABASE {quote_identifier(database)} "
|
|
361
|
+
f"CHARACTER SET {charset} COLLATE {collation}",
|
|
362
|
+
None,
|
|
363
|
+
),
|
|
364
|
+
"create_user": (
|
|
365
|
+
"CREATE USER %s@%s IDENTIFIED BY %s",
|
|
366
|
+
(username, user_host, wp.password),
|
|
367
|
+
),
|
|
368
|
+
"set_password": (
|
|
369
|
+
"ALTER USER %s@%s IDENTIFIED BY %s",
|
|
370
|
+
(username, user_host, wp.password),
|
|
371
|
+
),
|
|
372
|
+
"grant": (
|
|
373
|
+
f"GRANT ALL PRIVILEGES ON {literal(quote_grant_database(database))}.* TO %s@%s",
|
|
374
|
+
(username, user_host),
|
|
375
|
+
),
|
|
376
|
+
}
|
|
377
|
+
completed: list[str] = []
|
|
378
|
+
with session, session.cursor() as cur:
|
|
379
|
+
try:
|
|
380
|
+
facts = _ensure_facts(cur, connection, wp, user_host)
|
|
381
|
+
steps = plan_ensure(facts)
|
|
382
|
+
except pymysql.err.MySQLError as exc:
|
|
383
|
+
raise _as_package_error(exc, ProvisioningError) from exc
|
|
384
|
+
try:
|
|
385
|
+
for step in steps:
|
|
386
|
+
sql, args = statements[step]
|
|
387
|
+
cur.execute(sql, args)
|
|
388
|
+
completed.append(step)
|
|
389
|
+
session.commit()
|
|
390
|
+
except pymysql.err.MySQLError as exc:
|
|
391
|
+
msg = f"ensuring {database!r} failed after {len(completed)} step(s)"
|
|
392
|
+
raise ProvisioningError(msg, completed=tuple(completed)) from exc
|
|
393
|
+
|
|
394
|
+
if facts.verifiable:
|
|
395
|
+
final = inspect(connection, Explicit(wp.username, wp.password))
|
|
396
|
+
if not final.is_ready:
|
|
397
|
+
msg = f"ensure ran but {database!r} is still not usable by {username!r}: {final}"
|
|
398
|
+
raise ProvisioningError(msg, completed=tuple(completed))
|
|
399
|
+
else:
|
|
400
|
+
verdict = verdict.with_findings(
|
|
401
|
+
Finding(
|
|
402
|
+
Code.PASSWORD_UNVERIFIED,
|
|
403
|
+
f"{username!r}@{user_host!r} does not match this machine, so the result "
|
|
404
|
+
"was not verified by logging in and an existing password was left unchanged",
|
|
405
|
+
{"user_host": user_host},
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
result = EnsureResult(
|
|
410
|
+
created_database="create_database" in completed,
|
|
411
|
+
created_user="create_user" in completed,
|
|
412
|
+
password_set="create_user" in completed or "set_password" in completed,
|
|
413
|
+
granted="grant" in completed,
|
|
414
|
+
verdict=verdict,
|
|
415
|
+
)
|
|
416
|
+
fields[VERDICT_KEY] = verdict
|
|
417
|
+
fields.update(
|
|
418
|
+
created_database=result.created_database,
|
|
419
|
+
created_user=result.created_user,
|
|
420
|
+
password_set=result.password_set,
|
|
421
|
+
granted=result.granted,
|
|
422
|
+
)
|
|
423
|
+
return result
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@dataclass(frozen=True, slots=True)
|
|
427
|
+
class OperationResult:
|
|
428
|
+
"""Whether an operation changed anything, and what its check found."""
|
|
429
|
+
|
|
430
|
+
changed: bool
|
|
431
|
+
verdict: Verdict
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
#: Reading mysql.user to answer "does this account exist?" needs global SELECT.
|
|
435
|
+
_ACCOUNT_LOOKUP = ("SELECT",)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _confirmation(target: str, confirm: str) -> list[Finding]:
|
|
439
|
+
if confirm == target:
|
|
440
|
+
return []
|
|
441
|
+
return [
|
|
442
|
+
Finding(
|
|
443
|
+
Code.CONFIRMATION_MISMATCH,
|
|
444
|
+
f"confirm={confirm!r} does not match the target {target!r}",
|
|
445
|
+
{"target": target, "confirm": confirm},
|
|
446
|
+
)
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _user_exists(cur: Any, user: str, user_host: str) -> bool:
|
|
451
|
+
cur.execute("SELECT COUNT(*) FROM mysql.user WHERE User = %s AND Host = %s", (user, user_host))
|
|
452
|
+
return int(cur.fetchone()[0]) > 0
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _looks_live(cur: Any, database: str) -> bool:
|
|
456
|
+
"""Whether the database holds a WordPress options table with a schema version."""
|
|
457
|
+
cur.execute(
|
|
458
|
+
"SELECT TABLE_NAME FROM information_schema.TABLES "
|
|
459
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s",
|
|
460
|
+
(database, "%options"),
|
|
461
|
+
)
|
|
462
|
+
for (table,) in cur.fetchall():
|
|
463
|
+
try:
|
|
464
|
+
cur.execute(
|
|
465
|
+
f"SELECT COUNT(*) FROM {literal(quote_identifier(database))}" # noqa: S608 - quoted identifiers
|
|
466
|
+
f".{literal(quote_identifier(table))} "
|
|
467
|
+
"WHERE option_name = %s",
|
|
468
|
+
("db_version",),
|
|
469
|
+
)
|
|
470
|
+
except pymysql.err.MySQLError:
|
|
471
|
+
continue # an "...options" table that is not WordPress's
|
|
472
|
+
if int(cur.fetchone()[0]) > 0:
|
|
473
|
+
return True
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _guarded(
|
|
478
|
+
connection: Connection,
|
|
479
|
+
admin: CredentialSource,
|
|
480
|
+
name: str,
|
|
481
|
+
required: tuple[str, ...],
|
|
482
|
+
extra: list[Finding],
|
|
483
|
+
*,
|
|
484
|
+
user: tuple[str, str] | None = None,
|
|
485
|
+
live_check: bool = False,
|
|
486
|
+
) -> tuple[Verdict, Any | None]:
|
|
487
|
+
"""The preflight, plus confirmation, account existence and the live-site warning.
|
|
488
|
+
|
|
489
|
+
Returns the verdict and, only when it holds no blocker, the open session.
|
|
490
|
+
"""
|
|
491
|
+
verdict, session = _preflight(
|
|
492
|
+
connection, admin, name, required, _ACCOUNT_LOOKUP if user else ()
|
|
493
|
+
)
|
|
494
|
+
verdict = verdict.with_findings(*extra)
|
|
495
|
+
if session is None:
|
|
496
|
+
return verdict, None
|
|
497
|
+
try:
|
|
498
|
+
with session.cursor() as cur:
|
|
499
|
+
if user is not None and not _user_exists(cur, *user):
|
|
500
|
+
verdict = verdict.with_findings(
|
|
501
|
+
Finding(Code.USER_ABSENT, f"{user[0]!r}@{user[1]!r} does not exist")
|
|
502
|
+
)
|
|
503
|
+
if live_check and _looks_live(cur, connection.database):
|
|
504
|
+
verdict = verdict.with_findings(
|
|
505
|
+
Finding(
|
|
506
|
+
Code.LIVE_WORDPRESS_SITE,
|
|
507
|
+
f"{connection.database!r} holds a WordPress options table with a "
|
|
508
|
+
"schema version",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
except pymysql.err.MySQLError as exc:
|
|
512
|
+
session.close()
|
|
513
|
+
raise _as_package_error(exc, DatabaseUnreachableError) from exc
|
|
514
|
+
except BaseException:
|
|
515
|
+
session.close()
|
|
516
|
+
raise
|
|
517
|
+
if not verdict.ok:
|
|
518
|
+
session.close()
|
|
519
|
+
return verdict, None
|
|
520
|
+
return verdict, session
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _note(fields: dict[str, object], verdict: Verdict) -> Verdict:
|
|
524
|
+
"""Hand a check's verdict to the operation wrapper, which logs its warnings."""
|
|
525
|
+
fields[VERDICT_KEY] = verdict
|
|
526
|
+
return verdict
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _check(verdict_and_session: tuple[Verdict, Any | None]) -> Verdict:
|
|
530
|
+
verdict, session = verdict_and_session
|
|
531
|
+
if session is not None:
|
|
532
|
+
session.close()
|
|
533
|
+
return verdict
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _rotate_guard(
|
|
537
|
+
connection: Connection, admin: CredentialSource, username: str, user_host: str
|
|
538
|
+
) -> tuple[Verdict, Any | None]:
|
|
539
|
+
return _guarded(
|
|
540
|
+
connection, admin, "rotate_password", ("CREATE USER",), [], user=(username, user_host)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def check_rotate_password(
|
|
545
|
+
connection: Connection,
|
|
546
|
+
admin: CredentialSource,
|
|
547
|
+
wordpress: CredentialSource,
|
|
548
|
+
*,
|
|
549
|
+
user_host: str = "%",
|
|
550
|
+
) -> Verdict:
|
|
551
|
+
"""What :func:`rotate_password` would find, without changing anything."""
|
|
552
|
+
with operation("check_rotate_password", connection) as fields:
|
|
553
|
+
wp = _resolve_or_finding(wordpress)
|
|
554
|
+
if isinstance(wp, Finding):
|
|
555
|
+
return _note(fields, Verdict("rotate_password", (wp,)))
|
|
556
|
+
username = validate_user_name(wp.username)
|
|
557
|
+
return _note(fields, _check(_rotate_guard(connection, admin, username, user_host)))
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def rotate_password(
|
|
561
|
+
connection: Connection,
|
|
562
|
+
admin: CredentialSource,
|
|
563
|
+
wordpress: CredentialSource,
|
|
564
|
+
*,
|
|
565
|
+
user_host: str = "%",
|
|
566
|
+
) -> OperationResult:
|
|
567
|
+
"""Set the account's password to the one ``wordpress`` resolves to."""
|
|
568
|
+
with operation("rotate_password", connection) as fields:
|
|
569
|
+
wp = _resolve_or_finding(wordpress)
|
|
570
|
+
if isinstance(wp, Finding):
|
|
571
|
+
raise RefusedError(Verdict("rotate_password", (wp,)))
|
|
572
|
+
username = validate_user_name(wp.username)
|
|
573
|
+
verdict, session = _rotate_guard(connection, admin, username, user_host)
|
|
574
|
+
if session is None:
|
|
575
|
+
raise RefusedError(verdict)
|
|
576
|
+
with session, session.cursor() as cur:
|
|
577
|
+
try:
|
|
578
|
+
cur.execute("ALTER USER %s@%s IDENTIFIED BY %s", (username, user_host, wp.password))
|
|
579
|
+
session.commit()
|
|
580
|
+
except pymysql.err.MySQLError as exc:
|
|
581
|
+
msg = f"setting the password for {username!r} failed"
|
|
582
|
+
raise ProvisioningError(msg) from exc
|
|
583
|
+
fields["changed"] = True
|
|
584
|
+
fields[VERDICT_KEY] = verdict
|
|
585
|
+
return OperationResult(True, verdict)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _revoke_guard(
|
|
589
|
+
connection: Connection, admin: CredentialSource, user: str, user_host: str, confirm: str
|
|
590
|
+
) -> tuple[Verdict, Any | None]:
|
|
591
|
+
return _guarded(
|
|
592
|
+
connection,
|
|
593
|
+
admin,
|
|
594
|
+
"revoke",
|
|
595
|
+
("GRANT OPTION",),
|
|
596
|
+
_confirmation(user, confirm),
|
|
597
|
+
user=(user, user_host),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def check_revoke(
|
|
602
|
+
connection: Connection,
|
|
603
|
+
admin: CredentialSource,
|
|
604
|
+
user: str,
|
|
605
|
+
*,
|
|
606
|
+
user_host: str = "%",
|
|
607
|
+
confirm: str,
|
|
608
|
+
) -> Verdict:
|
|
609
|
+
"""What :func:`revoke` would find, without changing anything."""
|
|
610
|
+
with operation("check_revoke", connection) as fields:
|
|
611
|
+
validate_user_name(user)
|
|
612
|
+
return _note(fields, _check(_revoke_guard(connection, admin, user, user_host, confirm)))
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def revoke(
|
|
616
|
+
connection: Connection,
|
|
617
|
+
admin: CredentialSource,
|
|
618
|
+
user: str,
|
|
619
|
+
*,
|
|
620
|
+
user_host: str = "%",
|
|
621
|
+
confirm: str,
|
|
622
|
+
) -> OperationResult:
|
|
623
|
+
"""Revoke the account's privileges on this database only.
|
|
624
|
+
|
|
625
|
+
An account holding no grant here is ``changed=False``.
|
|
626
|
+
"""
|
|
627
|
+
with operation("revoke", connection) as fields:
|
|
628
|
+
validate_user_name(user)
|
|
629
|
+
verdict, session = _revoke_guard(connection, admin, user, user_host, confirm)
|
|
630
|
+
if session is None:
|
|
631
|
+
raise RefusedError(verdict)
|
|
632
|
+
# The escaped pattern is what ensure grants; the unescaped name is what
|
|
633
|
+
# the 0.0.x package granted, and it also matched neighbouring names.
|
|
634
|
+
targets = dict.fromkeys(
|
|
635
|
+
[quote_grant_database(connection.database), quote_identifier(connection.database)]
|
|
636
|
+
)
|
|
637
|
+
changed = False
|
|
638
|
+
with session, session.cursor() as cur:
|
|
639
|
+
for target in targets:
|
|
640
|
+
try:
|
|
641
|
+
cur.execute(
|
|
642
|
+
f"REVOKE ALL PRIVILEGES ON {literal(target)}.* FROM %s@%s", # noqa: S608 - quoted identifier
|
|
643
|
+
(user, user_host),
|
|
644
|
+
)
|
|
645
|
+
except pymysql.err.MySQLError as exc:
|
|
646
|
+
if error_code(exc) != ER_NONEXISTING_GRANT:
|
|
647
|
+
msg = f"revoking {user!r} on {connection.database!r} failed"
|
|
648
|
+
raise _as_package_error(exc, ProvisioningError, msg) from exc
|
|
649
|
+
else:
|
|
650
|
+
changed = True
|
|
651
|
+
session.commit()
|
|
652
|
+
fields["changed"] = changed
|
|
653
|
+
fields[VERDICT_KEY] = verdict
|
|
654
|
+
return OperationResult(changed, verdict)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _drop_user_guard(
|
|
658
|
+
connection: Connection, admin: CredentialSource, user: str, confirm: str
|
|
659
|
+
) -> tuple[Verdict, Any | None]:
|
|
660
|
+
return _guarded(connection, admin, "drop_user", ("CREATE USER",), _confirmation(user, confirm))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def check_drop_user(
|
|
664
|
+
connection: Connection,
|
|
665
|
+
admin: CredentialSource,
|
|
666
|
+
user: str,
|
|
667
|
+
*,
|
|
668
|
+
user_host: str = "%", # noqa: ARG001 - kept so check_drop_user mirrors drop_user's signature
|
|
669
|
+
confirm: str,
|
|
670
|
+
) -> Verdict:
|
|
671
|
+
"""What :func:`drop_user` would find, without changing anything."""
|
|
672
|
+
with operation("check_drop_user", connection) as fields:
|
|
673
|
+
validate_user_name(user)
|
|
674
|
+
return _note(fields, _check(_drop_user_guard(connection, admin, user, confirm)))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def drop_user(
|
|
678
|
+
connection: Connection,
|
|
679
|
+
admin: CredentialSource,
|
|
680
|
+
user: str,
|
|
681
|
+
*,
|
|
682
|
+
user_host: str = "%",
|
|
683
|
+
confirm: str,
|
|
684
|
+
) -> OperationResult:
|
|
685
|
+
"""Drop the account. An absent account is ``changed=False``."""
|
|
686
|
+
with operation("drop_user", connection) as fields:
|
|
687
|
+
validate_user_name(user)
|
|
688
|
+
verdict, session = _drop_user_guard(connection, admin, user, confirm)
|
|
689
|
+
if session is None:
|
|
690
|
+
raise RefusedError(verdict)
|
|
691
|
+
with session, session.cursor() as cur:
|
|
692
|
+
# Existence is decided by the DROP itself (1396 = no such account), so
|
|
693
|
+
# the run needs only CREATE USER, exactly what the check verified.
|
|
694
|
+
existed = True
|
|
695
|
+
try:
|
|
696
|
+
cur.execute("DROP USER %s@%s", (user, user_host))
|
|
697
|
+
session.commit()
|
|
698
|
+
except pymysql.err.MySQLError as exc:
|
|
699
|
+
if error_code(exc) != ER_CANNOT_USER:
|
|
700
|
+
msg = f"dropping {user!r} failed"
|
|
701
|
+
raise _as_package_error(exc, ProvisioningError, msg) from exc
|
|
702
|
+
existed = False
|
|
703
|
+
fields["changed"] = existed
|
|
704
|
+
fields[VERDICT_KEY] = verdict
|
|
705
|
+
return OperationResult(existed, verdict)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _drop_database_guard(
|
|
709
|
+
connection: Connection, admin: CredentialSource, confirm: str
|
|
710
|
+
) -> tuple[Verdict, Any | None]:
|
|
711
|
+
return _guarded(
|
|
712
|
+
connection,
|
|
713
|
+
admin,
|
|
714
|
+
"drop_database",
|
|
715
|
+
("DROP",),
|
|
716
|
+
_confirmation(connection.database, confirm),
|
|
717
|
+
live_check=True,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def check_drop_database(
|
|
722
|
+
connection: Connection, admin: CredentialSource, *, confirm: str
|
|
723
|
+
) -> Verdict:
|
|
724
|
+
"""What :func:`drop_database` would find, without changing anything."""
|
|
725
|
+
with operation("check_drop_database", connection) as fields:
|
|
726
|
+
return _note(fields, _check(_drop_database_guard(connection, admin, confirm)))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def drop_database(
|
|
730
|
+
connection: Connection, admin: CredentialSource, *, confirm: str
|
|
731
|
+
) -> OperationResult:
|
|
732
|
+
"""Drop the database. An absent database is ``changed=False``."""
|
|
733
|
+
with operation("drop_database", connection) as fields:
|
|
734
|
+
verdict, session = _drop_database_guard(connection, admin, confirm)
|
|
735
|
+
if session is None:
|
|
736
|
+
raise RefusedError(verdict)
|
|
737
|
+
with session, session.cursor() as cur:
|
|
738
|
+
try:
|
|
739
|
+
cur.execute(
|
|
740
|
+
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
|
|
741
|
+
(connection.database,),
|
|
742
|
+
)
|
|
743
|
+
existed = int(cur.fetchone()[0]) > 0
|
|
744
|
+
cur.execute(f"DROP DATABASE IF EXISTS {quote_identifier(connection.database)}")
|
|
745
|
+
session.commit()
|
|
746
|
+
except pymysql.err.MySQLError as exc:
|
|
747
|
+
msg = f"dropping {connection.database!r} failed"
|
|
748
|
+
raise _as_package_error(exc, ProvisioningError, msg) from exc
|
|
749
|
+
fields["changed"] = existed
|
|
750
|
+
fields[VERDICT_KEY] = verdict
|
|
751
|
+
return OperationResult(existed, verdict)
|