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.
@@ -0,0 +1,196 @@
1
+ """Database credentials and their sources.
2
+
3
+ AD-9: resolution is an explicit call, never a property read. The original
4
+ exposed ``username`` and ``password`` as property *getters* that performed an
5
+ AWS round trip and mutated ``self`` -- hidden network I/O behind attribute
6
+ access, with failures surfacing deep inside an unrelated database call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ import structlog
17
+
18
+ from .errors import (
19
+ CredentialsAbsentError,
20
+ CredentialsUnobtainableError,
21
+ InvalidArgumentsError,
22
+ MissingDependencyError,
23
+ RegionNotKnownError,
24
+ )
25
+
26
+ _log = structlog.get_logger(__name__)
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ResolvedCredentials:
31
+ """A concrete username and password, and which source produced them.
32
+
33
+ The password is redacted in ``repr`` so it cannot reach a log line or a
34
+ traceback frame summary by accident.
35
+ """
36
+
37
+ username: str
38
+ password: str
39
+ source: str = "explicit"
40
+
41
+ def __repr__(self) -> str:
42
+ return (
43
+ f"ResolvedCredentials(username={self.username!r}, password='***', "
44
+ f"source={self.source!r})"
45
+ )
46
+
47
+
48
+ @runtime_checkable
49
+ class CredentialSource(Protocol):
50
+ """Anything that can produce credentials. Nothing needs to subclass a package type."""
51
+
52
+ @property
53
+ def source(self) -> str:
54
+ """A label for logs and errors. Never the secret."""
55
+ ...
56
+
57
+ def resolve(self) -> ResolvedCredentials:
58
+ """Obtain the credentials, performing I/O if the source requires it.
59
+
60
+ Raises:
61
+ CredentialsAbsentError: the source has nothing to offer.
62
+ CredentialsUnobtainableError: the source tried and failed.
63
+ """
64
+ ...
65
+
66
+
67
+ @dataclass(frozen=True, slots=True, repr=False)
68
+ class Explicit:
69
+ """Credentials given directly."""
70
+
71
+ username: str
72
+ password: str
73
+
74
+ @property
75
+ def source(self) -> str:
76
+ return "explicit"
77
+
78
+ def resolve(self) -> ResolvedCredentials:
79
+ return ResolvedCredentials(self.username, self.password, self.source)
80
+
81
+ def __repr__(self) -> str:
82
+ return f"Explicit(username={self.username!r}, password='***')"
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class Environment:
87
+ """Credentials held in two environment variables."""
88
+
89
+ username_var: str = "DB_USER"
90
+ password_var: str = "DB_PASSWORD" # noqa: S105 - name of an environment variable, not a secret
91
+
92
+ @property
93
+ def source(self) -> str:
94
+ return f"environment:{self.username_var},{self.password_var}"
95
+
96
+ def resolve(self) -> ResolvedCredentials:
97
+ username = os.environ.get(self.username_var)
98
+ password = os.environ.get(self.password_var)
99
+ if username is None and password is None:
100
+ msg = f"neither {self.username_var} nor {self.password_var} is set"
101
+ raise CredentialsAbsentError(msg, sources=(self.source,))
102
+ if username is None or password is None:
103
+ unset = self.username_var if username is None else self.password_var
104
+ msg = f"{unset} is not set, although its partner variable is"
105
+ raise CredentialsUnobtainableError(msg)
106
+ return ResolvedCredentials(username, password, self.source)
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class AwsSecretsManager:
111
+ """Credentials held in AWS Secrets Manager as JSON ``username``/``password``.
112
+
113
+ Needs the ``aws`` extra.
114
+ """
115
+
116
+ secret_id: str
117
+ region: str | None = None
118
+
119
+ @property
120
+ def source(self) -> str:
121
+ return f"aws-secrets-manager:{self.secret_id}"
122
+
123
+ def resolve(self) -> ResolvedCredentials:
124
+ # AD-8: the optional dependency is imported inside the function that uses it.
125
+ try:
126
+ import boto3
127
+ except ImportError as exc:
128
+ raise MissingDependencyError(extra="aws", distribution="boto3") from exc
129
+
130
+ # botocore's exceptions are not a public type (AD-14): everything that
131
+ # talks to AWS is in one block, and parsing what came back in another,
132
+ # so a client-side ValueError is never reported as a malformed secret.
133
+ try:
134
+ region = self.region or boto3.session.Session().region_name
135
+ except Exception as exc: # noqa: BLE001 - botocore session errors (e.g. ProfileNotFound)
136
+ msg = f"could not read secret {self.secret_id!r}: the AWS session could not be set up"
137
+ raise CredentialsUnobtainableError(msg) from exc
138
+ if not region:
139
+ raise RegionNotKnownError
140
+
141
+ _log.debug("resolving secret", source=self.source, region=region)
142
+ try:
143
+ client = boto3.client("secretsmanager", region_name=region)
144
+ payload: Any = client.get_secret_value(SecretId=self.secret_id)["SecretString"]
145
+ except Exception as exc: # noqa: BLE001 - boto3 errors are not a public type (AD-14)
146
+ msg = f"could not read secret {self.secret_id!r} from AWS Secrets Manager"
147
+ raise CredentialsUnobtainableError(msg) from exc
148
+
149
+ shape = (
150
+ f"secret {self.secret_id!r} is not shaped as expected: it must be a JSON "
151
+ "object whose 'username' and 'password' are strings"
152
+ )
153
+ try:
154
+ document = json.loads(payload)
155
+ except (TypeError, ValueError) as exc:
156
+ raise CredentialsUnobtainableError(shape) from exc
157
+ if not isinstance(document, dict):
158
+ raise CredentialsUnobtainableError(shape)
159
+ username, password = document.get("username"), document.get("password")
160
+ if not isinstance(username, str) or not isinstance(password, str):
161
+ raise CredentialsUnobtainableError(shape)
162
+ return ResolvedCredentials(username, password, self.source)
163
+
164
+
165
+ class CredentialChain:
166
+ """The first source with an answer.
167
+
168
+ Moves to the next source only when one is *absent*. A source that *failed*
169
+ stops the chain, because falling through to a different credential after a
170
+ failure is a silent substitution.
171
+ """
172
+
173
+ __slots__ = ("_sources",)
174
+
175
+ def __init__(self, *sources: CredentialSource) -> None:
176
+ if not sources:
177
+ msg = "a CredentialChain needs at least one source"
178
+ raise InvalidArgumentsError(msg)
179
+ self._sources = sources
180
+
181
+ @property
182
+ def source(self) -> str:
183
+ return "chain(" + ", ".join(s.source for s in self._sources) + ")"
184
+
185
+ def resolve(self) -> ResolvedCredentials:
186
+ tried: list[str] = []
187
+ for candidate in self._sources:
188
+ try:
189
+ return candidate.resolve()
190
+ except CredentialsAbsentError:
191
+ tried.append(candidate.source)
192
+ msg = "no credential source had an answer: " + ", ".join(tried)
193
+ raise CredentialsAbsentError(msg, sources=tuple(tried))
194
+
195
+ def __repr__(self) -> str:
196
+ return f"CredentialChain({self.source})"
@@ -0,0 +1,188 @@
1
+ """Optional age encryption of dumps (extra ``encryption``).
2
+
3
+ Recipients / identities go through ``pyrage``. Passphrase streaming uses
4
+ ``l3io-age-passphrase`` (preferred) or stock ``age`` with
5
+ ``age-plugin-batchpass`` (see ``L3IO_AGE_PASSPHRASE_BACKEND``). The format is
6
+ standard age, so any dump this package encrypts can be decrypted by the stock
7
+ ``age`` command line. Errors are mapped to package types: a wrong identity or
8
+ passphrase is :class:`DecryptionError`; a damaged, truncated or non-age stream
9
+ is :class:`CorruptDumpError` (probe V4). Secret identities and passphrases
10
+ never appear in messages.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+ from typing import IO, Any
18
+
19
+ from . import _passphrase
20
+ from .errors import CorruptDumpError, DecryptionError, InvalidArgumentsError, MissingDependencyError
21
+
22
+ AGE_HEADER = b"age-encryption.org/v1"
23
+ _SECRET_PREFIX = "AGE-SECRET-KEY-" # noqa: S105 - the prefix age identities carry, not a secret
24
+
25
+
26
+ def _pyrage() -> Any:
27
+ try:
28
+ import pyrage
29
+ except ImportError as exc:
30
+ raise MissingDependencyError(extra="encryption", distribution="pyrage") from exc
31
+ return pyrage
32
+
33
+
34
+ def generate_identity() -> tuple[str, str]:
35
+ """A new age identity and its public recipient: ``(identity, recipient)``."""
36
+ pyrage = _pyrage()
37
+ identity = pyrage.x25519.Identity.generate()
38
+ return str(identity), str(identity.to_public())
39
+
40
+
41
+ def read_identity(path: str | Path, passphrase: str | None = None) -> str:
42
+ """The age identity in a file, optionally passphrase-protected (age's own format)."""
43
+ pyrage = _pyrage()
44
+ try:
45
+ data = Path(path).read_bytes()
46
+ except OSError as exc:
47
+ msg = f"cannot read the identity file {str(path)!r}: {exc.strerror}"
48
+ raise InvalidArgumentsError(msg) from exc
49
+ if passphrase is not None:
50
+ try:
51
+ data = pyrage.passphrase.decrypt(data, passphrase)
52
+ except pyrage.DecryptError as exc:
53
+ msg = f"could not unlock the identity file {str(path)!r} with that passphrase"
54
+ raise DecryptionError(msg) from exc
55
+ for line in data.decode("utf-8", errors="replace").splitlines():
56
+ if line.strip().startswith(_SECRET_PREFIX):
57
+ return line.strip()
58
+ msg = f"{str(path)!r} holds no age identity"
59
+ raise InvalidArgumentsError(msg)
60
+
61
+
62
+ def _recipients(pyrage: Any, recipients: Sequence[str]) -> list[Any]:
63
+ if not recipients:
64
+ msg = "encryption needs at least one recipient"
65
+ raise InvalidArgumentsError(msg)
66
+ parsed: list[Any] = []
67
+ for recipient in recipients:
68
+ try:
69
+ if recipient.startswith(("ssh-", "ecdsa-")):
70
+ parsed.append(pyrage.ssh.Recipient.from_str(recipient))
71
+ else:
72
+ parsed.append(pyrage.x25519.Recipient.from_str(recipient))
73
+ except Exception as exc: # noqa: BLE001 - pyrage errors are not a public type (AD-14)
74
+ msg = f"not an age or SSH public key: {recipient[:12]}..."
75
+ raise InvalidArgumentsError(msg) from exc
76
+ return parsed
77
+
78
+
79
+ def _identities(pyrage: Any, identities: Sequence[str]) -> list[Any]:
80
+ if not identities:
81
+ msg = "decryption needs at least one identity"
82
+ raise InvalidArgumentsError(msg)
83
+ parsed: list[Any] = []
84
+ for index, identity in enumerate(identities):
85
+ text = identity.strip()
86
+ try:
87
+ if text.startswith(_SECRET_PREFIX):
88
+ parsed.append(pyrage.x25519.Identity.from_str(text))
89
+ else:
90
+ parsed.append(pyrage.ssh.Identity.from_buffer(text.encode()))
91
+ except Exception as exc: # noqa: BLE001 - pyrage errors are not a public type (AD-14)
92
+ msg = f"identity #{index + 1} is not a usable age or SSH private key"
93
+ raise InvalidArgumentsError(msg) from exc
94
+ return parsed
95
+
96
+
97
+ def _mutex_secret(
98
+ named: str,
99
+ sequence: Sequence[str] | None,
100
+ passphrase: str | None,
101
+ *,
102
+ action: str,
103
+ ) -> None:
104
+ has_seq = sequence is not None
105
+ has_pass = passphrase is not None
106
+ if has_seq == has_pass:
107
+ if has_seq:
108
+ msg = f"supply {named}= or passphrase=, not both"
109
+ else:
110
+ msg = f"{action} needs {named}= or passphrase="
111
+ raise InvalidArgumentsError(msg)
112
+ if has_pass and passphrase == "":
113
+ msg = "passphrase must not be empty"
114
+ raise InvalidArgumentsError(msg)
115
+
116
+
117
+ def check_recipients(recipients: Sequence[str]) -> None:
118
+ """Refuse recipients :func:`encrypt` could not use, before any data moves."""
119
+ _recipients(_pyrage(), recipients)
120
+
121
+
122
+ def check_passphrase(passphrase: str) -> None:
123
+ """Refuse a passphrase :func:`encrypt` could not use, before any data moves."""
124
+ _passphrase.check_ready(passphrase)
125
+
126
+
127
+ def encrypt(
128
+ reader: IO[bytes],
129
+ writer: IO[bytes],
130
+ recipients: Sequence[str] | None = None,
131
+ *,
132
+ passphrase: str | None = None,
133
+ ) -> None:
134
+ """Stream ``reader`` into ``writer`` as age.
135
+
136
+ Exactly one of ``recipients`` or ``passphrase``. Passphrase mode emits a
137
+ sole scrypt recipient (stock ``age -d``).
138
+ """
139
+ _mutex_secret("recipients", recipients, passphrase, action="encryption")
140
+ if passphrase is not None:
141
+ _passphrase.encrypt_io(reader, writer, passphrase)
142
+ return
143
+ pyrage = _pyrage()
144
+ try:
145
+ keys = _recipients(pyrage, recipients or ())
146
+ pyrage.encrypt_io(reader, writer, keys)
147
+ except pyrage.EncryptError as exc:
148
+ # pyrage flattens the caller's I/O exception into text, e.g. "OSError: [Errno 28] ..."
149
+ if str(exc).startswith("BrokenPipeError"):
150
+ raise BrokenPipeError(str(exc)) from exc
151
+ raise OSError(str(exc)) from exc
152
+
153
+
154
+ def decrypt(
155
+ reader: IO[bytes],
156
+ writer: IO[bytes],
157
+ identities: Sequence[str] | None = None,
158
+ *,
159
+ passphrase: str | None = None,
160
+ ) -> None:
161
+ """Stream an age ``reader`` into ``writer`` as plaintext.
162
+
163
+ Exactly one of ``identities`` or ``passphrase``.
164
+ """
165
+ _mutex_secret("identities", identities, passphrase, action="decryption")
166
+ if passphrase is not None:
167
+ _passphrase.decrypt_io(reader, writer, passphrase)
168
+ return
169
+ pyrage = _pyrage()
170
+ try:
171
+ keys = _identities(pyrage, identities or ())
172
+ pyrage.decrypt_io(reader, writer, keys)
173
+ except BrokenPipeError:
174
+ raise # the consumer stopped reading; not a property of the stream
175
+ except pyrage.DecryptError as exc:
176
+ text = str(exc)
177
+ if "No matching keys" in text:
178
+ msg = "none of the supplied identities can decrypt this dump"
179
+ raise DecryptionError(msg) from exc
180
+ msg = "the encrypted dump is damaged or not an age file"
181
+ raise CorruptDumpError(msg) from exc
182
+ except OSError as exc:
183
+ # pyrage's own stream failure is a bare OSError without errno; anything else is
184
+ # the caller's reader or writer failing and keeps its type and errno.
185
+ if type(exc) is not OSError or exc.errno is not None:
186
+ raise
187
+ msg = "the encrypted dump is truncated or damaged"
188
+ raise CorruptDumpError(msg) from exc
@@ -0,0 +1,186 @@
1
+ """Exception hierarchy owned by this package.
2
+
3
+ AD-14: no dependency's exception type crosses a public boundary. Driver and SDK
4
+ errors are caught and re-raised as one of these, with ``raise ... from``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from .verdict import Verdict
13
+
14
+
15
+ class DatabaseError(Exception):
16
+ """Base class for every error this package raises."""
17
+
18
+
19
+ class InvalidArgumentsError(DatabaseError):
20
+ """An invalid combination of arguments was supplied."""
21
+
22
+
23
+ class InvalidIdentifierError(DatabaseError):
24
+ """A database or user name is not usable as a MySQL identifier."""
25
+
26
+ def __init__(self, kind: str, value: str, reason: str) -> None:
27
+ self.kind = kind
28
+ self.value = value
29
+ self.reason = reason
30
+ super().__init__(f"{kind} {value!r} is not a valid identifier: {reason}")
31
+
32
+
33
+ class InvalidDatabaseNameError(InvalidIdentifierError):
34
+ """A database name is not usable as a MySQL identifier."""
35
+
36
+ def __init__(self, value: str, reason: str = "not a valid database name") -> None:
37
+ super().__init__("database name", value, reason)
38
+
39
+
40
+ class MissingDependencyError(DatabaseError):
41
+ """An optional feature was used without its extra installed.
42
+
43
+ AD-8: optional dependencies are imported inside the function that needs
44
+ them, and their absence is reported by naming the extra to install. A
45
+ version outside the extra's range is reported the same way, with ``found``
46
+ set to the installed version.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ extra: str,
52
+ distribution: str,
53
+ *,
54
+ found: str | None = None,
55
+ required: str | None = None,
56
+ ) -> None:
57
+ self.extra = extra
58
+ self.distribution = distribution
59
+ self.found = found
60
+ state = (
61
+ "which is not installed"
62
+ if found is None
63
+ else f"at {required}, and {found} is installed"
64
+ )
65
+ super().__init__(
66
+ f"this feature needs {distribution!r}, {state}. "
67
+ f"Install it with: pip install 'l3io-wordpress-database[{extra}]'"
68
+ )
69
+
70
+
71
+ class CredentialsUnobtainableError(DatabaseError):
72
+ """Credentials could not be obtained from their source.
73
+
74
+ Distinct from authentication failure: nothing was attempted against the
75
+ database, because there was nothing to attempt it with.
76
+ """
77
+
78
+
79
+ class CredentialsAbsentError(DatabaseError):
80
+ """A credential source had nothing to offer.
81
+
82
+ Distinct from :class:`CredentialsUnobtainableError`: an unset variable is
83
+ absence, a refused AWS call is failure. A
84
+ :class:`~l3io.wp.database.CredentialChain` moves on only after absence.
85
+ """
86
+
87
+ def __init__(self, message: str, sources: tuple[str, ...] = ()) -> None:
88
+ self.sources = sources
89
+ super().__init__(message)
90
+
91
+
92
+ class RegionNotKnownError(CredentialsUnobtainableError):
93
+ """An AWS region was neither supplied nor discoverable from the session."""
94
+
95
+ def __init__(self) -> None:
96
+ super().__init__(
97
+ "the AWS region was not supplied and could not be determined from the current session"
98
+ )
99
+
100
+
101
+ class ConfigurationError(DatabaseError):
102
+ """A WordPress configuration file could not be used."""
103
+
104
+
105
+ class ConfigUnreadableError(ConfigurationError):
106
+ """The configuration file could not be read."""
107
+
108
+ def __init__(self, path: str, reason: str) -> None:
109
+ self.path = path
110
+ super().__init__(f"could not read WordPress configuration {path!r}: {reason}")
111
+
112
+
113
+ class MissingConfigValueError(ConfigurationError):
114
+ """A setting the database needs is not defined in the configuration."""
115
+
116
+ def __init__(self, key: str, path: str) -> None:
117
+ self.key = key
118
+ self.path = path
119
+ super().__init__(f"{key} is not defined in {path!r}")
120
+
121
+
122
+ class DatabaseUnreachableError(DatabaseError):
123
+ """The database server could not be reached at all."""
124
+
125
+
126
+ class TlsHandshakeError(DatabaseError):
127
+ """TLS was required but the server did not offer or complete a handshake."""
128
+
129
+
130
+ class TlsVerificationError(DatabaseError):
131
+ """TLS was established but the server certificate did not verify."""
132
+
133
+
134
+ class AuthenticationFailedError(DatabaseError):
135
+ """The server was reached but rejected the supplied credentials."""
136
+
137
+
138
+ class AuthPluginRefusedError(DatabaseError):
139
+ """The server asked for an authentication plugin this package will not use.
140
+
141
+ ``plugin`` is the server-requested name. The message never includes the
142
+ password.
143
+ """
144
+
145
+ def __init__(self, plugin: str, message: str | None = None) -> None:
146
+ self.plugin = plugin
147
+ super().__init__(message or f"authentication plugin {plugin!r} is not allowed")
148
+
149
+
150
+ class AccessDeniedError(DatabaseError):
151
+ """Authentication succeeded but the account may not use the database."""
152
+
153
+
154
+ class ProvisioningError(DatabaseError):
155
+ """A provisioning operation started and failed part-way."""
156
+
157
+ def __init__(self, message: str, completed: tuple[str, ...] = ()) -> None:
158
+ self.completed = completed
159
+ super().__init__(message)
160
+
161
+
162
+ class RefusedError(DatabaseError):
163
+ """A check found a blocker, so the operation did not run."""
164
+
165
+ def __init__(self, verdict: Verdict) -> None:
166
+ self.verdict = verdict
167
+ reasons = "; ".join(f"{f.code}: {f.message}" for f in verdict.blockers)
168
+ super().__init__(f"{verdict.operation} refused: {reasons}")
169
+
170
+
171
+ class TransferError(DatabaseError):
172
+ """A dump or load started and failed. ``stderr`` is capped and redacted."""
173
+
174
+ def __init__(self, message: str, verdict: Verdict, exit_code: int | None, stderr: str) -> None:
175
+ self.verdict = verdict
176
+ self.exit_code = exit_code
177
+ self.stderr = stderr
178
+ super().__init__(message)
179
+
180
+
181
+ class DecryptionError(DatabaseError):
182
+ """None of the supplied identities or the passphrase can decrypt the source."""
183
+
184
+
185
+ class CorruptDumpError(DatabaseError):
186
+ """An encrypted source is truncated, damaged, or not an age file."""