diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md b/corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md new file mode 100644 index 0000000..6805140 --- /dev/null +++ b/corpwiki/docs/people-portal-dev-guide/deployment/gitea-upgrade.md @@ -0,0 +1,200 @@ +--- +sidebar_position: 7 +--- + +# Gitea Upgrade (1.24 → 1.27) + +Runbook for `git.appdevclub.com`: Docker, Postgres, Authentik OIDC login, +and sign-in required to view repositories. + +Verified on 2026-10-28 by upgrading `peopleportal/dev-stack` from 1.24.3 to +1.27.3 and running Gitea Actions jobs on a hardened `act_runner` VM. + +:::warning Deploy People Portal first or together +People Portal must be running a build whose Gitea webhook models accept +unknown fields (`GiteaHookRepositoryTrigger` and friends in +`peopleportal/server/src/clients/GiteaClient/models.ts`) before or together +with the Gitea upgrade. Without it, new team repositories are never provisioned +or branch-protected. See [People Portal compatibility](#people-portal-compatibility). +::: + +## Why upgrade + +1.24.3 has fork-PR Actions approval-gate bypasses: + +| Advisory | Status | +| ------------------------------------ | ------------------------------------------------------ | +| CVE-2026-58424 / GHSA-777r-4v59-6486 | Fixed in 1.26.3 | +| CVE-2026-71184 | Fixed in 1.27.3 | +| CVE-2026-104632 | Cancel/re-run of a waiting run; listed with no fix yet | + +The club runners are **instance-wide**: any organization's workflow can reach +them. Upgrading closes the fixed bypasses; the remaining advisory still needs +tracking. + +1.26 also added configurable Actions job-token permissions +([#36173](https://github.com/go-gitea/gitea/pull/36173)). See the +[1.27 release notes](https://blog.gitea.com/release-of-1.27.0/) and +[job-token permissions documentation](https://docs.gitea.com/usage/actions/token-permissions/). + +## Target configuration + +Set these in `app.ini`: + +```ini +[repository] +DISABLE_HTTP_GIT = false + +[service] +ENABLE_BASIC_AUTHENTICATION = false +REQUIRE_SIGNIN_VIEW = true + +[security] +DISABLE_QUERY_AUTH_TOKEN = true +``` + +Or use the equivalent Docker environment variables: + +```yaml +environment: + GITEA__repository__DISABLE_HTTP_GIT: "false" + GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false" + GITEA__service__REQUIRE_SIGNIN_VIEW: "true" + GITEA__security__DISABLE_QUERY_AUTH_TOKEN: "true" +``` + +Production currently has `DISABLE_HTTP_GIT = true`. That makes +`actions/checkout` fail with 403: +`Interacting with repositories by HTTP protocol is not allowed`. + +`DISABLE_HTTP_GIT` only gates Git smart-HTTP. The REST archive endpoint, +`/api/v1/repos/{owner}/{repo}/archive/*`, already serves source to tokens with +repository access, so the old setting protected little. Enabling smart-HTTP +lets checkout work while `ENABLE_BASIC_AUTHENTICATION = false` refuses account +passwords over HTTP Basic for both Git and the API. Tokens still work, including +a token sent as the Basic password. `DISABLE_QUERY_AUTH_TOKEN = true` rejects +`?token=` authentication. Keep `REQUIRE_SIGNIN_VIEW = true`. + +### Authentication results + +Expected on 1.27.3 with the settings above and a token with the required access: + +| Request | Result | +| ------------------------------------------------- | ------- | +| Git clone with account password | Refused | +| Git clone with token as Basic password | Allowed | +| Anonymous clone with `REQUIRE_SIGNIN_VIEW` | Refused | +| API HTTP Basic with account password | 401 | +| API `Authorization: token` | 200 | +| API `?token=` | 401 | +| Token-management API with token as Basic password | 200 | + +## Upgrade + +1. Deploy People Portal with the webhook-model fix, or include it in the same + maintenance window before Gitea resumes sending webhooks. +2. Stop the Gitea container. Leave Postgres running for the dump. +3. Back up the Gitea database with a Postgres dump (`pg_dump`). Back up the + complete `/data` volume too, including configuration and repositories. Keep + both backups from this stopped state. +4. Change the Gitea image to `docker.gitea.com/gitea:1.27.3`. + Skipping 1.25 and 1.26 is fine: migrations 336..342 ran in one boot locally. +5. Apply the [target configuration](#target-configuration). +6. Start Gitea, recreating the container so image and environment changes take + effect. Watch its logs for migrations and startup errors. +7. Check `https://git.appdevclub.com/api/v1/version`. It must report `1.27.3`. +8. Set [restricted token permissions](#actions-token-permissions) for each + organization and complete the [verification checklist](#verification-checklist). + +:::warning Rollback requires both backups +Migrations are one-way. To roll back, stop Gitea, restore **both** the database +dump and `/data` backup, restore the old image and configuration, then start it. +Changing the image tag alone does not undo the migration. +::: + +### First-boot Authentik message + +An `[E] Unable to register source: authentik` can appear if the IdP is not +reachable at that instant. Gitea registers the source on first use; the +`/user/oauth2/` login redirect worked afterwards. This transient message +is harmless if the later Authentik login succeeds. A persistent login failure +still needs investigation. + +## Release-note checks + +Check these changes from the 1.25–1.27 release notes against the deployment: + +| Change | Action or impact | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Runner registration-token endpoint changed from GET to POST in 1.26 | Update callers that register runners. People Portal does not call it. | +| Reusable workflows run server-side in 1.27 | Review existing reusable workflows. External-instance reusable-workflow URLs are rejected. | +| `[cors] X_FRAME_OPTIONS` moved to `[security]` | Move the setting if configured. | +| `[webhook] ALLOWED_HOST_LIST` has a successor, `[security] ALLOWED_HOST_LIST` | The old setting is still accepted; review the successor when updating configuration. | +| Admin hooks API returns an empty `authorization_header` | People Portal re-applies the header on every start, so its hook setup remains compatible. | +| `environment-to-ini` became `gitea config edit-ini` | Update direct invocations. The Docker image retains a compatibility wrapper. | + +## People Portal compatibility + +Gitea 1.27 adds fields to webhook `repository` objects, including `branch_count`, +`has_code`, and `allow_merge_update`. The People Portal server uses tsoa with +`noImplicitAdditionalProperties: throw-on-extras`. Without the webhook-model +fix, those payloads receive 422. + +That prevents provisioning: new team repositories are not made public, the +creator is not removed, the default branch is not set to `main`, and branch +protection is not applied. + +After the fix, the end-to-end check showed: + +- A new organization repository changed from private to public with default + branch `main`. +- The first push to `main` created the `main` protection rule with **2 required + approvals**. +- Push, merge, and approval allowlists were all `PeoplePortalTeamOwners`. +- Force push was disabled. + +## Actions token permissions + +For **each organization**, open **Settings → Actions → General → Default Token +Permissions** and select **Restricted**. + +:::warning Manual setting in 1.27.3 +This is UI-only. There is no REST endpoint or `app.ini` default in 1.27.3, so +People Portal cannot set it automatically. +::: + +With Restricted selected: + +| Job | Result | +| ------------------------------------------------------------- | ------- | +| Default job token creates a tag | 403 | +| Job declares `permissions: contents: write` and creates a tag | 201 | +| `actions/checkout` reads the repository | Allowed | + +For a job that needs tag creation, declare the mapping explicitly: + +```yaml +jobs: + release: + permissions: + contents: write + # steps follow +``` + +Fork-PR jobs are always read-only. + +## Verification checklist + +- [ ] `/api/v1/version` reports `1.27.3`. +- [ ] Authentik OIDC login through `/user/oauth2/authentik` succeeds. +- [ ] A workflow using `actions/checkout` passes on a club runner. +- [ ] Every request in the [authentication table](#authentication-results) + produces the expected result. +- [ ] Each organization's default token permissions are Restricted; default + tag creation gets 403 and explicit `contents: write` gets 201. +- [ ] Create a test repository in a team organization. Confirm it becomes + public, the creator is removed, and the default branch is `main`. +- [ ] Push to `main`. Confirm the `main` rule requires 2 approvals, all three + allowlists are `PeoplePortalTeamOwners`, and force push is disabled. +- [ ] People Portal logs contain no + `Caught Validation Error for /api/webhook/git/...` during those events. diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md index 19fff5e..f124dbb 100644 --- a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md +++ b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md @@ -311,7 +311,7 @@ networks: services: server: - image: gitea/gitea:1.21.0 + image: docker.gitea.com/gitea:1.27.3 container_name: gitea environment: - USER_UID=1000 diff --git a/peopleportal/dev-stack/docker-compose.yml b/peopleportal/dev-stack/docker-compose.yml index dcaf276..98a9f07 100644 --- a/peopleportal/dev-stack/docker-compose.yml +++ b/peopleportal/dev-stack/docker-compose.yml @@ -79,7 +79,7 @@ services: start_period: 60s gitea: - image: docker.gitea.com/gitea:1.24.3 + image: docker.gitea.com/gitea:1.27.3 restart: unless-stopped environment: USER_UID: "1000" @@ -93,6 +93,12 @@ services: GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222} GITEA__service__DISABLE_REGISTRATION: "true" GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + # Same hardening as git.appdevclub.com: git over HTTP stays on so Actions + # checkout works with the job token, but passwords are refused over HTTP + # Basic and tokens are refused in query strings. + GITEA__repository__DISABLE_HTTP_GIT: "false" + GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false" + GITEA__security__DISABLE_QUERY_AUTH_TOKEN: "true" GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true" GITEA__webhook__ALLOWED_HOST_LIST: host.docker.internal,${PUBLIC_HOST:-localhost} GITEA__mailer__ENABLED: "true" diff --git a/peopleportal/dev-stack/scripts/bootstrap-gitea.sh b/peopleportal/dev-stack/scripts/bootstrap-gitea.sh index 7a58fe3..37a03c6 100755 --- a/peopleportal/dev-stack/scripts/bootstrap-gitea.sh +++ b/peopleportal/dev-stack/scripts/bootstrap-gitea.sh @@ -17,36 +17,80 @@ if ! awk -v user="$GITEA_ADMIN_USER" '$2 == user { found=1 } END { exit !found } --admin --must-change-password=false >&2 fi +# Tokens never go on a command line, where any local process could read them: +# curl takes them from a --config on stdin, the Python helper from its env. token_valid=false if [[ -s .gitea-token ]]; then token=$(<.gitea-token) - if curl --fail --silent --max-time 10 -H "Authorization: token $token" "$url/api/v1/user" >/dev/null; then + if printf 'header = "Authorization: token %s"\n' "$token" | + curl --fail --silent --max-time 10 --config - "$url/api/v1/user" >/dev/null; then token_valid=true fi fi -if [[ $token_valid == false ]]; then - # Run JSON parsing inside authentik's image. HTTP failures must abort rather - # than masquerade as "already exists". Only delete this stack's named token. - token_id=$(compose exec -T authentik-worker python - "$GITEA_ADMIN_USER" "$GITEA_ADMIN_PASSWORD" <<'PY' -import base64, json, sys, urllib.request -user, password = sys.argv[1:] +new_token() { + local raw + raw=$(gitea_admin user generate-access-token --username "$GITEA_ADMIN_USER" \ + --token-name "$1" --scopes all --raw) + raw=$(awk '/^[0-9a-fA-F]+$/ && length($0) == 40 {print}' <<<"$raw") + [[ $raw =~ ^[0-9a-fA-F]{40}$ ]] || die "Gitea did not return a single raw token." + printf '%s\n' "$raw" +} + +# Prints the ids of the admin's tokens named $2, or starting with $3 if given, +# except one named $4, authenticating with token $1. Gitea's token API needs +# HTTP Basic, and ENABLE_BASIC_AUTHENTICATION=false refuses passwords there, so +# the credential is a token sent as the password. JSON is parsed inside +# authentik's image; HTTP failures must abort rather than read as "none". +token_ids() { + GITEA_BASIC_SECRET=$1 compose exec -T -e GITEA_BASIC_SECRET authentik-worker \ + python - "$GITEA_ADMIN_USER" "$2" "${3:-}" "${4:-}" <<'PY' +import base64, json, os, sys, urllib.request +user, name, prefix, skip = sys.argv[1:] +secret = os.environ['GITEA_BASIC_SECRET'] request = urllib.request.Request(f'http://gitea:3000/api/v1/users/{user}/tokens') -request.add_header('Authorization', 'Basic ' + base64.b64encode(f'{user}:{password}'.encode()).decode()) +request.add_header('Authorization', 'Basic ' + base64.b64encode(f'{user}:{secret}'.encode()).decode()) with urllib.request.urlopen(request, timeout=10) as response: tokens = json.load(response) -print(next((t['id'] for t in tokens if t['name'] == 'people-portal-dev'), '')) +for t in tokens: + if t['name'] != skip and (t['name'] == name or (prefix and t['name'].startswith(prefix))): + print(t['id']) PY -) - if [[ -n $token_id ]]; then - curl --fail --silent --show-error --max-time 10 --request DELETE \ - --user "$GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD" \ - "$url/api/v1/users/$GITEA_ADMIN_USER/tokens/$token_id" >/dev/null - fi - token=$(gitea_admin user generate-access-token --username "$GITEA_ADMIN_USER" \ - --token-name people-portal-dev --scopes all --raw) - token=$(awk '/^[0-9a-fA-F]+$/ && length($0) == 40 {print}' <<<"$token") - [[ $token =~ ^[0-9a-fA-F]{40}$ ]] || die "Gitea did not return a single raw token." +} + +# Deletes the admin's token $2 (an id or a name), authenticating with token $1. +delete_token() { + printf 'user = "%s:%s"\n' "$GITEA_ADMIN_USER" "$1" | + curl --fail --silent --show-error --max-time 10 --config - --request DELETE \ + "$url/api/v1/users/$GITEA_ADMIN_USER/tokens/$2" >/dev/null +} + +# If recovery stops partway, the short-lived token revokes itself on exit so a +# failed run doesn't leave an extra admin token behind. By name, so this needs +# only curl and Gitea, not the lookup that may be what failed. +revoke_rotate() { + [[ -n ${rotate:-} ]] || return 0 + delete_token "$rotate" "$rotate_name" || + echo "WARNING: could not revoke Gitea token $rotate_name; delete it in $GITEA_ADMIN_USER's Applications settings." >&2 +} + +if [[ $token_valid == false ]]; then + # The CLI can mint tokens but not list or delete them. A short-lived token + # clears the stale people-portal-dev one, and any rotation tokens an earlier + # failed run left, so the name can be reused. The replacement is saved before + # it deletes the short-lived token. Only this stack's names are touched. + rotate_name="people-portal-dev-rotate-$(openssl rand -hex 4)" + rotate=$(new_token "$rotate_name") + trap revoke_rotate EXIT + # Assign first: set -e ignores a failed substitution in a for list. + stale_ids=$(token_ids "$rotate" people-portal-dev people-portal-dev-rotate- "$rotate_name") + for id in $stale_ids; do + delete_token "$rotate" "$id" + done + token=$(new_token people-portal-dev) private_file "$STACK_DIR/.gitea-token" <<<"$token" + delete_token "$token" "$rotate_name" + rotate="" + trap - EXIT fi chmod 600 .gitea-token diff --git a/peopleportal/server/src/clients/GiteaClient/index.ts b/peopleportal/server/src/clients/GiteaClient/index.ts index f788ea0..c3341c6 100644 --- a/peopleportal/server/src/clients/GiteaClient/index.ts +++ b/peopleportal/server/src/clients/GiteaClient/index.ts @@ -23,7 +23,7 @@ import { GiteaAPITeamDefinition, GiteaAPIUserDefinition, GiteaBranchProtection, - GiteaHookRepositoryTrigger, + GiteaRepoProvisioningRequest, GiteaRepository, GiteaRepositoryBrief, } from "./models"; @@ -320,9 +320,11 @@ export class GiteaClient implements SharedResourceClient { * If requests fail, we throw the errors as-is without catching. The callee is * expected to delete the newly created repository considering standards mismatch. * - * @param repoEvent Gitea Repository Event + * @param repoEvent The repository (as fetched from the API) and its creator */ - public async handleRepoProvisioning(repoEvent: GiteaHookRepositoryTrigger) { + public async handleRepoProvisioning( + repoEvent: GiteaRepoProvisioningRequest, + ) { /* 1. Remove the creator from the collaborators list so team permissions take over */ var DeleteCollabRequestConfig: any = { ...this.GiteaBaseConfig, diff --git a/peopleportal/server/src/clients/GiteaClient/models.ts b/peopleportal/server/src/clients/GiteaClient/models.ts index 8476256..9155e69 100644 --- a/peopleportal/server/src/clients/GiteaClient/models.ts +++ b/peopleportal/server/src/clients/GiteaClient/models.ts @@ -155,43 +155,49 @@ export interface GiteaRepository extends GiteaRepositoryBrief { licenses: string[]; } -export interface GiteaHookRepositoryTrigger { - action: string; - repository: GiteaRepository; - organization: GiteaUser; - sender: GiteaUser; +/* + * Webhook bodies. Gitea adds fields to these payloads in most releases (1.27 + * added branch_count, has_code and more to `repository`), and tsoa runs with + * noImplicitAdditionalProperties "throw-on-extras", so a full model turns every + * delivery into a 400 after an upgrade. Declare only what the handlers read and + * accept the rest: the handlers re-fetch the repository from the API before + * acting on it, so nothing else in the body is trusted anyway. + */ +export interface GiteaHookUser { + id: number; + username: string; + [key: string]: unknown; } -export interface GiteaCommitUser { +export interface GiteaHookRepository { + id: number; name: string; - email: string; - username: string; + full_name: string; + owner: GiteaHookUser; + [key: string]: unknown; } -export interface GiteaCommit { - id: string; - message: string; - url: string; - author: GiteaCommitUser; - committer: GiteaCommitUser; - verification: any; - timestamp: string; - added: string[]; - removed: string[]; - modified: string[]; +export interface GiteaHookRepositoryTrigger { + action: string; + repository: GiteaHookRepository; + organization: GiteaHookUser; + sender: GiteaHookUser; + [key: string]: unknown; } export interface GiteaHookCommitTrigger { ref: string; before: string; after: string; - compare_url: string; - commits: GiteaCommit[]; - total_commits: number; - head_commit: GiteaCommit; - repository: GiteaRepository; - pusher: GiteaUser; - sender: GiteaUser; + repository: GiteaHookRepository; + [key: string]: unknown; +} + +/* What repository provisioning needs: the API's copy of the repository and the + creator to remove as a collaborator. */ +export interface GiteaRepoProvisioningRequest { + repository: GiteaRepositoryBrief; + sender: { username: string }; } export interface GiteaBranchProtection { diff --git a/peopleportal/server/src/controllers/HooksController.ts b/peopleportal/server/src/controllers/HooksController.ts index 0f1a2d1..1e79c17 100644 --- a/peopleportal/server/src/controllers/HooksController.ts +++ b/peopleportal/server/src/controllers/HooksController.ts @@ -126,8 +126,8 @@ export class HooksController extends Controller { } await this.giteaClient.handleRepoProvisioning({ - ...repoEvent, repository, + sender: repoEvent.sender, }); } catch (e: any) { /* Processing failed; leave the repository untouched for an authorized operator to review. */ diff --git a/peopleportal/server/src/routes.ts b/peopleportal/server/src/routes.ts index 4a19fa8..619a73a 100644 --- a/peopleportal/server/src/routes.ts +++ b/peopleportal/server/src/routes.ts @@ -669,157 +669,35 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaRepositoryPermissions": { - "dataType": "refObject", - "properties": { - "admin": {"dataType":"boolean","required":true}, - "push": {"dataType":"boolean","required":true}, - "pull": {"dataType":"boolean","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaRepositoryInternalTracker": { - "dataType": "refObject", - "properties": { - "enable_time_tracker": {"dataType":"boolean","required":true}, - "allow_only_contributors_to_track_time": {"dataType":"boolean","required":true}, - "enable_issue_dependencies": {"dataType":"boolean","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaUser": { + "GiteaHookUser": { "dataType": "refObject", "properties": { "id": {"dataType":"double","required":true}, - "login": {"dataType":"string","required":true}, - "login_name": {"dataType":"string","required":true}, - "source_id": {"dataType":"double","required":true}, - "full_name": {"dataType":"string","required":true}, - "email": {"dataType":"string","required":true}, - "avatar_url": {"dataType":"string","required":true}, - "html_url": {"dataType":"string","required":true}, - "language": {"dataType":"string","required":true}, - "is_admin": {"dataType":"boolean","required":true}, - "last_login": {"dataType":"string","required":true}, - "created": {"dataType":"string","required":true}, - "restricted": {"dataType":"boolean","required":true}, - "active": {"dataType":"boolean","required":true}, - "prohibit_login": {"dataType":"boolean","required":true}, - "location": {"dataType":"string","required":true}, - "website": {"dataType":"string","required":true}, - "description": {"dataType":"string","required":true}, - "visibility": {"dataType":"string","required":true}, - "followers_count": {"dataType":"double","required":true}, - "following_count": {"dataType":"double","required":true}, - "starred_repos_count": {"dataType":"double","required":true}, "username": {"dataType":"string","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaRepository": { + "GiteaHookRepository": { "dataType": "refObject", "properties": { - "owner": {"ref":"GiteaUser","required":true}, - "name": {"dataType":"string","required":true}, "id": {"dataType":"double","required":true}, + "name": {"dataType":"string","required":true}, "full_name": {"dataType":"string","required":true}, - "description": {"dataType":"string","required":true}, - "empty": {"dataType":"boolean","required":true}, - "private": {"dataType":"boolean","required":true}, - "fork": {"dataType":"boolean","required":true}, - "template": {"dataType":"boolean","required":true}, - "mirror": {"dataType":"boolean","required":true}, - "size": {"dataType":"double","required":true}, - "language": {"dataType":"string","required":true}, - "languages_url": {"dataType":"string","required":true}, - "html_url": {"dataType":"string","required":true}, - "url": {"dataType":"string","required":true}, - "link": {"dataType":"string","required":true}, - "ssh_url": {"dataType":"string","required":true}, - "clone_url": {"dataType":"string","required":true}, - "original_url": {"dataType":"string","required":true}, - "website": {"dataType":"string","required":true}, - "stars_count": {"dataType":"double","required":true}, - "forks_count": {"dataType":"double","required":true}, - "watchers_count": {"dataType":"double","required":true}, - "open_issues_count": {"dataType":"double","required":true}, - "open_pr_counter": {"dataType":"double","required":true}, - "release_counter": {"dataType":"double","required":true}, - "default_branch": {"dataType":"string","required":true}, - "archived": {"dataType":"boolean","required":true}, - "created_at": {"dataType":"string","required":true}, - "updated_at": {"dataType":"string","required":true}, - "archived_at": {"dataType":"string","required":true}, - "permissions": {"ref":"GiteaRepositoryPermissions","required":true}, - "has_issues": {"dataType":"boolean","required":true}, - "internal_tracker": {"ref":"GiteaRepositoryInternalTracker","required":true}, - "has_wiki": {"dataType":"boolean","required":true}, - "has_pull_requests": {"dataType":"boolean","required":true}, - "has_projects": {"dataType":"boolean","required":true}, - "projects_mode": {"dataType":"string","required":true}, - "has_releases": {"dataType":"boolean","required":true}, - "has_packages": {"dataType":"boolean","required":true}, - "has_actions": {"dataType":"boolean","required":true}, - "ignore_whitespace_conflicts": {"dataType":"boolean","required":true}, - "allow_merge_commits": {"dataType":"boolean","required":true}, - "allow_rebase": {"dataType":"boolean","required":true}, - "allow_rebase_explicit": {"dataType":"boolean","required":true}, - "allow_squash_merge": {"dataType":"boolean","required":true}, - "allow_fast_forward_only_merge": {"dataType":"boolean","required":true}, - "allow_rebase_update": {"dataType":"boolean","required":true}, - "default_delete_branch_after_merge": {"dataType":"boolean","required":true}, - "default_merge_style": {"dataType":"string","required":true}, - "default_allow_maintainer_edit": {"dataType":"boolean","required":true}, - "avatar_url": {"dataType":"string","required":true}, - "internal": {"dataType":"boolean","required":true}, - "mirror_interval": {"dataType":"string","required":true}, - "object_format_name": {"dataType":"string","required":true}, - "mirror_updated": {"dataType":"string","required":true}, - "topics": {"dataType":"array","array":{"dataType":"string"},"required":true}, - "licenses": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "owner": {"ref":"GiteaHookUser","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GiteaHookRepositoryTrigger": { "dataType": "refObject", "properties": { "action": {"dataType":"string","required":true}, - "repository": {"ref":"GiteaRepository","required":true}, - "organization": {"ref":"GiteaUser","required":true}, - "sender": {"ref":"GiteaUser","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaCommitUser": { - "dataType": "refObject", - "properties": { - "name": {"dataType":"string","required":true}, - "email": {"dataType":"string","required":true}, - "username": {"dataType":"string","required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "GiteaCommit": { - "dataType": "refObject", - "properties": { - "id": {"dataType":"string","required":true}, - "message": {"dataType":"string","required":true}, - "url": {"dataType":"string","required":true}, - "author": {"ref":"GiteaCommitUser","required":true}, - "committer": {"ref":"GiteaCommitUser","required":true}, - "verification": {"dataType":"any","required":true}, - "timestamp": {"dataType":"string","required":true}, - "added": {"dataType":"array","array":{"dataType":"string"},"required":true}, - "removed": {"dataType":"array","array":{"dataType":"string"},"required":true}, - "modified": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "repository": {"ref":"GiteaHookRepository","required":true}, + "organization": {"ref":"GiteaHookUser","required":true}, + "sender": {"ref":"GiteaHookUser","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GiteaHookCommitTrigger": { @@ -828,15 +706,9 @@ const models: TsoaRoute.Models = { "ref": {"dataType":"string","required":true}, "before": {"dataType":"string","required":true}, "after": {"dataType":"string","required":true}, - "compare_url": {"dataType":"string","required":true}, - "commits": {"dataType":"array","array":{"dataType":"refObject","ref":"GiteaCommit"},"required":true}, - "total_commits": {"dataType":"double","required":true}, - "head_commit": {"ref":"GiteaCommit","required":true}, - "repository": {"ref":"GiteaRepository","required":true}, - "pusher": {"ref":"GiteaUser","required":true}, - "sender": {"ref":"GiteaUser","required":true}, + "repository": {"ref":"GiteaHookRepository","required":true}, }, - "additionalProperties": false, + "additionalProperties": {"dataType":"any"}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetEventListResponse": { diff --git a/peopleportal/server/test/unit/gitea-webhook-payloads.test.js b/peopleportal/server/test/unit/gitea-webhook-payloads.test.js new file mode 100644 index 0000000..45023a3 --- /dev/null +++ b/peopleportal/server/test/unit/gitea-webhook-payloads.test.js @@ -0,0 +1,199 @@ +/** + * Gitea webhook bodies must survive new fields from a Gitea upgrade. + * + * tsoa runs with noImplicitAdditionalProperties "throw-on-extras". When the + * webhook models described the full repository object, Gitea 1.27 (which added + * branch_count, has_code and more) turned every delivery into a 422, so repos + * were never provisioned or branch-protected. These tests post payloads with + * those fields through the generated routes. The action and ref are ones the + * handlers ignore, so no Gitea or Authentik call is made. + */ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const express = require("express"); +const request = require("supertest"); +const { ValidateError } = require("tsoa"); + +const { RegisterRoutes } = require("../../dist/routes.js"); + +function buildApp() { + const app = express(); + app.use(express.json()); + RegisterRoutes(app); + /* Same mapping as app.ts's error handler. */ + app.use((err, _req, res, _next) => { + if (err instanceof ValidateError) { + return res.status(422).json({ details: err.fields }); + } + return res.status(500).end(); + }); + return app; +} + +/* Fields Gitea 1.27.3 sends that the 1.24-era model did not declare. */ +const GITEA_1_27_REPOSITORY_EXTRAS = { + branch_count: 1, + has_code: true, + allow_merge_update: true, + allow_manual_merge: false, + autodetect_manual_merge: false, + default_update_style: "merge", + mirror_last_sync_at: "0001-01-01T00:00:00Z", +}; + +const user = (username) => ({ + id: 7, + login: username, + username, + full_name: "", + email: `${username}@example.invalid`, +}); + +const repository = { + id: 42, + name: "hook-test", + full_name: "TechOps/hook-test", + owner: user("TechOps"), + private: true, + default_branch: "main", + ...GITEA_1_27_REPOSITORY_EXTRAS, +}; + +test("repository event with fields added in Gitea 1.27 is accepted", async () => { + const response = await request(buildApp()) + .post("/api/webhook/git/repoevent") + .send({ + action: "deleted", + repository, + organization: user("TechOps"), + sender: user("devadmin"), + }); + + assert.equal(response.status, 200, JSON.stringify(response.body)); +}); + +test("push event with fields added in Gitea 1.27 is accepted", async () => { + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send({ + ref: "refs/heads/feature", + before: "1111111111111111111111111111111111111111", + after: "2222222222222222222222222222222222222222", + compare_url: "", + commits: [], + total_commits: 0, + head_commit: null, + repository, + pusher: user("devadmin"), + sender: user("devadmin"), + }); + + assert.equal(response.status, 200, JSON.stringify(response.body)); +}); + +/* The privileged paths, with the clients faked. HooksController builds new + clients per request and reads the classes off these module objects at call + time, so swapping the exports reaches it. */ +const giteaModule = require("../../dist/clients/GiteaClient/index.js"); +const authentikModule = require("../../dist/clients/AuthentikClient/index.js"); +const RealGiteaClient = giteaModule.GiteaClient; +const RealAuthentikClient = authentikModule.AuthentikClient; + +function withFakeClients(apiRepository, run) { + const calls = { provisioning: [], protection: [] }; + giteaModule.GiteaClient = class { + static GIT_REF_MAINBRANCH = RealGiteaClient.GIT_REF_MAINBRANCH; + static GIT_NULL_COMMITID = RealGiteaClient.GIT_NULL_COMMITID; + async getRepository() { + return apiRepository; + } + async handleRepoProvisioning(req) { + calls.provisioning.push(req); + } + async handleBranchProtectionSync(team, repositories) { + calls.protection.push({ team, repositories }); + } + }; + authentikModule.AuthentikClient = class { + getGroupPkFromName = async () => "team-pk"; + getGroupInfo = async () => ({ name: "TechOps", subteams: [] }); + }; + return run(calls).finally(() => { + giteaModule.GiteaClient = RealGiteaClient; + authentikModule.AuthentikClient = RealAuthentikClient; + }); +} + +/* What the API returns: same identity as the webhook body, but its own copy. */ +const apiRepository = { id: 42, name: "hook-test", owner: { username: "TechOps" } }; + +const createdEvent = { + action: "created", + repository, + organization: user("TechOps"), + sender: user("devadmin"), +}; + +const firstMainPush = { + ref: RealGiteaClient.GIT_REF_MAINBRANCH, + before: RealGiteaClient.GIT_NULL_COMMITID, + after: "2222222222222222222222222222222222222222", + repository, +}; + +test("repo creation provisions the API's copy of the repository, not the body's", () => + withFakeClients(apiRepository, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/repoevent") + .send(createdEvent); + + assert.equal(response.status, 200, JSON.stringify(response.body)); + assert.equal(calls.provisioning.length, 1); + assert.equal(calls.provisioning[0].repository, apiRepository); + assert.equal(calls.provisioning[0].sender.username, "devadmin"); + })); + +test("repo creation does nothing when the API's repository doesn't match the body", () => + withFakeClients({ ...apiRepository, id: 99 }, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/repoevent") + .send(createdEvent); + + assert.equal(response.status, 200); + assert.equal(calls.provisioning.length, 0); + })); + +test("first push to main protects the API's copy of the repository", () => + withFakeClients(apiRepository, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send(firstMainPush); + + assert.equal(response.status, 200, JSON.stringify(response.body)); + assert.equal(calls.protection.length, 1); + assert.deepEqual(calls.protection[0].repositories, [apiRepository]); + })); + +test("first push to main does nothing when the API's repository doesn't match", () => + withFakeClients({ ...apiRepository, owner: { username: "Elsewhere" } }, async (calls) => { + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send(firstMainPush); + + assert.equal(response.status, 200); + assert.equal(calls.protection.length, 0); + })); + +test("a payload missing a field the handlers read is still rejected", async () => { + const { id: _id, ...repositoryWithoutId } = repository; + const response = await request(buildApp()) + .post("/api/webhook/git/commitevent") + .send({ + ref: "refs/heads/main", + before: "0000000000000000000000000000000000000000", + after: "2222222222222222222222222222222222222222", + repository: repositoryWithoutId, + }); + + assert.equal(response.status, 422); +});