Skip to content

Commit 9543608

Browse files
authored
Allow multiple attestations per distribution (#17134)
* attestations: allow multiple attestations per dist Signed-off-by: Facundo Tuesca <[email protected]> * docs: document multiple attestations per file upload Signed-off-by: Facundo Tuesca <[email protected]> * attestations: improve error messages Signed-off-by: Facundo Tuesca <[email protected]> --------- Signed-off-by: Facundo Tuesca <[email protected]>
1 parent fc3d73a commit 9543608

File tree

3 files changed

+81
-13
lines changed

3 files changed

+81
-13
lines changed

docs/user/attestations/index.md

+5
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ Currently, PyPI allows the following attestation predicates:
3232
* [SLSA Provenance]
3333
* [PyPI Publish]
3434

35+
Each file can be uploaded along its attestations. Currently PyPI supports two
36+
attestations per file: one for each of the allowed predicates. Uploads with more
37+
than two attestations per file, or with attestations with repeated predicates will
38+
be rejected.
39+
3540
[in-toto Attestation Framework]: https://github.com/in-toto/attestation/blob/main/spec/README.md
3641

3742
[PEP 740]: https://peps.python.org/pep-0740/

tests/unit/attestations/test_services.py

+51-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212
import json
13+
import re
1314

1415
import pretend
1516
import pytest
@@ -132,28 +133,74 @@ def test_parse_attestations_fails_malformed_attestation(self, metrics, db_reques
132133
in metrics.increment.calls
133134
)
134135

135-
def test_parse_attestations_fails_multiple_attestations(
136+
def test_parse_attestations_fails_multiple_attestations_exceeds_limit(
136137
self, metrics, db_request, dummy_attestation
137138
):
138139
integrity_service = services.IntegrityService(
139140
metrics=metrics,
140141
session=db_request.db,
141142
)
142143

144+
max_attestations = len(services.SUPPORTED_ATTESTATION_TYPES)
145+
143146
db_request.oidc_publisher = pretend.stub(attestation_identity=pretend.stub())
144147
db_request.POST["attestations"] = TypeAdapter(list[Attestation]).dump_json(
145-
[dummy_attestation, dummy_attestation]
148+
[dummy_attestation] * (max_attestations + 1)
146149
)
147150
with pytest.raises(
148-
AttestationUploadError, match="Only a single attestation per file"
151+
AttestationUploadError,
152+
match=f"A maximum of {max_attestations} attestations per file are "
153+
f"supported",
149154
):
150155
integrity_service.parse_attestations(
151156
db_request,
152157
pretend.stub(),
153158
)
154159

155160
assert (
156-
pretend.call("warehouse.upload.attestations.failed_multiple_attestations")
161+
pretend.call(
162+
"warehouse.upload.attestations.failed_limit_multiple_attestations"
163+
)
164+
in metrics.increment.calls
165+
)
166+
167+
def test_parse_attestations_fails_multiple_attestations_same_predicate(
168+
self, metrics, monkeypatch, db_request, dummy_attestation
169+
):
170+
integrity_service = services.IntegrityService(
171+
metrics=metrics,
172+
session=db_request.db,
173+
)
174+
max_attestations = len(services.SUPPORTED_ATTESTATION_TYPES)
175+
db_request.oidc_publisher = pretend.stub(
176+
attestation_identity=pretend.stub(),
177+
)
178+
db_request.oidc_claims = {"sha": "somesha"}
179+
db_request.POST["attestations"] = TypeAdapter(list[Attestation]).dump_json(
180+
[dummy_attestation] * max_attestations
181+
)
182+
183+
monkeypatch.setattr(Verifier, "production", lambda: pretend.stub())
184+
monkeypatch.setattr(
185+
Attestation, "verify", lambda *args: (AttestationType.PYPI_PUBLISH_V1, {})
186+
)
187+
188+
with pytest.raises(
189+
AttestationUploadError,
190+
match=re.escape(
191+
"Multiple attestations for the same file with the same predicate "
192+
"type (https://docs.pypi.org/attestations/publish/v1) are not supported"
193+
),
194+
):
195+
integrity_service.parse_attestations(
196+
db_request,
197+
pretend.stub(),
198+
)
199+
200+
assert (
201+
pretend.call(
202+
"warehouse.upload.attestations.failed_duplicate_predicate_type"
203+
)
157204
in metrics.increment.calls
158205
)
159206

warehouse/attestations/services.py

+25-9
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,14 @@ def _extract_attestations_from_request(request: Request) -> list[Attestation]:
8181
"Malformed attestations: an empty attestation set is not permitted"
8282
)
8383

84-
# This is a temporary constraint; multiple attestations per file will
85-
# be supported in the future.
86-
if len(attestations) > 1:
87-
metrics.increment("warehouse.upload.attestations.failed_multiple_attestations")
88-
84+
# We currently allow at most one attestation per predicate type
85+
max_attestations = len(SUPPORTED_ATTESTATION_TYPES)
86+
if len(attestations) > max_attestations:
87+
metrics.increment(
88+
"warehouse.upload.attestations.failed_limit_multiple_attestations"
89+
)
8990
raise AttestationUploadError(
90-
"Only a single attestation per file is supported",
91+
f"A maximum of {max_attestations} attestations per file are supported",
9192
)
9293

9394
return attestations
@@ -158,9 +159,11 @@ def parse_attestations(
158159
# Sanity-checked above.
159160
expected_identity = request.oidc_publisher.attestation_identity
160161

162+
seen_predicate_types: set[AttestationType] = set()
163+
161164
for attestation_model in attestations:
162165
try:
163-
predicate_type, _ = attestation_model.verify(
166+
predicate_type_str, _ = attestation_model.verify(
164167
expected_identity,
165168
distribution,
166169
)
@@ -182,13 +185,26 @@ def parse_attestations(
182185
f"Unknown error while trying to verify included attestations: {e}",
183186
)
184187

185-
if predicate_type not in SUPPORTED_ATTESTATION_TYPES:
188+
if predicate_type_str not in SUPPORTED_ATTESTATION_TYPES:
186189
self.metrics.increment(
187190
"warehouse.upload.attestations.failed_unsupported_predicate_type"
188191
)
189192
raise AttestationUploadError(
190-
f"Attestation with unsupported predicate type: {predicate_type}",
193+
f"Attestation with unsupported predicate type: "
194+
f"{predicate_type_str}",
191195
)
196+
predicate_type = AttestationType(predicate_type_str)
197+
198+
if predicate_type in seen_predicate_types:
199+
self.metrics.increment(
200+
"warehouse.upload.attestations.failed_duplicate_predicate_type"
201+
)
202+
raise AttestationUploadError(
203+
f"Multiple attestations for the same file with the same "
204+
f"predicate type ({predicate_type.value}) are not supported",
205+
)
206+
207+
seen_predicate_types.add(predicate_type)
192208

193209
return attestations
194210

0 commit comments

Comments
 (0)