Skip to content

feat: detach tokens from extension versions - #2065

Merged
netomi merged 28 commits into
eclipse-openvsx:mainfrom
cstamas:detach-token-from-extension-version
Aug 27, 2026
Merged

feat: detach tokens from extension versions#2065
netomi merged 28 commits into
eclipse-openvsx:mainfrom
cstamas:detach-token-from-extension-version

Conversation

@cstamas

@cstamas cstamas commented Aug 12, 2026

Copy link
Copy Markdown
Member

Detach tokens from extension version, and allow to delete tokens, instead to preserve them as today.

Summary:

  • Added changes to V1_72__Trusted_Publisher.sql unreleased migration: new published_by_id column (FK → user_data), backfilled from published_with_id, and changed the published_with_id FK to ON DELETE SET NULL.
  • Added publishedBy field to ExtensionVersion (mirroring the removedBy precedent), fixed a pre-existing bug where displayName was silently excluded from equals().
  • Added publishedWithTt field to ExtensionVersion (contains token type)
  • Repointed every read path (isVerified in LocalRegistryService/RelevanceService, toExtensionJson, OrphanNamespaceMigration) from getPublishedWith().getUser() to getPublishedBy().
  • Redesigned PublisherComplianceChecker to iterate by publisher instead of by token, so it stays correct once tokens are deleted.
  • Renamed/added/deleted JPA repository methods so no query joins through personal_access_token anymore.
  • Rewrote ~12 raw jOOQ joins across ExtensionVersionJooqRepository and AdminStatisticCalculationsRepository to join user_data directly via published_by_id.
  • Applied the migration through real Flyway (via a temporary git stash of the Java changes) and regenerated jOOQ code.
  • Updated all affected test fixtures and mocks.
  • Dropped stale FixTargetPlatformsJobRequestHandler and related classes, and now it opened way to truly delete tokens.
  • ExtensionVersion published_with_id fully removed.
  • Dropped single use of OTT token; only LTT and TPT are in use now. On flyway upgrade, delete non-LLT active=false tokens from DB table.
  • AccessTokenService drops on-time-used tokens, and cleaned up carry-around of PersonalAccessToken entity, service now returns simple(r) record instead.
  • Verified: all tests pass.

So, now:

  • tokens are fully detached from extension versions and are deletable
  • use of OTT token that was quite "artificial" removed; type OTT is still there (due historical entries)
  • on upgrade non-LLT inactive tokens are deleted
  • one time tokens on use are being deleted by AccessTokenService on their "use"

As future plans may want us to delete tokens, instead accumulate them
and flag as deactivated.

Summary:
- Added V1_72__ExtensionVersion_PublishedBy.sql migration: new published_by_id column (FK → user_data), backfilled from published_with_id, and changed the published_with_id FK to ON DELETE SET NULL.
- Added publishedBy field to ExtensionVersion (mirroring the removedBy precedent), fixed a pre-existing bug where displayName was silently excluded from equals().
- Repointed every read path (isVerified in LocalRegistryService/RelevanceService, toExtensionJson, OrphanNamespaceMigration) from getPublishedWith().getUser() to getPublishedBy().
- Redesigned PublisherComplianceChecker to iterate by publisher instead of by token, so it stays correct once tokens are deleted.
- Renamed/added/deleted JPA repository methods so no query joins through personal_access_token anymore.
- Rewrote ~12 raw jOOQ joins across ExtensionVersionJooqRepository and AdminStatisticCalculationsRepository to join user_data directly via published_by_id.
- Applied the migration through real Flyway (via a temporary git stash of the Java changes) and regenerated jOOQ code.
- Updated all affected test fixtures and mocks.
- Verified: 841 unit tests + 922 integration tests (Testcontainers Postgres) all pass, and a manual SQL check confirms deleting a personal_access_token row no longer errors and correctly nulls published_with_id while leaving published_by_id intact.

Note: Known follow-up (out of scope): FixTargetPlatformsJobRequestHandler still republishes using the original token and would NPE if that token were ever deleted — this is a pre-existing gap in the publish pipeline's identity model, not introduced by this change.

@autumnfound autumnfound left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Covers data migration, new field, and looks to cover all cases where the PAT was used in joins. We also have the index I'd expect for the field to keep the lookups zippy!

@cstamas
cstamas marked this pull request as ready for review August 14, 2026 12:13
gnugomez
gnugomez previously approved these changes Aug 18, 2026

@gnugomez gnugomez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall LGTM!

that's such a cleanup ◡̈

.collect(Collectors.groupingBy(PersonalAccessToken::getUser));
publisherTokens.keySet().forEach(user -> {
var accessTokens = publisherTokens.get(user);
if (!accessTokens.isEmpty() && !isCompliant(user)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I think here we're now skipping an additional check that was made before where we would first check if the non compliant user had access tokens before performing the deactivation of its extensions.

Is that something we're doing on purpose?

@cstamas
cstamas requested a review from gnugomez August 19, 2026 12:39
cstamas and others added 4 commits August 24, 2026 17:58
…ension-version

# Conflicts:
#	server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
As user alone is not enough, and it is not tokens ONLY that
user can authenticate, when publishing.
…ions

Fixes four correctness bugs and two minor nits surfaced by review of the
token-detachment changes:

- publishedWithTt was never actually set on publish: AccessTokenAuthentication's
  token type was resolved but discarded by every caller between
  LocalRegistryService.publish and PublishExtensionVersionHandler. Thread it
  through so the trusted-publisher flag is recorded for versions actually
  published via a token.

- published_with_tt was selected by several ExtensionVersionJooqRepository
  queries but never read back into ExtensionVersion#publishedWithTt. While
  fixing that, found three of those queries' outer "latest" subquery select
  lists didn't even project the column through, so reading it would have
  thrown at runtime once the mapping was added - fixed those too.

- AdminService#forgetUser's canDeleteUser guard didn't account for
  extension_version.published_by_id, which is permanent and never cleared
  (not even once the version is soft-deleted). A user whose already-removed
  version still references them via published_by_id could pass the guard and
  then hit entityManager.remove(user), violating that FK (added with no ON
  DELETE clause). Guard on the already-computed allVersions list instead.

- AccessTokenService#useAccessToken could return a non-null
  AccessTokenAuthentication wrapping a null userData() for a token with no
  associated user (the column has no NOT NULL constraint at the schema
  level), which every caller dereferences unguarded. Treat a token with no
  user as invalid at the source instead.

- Removed a dead PersonalAccessToken fixture from
  ExtensionVersionJooqRepositoryTest that nothing read.

- Removed a redundant setActive(false) immediately before
  entityManager.remove(token) for one-time tokens.

Added/extended tests for all four correctness fixes, including
ExtensionVersionJooqRepositoryTest coverage verified against real Postgres.
@netomi

netomi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Reviewed this branch and pushed a follow-up commit (0ec4e02e) fixing four correctness gaps and two minor nits, rebased onto the AuthenticatedUser abstraction from the latest commit:

  • publishedWithTt wasn't reaching the entity in a few findLatest(...) read paths. ExtensionVersionJooqRepository selected published_with_tt in several queries but never mapped it back onto ExtensionVersion#publishedWithTt. While wiring that up, found that 3 of the 7 queries' outer "latest" subquery select lists didn't even project the column all the way through — those would have thrown at read time once the mapping was added. Fixed all 3, verified against real Postgres with new ExtensionVersionJooqRepositoryTest coverage (round-trip through findLatest by namespace+extension, by extension ids, and by user, plus a check that it stays null for a non-token publish).

  • AdminService#forgetUser's canDeleteUser guard didn't account for extension_version.published_by_id. That column is permanent and never cleared, not even once the version is soft-deleted, and its FK has no ON DELETE clause. A user whose already-removed version (removed by someone else, e.g. an admin) still references them via published_by_id could pass the old guard and then hit entityManager.remove(user), violating the FK. Now guards on the allVersions list forgetUser already computes. Added two regression tests, including the exact "already removed by someone else" scenario.

  • AccessTokenService#useAccessToken could return a non-null authentication wrapping a null user. personal_access_token.user_data has no NOT NULL constraint at the schema level, so a legacy/corrupt row with no user attached would come back as a "successfully authenticated" request that every caller dereferences unguarded — turning it into an unhandled NPE instead of the intended auth failure. Fixed at the source so useAccessToken returns null for that case, same as any other invalid token. Added AccessTokenServiceTest covering both the happy path and this case.

  • Removed a dead PersonalAccessToken test fixture from ExtensionVersionJooqRepositoryTest that nothing read.

  • Removed a redundant setActive(false) immediately before entityManager.remove(token) for one-time tokens (row is deleted right after, so the mutation has no observable effect).

All four correctness fixes have new/extended tests; full suite (664 tests across the affected packages) passes.

@netomi

netomi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

While looking at how trusted publishing interacts with this PR's changes, found a related fragility worth flagging separately from the fixes already pushed here.

The problem: AccessTokenService.createTrustedPublishingAccessToken issues the TPT token for trustedPublisher.getCreatedBy() — whichever human registered the trust relationship — and that same identity is used both as the token's owner and, at actual publish time, as the subject of a fresh namespace-membership check (PublishExtensionVersionHandler.checkPublishPermissionUserService.hasPublishPermission → requires the user to currently be a contributor or owner).

That couples trusted publishing's continued function to that one person remaining a namespace member, in a way that fails silently and confusingly:

  • Remove them from the namespace (or delete their account via GDPR erasure) and the TrustedPublisher registration itself is completely untouched — no cleanup, no warning anywhere in the namespace settings UI. It keeps looking perfectly healthy.
  • requestPublishToken still issues a fresh, valid token regardless — claim-matching against the registration doesn't check membership either.
  • The actual publish upload is fully received server-side, and only then fails with a generic "Insufficient access rights for publisher: <namespace>" (403). Nothing in that error, or anywhere in the UI, hints that it's because the registering user's membership status changed rather than anything about the CI trust relationship itself.

The token's own scope (AccessTokenScope.ExtensionScoped, matched against the specific extension when the token was issued) is already the real authorization signal here. Requiring the creator to also still be a live namespace member on top of that doesn't add security — it just makes CI publishing silently brittle to unrelated membership churn (someone leaving the team, a role change, an account deletion elsewhere).

Suggested fix: for a TPT-authenticated request, skip the membership check in checkPublishPermission entirely — the token's scope match already establishes it's allowed to publish this specific extension — rather than re-deriving permission from the creator's current namespace standing. I have this implemented locally as a small, isolated change (PublishExtensionVersionHandler + one call-site update in ExtensionService, plus a regression test proving the membership check is bypassed for TPT and still enforced for everything else). Happy to push it here if useful, or you may prefer to fold it in yourselves given it's adjacent to work already in flight in this PR.

Related, smaller issue also worth noting: AdminService.forgetUser's canDeleteUser guard doesn't account for TrustedPublisher.createdBy (created_by BIGINT NOT NULL REFERENCES public.user_data(id), no ON DELETE clause). A user who registered trusted publishing but never personally published a version themselves could still hit a DataIntegrityViolationException on GDPR erasure — same bug class as the published_by_id/allVersions fix already in this PR, just on trusted_publisher.created_by instead.

@netomi
netomi merged commit 24243f8 into eclipse-openvsx:main Aug 27, 2026
5 checks passed
@cstamas
cstamas deleted the detach-token-from-extension-version branch August 27, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants