leblob 0.4.0.dev1__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.
leblob/metrics.py ADDED
@@ -0,0 +1,226 @@
1
+ """Counters for `/-/metrics`, added up across worker processes through files.
2
+
3
+ A scrape reaches one worker, chosen by the kernel, so what each counts alone would jump
4
+ around between scrapes. Instead each worker writes its counters to a file every few
5
+ seconds and on each scrape, and the one answering adds up the files. A worker that dies
6
+ leaves its file, so totals never go down until the server restarts, when Prometheus
7
+ expects it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import os
16
+ import shutil
17
+ import threading
18
+ from bisect import bisect_left
19
+ from collections import defaultdict
20
+ from contextlib import suppress
21
+ from dataclasses import dataclass, field
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Iterable, Mapping
26
+ from pathlib import Path
27
+
28
+ type Labels = tuple[tuple[str, str], ...]
29
+ type Sample = tuple[str, Labels, float]
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _DIRECTORY = "metrics"
34
+ _FLUSH_SECONDS = 5
35
+ _REQUESTS = "leblob_http_requests_total"
36
+ _LATENCY = "leblob_http_request_seconds"
37
+ _LATENCY_BUCKETS = (0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 30.0)
38
+
39
+ BLOBS_COLLECTED = "leblob_blobs_collected_total"
40
+ COPIES_REPAIRED = "leblob_copies_repaired_total"
41
+ COPIES_SCRUBBED = "leblob_copies_scrubbed_total"
42
+ BAD_COPIES_REMOVED = "leblob_bad_copies_removed_total"
43
+ LIFECYCLE_OBJECTS_EXPIRED = "leblob_lifecycle_objects_expired_total"
44
+ LIFECYCLE_VERSIONS_ERASED = "leblob_lifecycle_versions_erased_total"
45
+ LIFECYCLE_UPLOADS_ABORTED = "leblob_lifecycle_uploads_aborted_total"
46
+ ONLY_COPIES_BAD = "leblob_only_copies_bad_total"
47
+ VOLUME_FAILURES = "leblob_volume_failures_total"
48
+ REPAIRS_QUEUED = "leblob_repairs_queued"
49
+ BLOBS_AWAITING_COLLECTION = "leblob_blobs_awaiting_collection"
50
+ COPIES = "leblob_copies_wanted"
51
+ VOLUME_UP = "leblob_volume_up"
52
+ VOLUME_DRAINING = "leblob_volume_draining"
53
+ VOLUME_FREE_BYTES = "leblob_volume_free_bytes"
54
+
55
+ _FAMILIES: Mapping[str, tuple[str, str]] = {
56
+ _REQUESTS: ("counter", "Requests answered, by S3 operation and status."),
57
+ _LATENCY: ("histogram", "Seconds to answer a request, by S3 operation."),
58
+ BLOBS_COLLECTED: ("counter", "Blobs deleted after their grace period."),
59
+ COPIES_REPAIRED: ("counter", "Copies of blobs restored by repair."),
60
+ COPIES_SCRUBBED: ("counter", "Copies checked against their MD5."),
61
+ BAD_COPIES_REMOVED: ("counter", "Bad copies scrub removed for repair to replace."),
62
+ ONLY_COPIES_BAD: ("counter", "Times scrub found a blob's only copy bad."),
63
+ VOLUME_FAILURES: ("counter", "I/O errors on a volume."),
64
+ LIFECYCLE_OBJECTS_EXPIRED: ("counter", "Objects expired by lifecycle rules."),
65
+ LIFECYCLE_VERSIONS_ERASED: ("counter", "Versions erased by lifecycle rules."),
66
+ LIFECYCLE_UPLOADS_ABORTED: ("counter", "Uploads aborted by lifecycle rules."),
67
+ REPAIRS_QUEUED: ("gauge", "Blobs waiting for repair."),
68
+ BLOBS_AWAITING_COLLECTION: ("gauge", "Blobs queued for deletion."),
69
+ COPIES: ("gauge", "Copies each blob should have."),
70
+ VOLUME_UP: ("gauge", "Whether a volume can be used, 1 or 0."),
71
+ VOLUME_DRAINING: ("gauge", "Whether a volume is being emptied, 1 or 0."),
72
+ VOLUME_FREE_BYTES: ("gauge", "Bytes free on a volume."),
73
+ }
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class _Latency:
78
+ """One operation's histogram, kept in separate buckets until a scrape adds them."""
79
+
80
+ buckets: list[int] = field(
81
+ default_factory=lambda: [0] * (len(_LATENCY_BUCKETS) + 1)
82
+ )
83
+ seconds: float = 0.0
84
+ count: int = 0
85
+
86
+ def samples(self, operation: str) -> list[Sample]:
87
+ result: list[Sample] = []
88
+ below = 0
89
+ for bound, seen in zip((*_LATENCY_BUCKETS, "+Inf"), self.buckets, strict=True):
90
+ below += seen
91
+ labels = (("le", str(bound)), ("operation", operation))
92
+ result.append((f"{_LATENCY}_bucket", labels, below))
93
+ by_operation = (("operation", operation),)
94
+ return [
95
+ *result,
96
+ (f"{_LATENCY}_sum", by_operation, self.seconds),
97
+ (f"{_LATENCY}_count", by_operation, self.count),
98
+ ]
99
+
100
+
101
+ class Counters:
102
+ """This process's counters. Worker threads and the event loop both add to them."""
103
+
104
+ def __init__(self) -> None:
105
+ self._values: dict[tuple[str, Labels], float] = defaultdict(float)
106
+ self._requests: dict[tuple[str, int], int] = defaultdict(int)
107
+ self._latency: dict[str, _Latency] = defaultdict(_Latency)
108
+ self._lock = threading.Lock()
109
+
110
+ def add(self, name: str, amount: float = 1, **labels: str) -> None:
111
+ key = name, tuple(sorted(labels.items()))
112
+ with self._lock:
113
+ self._values[key] += amount
114
+
115
+ def observe_request(self, operation: str, status: int, seconds: float) -> None:
116
+ """Count a request. On every request, so this costs little until a scrape."""
117
+ bucket = bisect_left(_LATENCY_BUCKETS, seconds)
118
+ with self._lock:
119
+ self._requests[operation, status] += 1
120
+ latency = self._latency[operation]
121
+ latency.buckets[bucket] += 1
122
+ latency.seconds += seconds
123
+ latency.count += 1
124
+
125
+ def samples(self) -> list[Sample]:
126
+ with self._lock:
127
+ samples: list[Sample] = [
128
+ (name, labels, value) for (name, labels), value in self._values.items()
129
+ ]
130
+ for (operation, status), count in self._requests.items():
131
+ labels = (("operation", operation), ("status", str(status)))
132
+ samples.append((_REQUESTS, labels, count))
133
+ for operation, latency in self._latency.items():
134
+ samples += latency.samples(operation)
135
+ return samples
136
+
137
+
138
+ counters = Counters()
139
+
140
+
141
+ def clear(data_dir: Path) -> None:
142
+ """Forget the workers of an earlier run, before any worker starts."""
143
+ shutil.rmtree(data_dir / _DIRECTORY, ignore_errors=True)
144
+
145
+
146
+ def _worker_file(data_dir: Path) -> Path:
147
+ return data_dir / _DIRECTORY / f"{os.getpid()}.json"
148
+
149
+
150
+ def flush(data_dir: Path) -> None:
151
+ """Replace this worker's file. It needn't survive a crash, so there is no fsync."""
152
+ path = _worker_file(data_dir)
153
+ path.parent.mkdir(exist_ok=True)
154
+ partial = path.with_suffix(".partial")
155
+ partial.write_text(json.dumps(counters.samples()), encoding="utf-8")
156
+ partial.rename(path)
157
+
158
+
159
+ async def run_flusher(data_dir: Path) -> None:
160
+ """Write this worker's counters regularly. A full disk mustn't stop it for good."""
161
+ try:
162
+ while True:
163
+ await asyncio.sleep(_FLUSH_SECONDS)
164
+ try:
165
+ await asyncio.to_thread(flush, data_dir)
166
+ except OSError:
167
+ logger.exception("Can't write metrics; they will be written later.")
168
+ finally:
169
+ # Shutting down: the last few seconds count too.
170
+ with suppress(OSError):
171
+ flush(data_dir)
172
+
173
+
174
+ def all_workers(data_dir: Path) -> list[Sample]:
175
+ """Every worker's counters, from their files, this worker's written just now.
176
+
177
+ Only ever from the files, so that any worker gives the same answer. Adding this
178
+ worker's live counts to the others' older files would make totals go down when the
179
+ next scrape reached another worker, which Prometheus takes for a restart.
180
+ """
181
+ with suppress(OSError): # On a full disk, its file is simply a little old.
182
+ flush(data_dir)
183
+ samples: list[Sample] = []
184
+ directory = data_dir / _DIRECTORY
185
+ if directory.is_dir():
186
+ for path in directory.glob("*.json"):
187
+ samples += [
188
+ (name, tuple(map(tuple, labels)), value)
189
+ for name, labels, value in json.loads(path.read_text("utf-8"))
190
+ ]
191
+ return samples
192
+
193
+
194
+ def _family_of(name: str) -> str:
195
+ for suffix in ("_bucket", "_sum", "_count"):
196
+ if name.endswith(suffix) and name.removesuffix(suffix) == _LATENCY:
197
+ return _LATENCY
198
+ return name
199
+
200
+
201
+ def _bucket_order(labels: Labels) -> tuple[Labels, float]:
202
+ """Sort buckets by their bound, which Prometheus requires."""
203
+ rest = tuple(item for item in labels if item[0] != "le")
204
+ bounds = [float(value) for name, value in labels if name == "le"]
205
+ return rest, bounds[0] if bounds else 0.0
206
+
207
+
208
+ def render(samples: Iterable[Sample]) -> str:
209
+ """Add up samples into the Prometheus text format."""
210
+ totals: dict[tuple[str, Labels], float] = defaultdict(float)
211
+ for name, labels, value in samples:
212
+ totals[name, labels] += value
213
+ by_family: dict[str, list[tuple[str, Labels, float]]] = defaultdict(list)
214
+ for (name, labels), value in totals.items():
215
+ by_family[_family_of(name)].append((name, labels, value))
216
+ lines = []
217
+ for family, members in sorted(by_family.items()):
218
+ kind, description = _FAMILIES[family]
219
+ lines += [f"# HELP {family} {description}", f"# TYPE {family} {kind}"]
220
+ members.sort(key=lambda member: (_bucket_order(member[1]), member[0]))
221
+ for name, labels, value in members:
222
+ text = ",".join(f'{key}="{item}"' for key, item in labels)
223
+ selector = f"{{{text}}}" if text else ""
224
+ # Not `g`, whose 6 digits would round a disk's free bytes.
225
+ lines.append(f"{name}{selector} {value:.17g}")
226
+ return "\n".join(lines) + "\n"
leblob/model.py ADDED
@@ -0,0 +1,339 @@
1
+ """Plain data shared by the pure core and the storage layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping
11
+
12
+
13
+ NULL_VERSION_ID = "null"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class HttpRequest:
18
+ """The parts of a request that routing and signatures depend on."""
19
+
20
+ method: str
21
+ raw_path: bytes
22
+ raw_query: bytes
23
+ query: Mapping[str, str]
24
+ headers: Mapping[str, str]
25
+ secure: bool = False # Whether it came over TLS.
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ObjectRef:
30
+ """A key in a bucket, whichever version of it."""
31
+
32
+ bucket: str
33
+ key: str
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ByteSpan:
38
+ """A range of an object's bytes, as its client knows them."""
39
+
40
+ start: int
41
+ length: int
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class UploadRef:
46
+ """What identifies a multipart upload. All three parts must match."""
47
+
48
+ upload_id: str
49
+ bucket: str
50
+ key: str
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class Blob:
55
+ """An immutable run of bytes on disk, named by a random ID."""
56
+
57
+ blob_id: str
58
+ size: int
59
+ md5: str
60
+ volume_ids: tuple[int, ...]
61
+
62
+
63
+ class ChecksumType(StrEnum):
64
+ """How a multipart object's checksum relates to its parts."""
65
+
66
+ COMPOSITE = "COMPOSITE"
67
+ FULL_OBJECT = "FULL_OBJECT"
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class KnownBlob:
72
+ """What the metadata says about a blob file."""
73
+
74
+ size: int
75
+ queued_for_collection: bool
76
+ volume_ids: tuple[int, ...]
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class BlobFile:
81
+ """A blob's file on one volume, as `fsck` reports it."""
82
+
83
+ volume_id: int
84
+ blob_id: str
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class VolumeRecord:
89
+ """A volume as the metadata knows it. Its path is relative to the data directory."""
90
+
91
+ volume_id: int
92
+ identity: str
93
+ path: str
94
+ draining: bool = False
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class RepairWork:
99
+ """A blob queued for repair, and what the metadata wants of it right now."""
100
+
101
+ blob: Blob
102
+ copies: int
103
+ draining: frozenset[int]
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class Checksum:
108
+ """A checksum, in the form S3 headers carry it."""
109
+
110
+ algorithm: str
111
+ value: str
112
+
113
+ @property
114
+ def checksum_type(self) -> ChecksumType:
115
+ """Tell them apart by the `-N` a composite value ends in, not valid base64."""
116
+ return ChecksumType.COMPOSITE if "-" in self.value else ChecksumType.FULL_OBJECT
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class ChecksumPlan:
121
+ """The checksum a multipart upload declared its object will have."""
122
+
123
+ algorithm: str
124
+ checksum_type: ChecksumType
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class ObjectRecord:
129
+ """The current version of an object: its metadata and ordered blobs."""
130
+
131
+ key: str
132
+ size: int
133
+ etag: str
134
+ modified_at: int
135
+ headers: Mapping[str, str]
136
+ checksum: Checksum | None
137
+ blobs: tuple[Blob, ...]
138
+ version_id: str = NULL_VERSION_ID
139
+ tags: Mapping[str, str] = field(default_factory=dict)
140
+ public: bool = False
141
+ sse_check: str | None = None # Set if encrypted with a key the client keeps.
142
+
143
+
144
+ @dataclass(frozen=True, slots=True)
145
+ class VersionSummary:
146
+ """The fields of an object version or delete marker that a listing returns."""
147
+
148
+ key: str
149
+ version_id: str
150
+ rank: int
151
+ is_delete_marker: bool
152
+ size: int
153
+ etag: str
154
+ modified_at: int
155
+ is_latest: bool = False
156
+ tags: Mapping[str, str] = field(default_factory=dict)
157
+
158
+
159
+ class Versioning(StrEnum):
160
+ """A bucket's versioning state. A bucket that never had any has none."""
161
+
162
+ ENABLED = "Enabled"
163
+ SUSPENDED = "Suspended"
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class DeleteTarget:
168
+ """What a bulk delete names: a key, and perhaps one version of it."""
169
+
170
+ key: str
171
+ version_id: str | None = None
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class DeleteOutcome:
176
+ """What a delete did. Unversioned buckets have no version to report."""
177
+
178
+ key: str
179
+ version_id: str | None = None
180
+ is_delete_marker: bool = False
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class ObjectSummary:
185
+ """The fields of an object that a listing returns."""
186
+
187
+ key: str
188
+ size: int
189
+ etag: str
190
+ modified_at: int
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class BucketRecord:
195
+ name: str
196
+ created_at: int
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class Part:
201
+ """One uploaded part of a multipart upload."""
202
+
203
+ number: int
204
+ blob: Blob
205
+ checksum: Checksum | None
206
+ modified_at: int
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class Upload:
211
+ """A multipart upload in progress."""
212
+
213
+ upload_id: str
214
+ bucket: str
215
+ key: str
216
+ headers: Mapping[str, str]
217
+ created_at: int
218
+ checksum: ChecksumPlan | None
219
+ tags: Mapping[str, str] = field(default_factory=dict)
220
+ public: bool = False
221
+ sse_check: str | None = None
222
+
223
+
224
+ @dataclass(frozen=True, slots=True)
225
+ class UploadExtras:
226
+ """What an upload promises the object it will become, beyond its headers."""
227
+
228
+ tags: Mapping[str, str] = field(default_factory=dict)
229
+ public: bool = False
230
+ sse_check: str | None = None
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class RequestedPart:
235
+ """A part a client asks to assemble, and what it believes about that part."""
236
+
237
+ number: int
238
+ etag: str
239
+ checksums: tuple[Checksum, ...] = ()
240
+
241
+
242
+ @dataclass(frozen=True, slots=True)
243
+ class CompletionRequest:
244
+ """A CompleteMultipartUpload request: the parts, and claims about the object."""
245
+
246
+ parts: tuple[RequestedPart, ...]
247
+ checksum: Checksum | None = None
248
+ checksum_type: str | None = None
249
+ object_size: int | None = None
250
+
251
+
252
+ @dataclass(frozen=True, slots=True)
253
+ class CompletedUpload:
254
+ """The result of completing an upload, which a retry must see unchanged."""
255
+
256
+ etag: str
257
+ checksum: Checksum | None
258
+ version_id: str | None = None
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class BlobSlice:
263
+ """A byte range inside one blob."""
264
+
265
+ blob: Blob
266
+ offset: int
267
+ length: int
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class RuleScope:
272
+ """What a lifecycle rule applies to, and which form the client wrote it in."""
273
+
274
+ prefix: str | None = None
275
+ legacy_prefix: bool = False # Written as <Prefix>, rather than inside <Filter>.
276
+ larger_than: int | None = None
277
+ smaller_than: int | None = None
278
+ tags: tuple[tuple[str, str], ...] = ()
279
+
280
+ def looks_inside_objects(self) -> bool:
281
+ """Whether it needs more than a key, as an upload has so far."""
282
+ return (
283
+ self.larger_than is not None
284
+ or self.smaller_than is not None
285
+ or bool(self.tags)
286
+ )
287
+
288
+
289
+ @dataclass(frozen=True, slots=True)
290
+ class RuleActions:
291
+ """What a lifecycle rule does. The default, doing nothing, isn't a valid rule."""
292
+
293
+ expire_days: int | None = None
294
+ expire_at: int | None = None # Milliseconds since the epoch.
295
+ expire_lone_markers: bool = False
296
+ noncurrent_days: int | None = None
297
+ keep_noncurrent: int = 0
298
+ abort_upload_days: int | None = None
299
+
300
+
301
+ @dataclass(frozen=True, slots=True)
302
+ class LifecycleRule:
303
+ rule_id: str
304
+ enabled: bool
305
+ scope: RuleScope
306
+ actions: RuleActions
307
+
308
+
309
+ @dataclass(frozen=True, slots=True)
310
+ class KeyVerdict:
311
+ """What lifecycle rules say to do with one key."""
312
+
313
+ expire_current: bool = False
314
+ erase: tuple[str, ...] = ()
315
+
316
+ @property
317
+ def removals(self) -> int:
318
+ return int(self.expire_current) + len(self.erase)
319
+
320
+
321
+ @dataclass(frozen=True, slots=True)
322
+ class LifecycleReport:
323
+ """What one batch of lifecycle work removed."""
324
+
325
+ expired: int = 0
326
+ erased: int = 0
327
+ aborted: int = 0
328
+
329
+
330
+ @dataclass(frozen=True, slots=True)
331
+ class CorsRule:
332
+ """Which browser pages may send which requests to a bucket."""
333
+
334
+ origins: tuple[str, ...]
335
+ methods: tuple[str, ...]
336
+ allowed_headers: tuple[str, ...] = ()
337
+ expose: tuple[str, ...] = ()
338
+ rule_id: str | None = None
339
+ max_age: int | None = None
leblob/s3/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The S3 protocol as pure functions: no sockets, files or databases here."""
leblob/s3/access.py ADDED
@@ -0,0 +1,110 @@
1
+ """What an access key may do: a pure decision, taken once per request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import IntEnum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from leblob.s3 import errors
10
+ from leblob.s3.errors import S3Error
11
+ from leblob.s3.routing import BucketRoute, ObjectRoute, Operation
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ from leblob.s3.routing import Route
17
+
18
+
19
+ class Level(IntEnum):
20
+ """A right on one bucket. Each level includes the ones below it."""
21
+
22
+ READ = 1
23
+ WRITE = 2
24
+ ADMIN = 3
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Scope:
29
+ """The buckets a scoped key was granted, or None for a key with full access."""
30
+
31
+ buckets: Mapping[str, Level] | None = None
32
+
33
+ def allows(self, bucket: str, level: Level) -> bool:
34
+ if self.buckets is None:
35
+ return True
36
+ granted = self.buckets.get(bucket)
37
+ return granted is not None and granted >= level
38
+
39
+ def sees(self, bucket: str) -> bool:
40
+ return self.buckets is None or bucket in self.buckets
41
+
42
+
43
+ FULL = Scope()
44
+ ANONYMOUS = Scope(buckets={}) # Sees nothing, unless an ACL says otherwise.
45
+ ANONYMOUS_BUCKET_OPERATIONS = frozenset({
46
+ Operation.HEAD_BUCKET,
47
+ Operation.LIST_OBJECTS,
48
+ Operation.LIST_OBJECTS_V2,
49
+ Operation.LIST_OBJECT_VERSIONS,
50
+ })
51
+ ANONYMOUS_OBJECT_OPERATIONS = frozenset({Operation.GET_OBJECT, Operation.HEAD_OBJECT})
52
+
53
+ # An operation left out of this table is for full-access keys only, so one added
54
+ # to the router later is closed to scoped keys until someone decides otherwise.
55
+ _REQUIRED = {
56
+ Operation.HEAD_BUCKET: Level.READ,
57
+ Operation.GET_BUCKET_LOCATION: Level.READ,
58
+ Operation.GET_BUCKET_VERSIONING: Level.READ,
59
+ Operation.GET_BUCKET_LIFECYCLE: Level.READ,
60
+ Operation.GET_BUCKET_CORS: Level.READ,
61
+ Operation.GET_BUCKET_ACL: Level.READ,
62
+ Operation.GET_OBJECT_ACL: Level.READ,
63
+ Operation.LIST_OBJECTS: Level.READ,
64
+ Operation.LIST_OBJECTS_V2: Level.READ,
65
+ Operation.LIST_OBJECT_VERSIONS: Level.READ,
66
+ Operation.GET_OBJECT: Level.READ,
67
+ Operation.HEAD_OBJECT: Level.READ,
68
+ Operation.GET_OBJECT_TAGGING: Level.READ,
69
+ Operation.PUT_OBJECT: Level.WRITE,
70
+ Operation.COPY_OBJECT: Level.WRITE,
71
+ Operation.DELETE_OBJECT: Level.WRITE,
72
+ Operation.DELETE_OBJECTS: Level.WRITE,
73
+ Operation.PUT_OBJECT_ACL: Level.WRITE, # And ADMIN to make it public.
74
+ Operation.PUT_OBJECT_TAGGING: Level.WRITE, # And ADMIN to tag an older version.
75
+ Operation.DELETE_OBJECT_TAGGING: Level.WRITE,
76
+ Operation.CREATE_MULTIPART_UPLOAD: Level.WRITE,
77
+ Operation.UPLOAD_PART: Level.WRITE,
78
+ Operation.UPLOAD_PART_COPY: Level.WRITE, # And READ on the source, once it's known.
79
+ Operation.COMPLETE_MULTIPART_UPLOAD: Level.WRITE,
80
+ Operation.ABORT_MULTIPART_UPLOAD: Level.WRITE,
81
+ Operation.LIST_PARTS: Level.WRITE,
82
+ Operation.LIST_MULTIPART_UPLOADS: Level.WRITE,
83
+ Operation.DELETE_BUCKET: Level.ADMIN,
84
+ # Erasing history takes more than writing: a stolen write key can't destroy what
85
+ # versioning is there to keep.
86
+ Operation.DELETE_OBJECT_VERSION: Level.ADMIN,
87
+ Operation.PUT_BUCKET_VERSIONING: Level.ADMIN,
88
+ # A rule can erase history as surely as a request can.
89
+ Operation.PUT_BUCKET_LIFECYCLE: Level.ADMIN,
90
+ Operation.DELETE_BUCKET_LIFECYCLE: Level.ADMIN,
91
+ Operation.PUT_BUCKET_CORS: Level.ADMIN,
92
+ # Publishing takes more than writing: a stolen write key can't expose a bucket.
93
+ Operation.PUT_BUCKET_ACL: Level.ADMIN,
94
+ Operation.DELETE_BUCKET_CORS: Level.ADMIN,
95
+ }
96
+
97
+
98
+ def require(scope: Scope, bucket: str, level: Level) -> None:
99
+ if not scope.allows(bucket, level):
100
+ raise S3Error(errors.ACCESS_DENIED)
101
+
102
+
103
+ def authorize(scope: Scope, route: Route) -> None:
104
+ """Refuse before any bucket is looked up, so a refusal says nothing about it."""
105
+ if scope.buckets is None or not isinstance(route, BucketRoute | ObjectRoute):
106
+ return # Listing buckets is open to all: it shows what a key can see.
107
+ level = _REQUIRED.get(route.operation)
108
+ if level is None:
109
+ raise S3Error(errors.ACCESS_DENIED)
110
+ require(scope, route.bucket, level)