A private repository on vm4006 (hs.hsadmin.ng) served every artifact without the control token: the repository is `internal` in a private organisation, and Werkator asked Gitea with its token and read only the `private` flag. - Visibility is asked as a visitor (without token); 401/403/404 and a visible `private`/`internal` answer mean private. An unclear answer hides the repository (fail closed, retried after a minute). Artifact routes answer 404 instead of 500 for unknown or hidden names. - `builds.<name>.publicArtifacts` releases chosen artifacts of a private repository: globs over workspace paths like `artifactDirs`, or `true`. The paths come from the built commit's own `.werkator.yml`; only a branch the primary configuration names literally in that definition's `trigger.branches` publishes. - Released artifacts answer `server.corsAllowedOrigins` (PR#96) via a Spring `CorsFilter`, preflight included; nothing else carries a CORS header. PR-doc: `docs/prs/2026-10-04-PR#114-private-artifacts-and-public-paths.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #114
266 lines
18 KiB
Markdown
266 lines
18 KiB
Markdown
# PR#114: Private Artifacts Stay Private, Released Paths Become Public
|
|
|
|
> This document describes the change of this pull request at the time it was made.
|
|
> Later pull requests may change the behaviour without updating it.
|
|
|
|
## The Problem
|
|
|
|
On vm4006 every artifact of `hs.hsadmin.ng` was served without the control token: `build.log`, `reports/**`, the jars.
|
|
The forge hides the repository from visitors: `https://dev.hostsharing.net/api/v1/repos/hostsharing/hs.hsadmin.ng` answers 404 without a login.
|
|
PR#43 promises that every route of a private repository answers 404 without the control token.
|
|
|
|
The routes were covered.
|
|
The visibility check was wrong.
|
|
`GiteaClient.readVisibility` asked with the instance's token and read only the `private` flag.
|
|
`hs.hsadmin.ng` is `internal` (signed-in users only) in an organisation whose visibility is `private`.
|
|
To the token it answers `private: false`, so Werkator called the repository public and served everything.
|
|
|
|
Some artifacts of a private repository are meant for everyone, though.
|
|
hsadmin-ng's code city (`reports/codecharta/mutation-crap.cc.json`) is to be shown by the CodeCharta viewer at javagil.de, without a token, while everything else stays hidden.
|
|
|
|
Two smaller defects came to light on the way:
|
|
|
|
- A forge that did not answer kept the last answer, so a repository once seen as public stayed public through an outage.
|
|
- The artifact routes answered 500 instead of 404 for an unknown repository name and for a hidden one.
|
|
|
|
## Non-Goals
|
|
|
|
- Repositories without a configured forge stay public, as PR#43 decided (Scenario#43.04): there is nothing to ask and nothing to hide behind.
|
|
- No new key for the allowed websites: `server.corsAllowedOrigins` (PR#96) serves.
|
|
- No switch in the host configuration: the released paths change with the code, and the branch limit is the primary configuration's trigger.
|
|
- Released builds show no page: the branch pages, the build list and the artifact index of a private repository stay hidden.
|
|
|
|
## The Scenarios
|
|
|
|
### Feature: a private repository serves no artifact to a visitor
|
|
|
|
#### Background
|
|
|
|
- A *visitor* is a browser without the control token cookie.
|
|
- The *artifact routes* are `/repos/<name>/artifacts/<key>/<path>`, `/repos/<name>/branches/<build-key>/<path>` and their unscoped forms `/artifacts/…` and `/branches/…`.
|
|
|
|
#### Scenario#114.01: a repository the forge hides from visitors serves no artifact
|
|
|
|
… so that a private repository gives out no content, however the forge closes it.
|
|
|
|
- **Given** the forge shows the repository to its token as `private: false, internal: true` in a private organisation
|
|
- **and** answers 404 to a request without a token
|
|
- **When** a visitor requests any artifact route of the repository, a report, the build log or a jar
|
|
- **Then** every one of them answers 404
|
|
- **And** a repository the forge shows to a visitor still serves its artifacts, as before
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactVisibilityIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactVisibilityIntegrationTest.kt) `a repository internal in a private organisation serves no artifact to a visitor`, `a repository the forge shows a visitor serves its artifacts, as before`
|
|
- [GiteaClientIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/gitea/GiteaClientIntegrationTest.kt) `asks as a visitor, without the token`, `a repository the forge hides from a visitor is private, however it is closed`, `a visible answer that still says private or internal is private`
|
|
|
|
#### Scenario#114.02: a forge that does not answer hides the repository
|
|
|
|
… so that a failed network call never reveals a private repository.
|
|
|
|
- **Given** the forge is configured for the repository
|
|
- **When** it answers with an error, an answer without the `private` flag, or not at all
|
|
- **or** the forge is configured without owner or repository name
|
|
- **Then** the repository counts as private, even when it was public at the last answer
|
|
- **and** the forge is asked again after a minute
|
|
- **And** a missing token changes nothing, since the forge is asked as a visitor
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactVisibilityIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactVisibilityIntegrationTest.kt) `a forge that does not answer serves no artifact to a visitor`
|
|
- [RepositoryPrivacyUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/RepositoryPrivacyUnitTest.kt) `a silent forge hides even what was public, and is asked again within a minute`
|
|
- [GiteaClientIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/gitea/GiteaClientIntegrationTest.kt) `a forge that cannot be asked is UNKNOWN, never public`, `a forge named without the repository is UNKNOWN, without a request`, `no forge at all is UNCONFIGURED; a missing token does not matter`
|
|
|
|
#### Scenario#114.03: an artifact route of an unknown or hidden repository answers 404
|
|
|
|
… so that the answer is the documented one and does not tell a private repository from an invented name.
|
|
|
|
- **Given** a visitor
|
|
- **When** they request an artifact route naming a hidden repository, or a name the instance does not serve
|
|
- **Then** both answer 404 with the same body, the name aside
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactVisibilityIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactVisibilityIntegrationTest.kt) `an unknown repository and a hidden one answer the same 404 on the artifact routes`
|
|
|
|
### Feature: a private repository releases chosen artifacts
|
|
|
|
#### Background
|
|
|
|
- The *primary configuration* is the checkout's `.werkator.yml` with the host layers; a branch's own committed file is not part of it.
|
|
- The example is hsadmin-ng's `release` definition, built for `master` as `master@release`, permanent key `master_release`.
|
|
|
|
#### Scenario#114.04: a released artifact is served to everyone
|
|
|
|
… so that a viewer on another site loads the code city of a private repository without a token.
|
|
|
|
- **Given** the primary configuration has `builds.release.trigger.branches: ["master"]`
|
|
- **and** the `.werkator.yml` of the built `master` commit has `builds.release.publicArtifacts: ["build/reports/codecharta/**"]`
|
|
- **When** a visitor requests `/repos/hs.hsadmin.ng/branches/master_release/reports/codecharta/mutation-crap.cc.json`
|
|
- **or** the same file under the unscoped form or under the build's artifact key
|
|
- **Then** the file is served, sandboxed like every artifact
|
|
- **And** a released directory serves its page, after the redirect to the trailing slash
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactPublicationUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactPublicationUnitTest.kt) `the built commit's own file names the released paths of its definition`
|
|
- [ArtifactFileControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerRestTest.kt) `a released artifact is served on every route`, `a released directory serves its page, after the redirect to the trailing slash`
|
|
|
|
#### Scenario#114.05: every other path of a private repository stays hidden
|
|
|
|
… so that a release opens exactly the listed files and not the log or the jar beside them.
|
|
|
|
- **Given** the release of Scenario#114.04
|
|
- **When** a visitor requests the build log, a jar, an unlisted report, an unknown file, an unknown branch or key, or a path climbing out of a released directory
|
|
- **Then** each answers 404 with the body of an unknown repository on a named route
|
|
- **And** a browser holding the control token still gets everything
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactPublicationUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactPublicationUnitTest.kt) `every other path of the released build stays hidden`
|
|
- [ArtifactFileControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerRestTest.kt) `every other path answers like an unknown repository`, `the control token opens everything, released or not`
|
|
|
|
#### Scenario#114.06: a branch cannot publish its own artifacts
|
|
|
|
… so that a careless commit on a branch never publishes what it builds.
|
|
|
|
- **Given** a branch `feature` whose own `.werkator.yml` sets `builds.release.trigger.branches: ["feature"]` and `publicArtifacts: true`
|
|
- **or** sets `publicArtifacts: true` on `builds.default`, which the primary configuration selects for `["*", "!master"]`
|
|
- **When** the watcher builds `feature@release` or `feature`
|
|
- **Then** none of their artifacts is released: the primary configuration does not name `feature` literally
|
|
- **And** a primary configuration that stops naming `master` withdraws the release of `master@release` at once
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactPublicationUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactPublicationUnitTest.kt) `a branch that gives itself the definition and the paths releases nothing`, `a definition the primary config selects by a glob releases nothing`, `a primary config that stops naming the branch withdraws the release at once`
|
|
|
|
#### Scenario#114.07: the released paths change with the code
|
|
|
|
… so that what is published is reviewed and versioned like the code that writes it.
|
|
|
|
- **Given** `publicArtifacts` is `true`, `false`, absent, or a list of globs over workspace paths, written as in `artifactDirs`
|
|
- **When** a build of a literally named branch is served
|
|
- **Then** the `.werkator.yml` of *its* commit decides: `true` all, a list its matches, `false` or absent nothing
|
|
- **and** each build by its own commit, so an older build keeps the paths it was built with
|
|
- **And** `build/reports/codecharta/**` releases what the URL shows under `…/reports/codecharta/`, since `build/reports` is stored as `reports/`
|
|
- **And** a value of another shape refuses the configuration, and a commit whose file cannot be read releases nothing
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactPublicationUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactPublicationUnitTest.kt) `without the key, or with false, nothing is released; true releases everything`, `the paths change with the code: each build is judged by its own commit`, `a commit's file is read once, as long as the primary config stays`, `a commit whose file cannot be read releases nothing of its own`, `the globs name workspace paths, as artifactDirs does`
|
|
- [ConfigLoaderUnitTest](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderUnitTest.kt) `publicArtifacts reads true, false or a list of globs, and refuses anything else`
|
|
|
|
#### Scenario#114.08: the pages of a private repository stay hidden
|
|
|
|
… so that a release reveals a file, not the branches, builds and commits around it.
|
|
|
|
- **Given** the release of Scenario#114.04
|
|
- **When** a visitor requests the artifact index `/repos/hs.hsadmin.ng/branches/master_release`, the branch list or the build history
|
|
- **Then** they answer 404, as before
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactFileControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerRestTest.kt) `the artifact index of the released build stays hidden`
|
|
- [BuildsApiControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerRestTest.kt) `a named private repository answers a visitor exactly like an unserved name` (Scenario#54.01)
|
|
|
|
#### Scenario#114.09: the Forgejo export names a release
|
|
|
|
… so that a workflow converted from the file does not silently lose the publication (ADR 0010).
|
|
|
|
- **Given** a definition with `publicArtifacts`
|
|
- **When** it is exported as a Forgejo Actions workflow
|
|
- **Then** the export lists it as untranslated: a Forgejo artifact is exactly as visible as its repository
|
|
|
|
##### Verified by
|
|
|
|
- [ForgejoWorkflowExporterUnitTest](../../src/test/kotlin/de/hoennig/werkator/forgejo/ForgejoWorkflowExporterUnitTest.kt) `released artifacts are named, since Forgejo shows an artifact only with its repository`
|
|
|
|
### Feature: a released artifact is readable from an allowed website
|
|
|
|
#### Background
|
|
|
|
- `server.corsAllowedOrigins` in the instance file names the websites whose scripts may read artifacts (PR#96).
|
|
This PR reuses it instead of a second list: it is instance-only already, and a released artifact is shared under the same rule as any artifact of a public repository.
|
|
|
|
#### Scenario#114.10: a released artifact answers an allowed origin, preflight included
|
|
|
|
… so that the CodeCharta viewer at javagil.de loads the code city of a private repository.
|
|
|
|
- **Given** `server.corsAllowedOrigins: ["https://javagil.de"]` and the release of Scenario#114.04
|
|
- **When** a script at `https://javagil.de` fetches the released file
|
|
- **Then** the response carries `Access-Control-Allow-Origin: https://javagil.de` and `Vary: Origin`, and no `Access-Control-Allow-Credentials`
|
|
- **And** an `OPTIONS` preflight for `GET` is answered with the same origin, the allowed methods and the requested headers
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactFileControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerRestTest.kt) `a released artifact answers an allowed origin, without credentials`, `the preflight of a released artifact is answered`
|
|
|
|
#### Scenario#114.11: nothing else carries a CORS header
|
|
|
|
… so that another website's script never reads what a visitor could not.
|
|
|
|
- **Given** the setting of Scenario#114.10
|
|
- **When** a script fetches an artifact of the private repository that is not released, even from a browser holding the control token
|
|
- **or** a released artifact from an origin the instance does not name
|
|
- **Then** neither the response nor its preflight carries `Access-Control-Allow-Origin`
|
|
- **And** every artifact of a public repository answers an allowed origin, as since PR#96
|
|
|
|
##### Verified by
|
|
|
|
- [ArtifactFileControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerRestTest.kt) `an artifact that is not released gets no CORS header, not even with the token`, `a released artifact gets no CORS header for an origin the instance does not name`, `an artifact file answers an allowed origin, without credentials` (Scenario#96.01), `another origin, or none, gets no CORS header` (Scenario#96.02)
|
|
|
|
## The Solution
|
|
|
|
### Asking the forge as a visitor
|
|
|
|
`GiteaClient.readVisibility` now asks without the token.
|
|
The question is what an anonymous visitor may see, and the forge's answer to an anonymous caller covers every way a repository can be closed: `private`, `internal`, a private or limited organisation, an instance that requires a sign-in.
|
|
401, 403 and 404 mean private.
|
|
A visible answer that still says `private` or `internal` is private too.
|
|
Everything else that is no clear answer is `UNKNOWN`.
|
|
|
|
The red run: `ArtifactVisibilityIntegrationTest` against the code before this PR fails Scenario#114.01, #114.02 and #114.03, and passes the public case.
|
|
|
|
### Failing closed
|
|
|
|
`RepositoryPrivacy` treats `UNKNOWN` as private, always.
|
|
An answer that is no longer confirmed is not an answer.
|
|
The guess is kept for one minute instead of fifteen, so a public repository is back soon after the forge.
|
|
|
|
### 404 instead of 500
|
|
|
|
`ArtifactFileController` handles `UnknownRepositoryException` like `UiController` does.
|
|
|
|
### Releasing artifacts: what from the commit, who from the primary configuration
|
|
|
|
`builds.<name>.publicArtifacts` is `true` or a list of `java.nio` globs over workspace paths (`PublicArtifacts`).
|
|
They are written like `artifactDirs`, so `build/reports/codecharta/**` and not the stored `reports/codecharta/**`; `ArchivedPaths` maps a stored file back to where the build wrote it, the same rule `FileArtifactStore` archives by.
|
|
`ArtifactPublication` answers whether a file of a build is released, from two sources on purpose:
|
|
|
|
- *What*: the `.werkator.yml` of the built commit, read with `git show` and merged like the build's own configuration.
|
|
The released paths are written, reviewed and versioned with the code that produces the files.
|
|
A commit never changes, so the answer is cached per commit while the primary configuration stays the same.
|
|
- *Who*: the primary configuration must name the build's branch literally in the definition's `trigger.branches`.
|
|
A branch writes its own file, so it could otherwise publish its own artifacts; and it may redefine a trigger for itself, which is why the primary one counts.
|
|
A glob does not count: it also selects branches that do not exist yet.
|
|
|
|
Considered and dropped: reading the release from the host configuration only.
|
|
Paths belong to the code, and a switch in the instance file would be `true` for every repository anyway.
|
|
|
|
`ArtifactFileController` resolves a private repository for a visitor (`RepoRouting.served`) and serves a file only when its build released it — a directory by the page it serves.
|
|
Everything else answers exactly what it answered before: the 404 of an unknown repository on a named route, the route's own 404 on the unscoped form.
|
|
The check reads the configuration, so a private repository's 404 may take a few milliseconds longer than an invented name's; the forge is never asked on this path.
|
|
|
|
ADR 0010: the key shapes how Werkator serves its stored artifacts, like the retention keys it already exempts; the workflow counterpart of the files themselves is `actions/upload-artifact`, and the export names the release as untranslated.
|
|
|
|
### CORS in one place
|
|
|
|
`ArtifactCors` is the `CorsConfigurationSource` of a Spring `CorsFilter` in front of the artifact routes (`ArtifactCorsConfiguration`).
|
|
It shares an artifact with an origin from `server.corsAllowedOrigins` when everyone may have it anyway: any artifact of a public repository, a released one of a private repository.
|
|
A cross-site read carries no credentials, so the token in a browser does not widen it.
|
|
Spring's filter answers the preflight by the same rule as the `GET`; the controller no longer sets the header itself (PR#96 did), so the decision is not made twice.
|
|
Without a configuration the filter adds nothing, and the browser refuses the empty preflight.
|
|
|
|
`ArtifactRoute` parses an artifact URL with the controller's own path patterns, and `ArtifactFiles` holds the file resolution both share, so the filter and the controller judge the same file.
|
|
|