Skip to content

fix: default personal_access_token.version and .type - #2166

Merged
netomi merged 3 commits into
mainfrom
feat/pat-column-defaults
Sep 4, 2026
Merged

fix: default personal_access_token.version and .type#2166
netomi merged 3 commits into
mainfrom
feat/pat-column-defaults

Conversation

@netomi

@netomi netomi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Problem

V1_72__Trusted_Publisher.sql added personal_access_token.version and .type as NOT NULL with no default, so every INSERT into the table now has to name them. The bootstrap procedure documented for registries deployed without a login provider — deploy/kubernetes/README.md:197, and the wiki's Deleting Extensions page — fails outright on a current schema:

null value in column "version" of relation "personal_access_token" violates not-null constraint

The dev seed in server/src/dev/resources/db/migration/V1_0_1__Super_user.sql only still works because Flyway runs it before V1_72, which then backfills it.

Change

V1_74 defaults the two columns to exactly what V1_72's own backfill would have left behind, so an INSERT written against the pre-V1_72 schema still produces a working token.

version defaults to 0 — deliberately the oldest format, not the current one. The hash pepper lives in the application configuration and never reaches the database, so a value inserted by hand cannot be anything but plaintext; AccessTokenService#useAccessToken falls back to a plaintext lookup and hashes the row in place on first use, and the startup upgrade job does the same for tokens that are never used. The migration comment spells out that this must stay pinned at 0 rather than tracking TOKEN_CURRENT_VERSION, and that version 0 has to keep being understood by upgradeToken for as long as the default stands — raising it would make such a row read as an already-hashed value and silently never authenticate.

type defaults to LLT, the only type an operator would create by hand; OTT and TPT are issued by the server and carry state an INSERT omitting the type wouldn't be supplying either.

This can't mask a bug in the application's own inserts. PersonalAccessToken has @DynamicUpdate but not @DynamicInsert, so Hibernate lists every column on every insert — a code path that forgets setType still sends an explicit NULL and still fails on the constraint. The defaults are only ever reachable from hand-written SQL that omits the columns.

import-db-dump.sh

The script's gap detection keyed on is_nullable='NO' and column_default is null, which these two columns now stop matching — and with them KNOWN_BACKFILLS["personal_access_token.type"], the backfill that classifies pre-V1_72 one-time tokens as OTT by description. Without it they'd import as LLT, quietly turning a used-once credential into a permanent one.

Split the two things that check was conflating:

  • every target column the dump lacks may carry a backfill, which runs after the \copy;
  • only those that can't be imported at all (NOT NULL, no default) are required to have one, and only those get their NOT NULL dropped around the copy.

Testing

Exercised the rewritten detection against a scratch Postgres in three shapes:

target schema REQUIRED_GAPS BACKFILL_COLUMNS UNKNOWN_GAPS
with the V1_74 defaults version, type
V1_72/V1_73, no defaults version, type version, type
unregistered NOT NULL column + brand_new version, type brand_new (refused)

Newly-gained nullable columns (scope_extension_id, claims) are correctly ignored, and an older target still behaves exactly as before. Also ran the simulated import — dump-era column list, then the registered backfill — and confirmed a One time use publish token row still lands as OTT while the rest default to LLT at version = 0, and applied V1_74 itself against a post-V1_72 table.

Stale entity mapping

V1_72 also widened personal_access_token.value to 128 characters — the digest of whichever algorithm ovsx.access-token.token-hash-algorithm names need not be SHA-256's 64 hex characters — while PersonalAccessToken kept declaring @Column(length = 64). Nothing reads it under ddl-auto: none; it was just wrong about the schema it maps. Corrected, with a note on where the number comes from.

Wiki

The Deleting Extensions page documents the required columns in detail and needs the matching edit; that's prepared separately and not part of this PR.

🤖 Generated with Claude Code

V1_72 added both columns as NOT NULL without a default, so every INSERT
into the table now has to name them. The application always does -
PersonalAccessToken has no @DynamicInsert, so Hibernate lists every column
on every insert, and a code path that forgets setType still fails loudly on
the constraint - but hand-written SQL has to know about two columns it
previously didn't. The bootstrap procedure documented for registries without
a login provider (deploy/kubernetes/README.md, and the wiki) fails outright
on a current schema.

Default them to the values V1_72's own backfill would have left behind, so
an INSERT written against the older schema still produces a working token.
version 0 is deliberately the *oldest* format rather than the current one:
the hash pepper lives in the application configuration and never reaches the
database, so a value inserted by hand cannot be anything but plaintext, and
useAccessToken hashes such a row in place on first use.

import-db-dump.sh keyed its "column the dump can't supply" detection on
"NOT NULL and no default", which these two columns now stop matching - and
with it the backfill that classifies pre-V1_72 one-time tokens as OTT by
description, silently importing them as long-lived LLT tokens instead. Split
the two things it was conflating: every target column the dump lacks may
carry a backfill, and only the ones that cannot be imported at all are
required to have one and get their NOT NULL dropped around the copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
V1_72 extended personal_access_token.value to 128 characters - the digest of
whichever algorithm ovsx.access-token.token-hash-algorithm names need not be
SHA-256's 64 hex characters - but the entity kept declaring 64. Nothing reads
it today under ddl-auto: none; it is simply wrong about the schema it maps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI 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.

🟡 Changes recommended

There are a couple of concrete correctness/robustness issues in the new migration/script comments/parsing that should be adjusted before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses schema compatibility and operational tooling around personal_access_token after V1_72 introduced version and type as NOT NULL without defaults, which breaks older/manual INSERT workflows and the documented bootstrap procedure.

Changes:

  • Adds Flyway migration V1_74 to default personal_access_token.version to 0 and personal_access_token.type to LLT to restore compatibility for INSERTs that omit these newer columns.
  • Updates scripts/import-db-dump.sh gap detection to distinguish (1) columns that are truly un-importable without a backfill (NOT NULL + no default) from (2) columns that still need a backfill to preserve semantics even if a default exists.
File summaries
File Description
server/src/main/resources/db/migration/V1_74__PersonalAccessToken_Column_Defaults.sql Sets DB-level defaults for personal_access_token.version and .type to keep manual/older INSERTs working post-V1_72.
scripts/import-db-dump.sh Refines dump import gap detection to run backfills for semantic correctness while only dropping NOT NULL when strictly required.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/import-db-dump.sh Outdated
Both metadata queries ended in `| grep -v '^$' || true`, stripping the blank
line psql's tuples-only output leaves behind. grep exits 1 when it matches
nothing, which under pipefail is indistinguishable from psql itself having
failed, so the `|| true` that tolerated an empty result swallowed a psql that
couldn't reach the target along with it - and the checks concluded that
nothing had been dropped and nothing needed backfilling. sed exits 0 either
way, so an unreachable target now aborts the run before anything is touched.

Also -X on the target psql: the first branch runs the caller's own psql when
one is installed, and a ~/.psqlrc that turns on \timing, or echoes anything,
would be parsed as schema metadata.

And in V1_74, don't claim the defaults reproduce V1_72's backfill exactly:
that backfill also derived type = 'OTT' from the description of the one-time
publish tokens the server used to create.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netomi
netomi merged commit ffd2581 into main Sep 4, 2026
5 checks passed
@netomi
netomi deleted the feat/pat-column-defaults branch September 4, 2026 20:14
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.

2 participants