Trusted Publishing support#1967
Conversation
5898f77 to
833149e
Compare
| CREATE TABLE IF NOT EXISTS public.trusted_publisher | ||
| ( | ||
| id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('trusted_publisher_seq'), | ||
| namespace BIGINT NOT NULL REFERENCES public.namespace(id), |
There was a problem hiding this comment.
why is there a relationship to the namespace but not the extension?
I am not sure why we even need the relationship to the namespace here, as an extension has a relationship to the namespace anyways.
also we should add cascade rules for the table in case the foreign keys gets deleted
| * GitLab provider for <a href="https://gitlab.eclipse.org/">Eclipse GitLab Instance</a>. | ||
| */ | ||
| public class EclipseGitLabTrustedPublishingProvider extends GitLabTrustedPublishingProviderSupport { | ||
| public static final String PROVIDER_ID = "eclipse-gitlab"; |
There was a problem hiding this comment.
we want to make that completely configurable, not hard-coded in code if possible
There was a problem hiding this comment.
Right now there are 3 hardcoded providers, but yes, as you can see from their implementation, they are really just providerId, name (for human consumption), issuer URL.... so these could come from config as well.
| import static java.util.Objects.requireNonNull; | ||
|
|
||
| @Component | ||
| public class TrustedPublishingManager { |
There was a problem hiding this comment.
we generally use the terminology xyzService for stuff like that and annotate with @service annotation
| } | ||
|
|
||
| // select provider based on "iss" | ||
| TrustedPublishingProviderSupport provider = providers.values().stream() |
There was a problem hiding this comment.
at which point do we validate that the JWT is actually really coming from the expected authority?
Here is some python code that does to seem to verify the identity:
There was a problem hiding this comment.
This is the first part of flow (see ref here): "Parse the JWT without verifying the claims or signature to extract the issuer claim (iss). Be sure that this parsed JWT is discarded and that the JWT isn’t marked as verified by the service at this stage.".
After provider was selected based on iss, the provider does actual validation, see decoder in TrustedPublishingProviderSupport class, that pre-fetches keys using issuer URL from "well know" URL built from issuer URL.
f6a2f7e to
fcb7a00
Compare
How the finished flow works
1. A namespace owner registers a trusted publisher via `POST /user/namespace/{ns}/trusted-publishing/create` (body: provider, owner, repo, workflow, optional extension/environment). The provider resolves names to stable IDs (GitHub repos API, GitLab projects API) and the claims are stored in a new trusted_publisher table (JSONB column, migration V1_70). List and delete endpoints exist alongside it, all owner-only.
2. CI exchanges its OIDC ID token via POST /api/-/trusted-publishing/token (CSRF-exempted like /api/-/publish). The manager picks the provider by `iss`, validates the JWT (signature, issuer, audience, forbidden headers), and matches claims against registrations.
3. On match, it mints a short-lived `PersonalAccessToken` (default 15 min, `ovsx.trusted-publishing.token-expiry`) owned by the registering user, so the existing publish pipeline — permissions, auditing, expiry — is reused unchanged.
Key decisions baked in
- Matching is anchored on stable numeric IDs (repository_id/repository_owner_id, project_id/namespace_id) to block resurrection attacks, plus the workflow path with the @<ref> suffix stripped (any branch/tag can publish), plus environment only if pinned.
- The OIDC decoder is built lazily — `JwtDecoders.fromIssuerLocation()` does network discovery, which previously ran in the constructor and would have made app startup depend on external issuers.
- The tests for both providers added, also got offline matches() tests covering ref-independence, ID mismatch, and pinned environments.
Note: namespace-scoped tokens (the minted token has the registering user's full publish rights).
This is server-side work only; no CLI or WebUI changes added.
109d819 to
d4b3cde
Compare
| @PostConstruct | ||
| public void validate() { | ||
| if (enabled) { | ||
| if (audience == null || audience.isBlank()) { |
There was a problem hiding this comment.
that could be modelled with a @notblank annotation on the field
| description = description.substring(0, TOKEN_DESCRIPTION_SIZE); | ||
| } | ||
| logger.info("Issuing trusted publishing token for namespace {} to {}", namespace.getName(), claims.get(JwtClaimNames.SUB)); | ||
| return tokens.createAccessToken(match.getCreatedBy(), description, TimeUtil.getCurrentUTC().plus(config.getTokenExpiry())); |
There was a problem hiding this comment.
I understand that right now the PersonalAccessToken has a mandatory relationship to a user which is also displayed in the UI to show by whom the extension was published.
In the case of trusted publishing we also want to highlight that it was published like that and associating the publication with the user that created the trusted publishing setup does not feel right.
| /** | ||
| * Claims are internal only; should not leave the boundaries of application. | ||
| * Hence, {@link #toJson()} omits it. | ||
| */ |
There was a problem hiding this comment.
we will need to display it though in the UI e.g. which repo / workflow has been setup for the trusted publisher.
| @Nullable | ||
| private String owner; | ||
|
|
||
| @Nullable | ||
| private String repo; | ||
|
|
||
| @Nullable | ||
| private String workflow; | ||
|
|
||
| @Nullable | ||
| private String environment; |
There was a problem hiding this comment.
question: since you need to be the namespace owner in order to retrieve the trusted publishers, what's the harm of returning the owner, repo, workflow and environment values?
I think it would be a pretty useful information to display.
Do you think it is that sensitive?
So refactored a bit with comments, to make possible method override in UT.
The flow
POST /user/namespace/{ns}/trusted-publishing/create(body: provider, owner, repo, workflow, optional extension/environment). The provider resolves names to stable IDs (GitHub repos API, GitLab projects API) and the claims are stored in a new trusted_publisher table (JSONB column, migration V1_70). List and delete endpoints exist alongside it, all owner-only.iss, validates the JWT (signature, issuer, audience, forbidden headers), and matches claims against registrations.PersonalAccessToken(default 15 min,ovsx.trusted-publishing.token-expiry) owned by the registering user, so the existing publish pipeline — permissions, auditing, expiry — is reused unchanged.Key decisions baked in
JwtDecoders.fromIssuerLocation()does network discovery, which previously ran in the constructor and would have made app startup depend on external issuers.Note: namespace-scoped tokens (the minted token has the registering user's full publish rights). This is server-side work only; no CLI or WebUI changes added.
Fixes #1534