Skip to content

Fixes 36216: Check bot and personal access tokens against a per-server credential cache - #36217

Merged
harshach merged 115 commits into
mainfrom
agent/for-any-self-signed-token-fo-2
Oct 30, 2026
Merged

harshach merged 115 commits into
mainfrom
agent/for-any-self-signed-token-fo-2

Conversation

@harshach

@harshach harshach commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Describe your changes:

Fixes #36216

Bot JWTs and personal access tokens are now checked against a per-server, in-memory cache of the credentials the database stores for them. Each server confirms its cached principals against the database in sweep passes, each due 2 s after the previous one ends, with one indexed statement per 500 principals. A committed credential change therefore reaches every server within credentialVerification.maxStaleness (5 s by default). That holds with or without Redis, and for every writer: the REST API, openmetadata-ops and direct SQL. A cached check makes no Redis or database round trip. When a server cannot confirm a token, for example during a database outage, the request gets 503 with Retry-After. Session and identity-provider tokens are unchanged.

The commits build it from the bottom up:

  • Contract types, the user_tokens index migration and the database credential source.
  • CredentialVerifier, with its cache, single-flight reads, sweep and deep verify, and the Credentials holder that repositories notify after commit.
  • The Redis nudge to other servers, and the startup wiring.
  • Every authentication entry point switched to the verifier, the 503 mappings, and removal of the per-request token cache.
  • A unique jti on every minted token, and removal of the unused impersonation token generator.
  • Unit tests, integration tests, a second server in its own JVM for cross-server tests, and benchmarks.

Type of change:

  • Improvement

High-level design:

Cache. CredentialVerifier, one per server, keeps the SHA-256 digests of the tokens the database stores for each principal. Each entry is stamped with the monotonic time its read was sent. A check is answered from memory while that stamp is within maxStaleness. Otherwise the check reads on a bounded pool (maxConcurrentRequestReads, 16) and waits at most loadTimeout (2 s). Reads of one principal are single-flight. Caffeine bounds the cache at maxSubjects (4,000) and drops a principal that presents no token for idleExpiry (15 min). A token that the principal's last full read lacks gets a new read once that read is missRefreshMinAge (500 ms) old. Before then the check answers 503 with Retry-After, because the token may have been created on another server after the read. It answers 401 instead when the entry shows the token withdrawn: a read lacked it although a check presented it before that read began, or an earlier read held it and the read began after that one finished.

Sweep. A sweep pass is due sweepInterval (2 s) after the previous one ends. One fingerprint statement per batchSize (500) cached principals confirms the batch, and only principals whose fingerprint moved are re-read. Every deepVerifyInterval (10 min), the sweep re-reads every cached principal in full and forces each decrypt. That covers a secret edited inside an external secrets manager. Each pass drains nudges and reconfirms every batch first. While a deep verify has principals left, the pass then makes at least one of its reads, of at most 10 principals each, and starts another only while the pass is within its deep budget (maxStaleness minus twice sweepInterval, 1 s by default). The rest carries over to later passes, so the deep verify's decrypts delay the deep verify, not the next reconfirmations, while one of its reads takes less than sweepInterval. A principal whose stored credentials the deep read cannot convert, for example while the secrets manager cannot decrypt a bot's secret, is unconfirmed from then on. Its checks read it again and answer 503 until a read converts it, and the sweep does not confirm it by fingerprint. The new index idx_user_tokens_user_type (userId, tokenType, token) serves both statements, and makes the fingerprint statement index-only on Postgres.

Writes. Repositories call Credentials.changed after commit, so the writing server sees its own write on the next request. With Redis, the write is also published, and the other servers re-read at once. Correctness depends on neither, because the sweep sees every committed change, including changes made outside the server.

Entry points. REST and the MCP tool and prompt callbacks go through JwtFilter.authenticate. The WebSocket handshake and the MCP HTTP transport verify the signature with JwtFilter.verifySignatureOnly and then call checkValidationsForToken. Each path verifies the signature, classifies the token with OmCredential.classify as stored, not stored or unrecognized, and calls require for the stored kinds. The stored-credential check of an MCP tool or prompt call runs twice, once at the transport and once in its callback, both times on the token in the request's Authorization header. OmCredential.classify does not read client_id, so an MCP access token is not stored, like a login token, and checkValidationsForToken looks it up in oauth_access_tokens, the lookup that #36498 added. An impersonated request is checked against the presenting bot's own token. A token is rejected as unrecognized when OpenMetadata signed it with a claim combination the server never mints, or when another issuer signed it and it claims isBot=true or tokenType=PERSONAL_ACCESS. Probes that grant no access, such as the startup bot-token check, call only verifySignatureOnly.

Database unavailable. A check that cannot be confirmed answers 503 with Retry-After on REST, on the WebSocket upgrade and on the MCP transport. On MCP callbacks it answers JSON-RPC error -32003. A principal whose check read produced no record is not read again for a check for missRefreshMinAge (500 ms), so its checks cost at most two reads per second per server. Liveness (/healthcheck) does not depend on the verifier. Alert on om_credential_sweep_last_complete_age_seconds instead.

Compatibility.

  • BotTokenCache.getToken, BotTokenCache.EMPTY_STRING, UserTokenCache.invalidateToken, JwtFilter.validateJwtAndGetClaims and JwtFilter.checkValidationsForToken keep their signatures for other distributions. checkValidationsForToken now also runs the stored-credential check on the token it is given. Collate's main branch uses nothing this PR removes.
  • A one-time migration statement removes PAT rows whose user no longer exists.
  • Bot and PAT clients can receive 503 when a server cannot read the database, and bot clients while the secrets manager cannot decrypt their secret. Clients that retry on 5xx need no change.

Configuration. A new optional credentialVerification: block in conf/openmetadata.yaml, with CREDENTIAL_VERIFICATION_* environment variables. Metrics are om_credential_* on /prometheus.

Alternatives. A Redis read per request keeps a network round trip on every call and needs another path without Redis. A short local cache lifetime makes database reads grow with request volume instead of with the number of active principals.

Decision record. Stored tokens are checked by a per-server verifier.

Follow-ups, not in this PR.

  • Closing an open WebSocket connection when the credential it was opened with changes.
  • Storing a digest beside each token and checking against it. The decision record names this as the next step.
  • Moving the verifier's single flight and read hand-off onto Caffeine's AsyncLoadingCache.
  • In Collate, treating a 503 from the hybrid ingestion runner's WebSocket handshake as temporary rather than as an authentication failure.
  • In Collate, calling verifySignatureOnly before checkValidationsForToken in BearerTokenUserResolver, so each request counts one stored-credential check.

Tests:

Use cases covered

  • A bot token or PAT revoked through one server is rejected by a second server, running in its own JVM, within maxStaleness, with and without Redis. A bot token rotated through the first server is replaced on the second, and the PAT of a principal soft-deleted through the first is rejected by the second. A PAT created through one server is accepted by the other on a single request once missRefreshMinAge has passed since the other last read its owner. Before then it gets 503 with Retry-After.
  • Changes made by direct SQL (a deleted PAT row, a cleared bot mechanism, a soft delete) and by openmetadata-ops regenerate-bot-tokens reach requests within maxStaleness.
  • A bot or PAT request adds no database statement and no Redis call compared with a session-token request, and keeps working while Redis is down.
  • During a database outage, bot and PAT requests get 503 with Retry-After, while session tokens keep working. Bot and PAT requests succeed again within a sweep interval of the database returning.
  • The MCP transport and MCP tool calls check stored tokens.
  • Every token the server mints is recognized. A server-signed token of a kind the server never mints gets 401.
  • A PATCH cannot change a bot's authentication mechanism. The stored token keeps working, and the token in the PATCH body is rejected.

Unit tests

  • I added unit tests for the new and changed logic.
  • Run locally at bbc2a236b72, after the rebase onto main at b1941043ab4. The test planner (.github/scripts/plan_local_java_tests.py) selected 139 openmetadata-service classes and 9 openmetadata-mcp classes, and every selected class produced a report.
  • openmetadata-service: 2,301 tests ran, with 0 failures. That run had no Docker, so the 26 tests of OntologyEntityDAOTest and OntologyReleaseReviewListTest errored and the 13 tests of RedisCacheProviderClusterTest and RedisCacheProviderUnconfirmedWritesTest were skipped. Once Docker was up, those four classes passed at the same commit: 39 tests, 0 failures, 0 skipped.
  • openmetadata-mcp: all 122 tests in the 9 classes passed, including McpServerUnconfirmedTokenTest, OAuthHttpStatelessServerTransportProviderTest and main's McpServerRequestContextTest.promptAuthenticatesWithTheHeaderNotTheArguments.
  • fba8434df5a's OpenMetadataApplicationPushFeedUpgradeTest starts an embedded Jetty server and opens the push-feed upgrade with the JDK's WebSocket client. It gets 503 with Retry-After, and 500 with the fix reverted. bbc2a236b72 adds its 401 and 403 cases, which fail when the two answers are swapped.
  • Before the first rebase, the read-path, sweep and deep-pass fixes from the review rounds were each reverted once to confirm that a test fails without them. The four fixes in 6abe365eab8 were checked the same way: against the code before that commit, its tests fail in 8 places, at least one per fix. Of the three tests that ae3c7f88eb0 adds, two fail against the code at 6abe365eab8. The third covers the deep-verify retry, which was already right, and fails against two broken variants of that code.
  • Changed-line coverage was measured for the entry-point switch only, before its last two commits: 74 of 94 changed lines (79%). The uncovered lines are mostly the plain repository calls in UserResource, which the integration tests exercise. Coverage of the credentials package was not measured at the head commit.

Backend integration tests

  • I added integration tests in openmetadata-integration-tests/ for the new and changed behavior.

Integration tests at bbc2a236b72, after the rebase onto main at b1941043ab4. No merge-queue workflow runs the search integration tests, so they ran locally once Docker was up, on the engines the planner chose. LiveSearchSettingsChangeIT passed on MySQL + Elasticsearch (1 test), and the 26 classes the planner selected passed on PostgreSQL + OpenSearch (64 tests, 0 skipped). The three integration-test workflows were started by hand on this branch. Each failed one test, NativeOntologyImportListRulesIT.aFilePreviewAcceptsAlsoAppliesWhenItsListsAreUsable, and passed every other test it ran:

Workflow Run Tests run Failed Skipped
MySQL + Elasticsearch 40979017387 19,618 1 1,796
PostgreSQL + OpenSearch 40979020541 19,618 1 1,796
PostgreSQL + Elasticsearch + Redis 40979024482 19,333 1 1,601

That test fails the same way on main at b1941043ab4, the commit that adds it (#35991), in main's own MySQL + Elasticsearch and PostgreSQL + OpenSearch runs. In both runs, the change set it applies gets 400 with "Ontology validation failed: Declare or import the referenced property". This branch changes no ontology code.

Before this rebase, the workflows ran at 4f52eaf0c82, after the first rebase onto main at e32170cdf3c. Each failed one test, UserResourceIT.test_selfGeneratedToken_lifecycle, and passed every other test it ran:

Workflow Run Tests run Failed Skipped
MySQL + Elasticsearch 40904779893 19,561 1 1,796
PostgreSQL + OpenSearch 40904785740 19,561 1 1,796
PostgreSQL + Elasticsearch + Redis 40904791470 19,276 1 1,601

The counts add up every lane of each workflow. The Redis workflow has no RDF lane.

That test called POST /users/generateToken for a regular user, which main's #36156 restricted to bot users, so the call got 403. a50dab2b113 removes it, with the matching row of MintedTokensAreRecognizedTest. Main's test_generateToken_forOwnRegularAccount_forbidden covers the new rule, and test_deleteBotUser_rejectsBotToken and test_deleteUser_rejectsPersonalAccessToken still cover account deletion.

Before the first rebase, all three workflows passed at bd9aeb7e9ad (40833236407, 40833239431, 40833242095) and at 5cbe4c551ad (40527205207, 40527211134, 40527215679). At 057db895fbe, the Redis workflow (40829055078) failed one search-ranking test, SearchRankingRelevanceIT.singularQueryFindsThePluralTable, which passed in every other run. Those SHAs are the commits before the first rebase.

Integration tests added or changed
  • CredentialRevocationTwoProcessIT: cross-server revocation, rotation and soft delete, with and without Redis.
  • RevokeWaitForPropagationIT: waitForPropagation on both revoke endpoints.
  • OpsRegenerateBotTokensIT: tokens rotated through openmetadata-ops.
  • CredentialOutOfBandRevocationIT: SQL-only changes.
  • CredentialTwoVerifierConvergenceIT: two verifiers over one database.
  • CredentialScaleHelpersTest: the helpers the scale-lane benchmarks use. It runs in every lane.
  • CredentialHotPathNoIoIT, CredentialRedisNoRoundTripIT, CredentialRedisCommandStatsIT: per-request cost.
  • CredentialRedisDownIT: requests and revokes while Redis is down.
  • CredentialDatabaseOutageIT: 503 with Retry-After during a database outage.
  • CredentialSourceDaoIT, CredentialIndexMigrationIT: the credential statements and the index migration.
  • mcp/CredentialMcpRevocationIT: the MCP transport and tool calls.
  • UserResourceIT: new token tests.
  • security/scale/CredentialAuthBenchIT, CredentialSweepCostIT, CredentialMemoryIT, CredentialPatBurstIT: scale-lane benchmarks.
  • TokenRevocationRedisMultiNodeIT is deleted. CredentialRevocationTwoProcessIT replaces it.
  • ForkedServerNode and its helpers in it/bootstrap run a second server in its own JVM.

Performance

On one thread, the in-memory check takes 470 to 670 ns per request (3 JVM runs). That is about 2% of the RS256 signature verification that precedes it (28 µs median). Reproduce it with mvn -pl openmetadata-service test -Dtest=CredentialCheckBenchmark -Dsurefire.failIfNoSpecifiedTests=false. The machine was shared and loaded during these runs. The cross-server propagation and sweep-cost benchmarks have not run yet.

Ingestion integration tests

Not applicable. No ingestion changes.

Playwright (UI) tests

Not applicable. No UI changes.

Manual testing performed

None. Docker was unavailable, so no local stack ran.

UI screen recording:

Not applicable. Backend-only change.

Checklist:

  • I have read the CONTRIBUTING document.

  • My PR title is Fixes <issue-number>: <short explanation>

  • My PR is linked to a GitHub issue via Fixes #<issue-number> above.

  • I have commented on my code, particularly in hard-to-understand areas.

  • For JSON Schema changes: not applicable, no JSON Schema changes.

  • For a UI feature or feature task: not applicable, no UI changes.

  • I have added tests (unit / integration / Playwright as applicable) and listed them above.

  • Self-review: I reviewed the final diff against REVIEW.md (the pr-checklist skill runs it) and fixed every 🔴 finding, or answered it on the line below.
    Commit SHAs in this paragraph and the next are the commits as rebased onto main at b1941043ab4, except bd27ec8d9f2, a merge of main from before the first rebase. A REVIEW.md pass over the whole diff ran at bd27ec8d9f2, in three parts: the verifier, the wiring and the integration tests. Its blocking findings were in the integration tests and are fixed in 9b2d01c7b04 to b1b28c0230f. Review comment r4384148610 is fixed in 553a29ef3ed to 374f530f96c. Independent reviews of both fix ranges, up to c4f708e57f5, found no blocking issue, and no 🔴 was answered instead of fixed. 374f530f96c adds the two tests and the wording that the last review asked for. Review comment 6449704660 is answered in 6abe365eab8 to b0ff181cbe1, and an independent review ran after each of those commits. The reviews of ae3c7f88eb0, f1c5781699c, 4fd9099bbc3 and c547a0712ae found 🔴 findings, each a statement in the decision record or a Javadoc that the code did not bear out, and the next commit fixes them. The other reviews found no 🔴. Each review's 🟡 findings are addressed in the next commit. The review of b0ff181cbe1 found no 🔴.
    The branch was first rebased onto main at e32170cdf3c, keeping both sides where they overlap. A review of the conflict resolutions found two 🔴 findings, both statements that main's stored MCP access tokens (fix(mcp): tool error codes and access token revoke #36498) made untrue: the decision record named only bot tokens and personal access tokens as stored, and MintedTokensAreRecognizedTest minted the MCP access token without the client id the server now adds. 97ee259a195 fixes both, with the review's 🟡 findings. The review of 97ee259a195 found one 🔴, a sentence of the checkValidationsForToken Javadoc that claimed more than the code does, and 1952921fb49 fixes it, with that review's 🟡 findings. The review of 1952921fb49 found no 🔴. The integration tests at 1952921fb49 then failed one test that fix(security): batch of backend security-hardening fixes #36156 made obsolete, and a50dab2b113 removes it. Earlier drafts of that commit rewrote the test instead. Their reviews found 🔴 findings, which removing the test resolves. The review of a50dab2b113 found no 🔴. Its two 🟡 findings, the bot row's Javadoc and a helper parameter in MintedTokensAreRecognizedTest, change no test and are not addressed. Main's fix(socket): validate credentials on push-feed WebSocket handshakes under all configurations #36143 then conflicted with the branch, and a merge of main resolved it first, keeping both sides. A review of that merge and of a first draft of fba8434df5a found no 🔴, and fba8434df5a addresses the 🟡 findings that still apply. The review of fba8434df5a found no 🔴, and ab0b1759392 fixes its two 🟡 findings: the upgrade test had no handshake timeout, and the decision record's Guard did not name the test. The review of ab0b1759392 found no 🔴, and bbc2a236b72 adds the 401 and 403 upgrade cases that its 🟡 finding asked for. The review of bbc2a236b72 found no 🔴. Its 🟡 finding, that the rejection test checks one status, is not addressed. The branch was then rebased onto main at b1941043ab4, which replaces the merge and again keeps both sides where they overlap. The rebase changed no line of the PR's diff against main, so no review ran on it.

  • Decision check: this PR adds docs/decisions/2026-10-25-stored-tokens-are-checked-by-a-per-server-verifier.md, and amends docs/decisions/2026-10-23-every-jetty-request-clears-per-request-thread-state.md, whose personal access token loader this PR removes.

  • I have added tests around the new logic.

RetriggerView in GreptileConfidence Score: 5/5

The reviewed changes appear safe to merge; no new blocking issue was confirmed.

Summary

Bot JWTs and personal access tokens now use a bounded credential cache on each server.

  • Bot and personal access tokens are checked against stored credentials on every server.
  • Credential changes reach other servers without requiring Redis.
  • Minted tokens now carry unique IDs, and the unused impersonation-token generator is removed.

No new actionable issue was confirmed. Tests were inspected but not executed during this review.

Previous threads were rechecked:

  • The scale-helper expectation, decrypt counters, failed-decrypt behavior, and overlapping-read fix are present.
  • harshach accepted the propagation wait’s local configuration bound because a cluster-wide bound would require membership tracking. The scope is documented.
  • harshach dismissed the legacy Redis rolling-upgrade concern because no released version used those keys; the obsolete code was removed.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Signed token] --> B[Classify token]
  B -->|Session or identity provider| C[Existing checks]
  B -->|Bot or personal token| D[Per-server credential cache]
  D -->|Fresh matching token| E[Continue request]
  D -->|Needs a read| F[Bounded database read]
  F -->|Current| E
  F -->|Not current| G[Reject token]
  F -->|Cannot confirm| H[Retry response]
  I[Background sweep] --> D
  J[Committed credential change] --> K[Local refresh and optional Redis nudge]
  K --> D
Loading

Reviews (6) · Last reviewed commit: "Merge main into the credential verifier ..." · Reviewed by Greptile

@harshach
harshach requested review from a team, akash-jain-10 and tutte as code owners October 26, 2026 08:47
@harshach harshach added safe to test Add this label to run secure Github workflows on PRs backend labels Oct 26, 2026
@harshach
harshach requested a review from a team as a code owner October 26, 2026 08:47
@harshach
harshach requested a review from manerow October 26, 2026 08:47
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

✅ PR checks passed

The linked issue has a description and all required Shipping project fields set. Thanks!

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

✅ SSO Login Nightly — PASSED

View workflow run · Commit 54be46f

Provider Status Pass Fail Flaky Skip Duration
basic ✅ pass 28 0 0 0 6m 43s
ldap ✅ pass 7 0 0 0 0m 58s
keycloak-oidc-confidential ✅ pass 12 0 0 0 1m 42s
keycloak-oidc-public ✅ pass 11 0 0 0 1m 04s
msal-mock ✅ pass 7 0 0 0 0m 37s
auth0-mock ✅ pass 6 0 0 0 0m 53s
okta ⏩ skipped - - - - -
keycloak-azure-saml ✅ pass 23 0 0 0 6m 23s
keycloak-azure-saml-crosssite ⏩ skipped - - - - -

Totals: 94 passed · 0 failed · 0 flaky · 0 skipped · duration 18m 23s

A subject that a deep read leaves out keeps its cache entry, marked
unconfirmed at the read's stamp. No check is answered from that entry.
Its next check reads the subject and answers by that read, or 503 while
the subject does not convert, with the usual backoff. A matching
fingerprint does not move an unconfirmed entry's stamp, and its record
is offered to no decrypt reuse. Installs stay newer-wins by stamp. A
read stamped before the deep read cannot replace the entry and a read
stamped after it can, and the deep read leaves an entry that a later
read installed as it is.

A subject left out does not fail the deep pass. The retry after
min(deepVerifyInterval, 1 min) is scheduled only when a batch's
reconfirmation or deep read throws.
The two sweep-thread tests count only the threads named
om-credential-verifier that appear after a snapshot taken before their
verifier starts, so a verifier that another test in the JVM still runs
does not count.

shippedWithBlock replaces only the credentialVerification block's own
lines, up to the next top-level key, so a key that follows the block in
conf/openmetadata.yaml stays in the parsed config.
The sweepOnce and deepReadQuietly Javadocs, the deep-verify warning,
the CredentialSource.read Javadoc and the decision record say that a
subject the deep read leaves out is unconfirmed, that its checks read
it again and answer 503 until a read converts it, and that the deep
pass is retried after min(deepVerifyInterval, 1 min) only when a
batch's reconfirmation or deep read throws.
Consequences now say that from the next deep pass on, a bot whose secret
the secrets manager cannot decrypt answers 503 until a read decrypts it,
and that its checks inside the backoff window count as cause=backoff.
…inute

The warning window ran from each failure and lasted the 500 ms backoff, so
a principal left out at every backoff window was logged with a stack trace
on every read. The window now starts at the warning and lasts one minute,
and a read that converts the principal still ends it.
The decision record now counts the sweep decrypts of a bot whose stored
rows changed, says that a hanging decrypt holds a request-read thread, and
gives the warning rate. The CredentialSource and CheckDecision Javadocs say
that only the deep verify's read unconfirms a subject.
…xactly

ReadBackoffTest covers a warning stamped before the open window that
finishes later, and a window opened just before the ticker wraps. The
decision record says that a decrypt that hangs during the deep read holds
the sweep thread, and that the warning rate is per principal.
Four changes to the credential verifier, from review:

- A check of a token that the principal's last full read lacks, while
  that read is younger than missRefreshMinAge, now answers 503 with
  Retry-After (cause=deferred) instead of 401. A token created on
  another server is then accepted on the retry. The check still answers
  401 when the entry shows the token withdrawn: a read lacked it
  although a check presented it before that read began, or an earlier
  read held it and the read began after that one finished. A counter
  taken around each read's statement orders the reads, and an entry
  keeps at most 8 withdrawn digests.
- Each sweep pass reconfirms every batch first, then continues the deep
  verify at 10 principals per read. It starts another read only while
  the pass has run less than maxStaleness minus twice sweepInterval, so
  slow decrypts delay the deep verify instead of the reconfirmations.
- An interrupt while a check waits for its read answers 503 under
  cause=interrupted instead of counting as a read timeout.
- JwtFilter.checkValidationsForToken runs the stored-credential check on
  the token it is given, from that token's own claims.

The decision record states each rule. test_patchBotAuthMechanism_isIgnored
now waits past a 503 for the 401.
Four changes from the review of 057db89:
- An entry lists its withdrawn digests newest first, starting with the
  one its own read shows withdrawn. A check whose read finds more than 8
  tokens gone then still answers 401 for the token it presented, not 503.
- SocketAddressFilter.validatePrefixedTokenRequest verifies the signature
  with verifySignatureOnly and leaves the stored-credential check to
  checkValidationsForToken. A WebSocket handshake or an MCP HTTP request
  now counts one check in om.credential.checks instead of two.
- A test covers a deep verify whose first read throws while the later
  passes succeed. It is retried a minute after it began.
- The decision record and the sweep Javadoc include the reconfirm time in
  the deep-verify timing, and say when the next deep pass begins after a
  long one. The record also covers a withdrawn token that comes back when
  a deleted principal is restored.
The message of bd9aeb7 says an MCP HTTP request now counts one
check. The transport does check each request once, but a tool or prompt
call is checked again by its callback, through getCatalogSecurityContext,
so it counts two checks instead of three. The decision record now says
so.

The record and the sweep Javadoc also count nudge drains on the sweep
thread in the deep-verify timing, and name the deep budget rather than
its default. The Javadoc now says when the next deep pass begins after
a long one.
The decision record and the sweep Javadoc gave the age between two
reconfirmations as a sum that left out the next pass's own drain. They
now say why the deep budget is maxStaleness minus twice sweepInterval,
which conditions keep entries within maxStaleness, and that checks of an
entry past it read the database.

A prompt call's callback checks the token in the prompt's Authorization
argument, as it did before this branch. The record now says so instead
of grouping prompt calls with tool calls.

The record also says a deep read covers at most 10 principals, and that
the next deep pass begins with the first sweep pass that starts once its
interval has passed.
The decision record and the sweep Javadoc listed conditions that keep
entries within maxStaleness, but one nudge drain still holding the sweep
thread when the next pass is due could age an entry past it with every
condition met. They now give an entry's age at its next reconfirmation
as the sum of the work between the two, say what the deep budget alone
keeps within maxStaleness, and say that the next check of an older entry
reads it again.

They also say that a sweep pass makes deep reads only while a deep pass
has principals left.

The sweepInterval validation message no longer says that the
half-of-maxStaleness rule absorbs a missed sweep, which holds only while
passes are short. Exactly half stays accepted.
The decision record said a slow decrypt delays only the deep pass. A
sweep or nudge re-read that cannot reuse the last decrypt, as for a bot
whose stored secret changed, also decrypts on the sweep thread, so a
slow decrypt there delays reconfirmations too. The record now names all
three places, and says the deep budget limits how many deep reads a pass
starts, not how long one takes.

The age breakdown in the record and the sweep Javadoc is an upper bound
for an entry the pass reconfirmed. An entry the pass did not reconfirm,
such as one in a failed batch, can be older. Both now say so.
From the review of ee62509:
- A sweep or nudge re-read reuses a decrypt only from a confirmed entry
  whose read saw the same stored mechanism and updatedAt, so any change
  to a bot's user row decrypts again, not only a changed secret.
- While the secrets manager cannot decrypt a bot's secret, the bot
  answers 503 from its next deep read, or sooner once its user row
  changes.
- A failed batch leaves to age out only the entries it did not
  reconfirm. Its matched entries are reconfirmed before the re-read of
  the changed ones.
- The age bound covers an entry that still holds the stamp its batch
  took in the pass.
- A sweep pass starts sweepInterval after the previous one ends, not
  every sweepInterval.
- The DEEP_CHUNK_SIZE Javadoc says how far past the budget a pass's
  deep reads can end.

Only the decision record, Javadoc comments and a configuration comment
change.
From the review of c26d72d:
- While the secrets manager cannot decrypt a bot's secret, every read
  that must decrypt it fails, so any check of the bot that cannot be
  answered from memory answers 503. That includes a server's first
  check of a bot it holds no entry for. The record now says so instead
  of listing when the 503s start, a list that left that case out.
- A sweep pass is due sweepInterval after the previous one ends. The
  first runs at once, and a nudge drain can delay one.
- Decrypt reuse is keyed on the stored mechanism and updatedAt, not on
  the whole user row.
- An entry is too old to answer from once its last read or
  reconfirmation is older than maxStaleness, whatever the gauge shows.
- A deep read takes fewer subjects when batchSize is smaller.

The reconfirmAll Javadoc, the failed-batch log message, the
CredentialRecord Javadoc and the sweepInterval comment in
openmetadata.yaml get the matching corrections.
From the review of 0ccf370:
- The reconfirmAll Javadoc said a failing batch leaves the other
  batches' entries reconfirmed. Another batch can hold an entry whose
  re-read cannot convert it, so the Javadoc now says only that a failing
  batch does not stop the rest from reconfirming.
- The decision record no longer says that writes to a bot user through
  the API change its updatedAt, since some writes keep it. The
  decrypt-reuse rule beside it already names the stored mechanism and
  updatedAt.
- While the secrets manager cannot decrypt a bot's secret, a check that
  waits on a read that must decrypt, or on the backoff after one,
  answers 503. A check that joins a nudge read able to reuse the last
  decrypt can still get an answer, so the record no longer says that
  every check it cannot answer from memory answers 503.
- A stale entry's checks need a read and answer 503 when the verifier
  cannot confirm within loadTimeout, in the record and in
  openmetadata.yaml.
- The sweepInterval comment in openmetadata.yaml now says a pass is due
  that long after the previous one ends, which the message of
  0ccf370 said it already did.
Two JwtFilterTest cases from main call the seven-argument test constructor, which this branch
extends with the verifier supplier.
…erifier docs

- The verifier's decision record says MCP access tokens are stored and that
  checkValidationsForToken looks them up, and that prompt callbacks, like tool
  callbacks, check the token in the request's Authorization header (#36531).
- Amend the per-request cleanup record: the personal access token loader it
  names is gone, and the verifier reads outside the request entity cache.
- OmCredential.NotStored and the checkValidationsForToken Javadoc name the MCP
  access token lookup and the rule that a provider token never signs in to a bot.
- Every minted token gets its jti in one place; the MCP branch adds only
  client_id.
- MintedTokensAreRecognizedTest mints the MCP access token with a client id, as
  issueAccessToken does, and describes the self-service case as a token minted
  before generateToken became bot-only.
- McpServerUnconfirmedTokenTest sends the prompt caller's token in the header.
…allback repeats

- The checkValidationsForToken Javadoc says the rule that a provider token never
  signs in to a bot account belongs to the legacy claim flow, where the code
  applies it.
- The decision record says an MCP tool or prompt callback repeats the
  stored-credential check, and that classify reads no client_id, which is why
  an MCP access token is NotStored like a login token.
- MintedTokensAreRecognizedTest names what it runs: the stored-credential check
  through validateJwtAndGetClaims, and reads back only what is kept for the
  verifier.
Since #36156, POST /users/generateToken mints tokens for bot users only, and
main's test_generateToken_forOwnRegularAccount_forbidden covers that rule.
UserResourceIT.test_selfGeneratedToken_lifecycle called it for a regular
user, so it now gets 403, and MintedTokensAreRecognizedTest had a row for the
same call. Both are removed. test_deleteBotUser_rejectsBotToken and
test_deleteUser_rejectsPersonalAccessToken still cover account deletion.
#36143 authenticates WebSocket upgrades in the upgrade creator, which
the servlet filter chain never reaches. A CredentialUnavailableException
thrown there escaped the creator, and Jetty answered the upgrade with
500. The upgrade now answers 503 with Retry-After, as the servlet path
and the REST and MCP entry points do, and stops the negotiation.

authenticateUpgrade becomes package-private and static, so
OpenMetadataApplicationPushFeedUpgradeTest can register it on an
embedded Jetty server and open the upgrade with the JDK's WebSocket
client. The authenticate Javadoc now lists
CredentialUnavailableException.
The JDK's HttpClient.close() waits for an open handshake, so a server
that accepted the connection and never answered would hold the test,
and the build, until the server closed it. connectTimeout fails the
handshake after 10 s instead, and the 20 s get bound stays above it.

The per-server verifier's decision record names the test in its Guard.
OpenMetadataApplicationPushFeedUpgradeTest now also opens upgrades that
authenticate rejects with a HandshakeRejectedException or an
AuthenticationException, and checks the status the client receives.
With the 503 case, each rejection that authenticateUpgrade answers is
tested at the upgrade layer.
@harshach
harshach force-pushed the agent/for-any-self-signed-token-fo-2 branch from 8608c29 to bbc2a23 Compare October 30, 2026 00:19
@gitar-bot

gitar-bot Bot commented Oct 30, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 4 closed / 4 findings

🔴 High risk · Reworking bot and personal-access-token authorization around cached database credentials could wrongly accept revoked tokens or reject valid requests during outages.

Adds a per-server credential cache so bot JWTs and personal access tokens are checked against database-stored credentials, with sweep-based convergence across servers. Resolved the verifier decision-record gaps, the waitForPropagation REST contract documentation, the unrecorded PERSONAL_ACCESS provider-token rejection, and repeated deep-pass decrypts of an unconvertible bot principal. No open issues remain.

✅ 4 closed
✅ Quality: Verifier limits and backoff defaults are not in the decision record

📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/CredentialVerificationConfig.java:38-46 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/CredentialVerificationConfig.java:80-94 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/CredentialVerifier.java:86-88 📄 conf/openmetadata.yaml:1038-1052
This PR sets several new defaults that were judgment calls, and the new ADR 2026-10-25-stored-tokens-are-checked-by-a-per-server-verifier.md doesn't record them. The ADR covers maxStaleness (5 s), the 2 s sweep, loadTimeout (2 s), the 500-principal batch and the 4000-principal cap. It does not cover these:

  • missRefreshMinAge = 500 ms. A principal whose read produced no record is refused without a read for this long. The description says this means "its checks cost at most two reads per second per server".
  • idleExpiry = 15 min.
  • deepVerifyInterval = 10 min. A failed deep pass retries after min(deepVerifyInterval, 1 min).
  • maxConcurrentRequestReads = 16, lowered to half the database pool.
  • QUEUED_READS_PER_THREAD = 16. Reads beyond this are shed as NO_PERMIT, so the client gets a 503.

These values decide when a valid bot or PAT request gets a 503 instead of an answer. They also set how much database load an unknown or revoked token can cause. The yaml comments say what each value does but not why it was chosen.

The rule the record would state: "A principal whose read produced no record is not read again for missRefreshMinAge (500 ms). Credential reads for checks run on a pool of at most min(16, dbPool/2) threads with 16 queued reads per thread, and overflow answers 503. Cached principals idle out after 15 min and are fully re-read with forced decrypts every 10 min."

✅ Quality: waitForPropagation REST contract is not in the verifier decision record

📄 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:194-197 📄 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:1007-1010 📄 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:1024-1026 📄 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:1945-1948 📄 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java:1962-1964 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/Credentials.java:37 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/Credentials.java:86-100 📄 docs/decisions/2026-10-25-stored-tokens-are-checked-by-a-per-server-verifier.md:55
This commit adds a public query parameter, waitForPropagation, to PUT /v1/users/revokeToken and PUT /v1/users/security/token/revoke. It also fixes how the parameter behaves: the call waits maxStaleness plus 50 ms on the monotonic clock after commit, and it answers 503 with Retry-After when no verifier is installed or the thread is interrupted. Clients have to rely on that behavior. The Accepted record docs/decisions/2026-10-25-stored-tokens-are-checked-by-a-per-server-verifier.md does not mention it. It only lists "a synchronous barrier on revoke" as a rejected alternative, so a reader could think this feature contradicts the record. The rule to record is: "waitForPropagation=true on the two revoke endpoints returns only after maxStaleness plus a 50 ms margin has passed on the server's monotonic clock since commit. This is a time bound, not a membership barrier. When the bound cannot be waited out, the call returns 503 with Retry-After and the client repeats the call."

✅ Quality: Rejecting provider tokens that claim PERSONAL_ACCESS is unrecorded

📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/OmCredential.java:77-78 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/OmCredential.java:100-101 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java:595-601 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java:626-633 📄 docs/decisions/2026-10-25-stored-tokens-are-checked-by-a-per-server-verifier.md:25-28 📄 docs/decisions/2026-10-25-stored-tokens-are-checked-by-a-per-server-verifier.md:42-43
OmCredential.classify now treats a token that OpenMetadata's key did not sign but that carries tokenType=PERSONAL_ACCESS as Unrecognized, so the request gets 401. Before this change it was NotStored and was accepted. This changes what happens to identity-provider tokens. The Accepted record says only that "An OpenMetadata-signed claim combination that matches no issued kind is rejected" and that "Session and identity-provider tokens are not affected". It has no rule for provider tokens that claim a PAT type. Either record the new rule, for example: "A token not signed by OpenMetadata that claims isBot=true or tokenType=PERSONAL_ACCESS is rejected as unrecognized." Or narrow the 'not affected' statement in the record. Separately, validateJwtAndGetClaims now calls requireCurrentCredential directly instead of going through JwtFilter.authenticate, so the record's sentence that every entry point goes through authenticate is no longer accurate.

✅ Performance: One unconvertible principal makes every deep pass re-decrypt all bots each minute

📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/CredentialVerifier.java:726-738 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/CredentialVerifier.java:789-802 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/credentials/CredentialVerifier.java:337-351
After this commit deepReadQuietly returns allConverted, so any batch where the source leaves out a subject counts as a failed deep pass, and sweepOnce sets the next deep pass to deepRetryIntervalNanos (1 min) instead of deepVerifyInterval (10 min). Leaving a subject out is often permanent, not transient. Take a bot whose secret was deleted from the external secrets manager, or a malformed PAT row. That entry stays cached and trusted by fingerprint for as long as checks keep it warm, so every later deep pass leaves it out again. The cluster then repeats a full forced-decrypt pass over every cached principal (up to 4000) every minute instead of every 10. That is 10x the secrets-manager calls on every server, with no backoff. The cause is a single bad row, and the unconverted-subject warning already reports it.

Fix: count only statement failures and Errors as a failed pass. For subjects left out, either retry just those subjects on the short interval, or back the retry off exponentially up to deepVerifyInterval.

Verification

Rules No rules evaluated

Functional validation Not enabled

Set up

Auto-approval Not enabled

Set up

For more verification details, comment gitar display:verbose.

Options

Display: compact → Showing compact verification results.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

@sonarqubecloud

Copy link
Copy Markdown

This branch was previously deployed

1 inactive deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Check bot and personal access tokens against a per-server credential cache

3 participants