Skip to content

[release] feat(lib-pairing): record the token issuer on the pairing record — v1.2.0 [COMP-2357] - #125

Closed
munishchouhan wants to merge 1 commit into
masterfrom
comp-2357-pairing-issuer
Closed

[release] feat(lib-pairing): record the token issuer on the pairing record — v1.2.0 [COMP-2357]#125
munishchouhan wants to merge 1 commit into
masterfrom
comp-2357-pairing-issuer

Conversation

@munishchouhan

Copy link
Copy Markdown
Member

Why

The pairing URL carries only endpoint — where a paired service receives API calls. That is not the iss the service stamps on the tokens it signs, and on Seqera Cloud the two genuinely differ:

endpoint   https://api.cloud.seqera.io
issuer     https://cloud.seqera.io/api

A verifier holding one cannot derive the other.

That gap blocks two things already in flight:

  • seqeralabs/platform#12431 already sends issuer= on the Sched pairing URL. The route did not bind the param, so Micronaut never passed it to anything and it was silently dropped — the PR is inert until this lands.
  • seqeralabs/sched#813 verifies Platform-issued tokens against a published JWKS instead of holding TOWER_JWT_SECRET. For a customer-hosted Platform, Sched needs a per-installation issuer to resolve a key set from. Taking it from a caller-supplied header instead would put the tenant binding outside the signed token, so it has to arrive at pairing time.

What

issuer added alongside the existing token, following that field exactly:

File Change
PairingWebSocket Route → {?endpoint,issuer}; value bound in @OnOpen and passed to the service
PairingRecord New String issuer
PairingService New acquirePairingKey(service, endpoint, token, issuer); 2- and 3-arg forms delegate to it
PairingServiceImpl Refreshes issuer on re-pair in the same store.put as token, so a record cannot persist with half the update applied

The issuer is only recorded here, not yet trusted. Turning it into a JWKS lookup — gated so an issuer must belong to a service that actually paired — is Scheduler-side work.

Compatibility — both directions

  • The bound param is @Nullable because a client predating it omits the query string entirely. Without that, binding would fail for every existing caller.
  • The 2- and 3-arg overloads are retained, so nothing that compiles today breaks.
  • A record written before the field decodes with a null issuer rather than erroring. Covered by a test that derives the legacy JSON from a real encode, so it stays correct whatever byte[] encoding Moshi uses.

One deliberate behaviour: a re-pair that omits the issuer does not erase a recorded one. A rolled-back client would otherwise silently drop the trust anchor for a service that had already announced it.

Testing

:lib-pairing:test37 pass, 0 failures.

Five new cases in PairingServiceIssuerTest: recording, the null case, refresh-on-re-pair (asserting the key pair is not regenerated), the omit-does-not-erase rule, and token+issuer refreshed together. Two more in PairingRecordSerializationTest for the round-trip and the pre-issuer record.

PairingServiceIssuerTest uses a real PairingStore over the in-memory LocalStateProvider rather than a Spock mock — PairingStore is a concrete class and cglib cannot proxy classes on Java 25 (Unsupported class file major version 69). Using the real store also exercises the Moshi round-trip on every put/get, so a field the encoder cannot handle surfaces here rather than in production.

Release

lib-pairing/VERSION1.2.0, README dependency snippet updated. Minor rather than patch: new public API surface, additive only.

Downstream, after this releases

  1. sched — bump the dependency and persist the issuer. Note this is a breaking bump regardless: acquirePairingKey gained a third arg in 1.1.0 and sched is still on 1.0.0.
  2. sched — resolve the JWKS per iss, gated on an existing PairingRecord, which is what makes Enterprise multi-tenancy work.
  3. platform#12431 stops being inert.

The pairing URL carried only `endpoint` — where a paired service receives API
calls. That is not the `iss` the service stamps on the tokens it signs, and on
Seqera Cloud the two genuinely differ: the API is served at
https://api.cloud.seqera.io while the issuer is https://cloud.seqera.io/api. A
verifier holding one cannot derive the other.

That gap blocks seqeralabs/platform#12431, which already sends `issuer=` on the
Sched pairing URL: the route did not bind the param, so Micronaut never passed
it to anything and it was silently dropped. It also blocks the Scheduler work it
exists for — verifying Platform-issued tokens against a published JWKS
(seqeralabs/sched#813) needs a per-installation issuer to resolve a key set
from, and taking it from a caller-supplied header would put the tenant binding
outside the signed token.

Add `issuer` alongside the existing `token`, following that field exactly:

- `PairingWebSocket` route becomes `{?endpoint,issuer}`, with the value bound in
  `@OnOpen` and passed to the service.
- `PairingRecord` gains a `String issuer`.
- `PairingService` gains a 4-arg `acquirePairingKey(service, endpoint, token,
  issuer)`; the 2- and 3-arg forms delegate to it.
- `PairingServiceImpl` refreshes `issuer` on re-pair in the same `store.put` as
  `token`, so a record can never persist with only half the update applied.

Backward compatible in both directions. The bound param is `@Nullable` because
a client predating it omits the query string entirely — without that, binding
would fail for every existing caller. The shorter overloads are retained, so
nothing that compiles today breaks. And a record written before the field
decodes with a null issuer rather than erroring.

A re-pair that omits the issuer deliberately does NOT erase a recorded one: a
rolled-back client would otherwise silently drop the trust anchor for a service
that had already announced it.

The issuer is only recorded here, not yet trusted. Turning it into a JWKS
lookup — gated so an issuer must belong to a service that actually paired — is
Scheduler-side work.

Tests: lib-pairing 37 pass. Five new cases cover recording, the null case, the
refresh-on-re-pair, the omit-does-not-erase rule, and token+issuer refreshed
together; two more cover serialization and decoding a pre-issuer record.

`PairingServiceIssuerTest` uses a real `PairingStore` over the in-memory
`LocalStateProvider` rather than a Spock mock: `PairingStore` is a concrete
class and cglib cannot proxy classes on Java 25 ("Unsupported class file major
version 69"). The real store also exercises the Moshi round-trip on every
put/get, so a field the encoder cannot handle surfaces in the test.

Signed-off-by: munishchouhan <hrma017@gmail.com>
@pditommaso

Copy link
Copy Markdown
Contributor

Think we had already discussed this in the past with Jon and discard it

@munishchouhan

Copy link
Copy Markdown
Member Author

i will close it

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