diff --git a/app.py b/app.py index 19222226..73008e7d 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ from repository_service_tuf_api.api.artifacts import router as artifacts_v1 from repository_service_tuf_api.api.bootstrap import router as bootstrap_v1 from repository_service_tuf_api.api.config import router as config_v1 +from repository_service_tuf_api.api.delegations import router as delegations_v1 from repository_service_tuf_api.api.metadata import router as metadata_v1 from repository_service_tuf_api.api.tasks import router as tasks_v1 @@ -60,6 +61,7 @@ def _custom_openapi(): # pragma: no cover -- not used by RSTUF logic bootstrap_v1, config_v1, metadata_v1, + delegations_v1, artifacts_v1, tasks_v1, ] diff --git a/docs/source/devel/repository_service_tuf_api.api.rst b/docs/source/devel/repository_service_tuf_api.api.rst index eb189550..519bc5cd 100644 --- a/docs/source/devel/repository_service_tuf_api.api.rst +++ b/docs/source/devel/repository_service_tuf_api.api.rst @@ -28,6 +28,14 @@ repository\_service\_tuf\_api.api.config module :undoc-members: :show-inheritance: +repository\_service\_tuf\_api.api.delegations module +---------------------------------------------------- + +.. automodule:: repository_service_tuf_api.api.delegations + :members: + :undoc-members: + :show-inheritance: + repository\_service\_tuf\_api.api.metadata module ------------------------------------------------- diff --git a/docs/source/devel/repository_service_tuf_api.rst b/docs/source/devel/repository_service_tuf_api.rst index 3562ab2e..a8438c8d 100644 --- a/docs/source/devel/repository_service_tuf_api.rst +++ b/docs/source/devel/repository_service_tuf_api.rst @@ -44,6 +44,14 @@ repository\_service\_tuf\_api.config module :undoc-members: :show-inheritance: +repository\_service\_tuf\_api.delegations module +------------------------------------------------ + +.. automodule:: repository_service_tuf_api.delegations + :members: + :undoc-members: + :show-inheritance: + repository\_service\_tuf\_api.metadata module --------------------------------------------- diff --git a/docs/swagger.json b/docs/swagger.json index 8dd28dfe..03a018c0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -345,6 +345,139 @@ } } }, + "/api/v1/delegations/": { + "put": { + "tags": [ + "Delegations" + ], + "summary": "Put a task to update delegation(s).", + "description": "Submit an asynchronous task to update delegation(s). Use the task ID to retrieve the task status in the endpoint /api/v1/task.", + "operationId": "put_delegation_api_v1_delegations__put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDelegationsPayload" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegationsResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "Delegations" + ], + "summary": "Post a task to create a new delegation.", + "description": "Submit an asynchronous task to create a new delegation. Use the task ID to retrieve the task status in the endpoint /api/v1/task.", + "operationId": "post_delegation_api_v1_delegations__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDelegationsPayload" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegationsResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/delegations/delete": { + "post": { + "tags": [ + "Delegations" + ], + "summary": "Post a task to create a delete delegation.", + "description": "Submit an asynchronous task to delete delegation. Use the task ID to retrieve the task status in the endpoint /api/v1/task.", + "operationId": "delete_delegation_api_v1_delegations_delete_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDelegationDeletePayload" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegationsResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/artifacts/": { "post": { "tags": [ @@ -833,28 +966,63 @@ "message": "Bootstrap accepted." } }, - "DelegatedRole": { + "DelegationRolesData": { "properties": { - "expiration": { - "type": "integer", - "exclusiveMinimum": 0.0, - "title": "Expiration" - }, - "path_patterns": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "DelegationRolesData" + }, + "DelegationsData": { + "properties": { + "roles": { "items": { - "type": "string" + "$ref": "#/components/schemas/DelegationRolesData" }, "type": "array", - "minItems": 1, - "title": "Path Patterns" + "title": "Roles" } }, "type": "object", "required": [ - "expiration", - "path_patterns" + "roles" ], - "title": "DelegatedRole" + "title": "DelegationsData" + }, + "DelegationsResponse": { + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/repository_service_tuf_api__delegations__ResponseData" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "DelegationsResponse", + "example": { + "data": { + "last_update": "2022-12-01T12:10:00.578086", + "task_id": "7a634b556f784ae88785d36425f9a218" + } + } }, "DeletePayload": { "properties": { @@ -975,6 +1143,79 @@ "type": "object", "title": "HTTPValidationError" }, + "MetadataDelegationDeletePayload": { + "properties": { + "delegations": { + "$ref": "#/components/schemas/DelegationsData" + } + }, + "type": "object", + "required": [ + "delegations" + ], + "title": "MetadataDelegationDeletePayload", + "example": { + "delegations": { + "roles": [ + { + "name": "dev" + }, + { + "name": "legacy" + } + ] + } + } + }, + "MetadataDelegationsPayload": { + "properties": { + "delegations": { + "$ref": "#/components/schemas/TUFDelegations" + } + }, + "type": "object", + "required": [ + "delegations" + ], + "title": "MetadataDelegationsPayload", + "example": { + "delegations": { + "keys": { + "2f685fa7546f1856b123223ab086b3def14c89d24eef18f49c32508c2f60e241": { + "keytype": "rsa", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwhX6rioiL/cX5Ys32InF\nU52H8tL14QeX0tacZdb+AwcH6nIh97h3RSHvGD7Xy6uaMRmGldAnSVYwJHqoJ5j2\nynVzU/RFpr+6n8Ps0QFg5GmlEqZboFjLbS0bsRQcXXnqJNsVLEPT3ULvu1rFRbWz\nAMFjNtNNk5W/u0GEzXn3D03jIdhD8IKAdrTRf0VMD9TRCXLdMmEU2vkf1NVUnOTb\n/dRX5QA8TtBylVnouZknbavQ0J/pPlHLfxUgsKzodwDlJmbPG9BWwXqQCmP0DgOG\nNIZ1X281MOBaGbkNVEuntNjCSaQxQjfALVVU5NAfal2cwMINtqaoc7Wa+TWvpFEI\nWwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "scheme": "rsassa-pss-sha256", + "x-rstuf-key-name": "JC" + }, + "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc": { + "keytype": "ed25519", + "keyval": { + "public": "4f66dabebcf30628963786001984c0b75c175cdcf3bc4855933a2628f0cd0a0f" + }, + "scheme": "ed25519", + "x-rstuf-key-name": "JH" + } + }, + "roles": [ + { + "keyids": [ + "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", + "2f685fa7546f1856b123223ab086b3def14c89d24eef18f49c32508c2f60e241" + ], + "name": "tenant1-group1-policy", + "paths": [ + "tenant1/group1/policy/*" + ], + "terminating": true, + "threshold": 2, + "x-rstuf-expire-policy": 365 + } + ] + } + } + }, "MetadataOnlinePostPayload": { "properties": { "roles": { @@ -1617,19 +1858,15 @@ } ] }, - "delegated_roles": { + "delegations": { "anyOf": [ { - "additionalProperties": { - "$ref": "#/components/schemas/DelegatedRole" - }, - "type": "object" + "$ref": "#/components/schemas/TUFDelegations" }, { "type": "null" } - ], - "title": "Delegated Roles" + ] } }, "type": "object", @@ -1655,6 +1892,16 @@ "type": "null" } ] + }, + "trusted_targets": { + "anyOf": [ + { + "$ref": "#/components/schemas/TUFMetadata-Output" + }, + { + "type": "null" + } + ] } }, "type": "object", @@ -1666,7 +1913,13 @@ "SigningData": { "properties": { "metadata": { - "$ref": "#/components/schemas/RolesData-Output" + "anyOf": [ + { + "$ref": "#/components/schemas/RolesData-Output" + }, + {} + ], + "title": "Metadata" } }, "type": "object", @@ -1675,6 +1928,30 @@ ], "title": "SigningData" }, + "TUFDelegations": { + "properties": { + "keys": { + "additionalProperties": { + "$ref": "#/components/schemas/TUFKeys" + }, + "type": "object", + "title": "Keys" + }, + "roles": { + "items": { + "$ref": "#/components/schemas/TUFSignedDelegationsRoles" + }, + "type": "array", + "title": "Roles" + } + }, + "type": "object", + "required": [ + "keys", + "roles" + ], + "title": "TUFDelegations" + }, "TUFKeys": { "properties": { "keytype": { @@ -2082,17 +2359,11 @@ "title": "Threshold" }, "paths": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, "title": "Paths" }, "path_hash_prefixes": { @@ -2108,6 +2379,11 @@ } ], "title": "Path Hash Prefixes" + }, + "x-rstuf-expire-policy": { + "type": "integer", + "title": "X-Rstuf-Expire-Policy", + "description": "Expire Policy for the role" } }, "type": "object", @@ -2115,7 +2391,8 @@ "name", "terminating", "keyids", - "threshold" + "threshold", + "paths" ], "title": "TUFSignedDelegationsRoles" }, @@ -2195,6 +2472,7 @@ "update_settings", "publish_artifacts", "metadata_update", + "metadata_delegation", "sign_metadata", "delete_sign_metadata" ], @@ -2418,6 +2696,31 @@ ], "title": "Settings" }, + "repository_service_tuf_api__delegations__ResponseData": { + "properties": { + "task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + }, + "last_update": { + "type": "string", + "format": "date-time", + "title": "Last Update" + } + }, + "type": "object", + "required": [ + "last_update" + ], + "title": "ResponseData" + }, "repository_service_tuf_api__metadata__ResponseData": { "properties": { "task_id": { diff --git a/repository_service_tuf_api/api/delegations.py b/repository_service_tuf_api/api/delegations.py new file mode 100644 index 00000000..bb23517b --- /dev/null +++ b/repository_service_tuf_api/api/delegations.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2024 Repository Service for TUF Contributors +# +# SPDX-License-Identifier: MIT + +from fastapi import APIRouter, status + +from repository_service_tuf_api import delegations + +router = APIRouter( + prefix="/delegations", + tags=["Delegations"], + responses={404: {"description": "Not found"}}, +) + + +@router.post( + "/", + summary="Post a task to create a new delegation.", + description=( + "Submit an asynchronous task to create a new delegation. " + "Use the task ID to retrieve the task status in the endpoint " + "/api/v1/task." + ), + response_model=delegations.DelegationsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_202_ACCEPTED, +) +def post_delegation(payload: delegations.MetadataDelegationsPayload): + return delegations.metadata_delegation(payload, action="add") + + +@router.put( + "/", + summary="Put a task to update delegation(s).", + description=( + "Submit an asynchronous task to update delegation(s). " + "Use the task ID to retrieve the task status in the endpoint " + "/api/v1/task." + ), + response_model=delegations.DelegationsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_202_ACCEPTED, +) +def put_delegation(payload: delegations.MetadataDelegationsPayload): + return delegations.metadata_delegation(payload, action="update") + + +@router.post( + "/delete", + summary="Post a task to create a delete delegation.", + description=( + "Submit an asynchronous task to delete delegation. " + "Use the task ID to retrieve the task status in the endpoint " + "/api/v1/task." + ), + response_model=delegations.DelegationsResponse, + response_model_exclude_none=True, + status_code=status.HTTP_202_ACCEPTED, +) +def delete_delegation(payload: delegations.MetadataDelegationDeletePayload): + return delegations.metadata_delegation(payload, action="delete") diff --git a/repository_service_tuf_api/bootstrap.py b/repository_service_tuf_api/bootstrap.py index 87755492..04565e15 100644 --- a/repository_service_tuf_api/bootstrap.py +++ b/repository_service_tuf_api/bootstrap.py @@ -23,6 +23,7 @@ ) from repository_service_tuf_api.common_models import ( BaseErrorResponse, + TUFDelegations, TUFMetadata, ) @@ -64,17 +65,17 @@ class RolesData(BaseModel): snapshot: Role timestamp: Role bins: Optional[BinsRole] = Field(default=None) - delegated_roles: Optional[Dict[str, DelegatedRole]] = Field(default=None) + delegations: Optional[TUFDelegations] = Field(default=None) @model_validator(mode="before") @classmethod def validate_delegations(cls, values: Dict[str, Any]) -> Dict[str, Any]: bins = values.get("bins") - delegated_roles = values.get("delegated_roles") - if (bins is None and delegated_roles is None) or ( - bins is not None and delegated_roles is not None + delegations = values.get("delegations") + if (bins is None and delegations is None) or ( + bins is not None and delegations is not None ): - err_msg = "Exactly one of 'bins' and 'delegated_roles' must be set" + err_msg = "Exactly one of 'bins' and 'delegations' must be set" raise ValueError(err_msg) return values @@ -87,10 +88,11 @@ def validate_delegated_roles_names( # Validation of custom target delegated names is required as otherwise # an attacker can use a custom target name to point to a specific place # in a file system and override a file or cause unexpected behavior. - delegated_roles = values.get("delegated_roles") - if delegated_roles is not None: + delegations = values.get("delegations") + if delegations is not None: # The keys of the delegated_roles dict are the names of the roles. - for role_name in delegated_roles.keys(): + for role in delegations.get("roles"): + role_name = role.get("name") if re.fullmatch(DELEGATED_NAMES_PATTERN, role_name) is None: raise ValueError( f"Delegated custom target name {role_name} not allowed" diff --git a/repository_service_tuf_api/common_models.py b/repository_service_tuf_api/common_models.py index 7c35bfa0..a6c57685 100644 --- a/repository_service_tuf_api/common_models.py +++ b/repository_service_tuf_api/common_models.py @@ -71,6 +71,23 @@ class TUFSignedDelegationsRoles(BaseModel): threshold: int paths: List[str] | None = None path_hash_prefixes: List[str] | None = None + x_rstuf_expire_policy: int = Field( + alias="x-rstuf-expire-policy", + description="Expire Policy for the role", + default=None, + ) + # Note: No validation is required for paths as these patterns are only used + # to distribute artifacts. No files are created based on them. + paths: List[str] = Field(min_length=1) + + @model_validator(mode="before") + @classmethod + def validate_path_patterns(cls, values: Dict[str, Any]): + path_patterns = values.get("paths") + if any(len(pattern) < 1 for pattern in path_patterns): + raise ValueError("No empty strings are allowed as path patterns") + + return values class TUFSignedDelegationsSuccinctRoles(BaseModel): @@ -161,3 +178,8 @@ class TUFSignatures(BaseModel): class TUFMetadata(BaseModel): signatures: List[TUFSignatures] signed: TUFSigned + + +class TUFDelegations(BaseModel): + keys: Dict[str, TUFKeys] + roles: List[TUFSignedDelegationsRoles] diff --git a/repository_service_tuf_api/delegations.py b/repository_service_tuf_api/delegations.py new file mode 100644 index 00000000..63e2d498 --- /dev/null +++ b/repository_service_tuf_api/delegations.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2024 Repository Service for TUF Contributors +# +# SPDX-License-Identifier: MIT + +import json +from datetime import datetime, timezone +from typing import List + +from fastapi import HTTPException, status +from pydantic import BaseModel, ConfigDict + +from repository_service_tuf_api import ( + bootstrap_state, + get_task_id, + repository_metadata, +) +from repository_service_tuf_api.common_models import TUFDelegations + +with open("tests/data_examples/metadata/delegation-payload.json") as f: + content = f.read() +delegation_payload_example = json.loads(content) + + +class ResponseData(BaseModel): + task_id: str | None = None + last_update: datetime + + +class DelegationsResponse(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "data": { + "task_id": "7a634b556f784ae88785d36425f9a218", + "last_update": "2022-12-01T12:10:00.578086", + } + } + } + ) + data: ResponseData | None = None + message: str + + +# +# Metadata Delegation +# +class DelegationRolesData(BaseModel): + # TODO: add parameters for delegation roles, for example 'purge', + # 'force' during removing or managing delegation roles + name: str + + +class DelegationsData(BaseModel): + roles: List[DelegationRolesData] + + +class MetadataDelegationsPayload(BaseModel): + model_config = ConfigDict( + json_schema_extra={"example": delegation_payload_example} + ) + + delegations: TUFDelegations + + +# Metadata Delegation (Delete) +class MetadataDelegationDeletePayload(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "delegations": {"roles": [{"name": "dev"}, {"name": "legacy"}]} + } + } + ) + + delegations: DelegationsData + + +def metadata_delegation( + payload: MetadataDelegationsPayload | MetadataDelegationDeletePayload, + action: str, +): + bs_state = bootstrap_state() + if bs_state.bootstrap is False: + raise HTTPException( + status.HTTP_200_OK, + detail={ + "message": "Task not accepted.", + "error": ( + f"Requires bootstrap finished. State: {bs_state.state}" + ), + }, + ) + + task_id = get_task_id() + worker_payload = payload.model_dump(by_alias=True, exclude_none=True) + worker_payload["action"] = action + + repository_metadata.apply_async( + kwargs={ + "action": "metadata_delegation", + "payload": worker_payload, + }, + task_id=task_id, + queue="metadata_repository", + acks_late=True, + ) + + message = f"Metadata delegation {action} accepted." + data = { + "task_id": task_id, + "last_update": datetime.now(timezone.utc), + } + + return DelegationsResponse(data=data, message=message) diff --git a/repository_service_tuf_api/metadata.py b/repository_service_tuf_api/metadata.py index 2cab3c3c..7d569dd7 100644 --- a/repository_service_tuf_api/metadata.py +++ b/repository_service_tuf_api/metadata.py @@ -5,7 +5,7 @@ import json from datetime import datetime, timezone -from typing import Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from fastapi import HTTPException, status from pydantic import BaseModel, ConfigDict @@ -18,6 +18,7 @@ ) from repository_service_tuf_api.common_models import ( Roles, + TUFDelegations, TUFMetadata, TUFSignatures, ) @@ -30,7 +31,14 @@ content = f.read() das_payload_example = json.loads(content) +with open("tests/data_examples/metadata/delegation-payload.json") as f: + content = f.read() +delegation_payload_example = json.loads(content) + +# +# Metadata Update +# class MetadataPostPayload(BaseModel): model_config = ConfigDict( json_schema_extra={"example": update_payload_example} @@ -92,6 +100,82 @@ def post_metadata(payload: MetadataPostPayload) -> MetadataPostResponse: return MetadataPostResponse(data=data, message=message) +# +# Metadata Delegation +# +class DelegationRolesData(BaseModel): + # TODO: add parameters for delegation roles, for example 'purge', + # 'force' during removing or managing delegation roles + name: str + + +class DelegationsData(BaseModel): + roles: List[DelegationRolesData] + + +class MetadataDelegationsPayload(BaseModel): + model_config = ConfigDict( + json_schema_extra={"example": delegation_payload_example} + ) + + delegations: TUFDelegations + + +# Metadata Delegation (Delete) +class MetadataDelegationDeletePayload(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "delegations": {"roles": [{"name": "dev"}, {"name": "legacy"}]} + } + } + ) + + delegations: DelegationsData + + +def metadata_delegation( + payload: MetadataDelegationsPayload | MetadataDelegationDeletePayload, + action: str, +): + bs_state = bootstrap_state() + if bs_state.bootstrap is False: + raise HTTPException( + status.HTTP_200_OK, + detail={ + "message": "Task not accepted.", + "error": ( + f"Requires bootstrap finished. State: {bs_state.state}" + ), + }, + ) + + task_id = get_task_id() + worker_payload = payload.model_dump(by_alias=True, exclude_none=True) + worker_payload["action"] = action + + repository_metadata.apply_async( + kwargs={ + "action": "metadata_delegation", + "payload": worker_payload, + }, + task_id=task_id, + queue="metadata_repository", + acks_late=True, + ) + + message = f"Metadata delegation {action} accepted." + data = { + "task_id": task_id, + "last_update": datetime.now(timezone.utc), + } + + return MetadataPostResponse(data=data, message=message) + + +# +# Metadata Online Bump +# class MetadataOnlinePostPayload(BaseModel): roles: List[str] @@ -201,13 +285,17 @@ def post_metadata_online( return MetadataOnlinePostResponse(data=data, message=message) +# +# Metadata Sign +# class RolesData(BaseModel): root: TUFMetadata trusted_root: TUFMetadata | None = None + trusted_targets: TUFMetadata | None = None class SigningData(BaseModel): - metadata: RolesData + metadata: RolesData | Any class MetadataSignGetResponse(BaseModel): @@ -246,6 +334,15 @@ def get_metadata_sign() -> MetadataSignGetResponse: ) md_response = {} + trusted_root = settings_repository.get("TRUSTED_ROOT") + trusted_targets = settings_repository.get("TRUSTED_TARGETS") + + if trusted_root: + md_response["trusted_root"] = trusted_root.to_dict() + + if trusted_targets: + md_response["trusted_targets"] = trusted_targets.to_dict() + for role_setting in pending_signing: signing_role_obj = settings_repository.get(role_setting) if signing_role_obj is not None: @@ -253,10 +350,6 @@ def get_metadata_sign() -> MetadataSignGetResponse: role = role_setting.split("_")[0].lower() md_response[role] = signing_role_dict - trusted_obj = settings_repository.get(f"TRUSTED_{role.upper()}") - if trusted_obj is not None: - md_response[f"trusted_{role}"] = trusted_obj.to_dict() - if len(md_response) > 0: data = {"metadata": md_response} msg = "Metadata role(s) pending signing" @@ -349,6 +442,7 @@ def post_metadata_sign( return MetadataPostResponse(data=data, message=message) +# Metadata Sign (Delete) class MetadataSignDeletePayload(BaseModel): model_config = ConfigDict(json_schema_extra={"example": {"role": "root"}}) role: str diff --git a/repository_service_tuf_api/tasks.py b/repository_service_tuf_api/tasks.py index 25cf97ad..6c97b6d8 100644 --- a/repository_service_tuf_api/tasks.py +++ b/repository_service_tuf_api/tasks.py @@ -36,6 +36,7 @@ class TaskName(str, enum.Enum): UPDATE_SETTINGS = "update_settings" PUBLISH_ARTIFACTS = "publish_artifacts" METADATA_UPDATE = "metadata_update" + METADATA_DELEGATION = "metadata_delegation" SIGN_METADATA = "sign_metadata" DELETE_SIGN_METADATA = "delete_sign_metadata" diff --git a/tests/data_examples/bootstrap/payload_custom_targets.json b/tests/data_examples/bootstrap/payload_custom_targets.json index 68aab8ed..10e47426 100644 --- a/tests/data_examples/bootstrap/payload_custom_targets.json +++ b/tests/data_examples/bootstrap/payload_custom_targets.json @@ -13,15 +13,50 @@ "targets": { "expiration": 365 }, - "delegated_roles": { - "foo": { - "expiration": 30, - "path_patterns": ["project/f"] + "delegations": { + "keys": { + "2f685fa7546f1856b123223ab086b3def14c89d24eef18f49c32508c2f60e241": { + "keytype": "rsa", + "scheme": "rsassa-pss-sha256", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwhX6rioiL/cX5Ys32InF\nU52H8tL14QeX0tacZdb+AwcH6nIh97h3RSHvGD7Xy6uaMRmGldAnSVYwJHqoJ5j2\nynVzU/RFpr+6n8Ps0QFg5GmlEqZboFjLbS0bsRQcXXnqJNsVLEPT3ULvu1rFRbWz\nAMFjNtNNk5W/u0GEzXn3D03jIdhD8IKAdrTRf0VMD9TRCXLdMmEU2vkf1NVUnOTb\n/dRX5QA8TtBylVnouZknbavQ0J/pPlHLfxUgsKzodwDlJmbPG9BWwXqQCmP0DgOG\nNIZ1X281MOBaGbkNVEuntNjCSaQxQjfALVVU5NAfal2cwMINtqaoc7Wa+TWvpFEI\nWwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "x-rstuf-key-name": "JC" + }, + "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc": { + "keytype": "ed25519", + "scheme": "ed25519", + "keyval": { + "public": "4f66dabebcf30628963786001984c0b75c175cdcf3bc4855933a2628f0cd0a0f" + }, + "x-rstuf-key-name": "JH" + } }, - "bar": { - "expiration": 60, - "path_patterns": ["project/b"] - } + "roles": [ + { + "name": "default", + "terminating": true, + "keyids": [], + "threshold": 1, + "x-rstuf-expire-policy": 1, + "paths": [ + "*" + ] + }, + { + "name": "production", + "terminating": true, + "keyids": [ + "2f685fa7546f1856b123223ab086b3def14c89d24eef18f49c32508c2f60e241", + "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc" + ], + "threshold": 2, + "x-rstuf-expire-policy": 7, + "paths": [ + "production/*" + ] + } + ] } } }, diff --git a/tests/data_examples/metadata/delegation-payload.json b/tests/data_examples/metadata/delegation-payload.json new file mode 100644 index 00000000..4bf6db38 --- /dev/null +++ b/tests/data_examples/metadata/delegation-payload.json @@ -0,0 +1,37 @@ +{ + "delegations": { + "keys": { + "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc": { + "keytype": "ed25519", + "scheme": "ed25519", + "keyval": { + "public": "4f66dabebcf30628963786001984c0b75c175cdcf3bc4855933a2628f0cd0a0f" + }, + "x-rstuf-key-name": "JH" + }, + "2f685fa7546f1856b123223ab086b3def14c89d24eef18f49c32508c2f60e241": { + "keytype": "rsa", + "scheme": "rsassa-pss-sha256", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwhX6rioiL/cX5Ys32InF\nU52H8tL14QeX0tacZdb+AwcH6nIh97h3RSHvGD7Xy6uaMRmGldAnSVYwJHqoJ5j2\nynVzU/RFpr+6n8Ps0QFg5GmlEqZboFjLbS0bsRQcXXnqJNsVLEPT3ULvu1rFRbWz\nAMFjNtNNk5W/u0GEzXn3D03jIdhD8IKAdrTRf0VMD9TRCXLdMmEU2vkf1NVUnOTb\n/dRX5QA8TtBylVnouZknbavQ0J/pPlHLfxUgsKzodwDlJmbPG9BWwXqQCmP0DgOG\nNIZ1X281MOBaGbkNVEuntNjCSaQxQjfALVVU5NAfal2cwMINtqaoc7Wa+TWvpFEI\nWwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "x-rstuf-key-name": "JC" + } + }, + "roles": [ + { + "name": "tenant1-group1-policy", + "terminating": true, + "keyids": [ + "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", + "2f685fa7546f1856b123223ab086b3def14c89d24eef18f49c32508c2f60e241" + ], + "threshold": 2, + "x-rstuf-expire-policy": 365, + "paths": [ + "tenant1/group1/policy/*" + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/api/test_bootstrap.py b/tests/unit/api/test_bootstrap.py index dd7f42fc..1fba5156 100644 --- a/tests/unit/api/test_bootstrap.py +++ b/tests/unit/api/test_bootstrap.py @@ -7,7 +7,6 @@ from datetime import timezone import pretend -import pytest from fastapi import status BOOTSTRAP_URL = "/api/v1/bootstrap/" @@ -143,54 +142,6 @@ def test_post_bootstrap_bins_delegation( pretend.call(task_id="123", timeout=300) ] - def test_post_bootstrap_custom_delegation( - self, test_client, monkeypatch, fake_datetime - ): - mocked_bootstrap_state = pretend.call_recorder( - lambda *a: pretend.stub( - bootstrap=False, state="finished", task_id="task_id" - ) - ) - monkeypatch.setattr( - f"{MOCK_PATH}.bootstrap_state", mocked_bootstrap_state - ) - mocked_async_result = pretend.stub(state="SUCCESS") - mocked_repository_metadata = pretend.stub( - apply_async=pretend.call_recorder(lambda *a, **kw: None), - AsyncResult=pretend.call_recorder(lambda *a: mocked_async_result), - ) - monkeypatch.setattr( - f"{MOCK_PATH}.repository_metadata", mocked_repository_metadata - ) - monkeypatch.setattr(f"{MOCK_PATH}.get_task_id", lambda: "123") - monkeypatch.setattr(f"{MOCK_PATH}.pre_lock_bootstrap", lambda *a: None) - mocked__check_bootstrap_status = pretend.call_recorder(lambda *a: None) - monkeypatch.setattr( - f"{MOCK_PATH}._check_bootstrap_status", - mocked__check_bootstrap_status, - ) - - monkeypatch.setattr(f"{MOCK_PATH}.datetime", fake_datetime) - - path = "tests/data_examples/bootstrap/payload_custom_targets.json" - with open(path) as f: - f_data = f.read() - payload = json.loads(f_data) - - response = test_client.post(BOOTSTRAP_URL, json=payload) - - assert fake_datetime.now.calls == [pretend.call(timezone.utc)] - assert response.status_code == status.HTTP_202_ACCEPTED - assert response.url == f"{test_client.base_url}{BOOTSTRAP_URL}" - assert response.json() == { - "message": "Bootstrap accepted.", - "data": {"task_id": "123", "last_update": "2019-06-16T09:05:01Z"}, - } - assert mocked_bootstrap_state.calls == [pretend.call()] - assert mocked__check_bootstrap_status.calls == [ - pretend.call(task_id="123", timeout=300) - ] - def test_post_bootstrap_unrecognized_field( self, test_client, monkeypatch, fake_datetime ): @@ -454,7 +405,7 @@ def test_post_bootstrap_empty_payload(self, test_client): ] } - def test_post_payload_no_bins_or_delegated_targets( + def test_post_payload_no_bins_or_delegations( self, test_client, monkeypatch ): mocked_bootstrap_state = pretend.call_recorder( @@ -473,61 +424,5 @@ def test_post_payload_no_bins_or_delegated_targets( response = test_client.post(BOOTSTRAP_URL, json=payload) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.url == f"{test_client.base_url}{BOOTSTRAP_URL}" - err_msg = "Exactly one of 'bins' and 'delegated_roles' must be set" - assert err_msg in response.text - - @pytest.mark.parametrize("name", ["bad*", "|bad", ".bad", "/", "\\"]) - def test_post_payload_bad_delegated_role_names( - self, test_client, monkeypatch, name - ): - mocked_bootstrap_state = pretend.call_recorder( - lambda *a: pretend.stub( - bootstrap=False, state="finished", task_id="task_id" - ) - ) - monkeypatch.setattr( - f"{MOCK_PATH}.bootstrap_state", mocked_bootstrap_state - ) - path = "tests/data_examples/bootstrap/payload_custom_targets.json" - with open(path) as f: - f_data = f.read() - - payload = json.loads(f_data) - payload["settings"]["roles"]["delegated_roles"] = { - name: {"expiration": 30, "path_prefixes": ["project/f"]}, - } - response = test_client.post(BOOTSTRAP_URL, json=payload) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.url == f"{test_client.base_url}{BOOTSTRAP_URL}" - err_msg_1 = "Delegated custom target name" - err_msg_2 = "not allowed" - err_msg_3 = " Only a-z, A-Z, 0-9, - and _ characters can be used" - assert err_msg_1 in response.text - assert err_msg_2 in response.text - assert err_msg_3 in response.text - - def test_post_payload_delegated_role_with_empty_path_pattern( - self, test_client, monkeypatch - ): - mocked_bootstrap_state = pretend.call_recorder( - lambda *a: pretend.stub( - bootstrap=False, state="finished", task_id="task_id" - ) - ) - monkeypatch.setattr( - f"{MOCK_PATH}.bootstrap_state", mocked_bootstrap_state - ) - path = "tests/data_examples/bootstrap/payload_custom_targets.json" - with open(path) as f: - f_data = f.read() - - payload = json.loads(f_data) - payload["settings"]["roles"]["delegated_roles"]["foo"] = { - "expiration": 30, - "path_patterns": [""], - } - response = test_client.post(BOOTSTRAP_URL, json=payload) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.url == f"{test_client.base_url}{BOOTSTRAP_URL}" - err_msg = "No empty strings are allowed as path patterns" + err_msg = "Exactly one of 'bins' and 'delegations' must be set" assert err_msg in response.text diff --git a/tests/unit/api/test_metadata.py b/tests/unit/api/test_metadata.py index f787db5f..6c88a31b 100644 --- a/tests/unit/api/test_metadata.py +++ b/tests/unit/api/test_metadata.py @@ -491,8 +491,9 @@ def get_role(role_setting: str): assert mocked_bootstrap_state.calls == [pretend.call()] assert mocked_settings_repository.reload.calls == [pretend.call()] assert mocked_settings_repository.get.calls == [ - pretend.call("ROOT_SIGNING"), pretend.call("TRUSTED_ROOT"), + pretend.call("TRUSTED_TARGETS"), + pretend.call("ROOT_SIGNING"), ] assert fake_metadata.to_dict.calls == [pretend.call()] @@ -551,8 +552,9 @@ def get_role(setting: str): assert mocked_bootstrap_state.calls == [pretend.call()] assert mocked_settings_repository.reload.calls == [pretend.call()] assert mocked_settings_repository.get.calls == [ - pretend.call("ROOT_SIGNING"), pretend.call("TRUSTED_ROOT"), + pretend.call("TRUSTED_TARGETS"), + pretend.call("ROOT_SIGNING"), ] assert fake_metadata.to_dict.calls == [pretend.call()] assert fake_trusted_metadata.to_dict.calls == [pretend.call()] @@ -569,6 +571,7 @@ def test_get_metadata_sign_no_pending_roles( mocked_settings_repository = pretend.stub( reload=pretend.call_recorder(lambda: None), + get=pretend.call_recorder(lambda *a: None), ) monkeypatch.setattr( f"{MOCK_PATH}.settings_repository", mocked_settings_repository