lcp-reference-platform 1.0.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.
- lcp_platform/__init__.py +17 -0
- lcp_platform/admin.py +90 -0
- lcp_platform/api_keys.py +50 -0
- lcp_platform/attachments.py +361 -0
- lcp_platform/auth.py +202 -0
- lcp_platform/config.py +157 -0
- lcp_platform/crypto.py +94 -0
- lcp_platform/database.py +18 -0
- lcp_platform/mapping.py +309 -0
- lcp_platform/matching.py +147 -0
- lcp_platform/messages.py +265 -0
- lcp_platform/postgres.py +235 -0
- lcp_platform/router.py +1116 -0
- lcp_platform/secrets.py +70 -0
- lcp_platform/security.py +76 -0
- lcp_platform/server.py +97 -0
- lcp_platform/service.py +147 -0
- lcp_platform/storage.py +1300 -0
- lcp_platform/validation.py +118 -0
- lcp_platform/worker.py +30 -0
- lcp_platform/wsgi.py +50 -0
- lcp_reference_platform-1.0.0.dist-info/METADATA +22 -0
- lcp_reference_platform-1.0.0.dist-info/RECORD +25 -0
- lcp_reference_platform-1.0.0.dist-info/WHEEL +4 -0
- lcp_reference_platform-1.0.0.dist-info/entry_points.txt +4 -0
lcp_platform/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Production-oriented reference LCP platform."""
|
|
2
|
+
|
|
3
|
+
from .config import PlatformConfig
|
|
4
|
+
from .mapping import MappingError, MappingRegistry, PublisherNormalizer
|
|
5
|
+
from .router import Platform, RequestError
|
|
6
|
+
from .service import PlatformService
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MappingError",
|
|
10
|
+
"MappingRegistry",
|
|
11
|
+
"Platform",
|
|
12
|
+
"PlatformConfig",
|
|
13
|
+
"PlatformService",
|
|
14
|
+
"PublisherNormalizer",
|
|
15
|
+
"RequestError",
|
|
16
|
+
]
|
|
17
|
+
__version__ = "1.0.0"
|
lcp_platform/admin.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Administrative CLI for local/reference deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import PlatformConfig
|
|
12
|
+
from .router import Platform
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
parser = argparse.ArgumentParser(description="Manage an LCP reference platform")
|
|
17
|
+
parser.add_argument("--database", default=None, help="SQLite path; defaults to LCP_DATABASE_PATH")
|
|
18
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
commands.add_parser("init", help="Initialize the database")
|
|
21
|
+
|
|
22
|
+
credential = commands.add_parser("credential", help="Manage sender credentials")
|
|
23
|
+
credential_sub = credential.add_subparsers(dest="credential_command", required=True)
|
|
24
|
+
add_credential = credential_sub.add_parser("upsert")
|
|
25
|
+
add_credential.add_argument("--sender-id", required=True)
|
|
26
|
+
add_credential.add_argument("--tenant-id", default="default")
|
|
27
|
+
add_credential.add_argument("--scope", action="append", dest="scopes")
|
|
28
|
+
add_credential.add_argument("--hmac-secret")
|
|
29
|
+
add_credential.add_argument("--previous-hmac-secret")
|
|
30
|
+
add_credential.add_argument("--api-key")
|
|
31
|
+
|
|
32
|
+
privacy = commands.add_parser("privacy", help="Perform controlled privacy operations")
|
|
33
|
+
privacy_sub = privacy.add_subparsers(dest="privacy_command", required=True)
|
|
34
|
+
erase_lead = privacy_sub.add_parser("erase-lead")
|
|
35
|
+
erase_lead.add_argument("--lead-id", required=True)
|
|
36
|
+
erase_lead.add_argument("--actor-id", default="operator")
|
|
37
|
+
|
|
38
|
+
mapping = commands.add_parser("mapping", help="Manage publisher form mappings")
|
|
39
|
+
mapping_sub = mapping.add_subparsers(dest="mapping_command", required=True)
|
|
40
|
+
upsert_mapping = mapping_sub.add_parser("upsert")
|
|
41
|
+
upsert_mapping.add_argument("--file", required=True, type=Path)
|
|
42
|
+
|
|
43
|
+
offer = commands.add_parser("offer", help="Manage buyer offers")
|
|
44
|
+
offer_sub = offer.add_subparsers(dest="offer_command", required=True)
|
|
45
|
+
upsert_offer = offer_sub.add_parser("upsert")
|
|
46
|
+
upsert_offer.add_argument("--file", required=True, type=Path)
|
|
47
|
+
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
config = PlatformConfig.from_env()
|
|
50
|
+
if args.database:
|
|
51
|
+
config = replace(config, database_path=Path(args.database))
|
|
52
|
+
platform = Platform(config)
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "init":
|
|
55
|
+
print(f"Initialized {config.database_path}")
|
|
56
|
+
elif args.command == "credential":
|
|
57
|
+
hmac_secret = args.hmac_secret or os.environ.get("LCP_ADMIN_HMAC_SECRET")
|
|
58
|
+
if not hmac_secret and not args.api_key:
|
|
59
|
+
raise SystemExit("Provide --hmac-secret, LCP_ADMIN_HMAC_SECRET, or --api-key")
|
|
60
|
+
platform.upsert_credential(
|
|
61
|
+
args.sender_id,
|
|
62
|
+
tenant_id=args.tenant_id,
|
|
63
|
+
scopes=args.scopes or ["*"],
|
|
64
|
+
hmac_secret=hmac_secret,
|
|
65
|
+
previous_hmac_secret=args.previous_hmac_secret,
|
|
66
|
+
api_key=args.api_key,
|
|
67
|
+
)
|
|
68
|
+
print(f"Upserted credential for {args.sender_id}")
|
|
69
|
+
elif args.command == "privacy":
|
|
70
|
+
if args.privacy_command == "erase-lead":
|
|
71
|
+
platform.erase_lead(args.lead_id, actor_id=args.actor_id)
|
|
72
|
+
print(f"Erased lead {args.lead_id}")
|
|
73
|
+
elif args.command == "mapping":
|
|
74
|
+
with args.file.open(encoding="utf-8") as handle:
|
|
75
|
+
mapping_data = json.load(handle)
|
|
76
|
+
mappings = mapping_data.get("mappings", []) if isinstance(mapping_data, dict) and "mappings" in mapping_data else [mapping_data]
|
|
77
|
+
for item in mappings:
|
|
78
|
+
platform.upsert_mapping(item)
|
|
79
|
+
print(f"Upserted mapping {item['mapping_id']} v{item['version']}")
|
|
80
|
+
elif args.command == "offer":
|
|
81
|
+
with args.file.open(encoding="utf-8") as handle:
|
|
82
|
+
offer_data = json.load(handle)
|
|
83
|
+
platform.upsert_offer(offer_data)
|
|
84
|
+
print(f"Upserted offer {offer_data['offer_id']}")
|
|
85
|
+
finally:
|
|
86
|
+
platform.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
lcp_platform/api_keys.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""API-key hashing for the reference platform.
|
|
2
|
+
|
|
3
|
+
API keys are stored as salted PBKDF2-HMAC-SHA256 digests so that a leaked
|
|
4
|
+
credentials table cannot be brute-forced offline. Every credential receives
|
|
5
|
+
its own random salt (per-credential salting defeats rainbow tables and batch
|
|
6
|
+
cracking), and verification uses constant-time comparison.
|
|
7
|
+
|
|
8
|
+
The API key itself is never stored; only the salt and digest are persisted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import secrets
|
|
16
|
+
|
|
17
|
+
# OWASP Password Storage Cheat Sheet (2023) minimum for PBKDF2-HMAC-SHA256.
|
|
18
|
+
# The reference platform holds a small credentials table, so the lookup cost
|
|
19
|
+
# of iterating rows during bearer-only authentication stays negligible.
|
|
20
|
+
API_KEY_PBKDF2_ITERATIONS = 600_000
|
|
21
|
+
_SALT_BYTES = 16
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_api_key(api_key: str) -> tuple[str, str]:
|
|
25
|
+
"""Return ``(salt, digest)`` for a new API key.
|
|
26
|
+
|
|
27
|
+
The salt is random per credential and must be stored alongside the
|
|
28
|
+
digest so the key can be verified later.
|
|
29
|
+
"""
|
|
30
|
+
salt = secrets.token_hex(_SALT_BYTES)
|
|
31
|
+
digest = hashlib.pbkdf2_hmac(
|
|
32
|
+
"sha256",
|
|
33
|
+
api_key.encode("utf-8"),
|
|
34
|
+
salt.encode("ascii"),
|
|
35
|
+
API_KEY_PBKDF2_ITERATIONS,
|
|
36
|
+
)
|
|
37
|
+
return salt, digest.hex()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_api_key(api_key: str, salt: str | None, stored_digest: str | None) -> bool:
|
|
41
|
+
"""Constant-time verification of an API key against stored salt/digest."""
|
|
42
|
+
if not salt or not stored_digest:
|
|
43
|
+
return False
|
|
44
|
+
digest = hashlib.pbkdf2_hmac(
|
|
45
|
+
"sha256",
|
|
46
|
+
api_key.encode("utf-8"),
|
|
47
|
+
salt.encode("ascii"),
|
|
48
|
+
API_KEY_PBKDF2_ITERATIONS,
|
|
49
|
+
)
|
|
50
|
+
return hmac.compare_digest(digest.hex(), stored_digest)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Secure attachment storage and scanning adapters.
|
|
2
|
+
|
|
3
|
+
The reference platform uses :class:`FileAttachmentStore` for a self-contained
|
|
4
|
+
sandbox. Production deployments can select :class:`S3ObjectStorageAttachmentStore`
|
|
5
|
+
for AWS S3 or another S3-compatible service. The adapter deliberately keeps
|
|
6
|
+
provider-specific URLs and object keys opaque to LCP messages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from hashlib import sha256
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import tempfile
|
|
18
|
+
from typing import Any, Protocol
|
|
19
|
+
|
|
20
|
+
from .crypto import EnvelopeCipher
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AttachmentError(ValueError):
|
|
24
|
+
"""Raised for invalid, missing, unsafe, or tampered attachment content."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class MalwareScanResult:
|
|
29
|
+
"""Safe-to-persist result of an attachment malware scan."""
|
|
30
|
+
|
|
31
|
+
status: str
|
|
32
|
+
engine: str
|
|
33
|
+
scanned_at: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MalwareScanner(Protocol):
|
|
37
|
+
def scan(self, content: bytes, *, filename: str, content_type: str) -> MalwareScanResult:
|
|
38
|
+
"""Return a clean result or raise :class:`AttachmentError`."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NoopMalwareScanner:
|
|
42
|
+
"""Test-only scanner; production configuration must use a real scanner."""
|
|
43
|
+
|
|
44
|
+
def scan(self, content: bytes, *, filename: str, content_type: str) -> MalwareScanResult:
|
|
45
|
+
from .storage import now_iso
|
|
46
|
+
|
|
47
|
+
return MalwareScanResult("not_scanned", "disabled-test-scanner", now_iso())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ClamAVMalwareScanner:
|
|
51
|
+
"""Fail-closed ClamAV scanner using a local ``clamd`` daemon."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
socket_path: str | None = None,
|
|
57
|
+
host: str = "127.0.0.1",
|
|
58
|
+
port: int = 3310,
|
|
59
|
+
client: Any | None = None,
|
|
60
|
+
):
|
|
61
|
+
if client is not None:
|
|
62
|
+
self.client = client
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
import clamd
|
|
66
|
+
except ImportError as exc:
|
|
67
|
+
raise AttachmentError(
|
|
68
|
+
"ClamAV scanning requires the reference-platform[object-storage] extra"
|
|
69
|
+
) from exc
|
|
70
|
+
try:
|
|
71
|
+
self.client = (
|
|
72
|
+
clamd.ClamdUnixSocket(path=socket_path)
|
|
73
|
+
if socket_path
|
|
74
|
+
else clamd.ClamdNetworkSocket(host=host, port=port)
|
|
75
|
+
)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise AttachmentError("Unable to initialize the ClamAV scanner") from exc
|
|
78
|
+
|
|
79
|
+
def scan(self, content: bytes, *, filename: str, content_type: str) -> MalwareScanResult:
|
|
80
|
+
del filename, content_type
|
|
81
|
+
try:
|
|
82
|
+
result = self.client.instream(content)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
raise AttachmentError("Malware scanner is unavailable") from exc
|
|
85
|
+
if not isinstance(result, dict):
|
|
86
|
+
raise AttachmentError("Malware scanner returned an invalid result")
|
|
87
|
+
stream_result = result.get("stream")
|
|
88
|
+
if not isinstance(stream_result, tuple) or not stream_result:
|
|
89
|
+
raise AttachmentError("Malware scanner returned an invalid result")
|
|
90
|
+
status = str(stream_result[0]).upper()
|
|
91
|
+
if status == "FOUND":
|
|
92
|
+
raise AttachmentError("Attachment failed malware scanning")
|
|
93
|
+
if status != "OK":
|
|
94
|
+
raise AttachmentError("Attachment malware scan did not complete cleanly")
|
|
95
|
+
from .storage import now_iso
|
|
96
|
+
|
|
97
|
+
return MalwareScanResult("clean", "clamav", now_iso())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ObjectStorageClient(Protocol):
|
|
101
|
+
"""Minimal S3-compatible client contract used by the production adapter."""
|
|
102
|
+
|
|
103
|
+
def put_object(self, **kwargs: Any) -> Any: ...
|
|
104
|
+
|
|
105
|
+
def get_object(self, **kwargs: Any) -> Any: ...
|
|
106
|
+
|
|
107
|
+
def head_object(self, **kwargs: Any) -> Any: ...
|
|
108
|
+
|
|
109
|
+
def delete_object(self, **kwargs: Any) -> Any: ...
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_ATTACHMENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
113
|
+
_FILENAME = re.compile(r"^[^/\\\x00]{1,255}$")
|
|
114
|
+
_CONTENT_TYPE = re.compile(r"^[^;\s]+/[^;\s]+$")
|
|
115
|
+
_RESIDENCY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,31}$")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_residency(residency: str) -> str:
|
|
119
|
+
"""Normalize and validate a country/region residency policy identifier."""
|
|
120
|
+
normalized = residency.strip().upper()
|
|
121
|
+
if not _RESIDENCY.fullmatch(normalized):
|
|
122
|
+
raise AttachmentError("attachment residency must be a non-empty country or region identifier")
|
|
123
|
+
return normalized
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class FileAttachmentStore:
|
|
127
|
+
"""Store encrypted attachment bytes with restrictive local permissions."""
|
|
128
|
+
|
|
129
|
+
encryption = "application_encrypted"
|
|
130
|
+
|
|
131
|
+
def __init__(self, root: str | Path, *, cipher: EnvelopeCipher):
|
|
132
|
+
self.root = Path(root)
|
|
133
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
if os.name != "nt":
|
|
135
|
+
self.root.chmod(0o700)
|
|
136
|
+
self.cipher = cipher
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def validate_metadata(*, attachment_id: str, filename: str, content_type: str, size_bytes: int) -> None:
|
|
140
|
+
if not _ATTACHMENT_ID.fullmatch(attachment_id):
|
|
141
|
+
raise AttachmentError("attachment_id contains unsafe characters")
|
|
142
|
+
if not _FILENAME.fullmatch(filename):
|
|
143
|
+
raise AttachmentError("filename must be a basename without path separators")
|
|
144
|
+
if not _CONTENT_TYPE.fullmatch(content_type):
|
|
145
|
+
raise AttachmentError("content_type must be a valid media type")
|
|
146
|
+
if size_bytes <= 0:
|
|
147
|
+
raise AttachmentError("attachment must not be empty")
|
|
148
|
+
|
|
149
|
+
def _path(self, attachment_id: str) -> Path:
|
|
150
|
+
self.validate_metadata(attachment_id=attachment_id, filename="x", content_type="x/x", size_bytes=1)
|
|
151
|
+
return self.root / f"{attachment_id}.bin"
|
|
152
|
+
|
|
153
|
+
def put(
|
|
154
|
+
self,
|
|
155
|
+
attachment_id: str,
|
|
156
|
+
content: bytes,
|
|
157
|
+
*,
|
|
158
|
+
sha256_hex: str,
|
|
159
|
+
content_type: str,
|
|
160
|
+
filename: str,
|
|
161
|
+
residency: str,
|
|
162
|
+
) -> str:
|
|
163
|
+
del content_type, filename
|
|
164
|
+
validate_residency(residency)
|
|
165
|
+
if sha256(content).hexdigest() != sha256_hex:
|
|
166
|
+
raise AttachmentError("attachment sha256 does not match the uploaded bytes")
|
|
167
|
+
path = self._path(attachment_id)
|
|
168
|
+
encrypted = self.cipher.encrypt_bytes(content)
|
|
169
|
+
with tempfile.NamedTemporaryFile(dir=self.root, prefix=f".{attachment_id}.", delete=False) as handle:
|
|
170
|
+
temporary = Path(handle.name)
|
|
171
|
+
handle.write(encrypted)
|
|
172
|
+
handle.flush()
|
|
173
|
+
os.fsync(handle.fileno())
|
|
174
|
+
if os.name != "nt":
|
|
175
|
+
temporary.chmod(0o600)
|
|
176
|
+
os.replace(temporary, path)
|
|
177
|
+
if os.name != "nt":
|
|
178
|
+
path.chmod(0o600)
|
|
179
|
+
return f"lcp://attachments/{attachment_id}"
|
|
180
|
+
|
|
181
|
+
def read(self, storage_ref: str, *, expected_sha256: str) -> bytes:
|
|
182
|
+
prefix = "lcp://attachments/"
|
|
183
|
+
if not storage_ref.startswith(prefix):
|
|
184
|
+
raise AttachmentError("unsupported attachment storage reference")
|
|
185
|
+
path = self._path(storage_ref.removeprefix(prefix))
|
|
186
|
+
try:
|
|
187
|
+
content = self.cipher.decrypt_bytes(path.read_bytes())
|
|
188
|
+
except FileNotFoundError as exc:
|
|
189
|
+
raise AttachmentError("attachment bytes are not available") from exc
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
raise AttachmentError("attachment decryption failed") from exc
|
|
192
|
+
if sha256(content).hexdigest() != expected_sha256:
|
|
193
|
+
raise AttachmentError("attachment integrity check failed")
|
|
194
|
+
return content
|
|
195
|
+
|
|
196
|
+
def delete(self, storage_ref: str) -> None:
|
|
197
|
+
prefix = "lcp://attachments/"
|
|
198
|
+
if not storage_ref.startswith(prefix):
|
|
199
|
+
return
|
|
200
|
+
path = self._path(storage_ref.removeprefix(prefix))
|
|
201
|
+
try:
|
|
202
|
+
path.unlink()
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class S3ObjectStorageAttachmentStore:
|
|
208
|
+
"""S3-compatible object storage with mandatory SSE-KMS and residency keys.
|
|
209
|
+
|
|
210
|
+
The adapter is intentionally dependency-injected for tests and for cloud-
|
|
211
|
+
neutral S3-compatible services. When no client is supplied it lazily uses
|
|
212
|
+
``boto3.client('s3')``. The KMS key and residency are immutable properties
|
|
213
|
+
of an adapter instance, so a process cannot accidentally write one tenant's
|
|
214
|
+
attachment to another configured region.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
encryption = "provider_encrypted"
|
|
218
|
+
_ref_prefix = "lcp-object://attachments/"
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
bucket: str,
|
|
224
|
+
prefix: str,
|
|
225
|
+
residency: str,
|
|
226
|
+
kms_key_id: str,
|
|
227
|
+
client: ObjectStorageClient | None = None,
|
|
228
|
+
endpoint_url: str | None = None,
|
|
229
|
+
region_name: str | None = None,
|
|
230
|
+
):
|
|
231
|
+
if not bucket or not re.fullmatch(r"[A-Za-z0-9._-]{3,255}", bucket):
|
|
232
|
+
raise AttachmentError("object storage bucket is invalid")
|
|
233
|
+
if not kms_key_id:
|
|
234
|
+
raise AttachmentError("object storage requires a KMS key identifier")
|
|
235
|
+
self.bucket = bucket
|
|
236
|
+
self.prefix = prefix.strip("/") or "lcp/attachments"
|
|
237
|
+
self.residency = validate_residency(residency)
|
|
238
|
+
self.kms_key_id = kms_key_id
|
|
239
|
+
if client is not None:
|
|
240
|
+
self.client = client
|
|
241
|
+
else:
|
|
242
|
+
try:
|
|
243
|
+
import boto3
|
|
244
|
+
except ImportError as exc:
|
|
245
|
+
raise AttachmentError(
|
|
246
|
+
"S3 object storage requires the reference-platform[object-storage] extra"
|
|
247
|
+
) from exc
|
|
248
|
+
self.client = boto3.client("s3", endpoint_url=endpoint_url, region_name=region_name)
|
|
249
|
+
|
|
250
|
+
def _key(self, attachment_id: str) -> str:
|
|
251
|
+
FileAttachmentStore.validate_metadata(
|
|
252
|
+
attachment_id=attachment_id,
|
|
253
|
+
filename="x",
|
|
254
|
+
content_type="x/x",
|
|
255
|
+
size_bytes=1,
|
|
256
|
+
)
|
|
257
|
+
return f"{self.prefix}/residency/{self.residency}/{attachment_id}.bin"
|
|
258
|
+
|
|
259
|
+
def _attachment_id(self, storage_ref: str) -> str:
|
|
260
|
+
if not storage_ref.startswith(self._ref_prefix):
|
|
261
|
+
raise AttachmentError("unsupported attachment storage reference")
|
|
262
|
+
attachment_id = storage_ref.removeprefix(self._ref_prefix)
|
|
263
|
+
self._key(attachment_id)
|
|
264
|
+
return attachment_id
|
|
265
|
+
|
|
266
|
+
def put(
|
|
267
|
+
self,
|
|
268
|
+
attachment_id: str,
|
|
269
|
+
content: bytes,
|
|
270
|
+
*,
|
|
271
|
+
sha256_hex: str,
|
|
272
|
+
content_type: str,
|
|
273
|
+
filename: str,
|
|
274
|
+
residency: str,
|
|
275
|
+
) -> str:
|
|
276
|
+
FileAttachmentStore.validate_metadata(
|
|
277
|
+
attachment_id=attachment_id,
|
|
278
|
+
filename=filename,
|
|
279
|
+
content_type=content_type,
|
|
280
|
+
size_bytes=len(content),
|
|
281
|
+
)
|
|
282
|
+
if validate_residency(residency) != self.residency:
|
|
283
|
+
raise AttachmentError("attachment residency does not match the object-store adapter")
|
|
284
|
+
if sha256(content).hexdigest() != sha256_hex:
|
|
285
|
+
raise AttachmentError("attachment sha256 does not match the uploaded bytes")
|
|
286
|
+
key = self._key(attachment_id)
|
|
287
|
+
kwargs: dict[str, Any] = {
|
|
288
|
+
"Bucket": self.bucket,
|
|
289
|
+
"Key": key,
|
|
290
|
+
"Body": content,
|
|
291
|
+
"ContentType": content_type,
|
|
292
|
+
"Metadata": {
|
|
293
|
+
"lcp-sha256": sha256_hex,
|
|
294
|
+
"lcp-residency": self.residency,
|
|
295
|
+
"lcp-filename": filename,
|
|
296
|
+
"lcp-malware-status": "clean",
|
|
297
|
+
},
|
|
298
|
+
"ServerSideEncryption": "aws:kms",
|
|
299
|
+
"SSEKMSKeyId": self.kms_key_id,
|
|
300
|
+
"SSEKMSEncryptionContext": json.dumps({"lcp_residency": self.residency}, separators=(",", ":")),
|
|
301
|
+
}
|
|
302
|
+
try:
|
|
303
|
+
self.client.put_object(**kwargs)
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
raise AttachmentError("object storage write failed") from exc
|
|
306
|
+
return f"{self._ref_prefix}{attachment_id}"
|
|
307
|
+
|
|
308
|
+
def read(self, storage_ref: str, *, expected_sha256: str) -> bytes:
|
|
309
|
+
attachment_id = self._attachment_id(storage_ref)
|
|
310
|
+
key = self._key(attachment_id)
|
|
311
|
+
try:
|
|
312
|
+
head = self.client.head_object(Bucket=self.bucket, Key=key)
|
|
313
|
+
metadata = head.get("Metadata", {})
|
|
314
|
+
if metadata.get("lcp-residency", "").upper() != self.residency:
|
|
315
|
+
raise AttachmentError("object residency metadata does not match policy")
|
|
316
|
+
if metadata.get("lcp-sha256", "").lower() != expected_sha256.lower():
|
|
317
|
+
raise AttachmentError("object metadata hash does not match the attachment record")
|
|
318
|
+
response = self.client.get_object(Bucket=self.bucket, Key=key)
|
|
319
|
+
body = response["Body"].read()
|
|
320
|
+
except AttachmentError:
|
|
321
|
+
raise
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
raise AttachmentError("object storage read failed") from exc
|
|
324
|
+
if sha256(body).hexdigest() != expected_sha256:
|
|
325
|
+
raise AttachmentError("attachment integrity check failed")
|
|
326
|
+
return body
|
|
327
|
+
|
|
328
|
+
def delete(self, storage_ref: str) -> None:
|
|
329
|
+
attachment_id = self._attachment_id(storage_ref)
|
|
330
|
+
try:
|
|
331
|
+
self.client.delete_object(Bucket=self.bucket, Key=self._key(attachment_id))
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
raise AttachmentError("object storage deletion failed") from exc
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_attachment_store(config: Any, *, cipher: EnvelopeCipher) -> FileAttachmentStore | S3ObjectStorageAttachmentStore:
|
|
337
|
+
"""Build the configured backend; imports cloud SDKs only when selected."""
|
|
338
|
+
if config.attachment_backend == "s3":
|
|
339
|
+
return S3ObjectStorageAttachmentStore(
|
|
340
|
+
bucket=config.attachment_object_bucket or "",
|
|
341
|
+
prefix=config.attachment_object_prefix,
|
|
342
|
+
residency=config.attachment_object_residency or "",
|
|
343
|
+
kms_key_id=config.attachment_object_kms_key_id or "",
|
|
344
|
+
endpoint_url=config.attachment_object_endpoint_url,
|
|
345
|
+
region_name=config.attachment_object_region,
|
|
346
|
+
)
|
|
347
|
+
if config.attachment_backend != "file":
|
|
348
|
+
raise AttachmentError("LCP_ATTACHMENT_BACKEND must be 'file' or 's3'")
|
|
349
|
+
return FileAttachmentStore(config.attachment_directory, cipher=cipher)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def build_malware_scanner(config: Any) -> MalwareScanner:
|
|
353
|
+
if config.attachment_scanner == "none":
|
|
354
|
+
return NoopMalwareScanner()
|
|
355
|
+
if config.attachment_scanner == "clamav":
|
|
356
|
+
return ClamAVMalwareScanner(
|
|
357
|
+
socket_path=config.attachment_clamav_socket,
|
|
358
|
+
host=config.attachment_clamav_host,
|
|
359
|
+
port=config.attachment_clamav_port,
|
|
360
|
+
)
|
|
361
|
+
raise AttachmentError("LCP_ATTACHMENT_SCANNER must be 'clamav' or 'none'")
|