From 357731efb66701accc5a9a70a84c72154a50b76b Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 21 Jul 2026 11:54:58 +0200 Subject: [PATCH 1/5] Trusted Publishing support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 @ 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. --- server/build.gradle | 4 +- .../eclipse/openvsx/TrustedPublishingAPI.java | 196 +++++++++++++ .../accesstoken/AccessTokenService.java | 20 +- .../openvsx/entities/TrustedPublisher.java | 187 ++++++++++++ .../openvsx/json/TrustedPublisherJson.java | 95 ++++++ .../json/TrustedPublisherListJson.java | 38 +++ .../json/TrustedPublisherProviderJson.java | 71 +++++ .../TrustedPublisherProviderListJson.java | 38 +++ .../TrustedPublisherTokenRequestJson.java | 56 ++++ .../repositories/RepositoryService.java | 18 +- .../TrustedPublisherRepository.java | 28 ++ .../openvsx/security/SecurityConfig.java | 1 + .../MissingRequiredClaimException.java | 31 ++ .../TrustedPublishingConfig.java | 85 ++++++ .../TrustedPublishingProviderSupport.java | 271 ++++++++++++++++++ .../TrustedPublishingService.java | 255 ++++++++++++++++ .../GitHubTrustedPublishingProvider.java | 29 ++ ...itHubTrustedPublishingProviderSupport.java | 153 ++++++++++ ...clipseGitLabTrustedPublishingProvider.java | 28 ++ .../GitLabTrustedPublishingProvider.java | 28 ++ ...itLabTrustedPublishingProviderSupport.java | 149 ++++++++++ .../trustedpublishing/package-info.java | 36 +++ .../db/migration/V1_70__Trusted_Publisher.sql | 17 ++ .../RepositoryServiceSmokeTest.java | 26 +- .../GitHubTrustedPublishingProviderTest.java | 161 +++++++++++ .../GitLabTrustedPublishingProviderTest.java | 161 +++++++++++ 26 files changed, 2164 insertions(+), 18 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/TrustedPublisher.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherListJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderListJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherTokenRequestJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/TrustedPublisherRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/MissingRequiredClaimException.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingConfig.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProvider.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/EclipseGitLabTrustedPublishingProvider.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProvider.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java create mode 100644 server/src/main/java/org/eclipse/openvsx/trustedpublishing/package-info.java create mode 100644 server/src/main/resources/db/migration/V1_70__Trusted_Publisher.sql create mode 100644 server/src/test/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderTest.java diff --git a/server/build.gradle b/server/build.gradle index cf47a6c72..57cb69b71 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -87,10 +87,10 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-cache" implementation "org.springframework.boot:spring-boot-starter-mail" implementation "org.springframework.boot:spring-boot-starter-zipkin" - implementation "org.springframework.boot:spring-boot-security-oauth2-client" implementation "org.springframework.boot:spring-boot-starter-session-jdbc" + implementation "org.springframework.boot:spring-boot-starter-restclient" - implementation "org.springframework.security:spring-security-oauth2-client" + implementation "org.springframework.boot:spring-boot-security-oauth2-client" implementation "org.springframework.security:spring-security-oauth2-jose" implementation "org.springframework.boot:spring-boot-thymeleaf" diff --git a/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java b/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java new file mode 100644 index 000000000..3d7bafb02 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java @@ -0,0 +1,196 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx; + +import java.util.Objects; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import org.eclipse.openvsx.entities.TrustedPublisher; +import org.eclipse.openvsx.json.AccessTokenJson; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.json.TrustedPublisherJson; +import org.eclipse.openvsx.json.TrustedPublisherListJson; +import org.eclipse.openvsx.json.TrustedPublisherProviderJson; +import org.eclipse.openvsx.json.TrustedPublisherProviderListJson; +import org.eclipse.openvsx.json.TrustedPublisherTokenRequestJson; +import org.eclipse.openvsx.settings.MutatingOperation; +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.NotFoundException; + +@RestController +public class TrustedPublishingAPI { + + private final UserService users; + private final TrustedPublishingService trustedPublishing; + + public TrustedPublishingAPI(UserService users, TrustedPublishingService trustedPublishing) { + this.users = users; + this.trustedPublishing = trustedPublishing; + } + + @PostMapping( + path = "/user/namespace/{namespace}/trusted-publishing/create", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @MutatingOperation + public ResponseEntity registerTrustedPublisher( + @PathVariable String namespace, + @RequestBody TrustedPublisherJson request + ) { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + if (!StringUtils.hasText(request.getProvider()) + || !StringUtils.hasText(request.getNamespace()) || !StringUtils.hasText(request.getExtension()) + || request.getRegistration() == null || request.getRegistration().isEmpty()) { + var json = TrustedPublisherJson + .error("The fields provider, namespace, extension and registration are mandatory."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); + } + if (!Objects.equals(namespace, request.getNamespace())) { + var json = TrustedPublisherJson.error("The namespace in the path and in the request body must match."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); + } + + try { + var publisher = trustedPublishing.registerTrustedPublisher( + user, + request.getNamespace(), + request.getExtension(), + request.getProvider(), + request.getRegistration()); + return new ResponseEntity<>(publisher.toJson(), HttpStatus.CREATED); + } catch (NotFoundException exc) { + var json = TrustedPublisherJson.error("Namespace not found: " + namespace); + return new ResponseEntity<>(json, HttpStatus.NOT_FOUND); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(TrustedPublisherJson.class); + } + } + + @GetMapping( + path = "/user/namespace/{namespace}/trusted-publishing", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getTrustedPublishers(@PathVariable String namespace) { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + try { + var json = new TrustedPublisherListJson(); + json.setTrustedPublishers( + trustedPublishing.getTrustedPublishers(user, namespace).stream() + .map(TrustedPublisher::toJson) + .toList()); + return ResponseEntity.ok(json); + } catch (NotFoundException exc) { + var json = TrustedPublisherListJson.error("Namespace not found: " + namespace); + return new ResponseEntity<>(json, HttpStatus.NOT_FOUND); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(TrustedPublisherListJson.class); + } + } + + @PostMapping( + path = "/user/namespace/{namespace}/trusted-publishing/delete/{id}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @MutatingOperation + public ResponseEntity deleteTrustedPublisher(@PathVariable String namespace, @PathVariable long id) { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + try { + return ResponseEntity.ok(trustedPublishing.deleteTrustedPublisher(user, namespace, id)); + } catch (NotFoundException exc) { + return new ResponseEntity<>(ResultJson.error("Trusted publisher does not exist."), HttpStatus.NOT_FOUND); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + + @GetMapping( + path = "/user/namespace/{namespace}/trusted-publishing/providers", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getTrustedPublisherProviders( + @PathVariable String namespace + ) { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + try { + var json = new TrustedPublisherProviderListJson(); + json.setTrustedPublisherProviders( + trustedPublishing.getTrustedPublisherProviders(user, namespace).values().stream() + .map(p -> { + var providerJson = new TrustedPublisherProviderJson(); + providerJson.setId(p.getProviderId()); + providerJson.setName(p.getProviderName()); + providerJson.setUrl(p.getProviderUrl()); + providerJson.setRegistrationKeys(p.getRegistrationKeys()); + return providerJson; + }) + .toList()); + return ResponseEntity.ok(json); + } catch (NotFoundException exc) { + var json = TrustedPublisherProviderListJson.error("Namespace not found: " + namespace); + return new ResponseEntity<>(json, HttpStatus.NOT_FOUND); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(TrustedPublisherProviderListJson.class); + } + } + + /** + * Exchanges a valid OIDC ID token, issued by a trusted publishing provider and matching a registered + * trusted publisher, for a short-lived access token to publish with. + */ + @PostMapping( + path = "/api/-/trusted-publishing/token", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @MutatingOperation + public ResponseEntity requestPublishToken(@RequestBody TrustedPublisherTokenRequestJson request) { + if (!StringUtils.hasText(request.getNamespace()) || !StringUtils.hasText(request.getToken())) { + var json = AccessTokenJson.error("The fields namespace and token are mandatory."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); + } + + try { + var json = trustedPublishing + .requestPublishToken(request.getNamespace(), request.getExtension(), request.getToken()); + return new ResponseEntity<>(json, HttpStatus.CREATED); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(AccessTokenJson.class); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java b/server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java index 598e1c771..ebb146b39 100644 --- a/server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java +++ b/server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java @@ -52,18 +52,22 @@ public AccessTokenService( @Transactional public AccessTokenJson createAccessToken(UserData user, String description) { + return createAccessToken( + user, + description, + config.isTokenExpiryEnabled() + ? TimeUtil.getCurrentUTC().plus(config.getExpiration()) + : null); + } + + @Transactional + public AccessTokenJson createAccessToken(UserData user, String description, LocalDateTime expiresTimestamp) { var token = new PersonalAccessToken(); token.setUser(user); token.setValue(generateTokenValue()); token.setActive(true); - - var createdAt = TimeUtil.getCurrentUTC(); - token.setCreatedTimestamp(createdAt); - - if (config.isTokenExpiryEnabled()) { - token.setExpiresTimestamp(createdAt.plus(config.getExpiration())); - } - + token.setCreatedTimestamp(TimeUtil.getCurrentUTC()); + token.setExpiresTimestamp(expiresTimestamp); token.setDescription(description); entityManager.persist(token); var json = token.toAccessTokenJson(); diff --git a/server/src/main/java/org/eclipse/openvsx/entities/TrustedPublisher.java b/server/src/main/java/org/eclipse/openvsx/entities/TrustedPublisher.java new file mode 100644 index 000000000..6f2737e4e --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/TrustedPublisher.java @@ -0,0 +1,187 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.entities; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import org.eclipse.openvsx.json.TrustedPublisherJson; +import org.eclipse.openvsx.util.TimeUtil; + +/** + * A trusted publisher registration: claims resolved from a trust request, pinned to a namespace + * and extension. The {@code claims} contains claims (extended with provider specific information), and + * are kept within boundaries of application, they should not leave it. + */ +@Entity +@Table(name = "trusted_publisher") +public class TrustedPublisher implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "trustedPublisherSeq") + @SequenceGenerator(name = "trustedPublisherSeq", sequenceName = "trusted_publisher_seq", allocationSize = 1) + private long id; + + @ManyToOne + @JoinColumn(name = "namespace", nullable = false) + private Namespace namespace; + + @Column(name = "extension_name", nullable = false) + private String extensionName; + + @Column(nullable = false, length = 32) + private String provider; + + /** + * Registration are public data; it may be shown to end user, and is in same format as user originally provided. + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + private Map registration; + + /** + * Claims are internal only; should not leave the boundaries of application and is in provider specific format. + * For example, workflow file is put into a "path"-like construct, that is provider specific. + * Hence, {@link #toJson()} omits it. + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + private Map claims; + + @ManyToOne + @JoinColumn(name = "created_by", nullable = false) + private UserData createdBy; + + @Column(name = "created_timestamp", nullable = false) + private LocalDateTime createdTimestamp; + + /** + * Convert to a JSON object. + */ + public TrustedPublisherJson toJson() { + var json = new TrustedPublisherJson(); + json.setId(this.getId()); + json.setProvider(this.getProvider()); + json.setNamespace(this.getNamespace().getName()); + json.setExtension(this.getExtensionName()); + json.setRegistration(this.getRegistration()); + if (this.getCreatedTimestamp() != null) { + json.setCreatedTimestamp(TimeUtil.toUTCString(this.getCreatedTimestamp())); + } + return json; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public Namespace getNamespace() { + return namespace; + } + + public void setNamespace(Namespace namespace) { + this.namespace = namespace; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public Map getRegistration() { + return registration; + } + + public void setRegistration(Map registration) { + this.registration = registration; + } + + public Map getClaims() { + return claims; + } + + public void setClaims(Map claims) { + this.claims = claims; + } + + public UserData getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(UserData createdBy) { + this.createdBy = createdBy; + } + + public LocalDateTime getCreatedTimestamp() { + return createdTimestamp; + } + + public void setCreatedTimestamp(LocalDateTime createdTimestamp) { + this.createdTimestamp = createdTimestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TrustedPublisher that = (TrustedPublisher) o; + return id == that.id + && Objects.equals(namespace, that.namespace) + && Objects.equals(extensionName, that.extensionName) + && Objects.equals(provider, that.provider) + && Objects.equals(claims, that.claims) + && Objects.equals(createdBy, that.createdBy) + && Objects.equals(createdTimestamp, that.createdTimestamp); + } + + @Override + public int hashCode() { + return Objects.hash(id, namespace, extensionName, provider, claims, createdBy, createdTimestamp); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherJson.java new file mode 100644 index 000000000..ef5099732 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherJson.java @@ -0,0 +1,95 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import org.jspecify.annotations.Nullable; + +/** + * A trusted publisher registration. On registration requests the {@code namespace}, {@code extension}, {@code provider} + * and {@code registration} field are filled in by the client; the remaining fields are filled in by the server on responses. + */ +@JsonInclude(Include.NON_NULL) +public class TrustedPublisherJson extends ResultJson { + + public static TrustedPublisherJson error(String message) { + var result = new TrustedPublisherJson(); + result.setError(message); + return result; + } + + private Long id; + + private String namespace; + + private String extension; + + private String provider; + + private Map registration; + + @Nullable + private String createdTimestamp; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtension() { + return extension; + } + + public void setExtension(String extension) { + this.extension = extension; + } + + public Map getRegistration() { + return registration; + } + + public void setRegistration(Map registration) { + this.registration = registration; + } + + @Nullable + public String getCreatedTimestamp() { + return createdTimestamp; + } + + public void setCreatedTimestamp(@Nullable String createdTimestamp) { + this.createdTimestamp = createdTimestamp; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherListJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherListJson.java new file mode 100644 index 000000000..b121dda1e --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherListJson.java @@ -0,0 +1,38 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +@JsonInclude(Include.NON_NULL) +public class TrustedPublisherListJson extends ResultJson { + + public static TrustedPublisherListJson error(String message) { + var result = new TrustedPublisherListJson(); + result.setError(message); + return result; + } + + private List trustedPublishers; + + public List getTrustedPublishers() { + return trustedPublishers; + } + + public void setTrustedPublishers(List trustedPublishers) { + this.trustedPublishers = trustedPublishers; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java new file mode 100644 index 000000000..695d8d37b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java @@ -0,0 +1,71 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * A trusted publisher provider description for client. This is one way, is listed to users only from server. + */ +@JsonInclude(Include.NON_NULL) +public class TrustedPublisherProviderJson extends ResultJson { + + public static TrustedPublisherProviderJson error(String message) { + var result = new TrustedPublisherProviderJson(); + result.setError(message); + return result; + } + + private String id; + + private String name; + + private String url; + + private Map registrationKeys; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getUrl() { + return url; + } + + public Map getRegistrationKeys() { + return registrationKeys; + } + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setUrl(String url) { + this.url = url; + } + + public void setRegistrationKeys(Map registrationKeys) { + this.registrationKeys = registrationKeys; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderListJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderListJson.java new file mode 100644 index 000000000..214b5a53c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderListJson.java @@ -0,0 +1,38 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +@JsonInclude(Include.NON_NULL) +public class TrustedPublisherProviderListJson extends ResultJson { + + public static TrustedPublisherProviderListJson error(String message) { + var result = new TrustedPublisherProviderListJson(); + result.setError(message); + return result; + } + + private List trustedPublisherProviders; + + public List getTrustedPublisherProviders() { + return trustedPublisherProviders; + } + + public void setTrustedPublisherProviders(List trustedPublisherProviders) { + this.trustedPublisherProviders = trustedPublisherProviders; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherTokenRequestJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherTokenRequestJson.java new file mode 100644 index 000000000..c9f95f525 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherTokenRequestJson.java @@ -0,0 +1,56 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import org.jspecify.annotations.Nullable; + +/** + * Request body for exchanging an OIDC ID token for a short-lived publishing access token. Here, publisher states + * to which {@code namespace} and {@code extension} publishing is about to happen, and provides a "proof" in form + * of an {@code token} that is OIDC ID token. + */ +@JsonInclude(Include.NON_NULL) +public class TrustedPublisherTokenRequestJson { + + private String namespace; + + private String extension; + + private String token; + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtension() { + return extension; + } + + public void setExtension(String extension) { + this.extension = extension; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 027c8bfb3..98dc4806d 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -50,6 +50,7 @@ import org.eclipse.openvsx.entities.SignatureKeyPair; import org.eclipse.openvsx.entities.Tier; import org.eclipse.openvsx.entities.TierType; +import org.eclipse.openvsx.entities.TrustedPublisher; import org.eclipse.openvsx.entities.UsageStats; import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.QueryRequest; @@ -108,6 +109,7 @@ public class RepositoryService { private final RateLimitTokenRepository rateLimitTokenRepository; private final DailyUsageStatsRepository dailyUsageStatsRepository; private final UserDataJooqRepository userDataJooqRepo; + private final TrustedPublisherRepository trustedPublisherRepo; public RepositoryService( NamespaceRepository namespaceRepo, @@ -144,7 +146,8 @@ public RepositoryService( UsageStatsRepository usageStatsRepository, RateLimitTokenRepository rateLimitTokenRepository, DailyUsageStatsRepository dailyUsageStatsRepository, - UserDataJooqRepository userDataJooqRepo + UserDataJooqRepository userDataJooqRepo, + TrustedPublisherRepository trustedPublisherRepo ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -181,6 +184,19 @@ public RepositoryService( this.rateLimitTokenRepository = rateLimitTokenRepository; this.dailyUsageStatsRepository = dailyUsageStatsRepository; this.userDataJooqRepo = userDataJooqRepo; + this.trustedPublisherRepo = trustedPublisherRepo; + } + + public Streamable findTrustedPublishers(Namespace namespace) { + return trustedPublisherRepo.findByNamespace(namespace); + } + + public TrustedPublisher findTrustedPublisher(long id) { + return trustedPublisherRepo.findById(id); + } + + public void deleteTrustedPublisher(TrustedPublisher trustedPublisher) { + trustedPublisherRepo.delete(trustedPublisher); } public Namespace findNamespace(String name) { diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/TrustedPublisherRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/TrustedPublisherRepository.java new file mode 100644 index 000000000..fd68fb6b8 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TrustedPublisherRepository.java @@ -0,0 +1,28 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.springframework.data.repository.Repository; +import org.springframework.data.util.Streamable; + +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.TrustedPublisher; + +public interface TrustedPublisherRepository extends Repository { + + Streamable findByNamespace(Namespace namespace); + + TrustedPublisher findById(long id); + + void delete(TrustedPublisher trustedPublisher); +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java index c911a7f2e..47bbea96d 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java @@ -85,6 +85,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, OAuth2UserServices use "/api/-/publish", "/api/-/namespace/create", "/api/-/query", + "/api/-/trusted-publishing/token", "/vscode/**", "/admin/api/**"))) .exceptionHandling(configurer -> configurer.authenticationEntryPoint(new Http403ForbiddenEntryPoint())); diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/MissingRequiredClaimException.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/MissingRequiredClaimException.java new file mode 100644 index 000000000..8d95363ad --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/MissingRequiredClaimException.java @@ -0,0 +1,31 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing; + +import org.springframework.security.oauth2.jwt.JwtException; + +/** + * JWT processing exception thrown when required claim is not present in token. + */ +public class MissingRequiredClaimException extends JwtException { + private final String claim; + + public MissingRequiredClaimException(String claim) { + super("Missing required claim: " + claim); + this.claim = claim; + } + + public String getClaim() { + return claim; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingConfig.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingConfig.java new file mode 100644 index 000000000..afcf28902 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingConfig.java @@ -0,0 +1,85 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing; + +import java.time.Duration; +import java.util.List; + +import jakarta.annotation.PostConstruct; +import org.jspecify.annotations.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TrustedPublishingConfig { + /** + * Whether trusted publishing is enabled at all. + */ + @Value("${ovsx.trusted-publishing.enabled:false}") + private boolean enabled; + + /** + * The audience to expect in OIDC ID token; by default it is the URL of this instance frontend. + */ + @Value("${ovsx.trusted-publishing.audience:${ovsx.webui.url:}}") + private String audience; + + /** + * Forbidden JWT headers that are enforced. + * See OpenID Connect Core 1.0 - ID Token + */ + @Value("${ovsx.trusted-publishing.forbidden-jwt-headers:x5u,x5c,jku,jwk}") + private List forbiddenJwtHeaders; + + /** + * The lifetime of access tokens issued in exchange for a valid OIDC ID token, in ISO-8601 duration format. + */ + @Value("${ovsx.trusted-publishing.token-expiry:PT15M}") + private String tokenExpiry; + + public boolean isEnabled() { + return enabled; + } + + @NonNull + public String getAudience() { + return audience; + } + + @NonNull + public List getForbiddenJwtHeaders() { + return forbiddenJwtHeaders; + } + + @NonNull + public Duration getTokenExpiry() { + return Duration.parse(tokenExpiry); + } + + @PostConstruct + public void validate() { + if (enabled) { + if (audience == null || audience.isBlank()) { + throw new IllegalStateException("Trusted publishing is enabled, but audience is not configured"); + } + if (forbiddenJwtHeaders == null || forbiddenJwtHeaders.isEmpty()) { + throw new IllegalStateException( + "Trusted publishing is enabled, but forbidden JWT headers are not configured"); + } + Duration expiry = getTokenExpiry(); // throws if unparseable + if (expiry.isNegative() || expiry.isZero()) { + throw new IllegalStateException("Trusted publishing is enabled, but token expiry is not positive"); + } + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java new file mode 100644 index 000000000..91a3dc8ce --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java @@ -0,0 +1,271 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.jwt.JwtEncodingException; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtValidationException; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.web.client.RestClient; + +import org.eclipse.openvsx.util.ErrorResultException; + +import static java.util.Objects.requireNonNull; + +/** + * Support for trusted publisher providers. + * + * @see Trusted Publishers for Package Repositories + * @see OpenID Connect Core 1.0 - ID Token + */ +public abstract class TrustedPublishingProviderSupport { + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + protected final TrustedPublishingConfig config; + protected final String providerId; + protected final String providerName; + protected final String providerUrl; + protected final String oidcIssuer; + protected final Map registrationKeys; + protected final RestClient restClient; + + private volatile JwtDecoder decoder; + + protected TrustedPublishingProviderSupport( + TrustedPublishingConfig config, + String providerId, + String providerName, + String providerUrl, + String oidcIssuer, + Map registrationKeys + ) { + this.config = requireNonNull(config); + this.providerId = requireNonNull(providerId); + this.providerName = requireNonNull(providerName); + this.providerUrl = requireNonNull(providerUrl); + this.oidcIssuer = requireNonNull(oidcIssuer); + this.registrationKeys = requireNonNull(registrationKeys); + this.restClient = RestClient.create(); + } + + /** + * Builds the decoder lazily: {@link JwtDecoders#fromIssuerLocation(String)} performs OIDC discovery over the + * network, which must not happen at application startup. + */ + private JwtDecoder decoder() { + JwtDecoder localDecoder = decoder; + if (localDecoder == null) { + synchronized (this) { + localDecoder = decoder; + if (localDecoder == null) { + OAuth2TokenValidator forbiddenHeadersValidator = jwt -> { + if (config.getForbiddenJwtHeaders().stream().anyMatch(jwt.getHeaders()::containsKey)) { + return OAuth2TokenValidatorResult.failure( + new OAuth2Error("invalid_headers", "The token contains forbidden headers.", null)); + } + return OAuth2TokenValidatorResult.success(); + }; + OAuth2TokenValidator issuerValidator = new JwtIssuerValidator(oidcIssuer); + OAuth2TokenValidator audienceValidator = jwt -> { + List audience = jwt.getAudience(); + if (audience != null && audience.contains(config.getAudience())) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure( + new OAuth2Error( + "invalid_audience", + "The token does not contain the expected audience.", + null)); + }; + NimbusJwtDecoder nimbusDecoder = JwtDecoders.fromIssuerLocation(oidcIssuer); + nimbusDecoder.setJwtValidator( + JwtValidators.createDefaultWithValidators( + List.of(issuerValidator, audienceValidator, forbiddenHeadersValidator))); + decoder = localDecoder = nimbusDecoder; + } + } + } + return localDecoder; + } + + /** + * The unique identifier of the provider. + */ + public String getProviderId() { + return providerId; + } + + /** + * The provider name, for human consumption. + */ + public String getProviderName() { + return providerName; + } + + /** + * The "well known" URL of the provider. + */ + public String getProviderUrl() { + return providerUrl; + } + + /** + * The issuer, to be found in "iss" claim of issued OIDC tokens. + */ + public String getOidcIssuer() { + return oidcIssuer; + } + + /** + * The provider registration keys, it requires. + */ + public Map getRegistrationKeys() { + return registrationKeys; + } + + /** + * Parses the raw OIDC ID token in form of JWT. If validation and parsing passed, and token is found valid, + * the contained "claims of interest" are returned as {@link Map}. Otherwise, the optional is empty. + */ + public Optional> extract(String oidcId) { + requireNonNull(oidcId); + if (!config.isEnabled()) { + return Optional.empty(); + } + try { + Jwt jwt = decoder().decode(oidcId); + Map claims = requireNonNull(extractClaims(jwt)); + if (claims.isEmpty()) { + logger.warn("Trusted Publishing OIDC ID token does not contain any claims of interest"); + return Optional.empty(); + } else { + return Optional.of(claims); + } + } catch (JwtEncodingException e) { + // token has encoding issues + logger.warn("Error decoding Trusted Publishing OIDC ID token", e); + } catch (JwtValidationException e) { + // token has validation issues + logger.warn("Error validating Trusted Publishing OIDC ID token: {}", e.getErrors(), e); + } catch (MissingRequiredClaimException e) { + // missing claims we expect; lack of information + logger.warn("Trusted Publishing OIDC ID token lack of information: {}", e.getClaim(), e); + } catch (JwtException e) { + // everything else; like JWK or network issues + logger.warn("Error processing Trusted Publishing OIDC ID token", e); + } + return Optional.empty(); + } + + protected static String mustRegister(Map registration, String key) throws ErrorResultException { + String value = registration.get(key); + if (value == null || value.isBlank()) { + throw new ErrorResultException("Malformed registration request"); + } + return value; + } + + /** + * Helper to require a claim from the JWT, throwing {@link MissingRequiredClaimException} if not present or blank. + */ + protected static void mustClaim(Jwt jwt, String claim, Map claims) + throws MissingRequiredClaimException { + String value = jwt.getClaimAsString(claim); + if (value == null || value.isBlank()) { + throw new MissingRequiredClaimException(claim); + } + claims.put(claim, value); + } + + /** + * Helper to optionally require a claim from the JWT, adding it to the claims map if present and not blank. + */ + protected static void mayClaim(Jwt jwt, String claim, Map claims) { + String value = jwt.getClaimAsString(claim); + if (value != null && !value.isBlank()) { + claims.put(claim, value); + } + } + + /** + * Helper to compare two claim values that must both be present and equal. + */ + protected static boolean claimEquals(String claim, Map registered, Map token) { + String registeredValue = registered.get(claim); + return registeredValue != null && registeredValue.equals(token.get(claim)); + } + + /** + * Helper that strips the trailing {@code @} part from a claim value like + * {@code owner/repo/.github/workflows/ci.yml@refs/heads/main}. + */ + protected static String stripRef(String value) { + if (value == null) { + return null; + } + int at = value.indexOf('@'); + return at < 0 ? value : value.substring(0, at); + } + + /** + * Helper to compare an optionally registered claim: if the registration pins the claim, the token + * must carry the equal value; if not pinned, anything (or nothing) is accepted. + */ + protected static boolean pinnedClaimMatches( + String claim, + Map registered, + Map token + ) { + String registeredValue = registered.get(claim); + return registeredValue == null || registeredValue.equals(token.get(claim)); + } + + /** + * Extracts issuer specific claims from the passed in JWT token, never returns {@code null}. + * + * @throws JwtException if extraction (including validation) fails in some way. + */ + @NonNull + protected abstract Map extractClaims(Jwt jwt) throws JwtException; + + /** + * Creates issuer specific claims from the passed in {@code registration}. This is provider specific, and + * involves remote access, to resolve usernames and repository names to more stable, provider specific IDs. + * + * @throws ErrorResultException if the request processing fails in some way. + */ + @NonNull + protected abstract Map extractRequest(Map registration) throws ErrorResultException; + + /** + * Decides whether the claims extracted from a presented OIDC ID token ({@code token}, produced by + * {@link #extractClaims(Jwt)}) satisfy a registered trust ({@code registered}, produced by + * {@link #extractRequest(Map)}). + */ + public abstract boolean matches(@NonNull Map registered, @NonNull Map token); +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java new file mode 100644 index 000000000..e48b9c85b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java @@ -0,0 +1,255 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing; + +import java.text.ParseException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.nimbusds.jwt.JWTParser; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.stereotype.Service; + +import org.eclipse.openvsx.accesstoken.AccessTokenService; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.TrustedPublisher; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.AccessTokenJson; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.trustedpublishing.github.GitHubTrustedPublishingProvider; +import org.eclipse.openvsx.trustedpublishing.gitlab.EclipseGitLabTrustedPublishingProvider; +import org.eclipse.openvsx.trustedpublishing.gitlab.GitLabTrustedPublishingProvider; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.NotFoundException; +import org.eclipse.openvsx.util.TimeUtil; + +import static java.util.Objects.requireNonNull; + +@Service +public class TrustedPublishingService { + private static final int TOKEN_DESCRIPTION_SIZE = 255; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final TrustedPublishingConfig config; + private final RepositoryService repositories; + private final AccessTokenService tokens; + private final EntityManager entityManager; + + private final Map providers; + + public TrustedPublishingService( + TrustedPublishingConfig config, + RepositoryService repositories, + AccessTokenService tokens, + EntityManager entityManager + ) { + this.config = requireNonNull(config); + this.repositories = requireNonNull(repositories); + this.tokens = requireNonNull(tokens); + this.entityManager = requireNonNull(entityManager); + + if (config.isEnabled()) { + this.providers = Map.of( + GitHubTrustedPublishingProvider.PROVIDER_ID, + new GitHubTrustedPublishingProvider(config), + GitLabTrustedPublishingProvider.PROVIDER_ID, + new GitLabTrustedPublishingProvider(config), + EclipseGitLabTrustedPublishingProvider.PROVIDER_ID, + new EclipseGitLabTrustedPublishingProvider(config)); + } else { + this.providers = Map.of(); + } + } + + public boolean isEnabled() { + return config.isEnabled(); + } + + private void ensureEnabled() { + if (!config.isEnabled()) { + throw new ErrorResultException("Trusted publishing is not enabled.", HttpStatus.NOT_FOUND); + } + } + + /** + * Client initiated registration of trusted publishing. The user must be an owner of the namespace. + */ + @Transactional + public TrustedPublisher registerTrustedPublisher( + UserData user, + String namespaceName, + String extensionName, + String providerId, + Map registration + ) { + requireNonNull(user); + requireNonNull(namespaceName); + requireNonNull(extensionName); + requireNonNull(providerId); + requireNonNull(registration); + ensureEnabled(); + TrustedPublishingProviderSupport provider = providers.get(providerId); + if (provider == null) { + throw new ErrorResultException("Unknown trusted publishing provider: " + providerId); + } + Namespace namespace = requireOwnedNamespace(user, namespaceName); + + Map claims = provider.extractRequest(registration); + + boolean duplicate = repositories.findTrustedPublishers(namespace).stream() + .anyMatch( + tp -> Objects.equals(extensionName, tp.getExtensionName()) + && tp.getProvider().equals(provider.getProviderId()) + && tp.getClaims().equals(claims)); + if (duplicate) { + throw new ErrorResultException("An equivalent trusted publisher is already registered."); + } + + TrustedPublisher publisher = new TrustedPublisher(); + publisher.setNamespace(namespace); + publisher.setExtensionName(extensionName); + publisher.setProvider(provider.getProviderId()); + publisher.setRegistration(registration); + publisher.setClaims(claims); + publisher.setCreatedBy(entityManager.merge(user)); + publisher.setCreatedTimestamp(TimeUtil.getCurrentUTC()); + entityManager.persist(publisher); + return publisher; + } + + /** + * Lists trusted publishers of a namespace. The user must be an owner of the namespace. + */ + public List getTrustedPublishers(UserData user, String namespaceName) { + requireNonNull(user); + requireNonNull(namespaceName); + ensureEnabled(); + Namespace namespace = requireOwnedNamespace(user, namespaceName); + return repositories.findTrustedPublishers(namespace).toList(); + } + + /** + * Deletes a trusted publisher registration. The user must be an owner of the namespace. + */ + @Transactional + public ResultJson deleteTrustedPublisher(UserData user, String namespaceName, long id) { + requireNonNull(user); + requireNonNull(namespaceName); + ensureEnabled(); + Namespace namespace = requireOwnedNamespace(user, namespaceName); + TrustedPublisher publisher = repositories.findTrustedPublisher(id); + if (publisher == null || publisher.getNamespace().getId() != namespace.getId()) { + throw new NotFoundException(); + } + repositories.deleteTrustedPublisher(publisher); + return ResultJson.success("Deleted trusted publisher for namespace " + namespace.getName() + "."); + } + + /** + * Lists trusted publisher providers supported on a namespace. The user must be an owner of the namespace. + * For now, we do not use any of the provided information to filter providers, just enforce required conditions. + */ + public Map getTrustedPublisherProviders( + UserData user, + String namespaceName + ) { + requireNonNull(user); + requireNonNull(namespaceName); + ensureEnabled(); + requireOwnedNamespace(user, namespaceName); + return providers; + } + + /** + * Client signaled publishing intent, by submitting OIDC ID token. If publishing intent is approved, + * service will issue an access token that is returned to client, and client should publish using received + * token. + */ + @Transactional + public AccessTokenJson requestPublishToken(String namespaceName, String extensionName, String token) { + requireNonNull(namespaceName); + requireNonNull(extensionName); + requireNonNull(token); + ensureEnabled(); + + // just blindly parse token to get "iss" claim from it; to identify provider to use + String issuer; + try { + Object iss = JWTParser.parse(token).getJWTClaimsSet().getClaim(JwtClaimNames.ISS); + if (iss == null) { + throw new ErrorResultException("Token does not have issuer set."); + } + issuer = iss instanceof String ? (String) iss : String.valueOf(iss); + } catch (ParseException e) { + throw new ErrorResultException("Failed to pre-parse token."); + } + + // select provider based on "iss" + TrustedPublishingProviderSupport provider = providers.values().stream() + .filter(p -> Objects.equals(issuer, p.getOidcIssuer())) + .findFirst() + .orElseThrow(() -> new ErrorResultException("Unsupported token issuer.")); + + // using provider validate token and extract claims of interest + Map claims = provider.extract(token) + .orElseThrow(() -> new ErrorResultException("The token could not be validated.", HttpStatus.FORBIDDEN)); + + Namespace namespace = repositories.findNamespace(namespaceName); + if (namespace == null) { + throw new ErrorResultException("No trusted publisher matches the presented token.", HttpStatus.FORBIDDEN); + } + + TrustedPublisher match = repositories.findTrustedPublishers(namespace).stream() + .filter(tp -> Objects.equals(extensionName, tp.getExtensionName())) + .filter(tp -> tp.getProvider().equals(provider.getProviderId())) + .filter(tp -> provider.matches(tp.getClaims(), claims)) + .findFirst() + .orElseThrow( + () -> new ErrorResultException( + "No trusted publisher matches the presented token.", + HttpStatus.FORBIDDEN)); + + // ponytail: the issued token is a regular personal access token of the registering user, valid for + // any namespace that user can publish to; add namespace-scoped tokens if broader scope becomes a concern + String description = "Trusted publishing (" + provider.getProviderId() + "): " + claims.get(JwtClaimNames.SUB); + if (description.length() > TOKEN_DESCRIPTION_SIZE) { + 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())); + } + + private Namespace requireOwnedNamespace(UserData user, String namespaceName) { + Namespace namespace = repositories.findNamespace(namespaceName); + if (namespace == null) { + throw new NotFoundException(); + } + if (!repositories.isNamespaceOwner(user, namespace)) { + throw new ErrorResultException("You must be an owner of this namespace."); + } + return namespace; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProvider.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProvider.java new file mode 100644 index 000000000..c5284e204 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProvider.java @@ -0,0 +1,29 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.github; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; + +/** + * GitHub provider for GitHub Public Instance. + */ +public class GitHubTrustedPublishingProvider extends GitHubTrustedPublishingProviderSupport { + public static final String PROVIDER_ID = "github"; + public static final String PROVIDER_URL = "https://github.com"; + private static final String OIDC_ISSUER = "https://token.actions.githubusercontent.com"; + private static final String API_RESOLVE_REQUEST = "https://api.github.com/repos/{owner}/{repo}"; + + public GitHubTrustedPublishingProvider(TrustedPublishingConfig config) { + super(config, PROVIDER_ID, "GitHub", PROVIDER_URL, OIDC_ISSUER, API_RESOLVE_REQUEST); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java new file mode 100644 index 000000000..53b433442 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java @@ -0,0 +1,153 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.github; + +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.NonNull; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.web.client.RestClientException; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingProviderSupport; +import org.eclipse.openvsx.util.ErrorResultException; + +import static java.util.Objects.requireNonNull; + +/** + * GitHub specific support. + * + * @see GitHub OpenID Connect + */ +public abstract class GitHubTrustedPublishingProviderSupport extends TrustedPublishingProviderSupport { + private static final String CLAIM_REPOSITORY = "repository"; // "octo-org/octo-repo" + private static final String CLAIM_REPOSITORY_ID = "repository_id"; // "74" + private static final String CLAIM_REPOSITORY_OWNER = "repository_owner"; // "octo-org" + private static final String CLAIM_REPOSITORY_OWNER_ID = "repository_owner_id"; // "65" + private static final String CLAIM_ENVIRONMENT = "environment"; // "prod"; optional + private static final String CLAIM_RUNNER_ENVIRONMENT = "runner_environment"; // "github-hosted"; for self-hosted GH runners this claim may not be included + private static final String CLAIM_WORKFLOW_REF = "workflow_ref"; // "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main" + + private static final String REG_OWNER = "owner"; + private static final String REG_REPO = "repo"; + private static final String REG_WORKFLOW = "workflow"; + private static final String REG_ENVIRONMENT = "environment"; + private static final Map REGISTRATION_KEYS = Map.of( + REG_OWNER, + "Organization or User name", + REG_REPO, + "Repository name", + REG_WORKFLOW, + "Workflow filename", + REG_ENVIRONMENT, + "Environment name (optional)"); + + private final String apiResolveRequest; + + protected GitHubTrustedPublishingProviderSupport( + TrustedPublishingConfig config, + String providerId, + String providerName, + String providerUrl, + String oidcIssuer, + String apiResolveRequest + ) { + super(config, providerId, providerName, providerUrl, oidcIssuer, REGISTRATION_KEYS); + this.apiResolveRequest = requireNonNull(apiResolveRequest); + } + + @NonNull + @Override + protected Map extractClaims(Jwt jwt) { + requireNonNull(jwt); + HashMap result = new HashMap<>(7); + mustClaim(jwt, JwtClaimNames.SUB, result); + mustClaim(jwt, CLAIM_REPOSITORY, result); + mustClaim(jwt, CLAIM_REPOSITORY_ID, result); + mustClaim(jwt, CLAIM_REPOSITORY_OWNER, result); + mustClaim(jwt, CLAIM_REPOSITORY_OWNER_ID, result); + mayClaim(jwt, CLAIM_ENVIRONMENT, result); + mayClaim(jwt, CLAIM_RUNNER_ENVIRONMENT, result); + mustClaim(jwt, CLAIM_WORKFLOW_REF, result); + return result; + } + + @NonNull + @Override + protected Map extractRequest(Map registration) throws ErrorResultException { + requireNonNull(registration); + + final String owner = mustRegister(registration, REG_OWNER); + final String repo = mustRegister(registration, REG_REPO); + final String workflow = mustRegister(registration, REG_WORKFLOW); + final String environment = registration.get(REG_ENVIRONMENT); + + Map response = resolve(owner, repo); + if (response == null || !(response.get("id") instanceof Number repositoryId) + || !(response.get("owner") instanceof Map ownerMap) + || !(ownerMap.get("id") instanceof Number ownerId)) { + throw new ErrorResultException( + "Unexpected GitHub response for repository " + + owner + "/" + repo); + } + + HashMap result = new HashMap<>(); + result.put(CLAIM_REPOSITORY, owner + "/" + repo); + result.put(CLAIM_REPOSITORY_ID, String.valueOf(repositoryId.longValue())); + result.put(CLAIM_REPOSITORY_OWNER, owner); + result.put(CLAIM_REPOSITORY_OWNER_ID, String.valueOf(ownerId.longValue())); + // registered without the "@" part: publishing is trusted regardless of branch or tag + result.put( + CLAIM_WORKFLOW_REF, + owner + "/" + repo + + "/.github/workflows/" + workflow); + if (environment != null) { + result.put(CLAIM_ENVIRONMENT, environment); + } + return result; + } + + /** + * Pulled out for testability; is mocked in UT to prevent real remote access. + */ + protected Map resolve(String owner, String repo) throws ErrorResultException { + try { + return restClient.get() + .uri(apiResolveRequest, owner, repo) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } catch (RestClientException e) { + throw new ErrorResultException( + "Could not resolve GitHub repository " + + owner + "/" + repo, + e); + } + } + + @Override + public boolean matches(@NonNull Map registered, @NonNull Map token) { + requireNonNull(registered); + requireNonNull(token); + return claimEquals(CLAIM_REPOSITORY_ID, registered, token) + && claimEquals(CLAIM_REPOSITORY_OWNER_ID, registered, token) + && registered.get(CLAIM_WORKFLOW_REF) != null + && registered.get(CLAIM_WORKFLOW_REF).equals(stripRef(token.get(CLAIM_WORKFLOW_REF))) + && pinnedClaimMatches(CLAIM_ENVIRONMENT, registered, token); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/EclipseGitLabTrustedPublishingProvider.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/EclipseGitLabTrustedPublishingProvider.java new file mode 100644 index 000000000..c24787fe5 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/EclipseGitLabTrustedPublishingProvider.java @@ -0,0 +1,28 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.gitlab; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; + +/** + * GitLab provider for Eclipse GitLab Instance. + */ +public class EclipseGitLabTrustedPublishingProvider extends GitLabTrustedPublishingProviderSupport { + public static final String PROVIDER_ID = "eclipse-gitlab"; + public static final String PROVIDER_URL = "https://gitlab.eclipse.org"; + private static final String OIDC_ISSUER = "https://gitlab.eclipse.org"; + + public EclipseGitLabTrustedPublishingProvider(TrustedPublishingConfig config) { + super(config, PROVIDER_ID, "Eclipse GitLab", PROVIDER_URL, OIDC_ISSUER); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProvider.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProvider.java new file mode 100644 index 000000000..1fee0449d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProvider.java @@ -0,0 +1,28 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.gitlab; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; + +/** + * GitLab provider for GitLab Public Instance. + */ +public class GitLabTrustedPublishingProvider extends GitLabTrustedPublishingProviderSupport { + public static final String PROVIDER_ID = "gitlab"; + public static final String PROVIDER_URL = "https://gitlab.com"; + private static final String OIDC_ISSUER = "https://gitlab.com"; + + public GitLabTrustedPublishingProvider(TrustedPublishingConfig config) { + super(config, PROVIDER_ID, "GitLab", PROVIDER_URL, OIDC_ISSUER); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java new file mode 100644 index 000000000..fbae1eb2c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java @@ -0,0 +1,149 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.gitlab; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.NonNull; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.web.client.RestClientException; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingProviderSupport; +import org.eclipse.openvsx.util.ErrorResultException; + +import static java.util.Objects.requireNonNull; + +/** + * GitLab specific support. + * + * @see GitLab OpenID Connect + */ +public abstract class GitLabTrustedPublishingProviderSupport extends TrustedPublishingProviderSupport { + private static final String CLAIM_NAMESPACE_ID = "namespace_id"; // "72" + private static final String CLAIM_NAMESPACE_PATH = "namespace_path"; // "my-group" + private static final String CLAIM_PROJECT_ID = "project_id"; // "20" + private static final String CLAIM_PROJECT_PATH = "project_path"; // "my-group/my-project" + private static final String CLAIM_ENVIRONMENT = "environment"; // "prod"; optional + private static final String CLAIM_RUNNER_ENVIRONMENT = "runner_environment"; // "gitlab-hosted" + private static final String CLAIM_CI_CONFIG_REF_URI = "ci_config_ref_uri"; // "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main" + + private static final String API_RESOLVE_REQUEST = "/api/v4/projects/{path}"; + + private static final String REG_NAMESPACE = "namespace"; + private static final String REG_PROJECT = "project"; + private static final String REG_WORKFLOW = "workflow"; + private static final String REG_ENVIRONMENT = "environment"; + private static final Map REGISTRATION_KEYS = Map.of( + REG_NAMESPACE, + "Namespace", + REG_PROJECT, + "Project name", + REG_WORKFLOW, + "Top-level CI filename", + REG_ENVIRONMENT, + "Environment name (optional)"); + + protected GitLabTrustedPublishingProviderSupport( + TrustedPublishingConfig config, + String providerId, + String providerName, + String providerUrl, + String oidcIssuer + ) { + super(config, providerId, providerName, providerUrl, oidcIssuer, REGISTRATION_KEYS); + } + + @NonNull + @Override + protected Map extractClaims(Jwt jwt) { + requireNonNull(jwt); + HashMap result = new HashMap<>(7); + mustClaim(jwt, JwtClaimNames.SUB, result); + mustClaim(jwt, CLAIM_NAMESPACE_ID, result); + mustClaim(jwt, CLAIM_NAMESPACE_PATH, result); + mustClaim(jwt, CLAIM_PROJECT_ID, result); + mustClaim(jwt, CLAIM_PROJECT_PATH, result); + mayClaim(jwt, CLAIM_ENVIRONMENT, result); + mustClaim(jwt, CLAIM_RUNNER_ENVIRONMENT, result); + mustClaim(jwt, CLAIM_CI_CONFIG_REF_URI, result); + return result; + } + + @NonNull + @Override + protected Map extractRequest(Map registration) throws ErrorResultException { + requireNonNull(registration); + + final String namespace = mustRegister(registration, REG_NAMESPACE); + final String project = mustRegister(registration, REG_PROJECT); + final String workflow = mustRegister(registration, REG_WORKFLOW); + final String environment = registration.get(REG_ENVIRONMENT); + final String projectPath = namespace + "/" + project; + + Map response = resolve(projectPath); + if (response == null || !(response.get("id") instanceof Number projectId) + || !(response.get("namespace") instanceof Map namespaceMap) + || !(namespaceMap.get("id") instanceof Number namespaceId)) { + throw new ErrorResultException("Unexpected GitLab response for project " + projectPath); + } + + HashMap result = new HashMap<>(); + result.put(CLAIM_NAMESPACE_ID, String.valueOf(namespaceId.longValue())); + result.put(CLAIM_NAMESPACE_PATH, namespace); + result.put(CLAIM_PROJECT_ID, String.valueOf(projectId.longValue())); + result.put(CLAIM_PROJECT_PATH, projectPath); + // registered without the "@" part: publishing is trusted regardless of branch or tag + result.put( + CLAIM_CI_CONFIG_REF_URI, + URI.create(providerUrl).getHost() + "/" + projectPath + + "//" + workflow); + if (environment != null) { + result.put(CLAIM_ENVIRONMENT, environment); + } + return result; + } + + /** + * Pulled out for testability; is mocked in UT to prevent real remote access. + */ + protected Map resolve(String projectPath) throws ErrorResultException { + try { + // the {path} template variable is URL-encoded by RestClient, turning "/" into "%2F" as GitLab expects + return restClient.get() + .uri(providerUrl + API_RESOLVE_REQUEST, projectPath) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } catch (RestClientException e) { + throw new ErrorResultException("Could not resolve GitLab project " + projectPath, e); + } + } + + @Override + public boolean matches(@NonNull Map registered, @NonNull Map token) { + requireNonNull(registered); + requireNonNull(token); + return claimEquals(CLAIM_PROJECT_ID, registered, token) + && claimEquals(CLAIM_NAMESPACE_ID, registered, token) + && registered.get(CLAIM_CI_CONFIG_REF_URI) != null + && registered.get(CLAIM_CI_CONFIG_REF_URI).equals(stripRef(token.get(CLAIM_CI_CONFIG_REF_URI))) + && pinnedClaimMatches(CLAIM_ENVIRONMENT, registered, token); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/package-info.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/package-info.java new file mode 100644 index 000000000..0a1333a60 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/package-info.java @@ -0,0 +1,36 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +/** + * Trusted publishing. + *

+ * Given there is a {@code USER}, who owns a (vetted, verified) {@code NAMESPACE}. Such a {@code USER} can manage + * (create, list, delete) {@code PUBLISHER} for given {@code NAMESPACE} and (possibly non-yet-existent) {@code EXTENSION} + * combination (identifying it by {@code $NS/$extensionName}). The extension name does not have to exist beforehand. + *

+ * The {@code PUBLISHER} is a combination of {@code PROVIDER_ID}, {@code OWNER}, {@code REPO}, {@code WORKFLOW} and optionally {@code ENVIRONMENT} + * assigned to targeted {@code NAMESPACE} and {@code EXTENSION} (as a name). + * On creation, {@code PUBLISHER} is "resolved" (in provider specific way) and all related data is persisted. + *

+ * The publishing workflow running on supported provider creates an OIDC ID token, which is then exchanged for a + * short-lived publishing access token. The exchange along token carries the targeted {@code NAMESPACE} and {@code EXTENSION} + * (as name). The OIDC ID token carries {@code PUBLISHER} information (extended with provider specific values). + * First, provider is selected based on "iss" claim. The selected provider then validates fully + * the OIDC ID token, and based on present claims creates "request data". + *

+ * Finally, match is performed between looked up "persisted data" and "request data" (created from OIDC token from exchange), + * and also {@code NAMESPACE} and {@code EXTENSION} are matched against persisted ones. + * If match is established, a short-lived publishing access token is returned to the workflow, that publishing workflow + * should use to publish extension in "usual way". + */ +package org.eclipse.openvsx.trustedpublishing; diff --git a/server/src/main/resources/db/migration/V1_70__Trusted_Publisher.sql b/server/src/main/resources/db/migration/V1_70__Trusted_Publisher.sql new file mode 100644 index 000000000..cb69ead84 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_70__Trusted_Publisher.sql @@ -0,0 +1,17 @@ +-- trusted_publisher table + +CREATE SEQUENCE IF NOT EXISTS trusted_publisher_seq START WITH 1 INCREMENT BY 1; + +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), + extension_name CHARACTER VARYING(255), + provider CHARACTER VARYING(32) NOT NULL, + registration JSONB NOT NULL, + claims JSONB NOT NULL, + created_by BIGINT NOT NULL REFERENCES public.user_data(id), + created_timestamp TIMESTAMP without time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS trusted_publisher_namespace_idx ON public.trusted_publisher (namespace, extension_name); diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 60c5a0dd1..487f99d50 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -12,8 +12,8 @@ import java.lang.reflect.Modifier; import java.time.Duration; import java.time.LocalDateTime; -import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import jakarta.persistence.EntityManager; @@ -43,6 +43,7 @@ import org.eclipse.openvsx.entities.SignatureKeyPair; import org.eclipse.openvsx.entities.Tier; import org.eclipse.openvsx.entities.TierType; +import org.eclipse.openvsx.entities.TrustedPublisher; import org.eclipse.openvsx.entities.UsageStats; import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.QueryRequest; @@ -149,6 +150,14 @@ void testExecuteQueries() { dailyUsageStats.setTotalRequests(1L); dailyUsageStats.setP95Requests(1L); + var trustedPublisher = new TrustedPublisher(); + trustedPublisher.setNamespace(namespace); + trustedPublisher.setProvider("provider"); + trustedPublisher.setRegistration(Map.of("foo", "bar")); + trustedPublisher.setClaims(Map.of("claim", "value")); + trustedPublisher.setCreatedBy(userData); + trustedPublisher.setCreatedTimestamp(NOW); + // Persist all entities consistently using EntityManager Stream.of( namespace, @@ -165,7 +174,8 @@ void testExecuteQueries() { scanCheckResult, tier, customer, - usageStats) + usageStats, + trustedPublisher) .forEach(em::persist); em.flush(); @@ -278,8 +288,8 @@ void testExecuteQueries() { () -> repositories.findPublicId("namespaceName", "extensionName"), () -> repositories.findPublicId("namespaceName.extensionName"), () -> repositories.findNamespacePublicId("namespaceName.extensionName"), - () -> repositories.updateExtensionPublicIds(Collections.emptyMap()), - () -> repositories.updateNamespacePublicIds(Collections.emptyMap()), + () -> repositories.updateExtensionPublicIds(Map.of()), + () -> repositories.updateNamespacePublicIds(Map.of()), () -> repositories.extensionPublicIdExists("namespaceName.extensionName"), () -> repositories.namespacePublicIdExists("namespaceName.extensionName"), () -> repositories.fetchSitemapRows(), @@ -347,12 +357,11 @@ void testExecuteQueries() { "extensionName", "namespaceName", "displayName", - Collections.emptyList(), + List.of(), 0.5, false, 10), - () -> repositories - .findSimilarNamespacesByLevenshtein("namespaceName", Collections.emptyList(), 0.5, false, 10), + () -> repositories.findSimilarNamespacesByLevenshtein("namespaceName", List.of(), 0.5, false, 10), () -> repositories.findExtensionScans(extVersion), () -> repositories.findLatestExtensionScan(extVersion), () -> repositories.findExtensionScans(extension), @@ -519,6 +528,9 @@ void testExecuteQueries() { () -> repositories.findDailyUsageStats(customer, NOW.toLocalDate()), () -> repositories.findUnprocessedDaysForDailyUsage(customer), () -> repositories.saveDailyUsageStats(dailyUsageStats), + () -> repositories.findTrustedPublishers(namespace), + () -> repositories.findTrustedPublisher(1L), + () -> repositories.deleteTrustedPublisher(trustedPublisher), () -> repositories.deleteTier(tier), () -> repositories.deleteCustomer(customer), // Extension scan delete method - add last, still not clear why but otherwise the test fails diff --git a/server/src/test/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderTest.java b/server/src/test/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderTest.java new file mode 100644 index 000000000..bebb7a85f --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderTest.java @@ -0,0 +1,161 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.github; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SpringExtension.class) +class GitHubTrustedPublishingProviderTest { + + @Autowired + TrustedPublishingConfig config; + + private static Map tokenClaims() { + var claims = new HashMap(); + claims.put("sub", "repo:eclipse-openvsx/openvsx:ref:refs/heads/main"); + claims.put("repository", "eclipse-openvsx/openvsx"); + claims.put("repository_id", "226955212"); + claims.put("repository_owner", "eclipse-openvsx"); + claims.put("repository_owner_id", "163524810"); + claims.put("runner_environment", "github-hosted"); + claims.put("workflow_ref", "eclipse-openvsx/openvsx/.github/workflows/release.yml@refs/heads/main"); + return claims; + } + + private static Map registeredClaims() { + var claims = new HashMap(); + claims.put("repository", "eclipse-openvsx/openvsx"); + claims.put("repository_id", "226955212"); + claims.put("repository_owner", "eclipse-openvsx"); + claims.put("repository_owner_id", "163524810"); + claims.put("workflow_ref", "eclipse-openvsx/openvsx/.github/workflows/release.yml"); + return claims; + } + + private static GitHubTrustedPublishingProvider newProvider(TrustedPublishingConfig config) { + return new GitHubTrustedPublishingProvider(config) { + @Override + protected Map resolve(String owner, String repo) { + return Map.of( + "id", + 226955212, + "owner", + Map.of("id", 163524810)); + } + }; + } + + @Test + void trustRequestWithoutEnv() throws Exception { + GitHubTrustedPublishingProvider gh = newProvider(config); + Map data = gh.extractRequest( + Map.of( + "owner", + "eclipse-openvsx", + "repo", + "openvsx", + "workflow", + "release.yml")); + assertEquals("eclipse-openvsx", data.get("repository_owner")); + assertEquals("163524810", data.get("repository_owner_id")); + assertEquals("eclipse-openvsx/openvsx", data.get("repository")); + assertEquals("226955212", data.get("repository_id")); + assertEquals("eclipse-openvsx/openvsx/.github/workflows/release.yml", data.get("workflow_ref")); + assertEquals(5, data.size()); + } + + @Test + void trustRequestWithEnv() throws Exception { + GitHubTrustedPublishingProvider gh = newProvider(config); + Map data = gh.extractRequest( + Map.of( + "owner", + "eclipse-openvsx", + "repo", + "openvsx", + "workflow", + "release.yml", + "environment", + "prod")); + assertEquals("eclipse-openvsx", data.get("repository_owner")); + assertEquals("163524810", data.get("repository_owner_id")); + assertEquals("eclipse-openvsx/openvsx", data.get("repository")); + assertEquals("226955212", data.get("repository_id")); + assertEquals("eclipse-openvsx/openvsx/.github/workflows/release.yml", data.get("workflow_ref")); + assertEquals("prod", data.get("environment")); + assertEquals(6, data.size()); + } + + @Test + void matchesRegardlessOfRef() { + GitHubTrustedPublishingProvider gh = newProvider(config); + var token = tokenClaims(); + assertTrue(gh.matches(registeredClaims(), token)); + token.put("workflow_ref", "eclipse-openvsx/openvsx/.github/workflows/release.yml@refs/tags/v1.0.0"); + assertTrue(gh.matches(registeredClaims(), token)); + } + + @Test + void mismatchOnDifferentRepositoryId() { + GitHubTrustedPublishingProvider gh = newProvider(config); + var token = tokenClaims(); + token.put("repository_id", "1"); // resurrection attack: same name, different repository + assertFalse(gh.matches(registeredClaims(), token)); + } + + @Test + void mismatchOnDifferentWorkflow() { + GitHubTrustedPublishingProvider gh = newProvider(config); + var token = tokenClaims(); + token.put("workflow_ref", "eclipse-openvsx/openvsx/.github/workflows/other.yml@refs/heads/main"); + assertFalse(gh.matches(registeredClaims(), token)); + } + + @Test + void pinnedEnvironment() { + GitHubTrustedPublishingProvider gh = newProvider(config); + var registered = registeredClaims(); + registered.put("environment", "prod"); + + var token = tokenClaims(); + assertFalse(gh.matches(registered, token)); // token not from any environment + token.put("environment", "staging"); + assertFalse(gh.matches(registered, token)); + token.put("environment", "prod"); + assertTrue(gh.matches(registered, token)); + // and with no pinned environment, any environment is accepted + assertTrue(gh.matches(registeredClaims(), token)); + } + + @TestConfiguration + static class TestConfig { + @Bean + TrustedPublishingConfig trustedPublishingConfig() { + return new TrustedPublishingConfig(); + } + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderTest.java b/server/src/test/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderTest.java new file mode 100644 index 000000000..992c380e8 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderTest.java @@ -0,0 +1,161 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.trustedpublishing.gitlab; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SpringExtension.class) +class GitLabTrustedPublishingProviderTest { + + @Autowired + TrustedPublishingConfig config; + + private static Map tokenClaims() { + var claims = new HashMap(); + claims.put("sub", "project_path:gitlab-org/gitlab:ref_type:branch:ref:master"); + claims.put("namespace_id", "9970"); + claims.put("namespace_path", "gitlab-org"); + claims.put("project_id", "278964"); + claims.put("project_path", "gitlab-org/gitlab"); + claims.put("runner_environment", "gitlab-hosted"); + claims.put("ci_config_ref_uri", "gitlab.com/gitlab-org/gitlab//.gitlab-ci.yml@refs/heads/master"); + return claims; + } + + private static Map registeredClaims() { + var claims = new HashMap(); + claims.put("namespace_id", "9970"); + claims.put("namespace_path", "gitlab-org"); + claims.put("project_id", "278964"); + claims.put("project_path", "gitlab-org/gitlab"); + claims.put("ci_config_ref_uri", "gitlab.com/gitlab-org/gitlab//.gitlab-ci.yml"); + return claims; + } + + private static GitLabTrustedPublishingProvider newProvider(TrustedPublishingConfig config) { + return new GitLabTrustedPublishingProvider(config) { + @Override + protected Map resolve(String projectPath) { + return Map.of( + "id", + 278964, + "namespace", + Map.of("id", 9970)); + } + }; + } + + @Test + void trustRequestWithoutEnv() throws Exception { + GitLabTrustedPublishingProvider gl = newProvider(config); + Map data = gl.extractRequest( + Map.of( + "namespace", + "gitlab-org", + "project", + "gitlab", + "workflow", + ".gitlab-ci.yml")); + assertEquals("9970", data.get("namespace_id")); + assertEquals("gitlab-org", data.get("namespace_path")); + assertEquals("278964", data.get("project_id")); + assertEquals("gitlab-org/gitlab", data.get("project_path")); + assertEquals("gitlab.com/gitlab-org/gitlab//.gitlab-ci.yml", data.get("ci_config_ref_uri")); + assertEquals(5, data.size()); + } + + @Test + void trustRequestWithEnv() throws Exception { + GitLabTrustedPublishingProvider gl = newProvider(config); + Map data = gl.extractRequest( + Map.of( + "namespace", + "gitlab-org", + "project", + "gitlab", + "workflow", + ".gitlab-ci.yml", + "environment", + "prod")); + assertEquals("9970", data.get("namespace_id")); + assertEquals("gitlab-org", data.get("namespace_path")); + assertEquals("278964", data.get("project_id")); + assertEquals("gitlab-org/gitlab", data.get("project_path")); + assertEquals("gitlab.com/gitlab-org/gitlab//.gitlab-ci.yml", data.get("ci_config_ref_uri")); + assertEquals("prod", data.get("environment")); + assertEquals(6, data.size()); + } + + @Test + void matchesRegardlessOfRef() { + GitLabTrustedPublishingProvider gl = newProvider(config); + var token = tokenClaims(); + assertTrue(gl.matches(registeredClaims(), token)); + token.put("ci_config_ref_uri", "gitlab.com/gitlab-org/gitlab//.gitlab-ci.yml@refs/tags/v1.0.0"); + assertTrue(gl.matches(registeredClaims(), token)); + } + + @Test + void mismatchOnDifferentProjectId() { + GitLabTrustedPublishingProvider gl = newProvider(config); + var token = tokenClaims(); + token.put("project_id", "1"); // resurrection attack: same path, different project + assertFalse(gl.matches(registeredClaims(), token)); + } + + @Test + void mismatchOnDifferentCiConfig() { + GitLabTrustedPublishingProvider gl = newProvider(config); + var token = tokenClaims(); + token.put("ci_config_ref_uri", "gitlab.com/gitlab-org/gitlab//other-ci.yml@refs/heads/master"); + assertFalse(gl.matches(registeredClaims(), token)); + } + + @Test + void pinnedEnvironment() { + GitLabTrustedPublishingProvider gl = newProvider(config); + var registered = registeredClaims(); + registered.put("environment", "prod"); + + var token = tokenClaims(); + assertFalse(gl.matches(registered, token)); // token not from any environment + token.put("environment", "staging"); + assertFalse(gl.matches(registered, token)); + token.put("environment", "prod"); + assertTrue(gl.matches(registered, token)); + // and with no pinned environment, any environment is accepted + assertTrue(gl.matches(registeredClaims(), token)); + } + + @TestConfiguration + static class TestConfig { + @Bean + TrustedPublishingConfig trustedPublishingConfig() { + return new TrustedPublishingConfig(); + } + } +} From 17291b86acb698834ce03903283ea9372e31b015 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Tue, 21 Jul 2026 12:19:54 +0200 Subject: [PATCH 2/5] Regenerate jOOQ classes with TP --- .../org/eclipse/openvsx/jooq/Indexes.java | 2 + .../org/eclipse/openvsx/jooq/Keys.java | 5 + .../org/eclipse/openvsx/jooq/Public.java | 8 + .../org/eclipse/openvsx/jooq/Sequences.java | 5 + .../org/eclipse/openvsx/jooq/Tables.java | 6 + .../openvsx/jooq/tables/TrustedPublisher.java | 275 ++++++++++++++++++ .../records/TrustedPublisherRecord.java | 171 +++++++++++ 7 files changed, 472 insertions(+) create mode 100644 server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/TrustedPublisher.java create mode 100644 server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/TrustedPublisherRecord.java diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Indexes.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Indexes.java index 2e7406f72..56b7fad87 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Indexes.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Indexes.java @@ -25,6 +25,7 @@ import org.eclipse.openvsx.jooq.tables.SignatureKeyPair; import org.eclipse.openvsx.jooq.tables.SpringSession; import org.eclipse.openvsx.jooq.tables.Tier; +import org.eclipse.openvsx.jooq.tables.TrustedPublisher; import org.jooq.Index; import org.jooq.OrderField; import org.jooq.impl.DSL; @@ -95,6 +96,7 @@ public class Indexes { public static final Index SPRING_SESSION_IX2 = Internal.createIndex(DSL.name("spring_session_ix2"), SpringSession.SPRING_SESSION, new OrderField[] { SpringSession.SPRING_SESSION.EXPIRY_TIME }, false); public static final Index SPRING_SESSION_IX3 = Internal.createIndex(DSL.name("spring_session_ix3"), SpringSession.SPRING_SESSION, new OrderField[] { SpringSession.SPRING_SESSION.PRINCIPAL_NAME }, false); public static final Index TIER_TIER_TYPE = Internal.createIndex(DSL.name("tier_tier_type"), Tier.TIER, new OrderField[] { Tier.TIER.TIER_TYPE }, false); + public static final Index TRUSTED_PUBLISHER_NAMESPACE_IDX = Internal.createIndex(DSL.name("trusted_publisher_namespace_idx"), TrustedPublisher.TRUSTED_PUBLISHER, new OrderField[] { TrustedPublisher.TRUSTED_PUBLISHER.NAMESPACE, TrustedPublisher.TRUSTED_PUBLISHER.EXTENSION_NAME }, false); public static final Index UNIQUE_ACTIVE_SIGNATURE_KEY_PAIR_IDX = Internal.createIndex(DSL.name("unique_active_signature_key_pair_idx"), SignatureKeyPair.SIGNATURE_KEY_PAIR, new OrderField[] { SignatureKeyPair.SIGNATURE_KEY_PAIR.ACTIVE }, true); public static final Index UNIQUE_ADMIN_STATISTICS = Internal.createIndex(DSL.name("unique_admin_statistics"), AdminStatistics.ADMIN_STATISTICS, new OrderField[] { AdminStatistics.ADMIN_STATISTICS.YEAR, AdminStatistics.ADMIN_STATISTICS.MONTH }, true); } diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java index be3b66fc5..a908fa917 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java @@ -39,6 +39,7 @@ import org.eclipse.openvsx.jooq.tables.SpringSession; import org.eclipse.openvsx.jooq.tables.SpringSessionAttributes; import org.eclipse.openvsx.jooq.tables.Tier; +import org.eclipse.openvsx.jooq.tables.TrustedPublisher; import org.eclipse.openvsx.jooq.tables.UsageStats; import org.eclipse.openvsx.jooq.tables.UserData; import org.eclipse.openvsx.jooq.tables.records.AdminScanDecisionRecord; @@ -76,6 +77,7 @@ import org.eclipse.openvsx.jooq.tables.records.SpringSessionAttributesRecord; import org.eclipse.openvsx.jooq.tables.records.SpringSessionRecord; import org.eclipse.openvsx.jooq.tables.records.TierRecord; +import org.eclipse.openvsx.jooq.tables.records.TrustedPublisherRecord; import org.eclipse.openvsx.jooq.tables.records.UsageStatsRecord; import org.eclipse.openvsx.jooq.tables.records.UserDataRecord; import org.jooq.ForeignKey; @@ -137,6 +139,7 @@ public class Keys { public static final UniqueKey SPRING_SESSION_ATTRIBUTES_PK = Internal.createUniqueKey(SpringSessionAttributes.SPRING_SESSION_ATTRIBUTES, DSL.name("spring_session_attributes_pk"), new TableField[] { SpringSessionAttributes.SPRING_SESSION_ATTRIBUTES.SESSION_PRIMARY_ID, SpringSessionAttributes.SPRING_SESSION_ATTRIBUTES.ATTRIBUTE_NAME }, true); public static final UniqueKey TIER_PKEY = Internal.createUniqueKey(Tier.TIER, DSL.name("tier_pkey"), new TableField[] { Tier.TIER.ID }, true); public static final UniqueKey TIER_UNIQUE_NAME = Internal.createUniqueKey(Tier.TIER, DSL.name("tier_unique_name"), new TableField[] { Tier.TIER.NAME }, true); + public static final UniqueKey TRUSTED_PUBLISHER_PKEY = Internal.createUniqueKey(TrustedPublisher.TRUSTED_PUBLISHER, DSL.name("trusted_publisher_pkey"), new TableField[] { TrustedPublisher.TRUSTED_PUBLISHER.ID }, true); public static final UniqueKey USAGE_STATS_PKEY = Internal.createUniqueKey(UsageStats.USAGE_STATS, DSL.name("usage_stats_pkey"), new TableField[] { UsageStats.USAGE_STATS.ID }, true); public static final UniqueKey USAGE_STATS_UNIQUE_CUSTOMER_WINDOW = Internal.createUniqueKey(UsageStats.USAGE_STATS, DSL.name("usage_stats_unique_customer_window"), new TableField[] { UsageStats.USAGE_STATS.CUSTOMER_ID, UsageStats.USAGE_STATS.WINDOW_START }, true); public static final UniqueKey UNIQUE_USER_DATA = Internal.createUniqueKey(UserData.USER_DATA, DSL.name("unique_user_data"), new TableField[] { UserData.USER_DATA.PROVIDER, UserData.USER_DATA.LOGIN_NAME }, true); @@ -178,5 +181,7 @@ public class Keys { public static final ForeignKey RATE_LIMIT_TOKEN__RATE_LIMIT_TOKEN_CUSTOMER_FK = Internal.createForeignKey(RateLimitToken.RATE_LIMIT_TOKEN, DSL.name("rate_limit_token_customer_fk"), new TableField[] { RateLimitToken.RATE_LIMIT_TOKEN.CUSTOMER }, Keys.CUSTOMER_PKEY, new TableField[] { Customer.CUSTOMER.ID }, true); public static final ForeignKey SCAN_CHECK_RESULT__FK_SCAN_CHECK_RESULT_SCAN = Internal.createForeignKey(ScanCheckResult.SCAN_CHECK_RESULT, DSL.name("fk_scan_check_result_scan"), new TableField[] { ScanCheckResult.SCAN_CHECK_RESULT.SCAN_ID }, Keys.EXTENSION_SCAN_PKEY, new TableField[] { ExtensionScan.EXTENSION_SCAN.ID }, true); public static final ForeignKey SPRING_SESSION_ATTRIBUTES__SPRING_SESSION_ATTRIBUTES_FK = Internal.createForeignKey(SpringSessionAttributes.SPRING_SESSION_ATTRIBUTES, DSL.name("spring_session_attributes_fk"), new TableField[] { SpringSessionAttributes.SPRING_SESSION_ATTRIBUTES.SESSION_PRIMARY_ID }, Keys.SPRING_SESSION_PK, new TableField[] { SpringSession.SPRING_SESSION.PRIMARY_ID }, true); + public static final ForeignKey TRUSTED_PUBLISHER__TRUSTED_PUBLISHER_CREATED_BY_FKEY = Internal.createForeignKey(TrustedPublisher.TRUSTED_PUBLISHER, DSL.name("trusted_publisher_created_by_fkey"), new TableField[] { TrustedPublisher.TRUSTED_PUBLISHER.CREATED_BY }, Keys.USER_DATA_PKEY, new TableField[] { UserData.USER_DATA.ID }, true); + public static final ForeignKey TRUSTED_PUBLISHER__TRUSTED_PUBLISHER_NAMESPACE_FKEY = Internal.createForeignKey(TrustedPublisher.TRUSTED_PUBLISHER, DSL.name("trusted_publisher_namespace_fkey"), new TableField[] { TrustedPublisher.TRUSTED_PUBLISHER.NAMESPACE }, Keys.NAMESPACE_PKEY, new TableField[] { Namespace.NAMESPACE.ID }, true); public static final ForeignKey USAGE_STATS__USAGE_STATS_CUSTOMER_ID_FK = Internal.createForeignKey(UsageStats.USAGE_STATS, DSL.name("usage_stats_customer_id_fk"), new TableField[] { UsageStats.USAGE_STATS.CUSTOMER_ID }, Keys.CUSTOMER_PKEY, new TableField[] { Customer.CUSTOMER.ID }, true); } diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Public.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Public.java index 20ba93b5a..1dbea1fbe 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Public.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Public.java @@ -42,6 +42,7 @@ import org.eclipse.openvsx.jooq.tables.SpringSession; import org.eclipse.openvsx.jooq.tables.SpringSessionAttributes; import org.eclipse.openvsx.jooq.tables.Tier; +import org.eclipse.openvsx.jooq.tables.TrustedPublisher; import org.eclipse.openvsx.jooq.tables.UsageStats; import org.eclipse.openvsx.jooq.tables.UserData; import org.jooq.Catalog; @@ -242,6 +243,11 @@ public class Public extends SchemaImpl { */ public final Tier TIER = Tier.TIER; + /** + * The table public.trusted_publisher. + */ + public final TrustedPublisher TRUSTED_PUBLISHER = TrustedPublisher.TRUSTED_PUBLISHER; + /** * The table public.usage_stats. */ @@ -294,6 +300,7 @@ public final List> getSequences() { Sequences.SETTING_SEQ, Sequences.SIGNATURE_KEY_PAIR_SEQ, Sequences.TIER_SEQ, + Sequences.TRUSTED_PUBLISHER_SEQ, Sequences.USAGE_STATS_SEQ, Sequences.USER_DATA_SEQ ); @@ -337,6 +344,7 @@ public final List> getTables() { SpringSession.SPRING_SESSION, SpringSessionAttributes.SPRING_SESSION_ATTRIBUTES, Tier.TIER, + TrustedPublisher.TRUSTED_PUBLISHER, UsageStats.USAGE_STATS, UserData.USER_DATA ); diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Sequences.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Sequences.java index 439a3c5f0..2f062abd2 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Sequences.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Sequences.java @@ -145,6 +145,11 @@ public class Sequences { */ public static final Sequence TIER_SEQ = Internal.createSequence("tier_seq", Public.PUBLIC, SQLDataType.BIGINT.nullable(false), null, 50, null, null, false, null); + /** + * The sequence public.trusted_publisher_seq + */ + public static final Sequence TRUSTED_PUBLISHER_SEQ = Internal.createSequence("trusted_publisher_seq", Public.PUBLIC, SQLDataType.BIGINT.nullable(false), null, null, null, null, false, null); + /** * The sequence public.usage_stats_seq */ diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Tables.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Tables.java index 04a9adb3b..88178b1e7 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Tables.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Tables.java @@ -39,6 +39,7 @@ import org.eclipse.openvsx.jooq.tables.SpringSession; import org.eclipse.openvsx.jooq.tables.SpringSessionAttributes; import org.eclipse.openvsx.jooq.tables.Tier; +import org.eclipse.openvsx.jooq.tables.TrustedPublisher; import org.eclipse.openvsx.jooq.tables.UsageStats; import org.eclipse.openvsx.jooq.tables.UserData; @@ -228,6 +229,11 @@ public class Tables { */ public static final Tier TIER = Tier.TIER; + /** + * The table public.trusted_publisher. + */ + public static final TrustedPublisher TRUSTED_PUBLISHER = TrustedPublisher.TRUSTED_PUBLISHER; + /** * The table public.usage_stats. */ diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/TrustedPublisher.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/TrustedPublisher.java new file mode 100644 index 000000000..48c0968db --- /dev/null +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/TrustedPublisher.java @@ -0,0 +1,275 @@ +/* + * This file is generated by jOOQ. + */ +package org.eclipse.openvsx.jooq.tables; + + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.eclipse.openvsx.jooq.Indexes; +import org.eclipse.openvsx.jooq.Keys; +import org.eclipse.openvsx.jooq.Public; +import org.eclipse.openvsx.jooq.tables.records.TrustedPublisherRecord; +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.Identity; +import org.jooq.Index; +import org.jooq.JSONB; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class TrustedPublisher extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of public.trusted_publisher + */ + public static final TrustedPublisher TRUSTED_PUBLISHER = new TrustedPublisher(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return TrustedPublisherRecord.class; + } + + /** + * The column public.trusted_publisher.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.BIGINT.nullable(false).identity(true), this, ""); + + /** + * The column public.trusted_publisher.namespace. + */ + public final TableField NAMESPACE = createField(DSL.name("namespace"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column public.trusted_publisher.extension_name. + */ + public final TableField EXTENSION_NAME = createField(DSL.name("extension_name"), SQLDataType.VARCHAR(255), this, ""); + + /** + * The column public.trusted_publisher.provider. + */ + public final TableField PROVIDER = createField(DSL.name("provider"), SQLDataType.VARCHAR(32).nullable(false), this, ""); + + /** + * The column public.trusted_publisher.registration. + */ + public final TableField REGISTRATION = createField(DSL.name("registration"), SQLDataType.JSONB.nullable(false), this, ""); + + /** + * The column public.trusted_publisher.claims. + */ + public final TableField CLAIMS = createField(DSL.name("claims"), SQLDataType.JSONB.nullable(false), this, ""); + + /** + * The column public.trusted_publisher.created_by. + */ + public final TableField CREATED_BY = createField(DSL.name("created_by"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column public.trusted_publisher.created_timestamp. + */ + public final TableField CREATED_TIMESTAMP = createField(DSL.name("created_timestamp"), SQLDataType.LOCALDATETIME(6).nullable(false), this, ""); + + private TrustedPublisher(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private TrustedPublisher(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased public.trusted_publisher table reference + */ + public TrustedPublisher(String alias) { + this(DSL.name(alias), TRUSTED_PUBLISHER); + } + + /** + * Create an aliased public.trusted_publisher table reference + */ + public TrustedPublisher(Name alias) { + this(alias, TRUSTED_PUBLISHER); + } + + /** + * Create a public.trusted_publisher table reference + */ + public TrustedPublisher() { + this(DSL.name("trusted_publisher"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : Public.PUBLIC; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.TRUSTED_PUBLISHER_NAMESPACE_IDX); + } + + @Override + public Identity getIdentity() { + return (Identity) super.getIdentity(); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.TRUSTED_PUBLISHER_PKEY; + } + + @Override + public List> getReferences() { + return Arrays.asList(Keys.TRUSTED_PUBLISHER__TRUSTED_PUBLISHER_CREATED_BY_FKEY, Keys.TRUSTED_PUBLISHER__TRUSTED_PUBLISHER_NAMESPACE_FKEY); + } + + @Override + public TrustedPublisher as(String alias) { + return new TrustedPublisher(DSL.name(alias), this); + } + + @Override + public TrustedPublisher as(Name alias) { + return new TrustedPublisher(alias, this); + } + + @Override + public TrustedPublisher as(Table alias) { + return new TrustedPublisher(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public TrustedPublisher rename(String name) { + return new TrustedPublisher(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public TrustedPublisher rename(Name name) { + return new TrustedPublisher(name, null); + } + + /** + * Rename this table + */ + @Override + public TrustedPublisher rename(Table name) { + return new TrustedPublisher(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public TrustedPublisher where(Condition condition) { + return new TrustedPublisher(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public TrustedPublisher where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public TrustedPublisher where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public TrustedPublisher where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public TrustedPublisher where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public TrustedPublisher where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public TrustedPublisher where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public TrustedPublisher where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public TrustedPublisher whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public TrustedPublisher whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/TrustedPublisherRecord.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/TrustedPublisherRecord.java new file mode 100644 index 000000000..6b2a7c4aa --- /dev/null +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/TrustedPublisherRecord.java @@ -0,0 +1,171 @@ +/* + * This file is generated by jOOQ. + */ +package org.eclipse.openvsx.jooq.tables.records; + + +import java.time.LocalDateTime; + +import org.eclipse.openvsx.jooq.tables.TrustedPublisher; +import org.jooq.JSONB; +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class TrustedPublisherRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for public.trusted_publisher.id. + */ + public void setId(Long value) { + set(0, value); + } + + /** + * Getter for public.trusted_publisher.id. + */ + public Long getId() { + return (Long) get(0); + } + + /** + * Setter for public.trusted_publisher.namespace. + */ + public void setNamespace(Long value) { + set(1, value); + } + + /** + * Getter for public.trusted_publisher.namespace. + */ + public Long getNamespace() { + return (Long) get(1); + } + + /** + * Setter for public.trusted_publisher.extension_name. + */ + public void setExtensionName(String value) { + set(2, value); + } + + /** + * Getter for public.trusted_publisher.extension_name. + */ + public String getExtensionName() { + return (String) get(2); + } + + /** + * Setter for public.trusted_publisher.provider. + */ + public void setProvider(String value) { + set(3, value); + } + + /** + * Getter for public.trusted_publisher.provider. + */ + public String getProvider() { + return (String) get(3); + } + + /** + * Setter for public.trusted_publisher.registration. + */ + public void setRegistration(JSONB value) { + set(4, value); + } + + /** + * Getter for public.trusted_publisher.registration. + */ + public JSONB getRegistration() { + return (JSONB) get(4); + } + + /** + * Setter for public.trusted_publisher.claims. + */ + public void setClaims(JSONB value) { + set(5, value); + } + + /** + * Getter for public.trusted_publisher.claims. + */ + public JSONB getClaims() { + return (JSONB) get(5); + } + + /** + * Setter for public.trusted_publisher.created_by. + */ + public void setCreatedBy(Long value) { + set(6, value); + } + + /** + * Getter for public.trusted_publisher.created_by. + */ + public Long getCreatedBy() { + return (Long) get(6); + } + + /** + * Setter for public.trusted_publisher.created_timestamp. + */ + public void setCreatedTimestamp(LocalDateTime value) { + set(7, value); + } + + /** + * Getter for public.trusted_publisher.created_timestamp. + */ + public LocalDateTime getCreatedTimestamp() { + return (LocalDateTime) get(7); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached TrustedPublisherRecord + */ + public TrustedPublisherRecord() { + super(TrustedPublisher.TRUSTED_PUBLISHER); + } + + /** + * Create a detached, initialised TrustedPublisherRecord + */ + public TrustedPublisherRecord(Long id, Long namespace, String extensionName, String provider, JSONB registration, JSONB claims, Long createdBy, LocalDateTime createdTimestamp) { + super(TrustedPublisher.TRUSTED_PUBLISHER); + + setId(id); + setNamespace(namespace); + setExtensionName(extensionName); + setProvider(provider); + setRegistration(registration); + setClaims(claims); + setCreatedBy(createdBy); + setCreatedTimestamp(createdTimestamp); + resetChangedOnNotNull(); + } +} From 736ac34e6999ae99cd9e8ba76b7f251364984ee3 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 22 Jul 2026 12:51:57 +0200 Subject: [PATCH 3/5] Fix response: it is 403 not 400 --- .../openvsx/trustedpublishing/TrustedPublishingService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java index e48b9c85b..83464d826 100644 --- a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java @@ -248,7 +248,7 @@ private Namespace requireOwnedNamespace(UserData user, String namespaceName) { throw new NotFoundException(); } if (!repositories.isNamespaceOwner(user, namespace)) { - throw new ErrorResultException("You must be an owner of this namespace."); + throw new ErrorResultException("You must be an owner of this namespace.", HttpStatus.FORBIDDEN); } return namespace; } From fc52926a721c69ec8d1ab5798473d9b0ba1920ef Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Wed, 22 Jul 2026 17:57:42 +0200 Subject: [PATCH 4/5] Add trustedPublishingUrl and sort out inputs --- .../eclipse/openvsx/TrustedPublishingAPI.java | 2 +- .../eclipse/openvsx/UpstreamProxyService.java | 3 + .../java/org/eclipse/openvsx/UserAPI.java | 2 + .../org/eclipse/openvsx/admin/AdminAPI.java | 1 + .../eclipse/openvsx/json/NamespaceJson.java | 11 +++ .../json/TrustedPublisherInputJson.java | 71 +++++++++++++++++++ .../json/TrustedPublisherProviderJson.java | 12 ++-- .../TrustedPublishingProviderSupport.java | 13 ++-- ...itHubTrustedPublishingProviderSupport.java | 18 +++-- ...itLabTrustedPublishingProviderSupport.java | 18 +++-- 10 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherInputJson.java diff --git a/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java b/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java index 3d7bafb02..78ffe45b0 100644 --- a/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java @@ -156,7 +156,7 @@ public ResponseEntity getTrustedPublisherProvi providerJson.setId(p.getProviderId()); providerJson.setName(p.getProviderName()); providerJson.setUrl(p.getProviderUrl()); - providerJson.setRegistrationKeys(p.getRegistrationKeys()); + providerJson.setRegistrationInputs(p.getRegistrationInputs()); return providerJson; }) .toList()); diff --git a/server/src/main/java/org/eclipse/openvsx/UpstreamProxyService.java b/server/src/main/java/org/eclipse/openvsx/UpstreamProxyService.java index d196f5a1e..92165a742 100644 --- a/server/src/main/java/org/eclipse/openvsx/UpstreamProxyService.java +++ b/server/src/main/java/org/eclipse/openvsx/UpstreamProxyService.java @@ -43,6 +43,9 @@ public NamespaceJson rewriteUrls(NamespaceJson json) { if (!StringUtils.isEmpty(json.getRoleUrl())) { json.setRoleUrl(rewriteUrl(json.getRoleUrl())); } + if (!StringUtils.isEmpty(json.getTrustedPublishingUrl())) { + json.setTrustedPublishingUrl(rewriteUrl(json.getTrustedPublishingUrl())); + } return json; } diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index d958e4ed5..7e606a8f5 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -447,6 +447,8 @@ public List getOwnNamespaces() { json.setMembersUrl(createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "members")); json.setRoleUrl(createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "role")); json.setDetailsUrl(createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "details")); + json.setTrustedPublishingUrl( + createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "trusted-publishing")); } return json; diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 48cb30a2e..a5399ead6 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -606,6 +606,7 @@ public ResponseEntity getNamespace( var adminNamespaceUrl = createAdminNamespaceUrl(namespace); namespace.setMembersUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "members")); namespace.setRoleUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "change-member")); + namespace.setTrustedPublishingUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "trusted-publishing")); return ResponseEntity.ok(namespace); } catch (NotFoundException exc) { var json = NamespaceJson.error("Namespace not found: " + namespaceName); diff --git a/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java b/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java index 8450e3e2b..4cafd10ef 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java @@ -59,6 +59,9 @@ public static NamespaceJson error(String message) { @Schema(hidden = true) private String detailsUrl; + @Schema(hidden = true) + private String trustedPublishingUrl; + public String getName() { return name; } @@ -114,4 +117,12 @@ public String getDetailsUrl() { public void setDetailsUrl(String detailsUrl) { this.detailsUrl = detailsUrl; } + + public String getTrustedPublishingUrl() { + return trustedPublishingUrl; + } + + public void setTrustedPublishingUrl(String trustedPublishingUrl) { + this.trustedPublishingUrl = trustedPublishingUrl; + } } diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherInputJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherInputJson.java new file mode 100644 index 000000000..757ab784c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherInputJson.java @@ -0,0 +1,71 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import static java.util.Objects.requireNonNull; + +/** + * A trusted publisher provider input. This is one way, is sent to users only from server. + */ +@JsonInclude(Include.NON_NULL) +public class TrustedPublisherInputJson extends ResultJson { + + public static TrustedPublisherInputJson error(String message) { + var result = new TrustedPublisherInputJson(); + result.setError(message); + return result; + } + + public static TrustedPublisherInputJson create(String key, String description, boolean optional) { + requireNonNull(key); + requireNonNull(description); + TrustedPublisherInputJson result = new TrustedPublisherInputJson(); + result.setKey(key); + result.setDescription(description); + result.setOptional(optional); + return result; + } + + private String key; + + private String description; + + private boolean optional; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isOptional() { + return optional; + } + + public void setOptional(boolean optional) { + this.optional = optional; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java index 695d8d37b..12017e31d 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/TrustedPublisherProviderJson.java @@ -12,7 +12,7 @@ *****************************************************************************/ package org.eclipse.openvsx.json; -import java.util.Map; +import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -35,7 +35,7 @@ public static TrustedPublisherProviderJson error(String message) { private String url; - private Map registrationKeys; + private List registrationInputs; public String getId() { return id; @@ -49,8 +49,8 @@ public String getUrl() { return url; } - public Map getRegistrationKeys() { - return registrationKeys; + public List getRegistrationInputs() { + return registrationInputs; } public void setId(String id) { @@ -65,7 +65,7 @@ public void setUrl(String url) { this.url = url; } - public void setRegistrationKeys(Map registrationKeys) { - this.registrationKeys = registrationKeys; + public void setRegistrationInputs(List registrationInputs) { + this.registrationInputs = registrationInputs; } } diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java index 91a3dc8ce..e206764ab 100644 --- a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingProviderSupport.java @@ -33,6 +33,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.web.client.RestClient; +import org.eclipse.openvsx.json.TrustedPublisherInputJson; import org.eclipse.openvsx.util.ErrorResultException; import static java.util.Objects.requireNonNull; @@ -51,7 +52,7 @@ public abstract class TrustedPublishingProviderSupport { protected final String providerName; protected final String providerUrl; protected final String oidcIssuer; - protected final Map registrationKeys; + protected final List registrationInputs; protected final RestClient restClient; private volatile JwtDecoder decoder; @@ -62,14 +63,14 @@ protected TrustedPublishingProviderSupport( String providerName, String providerUrl, String oidcIssuer, - Map registrationKeys + List registrationInputs ) { this.config = requireNonNull(config); this.providerId = requireNonNull(providerId); this.providerName = requireNonNull(providerName); this.providerUrl = requireNonNull(providerUrl); this.oidcIssuer = requireNonNull(oidcIssuer); - this.registrationKeys = requireNonNull(registrationKeys); + this.registrationInputs = requireNonNull(registrationInputs); this.restClient = RestClient.create(); } @@ -142,10 +143,10 @@ public String getOidcIssuer() { } /** - * The provider registration keys, it requires. + * The provider registration inputs, it requires. */ - public Map getRegistrationKeys() { - return registrationKeys; + public List getRegistrationInputs() { + return registrationInputs; } /** diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java index 53b433442..354e05c9b 100644 --- a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/github/GitHubTrustedPublishingProviderSupport.java @@ -13,6 +13,7 @@ package org.eclipse.openvsx.trustedpublishing.github; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.jspecify.annotations.NonNull; @@ -22,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.web.client.RestClientException; +import org.eclipse.openvsx.json.TrustedPublisherInputJson; import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; import org.eclipse.openvsx.trustedpublishing.TrustedPublishingProviderSupport; import org.eclipse.openvsx.util.ErrorResultException; @@ -46,15 +48,11 @@ public abstract class GitHubTrustedPublishingProviderSupport extends TrustedPubl private static final String REG_REPO = "repo"; private static final String REG_WORKFLOW = "workflow"; private static final String REG_ENVIRONMENT = "environment"; - private static final Map REGISTRATION_KEYS = Map.of( - REG_OWNER, - "Organization or User name", - REG_REPO, - "Repository name", - REG_WORKFLOW, - "Workflow filename", - REG_ENVIRONMENT, - "Environment name (optional)"); + private static final List REGISTRATION_INPUTS = List.of( + TrustedPublisherInputJson.create(REG_OWNER, "Organization or User name", false), + TrustedPublisherInputJson.create(REG_REPO, "Repository name", false), + TrustedPublisherInputJson.create(REG_WORKFLOW, "Workflow filename", false), + TrustedPublisherInputJson.create(REG_ENVIRONMENT, "Environment name (optional)", true)); private final String apiResolveRequest; @@ -66,7 +64,7 @@ protected GitHubTrustedPublishingProviderSupport( String oidcIssuer, String apiResolveRequest ) { - super(config, providerId, providerName, providerUrl, oidcIssuer, REGISTRATION_KEYS); + super(config, providerId, providerName, providerUrl, oidcIssuer, REGISTRATION_INPUTS); this.apiResolveRequest = requireNonNull(apiResolveRequest); } diff --git a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java index fbae1eb2c..930b05dac 100644 --- a/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java +++ b/server/src/main/java/org/eclipse/openvsx/trustedpublishing/gitlab/GitLabTrustedPublishingProviderSupport.java @@ -14,6 +14,7 @@ import java.net.URI; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.jspecify.annotations.NonNull; @@ -23,6 +24,7 @@ import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.web.client.RestClientException; +import org.eclipse.openvsx.json.TrustedPublisherInputJson; import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; import org.eclipse.openvsx.trustedpublishing.TrustedPublishingProviderSupport; import org.eclipse.openvsx.util.ErrorResultException; @@ -49,15 +51,11 @@ public abstract class GitLabTrustedPublishingProviderSupport extends TrustedPubl private static final String REG_PROJECT = "project"; private static final String REG_WORKFLOW = "workflow"; private static final String REG_ENVIRONMENT = "environment"; - private static final Map REGISTRATION_KEYS = Map.of( - REG_NAMESPACE, - "Namespace", - REG_PROJECT, - "Project name", - REG_WORKFLOW, - "Top-level CI filename", - REG_ENVIRONMENT, - "Environment name (optional)"); + private static final List REGISTRATION_INPUTS = List.of( + TrustedPublisherInputJson.create(REG_NAMESPACE, "Namespace", false), + TrustedPublisherInputJson.create(REG_PROJECT, "Project name", false), + TrustedPublisherInputJson.create(REG_WORKFLOW, "Top-level CI filename", false), + TrustedPublisherInputJson.create(REG_ENVIRONMENT, "Environment name (optional)", true)); protected GitLabTrustedPublishingProviderSupport( TrustedPublishingConfig config, @@ -66,7 +64,7 @@ protected GitLabTrustedPublishingProviderSupport( String providerUrl, String oidcIssuer ) { - super(config, providerId, providerName, providerUrl, oidcIssuer, REGISTRATION_KEYS); + super(config, providerId, providerName, providerUrl, oidcIssuer, REGISTRATION_INPUTS); } @NonNull From cdb8f41c6f5e5c0daa646cc6c64a06d455847c9a Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Thu, 23 Jul 2026 13:01:50 +0200 Subject: [PATCH 5/5] Add TODO and remove it for now --- server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index a5399ead6..245686f81 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -606,7 +606,8 @@ public ResponseEntity getNamespace( var adminNamespaceUrl = createAdminNamespaceUrl(namespace); namespace.setMembersUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "members")); namespace.setRoleUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "change-member")); - namespace.setTrustedPublishingUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "trusted-publishing")); + // TODO: decide do we have admin API for this + // namespace.setTrustedPublishingUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "trusted-publishing")); return ResponseEntity.ok(namespace); } catch (NotFoundException exc) { var json = NamespaceJson.error("Namespace not found: " + namespaceName);