From 69bfa34628840c9637d0a41f54dc22ffd14d883f Mon Sep 17 00:00:00 2001 From: ILay Date: Fri, 12 Jun 2026 12:55:36 +0200 Subject: [PATCH] Add Gitea CI workflow and harden credential generation --- .gitea/workflows/docker-publish.yml | 74 +++++++++ README.md | 2 +- demo-config.yaml | 2 +- .../config/gitea_workflow_config_mapping.py | 1 - .../ops/generate_gitea_credentials.py | 15 +- .../services/gitea_api_client.py | 143 +++++++++++++++--- 6 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 .gitea/workflows/docker-publish.yml diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..885c2d3 --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -0,0 +1,74 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-and-push: + runs-on: orchestration-platform + defaults: + run: + shell: sh + env: + REGISTRY: gitea.dataprovider01.sandbox-cat-dat.simpl-europe.eu + IMAGE_REPO: gitea.dataprovider01.sandbox-cat-dat.simpl-europe.eu/j.r/application-template-code-location + steps: + - name: Checkout repository (shell) + run: | + REPO_DIR="repo" + REPO_CLONE_URL="https://gitea.dataprovider01.sandbox-cat-dat.simpl-europe.eu/j.r/application-template-code-location.git" + CLONE_USER="${{ secrets.REGISTRY_USERNAME }}" + CLONE_PASS="${{ secrets.REGISTRY_PASSWORD }}" + REF_NAME="${GITHUB_REF_NAME}" + if [ -z "${REF_NAME}" ]; then + REF_NAME="${GITHUB_REF#refs/heads/}" + fi + + if [ -z "${CLONE_USER}" ] || [ -z "${CLONE_PASS}" ]; then + echo "Missing REGISTRY_USERNAME or REGISTRY_PASSWORD secret" + exit 1 + fi + + rm -rf "${REPO_DIR}" + AUTH_HEADER="$(printf '%s:%s' "${CLONE_USER}" "${CLONE_PASS}" | base64 | tr -d '\n')" + git clone --depth 1 --branch "${REF_NAME}" \ + -c "http.extraHeader=Authorization: Basic ${AUTH_HEADER}" \ + "${REPO_CLONE_URL}" \ + "${REPO_DIR}" + + if [ ! -f "${REPO_DIR}/Dockerfile" ]; then + echo "Dockerfile not found after clone" + exit 1 + fi + + - name: Validate registry secrets + run: | + if [ -z "${{ secrets.REGISTRY_USERNAME }}" ] || [ -z "${{ secrets.REGISTRY_PASSWORD }}" ]; then + echo "Missing REGISTRY_USERNAME or REGISTRY_PASSWORD secret" + exit 1 + fi + + - name: Login to registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \ + -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + + - name: Build image + run: | + COMMIT_SHA="${GITHUB_SHA:-$GITEA_SHA}" + SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)" + cd repo + docker build \ + -t "${IMAGE_REPO}:latest" \ + -t "${IMAGE_REPO}:${SHORT_SHA}" \ + . + + - name: Push image tags + run: | + COMMIT_SHA="${GITHUB_SHA:-$GITEA_SHA}" + SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)" + docker push "${IMAGE_REPO}:latest" + docker push "${IMAGE_REPO}:${SHORT_SHA}" diff --git a/README.md b/README.md index 8a6d2d9..d03e6c0 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ dagster dev The workflow accepts the following high-level inputs: - `repository_identifier` -- `consumer_email` with default literal `${CONSUMER_EMAIL}` +- `consumer_email` (required, must be a valid email address) - `access_level` Email subject and body templates are read from environment variables: diff --git a/demo-config.yaml b/demo-config.yaml index 72e4128..1e6718a 100644 --- a/demo-config.yaml +++ b/demo-config.yaml @@ -1,5 +1,5 @@ repository_identifier: simpl-open/platform-automation -consumer_email: "${CONSUMER_EMAIL}" +consumer_email: "consumer@example.local" access_level: read-only token_name_prefix: simpl-open token_expiration_days: 30 \ No newline at end of file diff --git a/src/simpl_open_credential_delivery/config/gitea_workflow_config_mapping.py b/src/simpl_open_credential_delivery/config/gitea_workflow_config_mapping.py index 4d9bfc3..6600455 100644 --- a/src/simpl_open_credential_delivery/config/gitea_workflow_config_mapping.py +++ b/src/simpl_open_credential_delivery/config/gitea_workflow_config_mapping.py @@ -31,7 +31,6 @@ simpl_open_gitea_workflow_config_mapping = config_mapping( ), "consumer_email": Field( str, - default_value="${CONSUMER_EMAIL}", description="Email address where credentials must be delivered", ), "access_level": Field( diff --git a/src/simpl_open_credential_delivery/ops/generate_gitea_credentials.py b/src/simpl_open_credential_delivery/ops/generate_gitea_credentials.py index bb7ab50..bf9c233 100644 --- a/src/simpl_open_credential_delivery/ops/generate_gitea_credentials.py +++ b/src/simpl_open_credential_delivery/ops/generate_gitea_credentials.py @@ -3,6 +3,7 @@ Generate Gitea credentials for a consumer request. """ import os +import re from dagster import Field, OpExecutionContext, Out, op # pylint: disable=import-error @@ -10,6 +11,10 @@ from simpl_open_credential_delivery.ops.retry_policy import DEFAULT_RETRY_POLICY from simpl_open_credential_delivery.services import GiteaAPIClient +def _is_valid_email(value: str) -> bool: + return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value)) + + @op( config_schema={ "repository_identifier": Field( @@ -54,6 +59,12 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict: "Missing required environment variable: GITEA_ADMIN_TOKEN" ) + consumer_email = str(config["consumer_email"]).strip() + if not _is_valid_email(consumer_email) or consumer_email.startswith("${"): + raise ValueError( + "Invalid consumer_email. Provide a real email address (for example, consumer@example.local)." + ) + context.log.info("Generating Gitea credentials for repository %s", config["repository_identifier"]) client = GiteaAPIClient( base_url=base_url, @@ -64,7 +75,7 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict: credential_bundle = client.generate_credentials( repository_identifier=config["repository_identifier"], access_level=config["access_level"], - consumer_email=config["consumer_email"], + consumer_email=consumer_email, token_name_prefix=config["token_name_prefix"], token_expiration_days=config["token_expiration_days"], ) @@ -77,7 +88,7 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict: return { "repository_identifier": config["repository_identifier"], - "consumer_email": config["consumer_email"], + "consumer_email": consumer_email, "access_level": config["access_level"], "username": credential_bundle.username, "access_token": credential_bundle.access_token, diff --git a/src/simpl_open_credential_delivery/services/gitea_api_client.py b/src/simpl_open_credential_delivery/services/gitea_api_client.py index d9f8fe3..50a93bc 100644 --- a/src/simpl_open_credential_delivery/services/gitea_api_client.py +++ b/src/simpl_open_credential_delivery/services/gitea_api_client.py @@ -6,6 +6,7 @@ from __future__ import annotations from dataclasses import dataclass import re +import time from typing import Any import requests @@ -55,6 +56,29 @@ class GiteaAPIClient: ) return response + def _request_as_user( + self, + method: str, + path: str, + username: str, + password: str, + expected_status: tuple[int, ...] = (200, 201, 204), + **kwargs: Any, + ) -> requests.Response: + response = requests.request( + method=method, + url=f"{self.base_url}{path}", + headers={"Accept": "application/json"}, + auth=(username, password), + timeout=self.timeout_seconds, + **kwargs, + ) + if response.status_code not in expected_status: + raise GiteaAPIError( + f"Gitea API call to {path} failed with status {response.status_code}: {response.text}" + ) + return response + def _resolve_repository_identifier(self, repository_identifier: str) -> str: if "/" in repository_identifier: return repository_identifier @@ -71,6 +95,40 @@ class GiteaAPIClient: return str(payload[key]) raise GiteaAPIError("Gitea token response did not contain a token value") + def _create_user_token( + self, + username: str, + password: str, + token_name: str, + token_expiration_days: int, + ) -> requests.Response: + base_payload = { + "name": token_name, + "expires_in_days": token_expiration_days, + } + + # Newer Gitea versions require at least one scope for access tokens. + try: + return self._request_as_user( + "POST", + f"/api/v1/users/{username}/tokens", + username=username, + password=password, + json={**base_payload, "scopes": ["all"]}, + ) + except GiteaAPIError as exc: + # Backward compatibility for older versions that do not support scopes. + if "unknown field" not in str(exc).lower() and "invalid character" not in str(exc).lower(): + raise + + return self._request_as_user( + "POST", + f"/api/v1/users/{username}/tokens", + username=username, + password=password, + json=base_payload, + ) + def generate_credentials( self, repository_identifier: str, @@ -84,38 +142,85 @@ class GiteaAPIClient: owner, repository_name = resolved_repository.split("/", 1) username_source = consumer_email.split("@", 1)[0] if "@" in consumer_email else consumer_email username = self._slugify(username_source) - token_name = f"{token_name_prefix}-{self._slugify(repository_name)}-{username}" + token_name = f"{token_name_prefix}-{self._slugify(repository_name)}-{username}-{int(time.time())}" + temporary_password = f"{username}-temporary-password" - self._request( + create_user_response = self._request( "POST", "/api/v1/admin/users", - expected_status=(201, 409), + expected_status=(201, 409, 422), json={ "username": username, "email": consumer_email, "login_name": username, - "password": f"{username}-temporary-password", - "must_change_password": True, + "password": temporary_password, + "must_change_password": False, }, ) - token_response = self._request( - "POST", - f"/api/v1/users/{username}/tokens", - json={ - "name": token_name, - "expires_in_days": token_expiration_days, - }, - ) + # Gitea may return 422 for an existing user depending on version. + if create_user_response.status_code == 422 and "already exists" not in create_user_response.text.lower(): + raise GiteaAPIError( + "Gitea user creation failed with validation error: " + f"{create_user_response.text}" + ) + + try: + token_response = self._create_user_token( + username=username, + password=temporary_password, + token_name=token_name, + token_expiration_days=token_expiration_days, + ) + except GiteaAPIError as exc: + # Existing users created by older runs may still have "must change password" set. + # Reset the user password policy via admin API, then retry token creation once. + if "must change your password" not in str(exc).lower(): + raise + + self._request( + "PATCH", + f"/api/v1/admin/users/{username}", + expected_status=(200, 204), + json={ + "password": temporary_password, + "must_change_password": False, + }, + ) + + token_response = self._create_user_token( + username=username, + password=temporary_password, + token_name=token_name, + token_expiration_days=token_expiration_days, + ) token_payload = token_response.json() if token_response.content else {} access_token = self._extract_token(token_payload) - self._request( - "POST", - f"/api/v1/repos/{owner}/{repository_name}/collaborators/{username}", - expected_status=(201, 204), - json={"permission": access_level}, - ) + collaborator_path = f"/api/v1/repos/{owner}/{repository_name}/collaborators/{username}" + try: + collaborator_response = self._request( + "PUT", + collaborator_path, + expected_status=(201, 204, 422), + json={"permission": access_level}, + ) + except GiteaAPIError as exc: + # Some deployments expose a POST variant; retry once for compatibility. + if "status 405" not in str(exc): + raise + collaborator_response = self._request( + "POST", + collaborator_path, + expected_status=(201, 204, 422), + json={"permission": access_level}, + ) + + if collaborator_response.status_code == 422 and "already" not in collaborator_response.text.lower(): + raise GiteaAPIError( + "Failed to grant repository collaborator access: " + f"{collaborator_response.text}" + ) return GiteaCredentialBundle( username=username,