Skip to content

feat(licence): offline licence validation in operator#423

Open
flemzord wants to merge 6 commits intomainfrom
feat/offline-licence-validation
Open

feat(licence): offline licence validation in operator#423
flemzord wants to merge 6 commits intomainfrom
feat/offline-licence-validation

Conversation

@flemzord
Copy link
Copy Markdown
Member

Summary

This PR upgrades go-libs from v2.2.3 to v4.1.0 and adds centralized offline licence validation directly in the operator. The operator now validates licence JWT tokens using an embedded Formance RSA public key — no outbound network call required.

What changes for users

Before (go-libs v2)

  • The operator only injected env vars (LICENCE_TOKEN, LICENCE_ISSUER, LICENCE_VALIDATE_TICK) into EE service deployments
  • Each service validated the licence independently by fetching JWKS from a remote endpoint (jwk.Fetch)
  • If a licence expired, each EE service crashed individually with exit code 126 → CrashLoopBackOff
  • No visibility from the operator side — kubectl get stack showed "Ready" even with an expired licence

After (go-libs v4 + operator validation)

  • The operator validates the licence JWT offline using the embedded Formance RSA public key
  • No outbound network call — works in air-gapped / restricted clusters
  • The operator enforces licence state with graceful degradation, not a kill switch:
Licence State Non-EE modules EE modules Stack Condition
Absent (no --licence-secret) Reconcile normally Not deployed No condition
Valid Reconcile normally Reconcile normally LicenceValid=True
Expired Reconcile normally Skipped (nothing deleted) LicenceValid=False, Reason=Expired
Invalid (malformed/bad signature) Reconcile normally Skipped (nothing deleted) LicenceValid=False, Reason=Invalid

Key behaviours

  • Non-EE modules are never impacted by licence state (Ledger, Gateway, etc.)
  • Already-running EE services are not touched — no deletion, no scaling down
  • Operator remains healthy (healthz/readyz pass) regardless of licence state
  • Clear observability: LicenceValid condition on Stack and EE module CRDs visible via kubectl describe
  • Services keep their own validation — the operator is a gating mechanism, not a replacement
  • payments-ee image is now only selected when licence is actually valid (not just present)
  • Licence env vars are only injected into EE deployments when licence is valid

Changes

New files

  • internal/core/licence.goLicenceState type + ValidateLicenceToken() using embedded RSA public key
  • internal/core/licence_test.go — 8 unit tests (valid, expired, malformed, wrong key, no exp, production key parse)
  • internal/resources/stacks/licence_test.go — 6 unit tests (condition lifecycle: set, transition, removal)

Modified files

  • go.mod / go.sum — go-libs v2→v4, added golang-jwt/jwt/v5
  • internal/core/platform.go — Added LicenceState and LicenceMessage fields
  • internal/core/controllers.go — EE gating in ForModule() (single enforcement point)
  • internal/resources/stacks/init.goLicenceValid condition on Stack
  • internal/resources/payments/init.gopayments-ee image only when LicenceStateValid
  • internal/resources/licence/licence.go — env var injection only when LicenceStateValid
  • cmd/main.go — Read licence Secret + validate JWT at startup
  • 33 Go files — import path go-libs/v2go-libs/v4

Test plan

  • 14 new unit tests pass (JWT validation + condition behaviour)
  • All existing unit tests pass (applications, databases, services, settings)
  • go build ./... compiles
  • go vet ./... clean
  • Integration tests (require etcd/kubebuilder — CI only)
  • Manual test: deploy with valid licence → EE modules deployed
  • Manual test: deploy with expired licence → EE modules skipped, condition visible
  • Manual test: deploy without licence → only non-EE modules

@flemzord flemzord requested a review from a team as a code owner March 12, 2026 11:07
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds JWT RS256 licence validation and startup licence resolution, stores licence namespace/state/message on Platform, gates EE reconciliation and image/resource creation on licence validity, and bumps many go-libs imports from v2 → v4. New unit tests cover licence logic and stack licence conditions.

Changes

Cohort / File(s) Summary
Licence Core & Tests
internal/core/licence.go, internal/core/licence_test.go
New licence logic: LicenceState enum, ValidateLicenceToken(), ResolveLicenceState(reader, secretName, namespace), test coverage for valid/expired/malformed/wrong-key/no-exp/embedded-key cases.
Platform fields
internal/core/platform.go
Platform gains LicenceNamespace string, LicenceState LicenceState, and LicenceMessage string.
Startup / CLI
cmd/main.go
Adds --licence-namespace flag (default from POD_NAMESPACE), resolves licence at startup via ResolveLicenceState, stores LicenceState/Message on Platform, sets absent state when no secret provided.
Controller gating
internal/core/controllers.go, internal/resources/stacks/init.go
EE module reconciliation now checks licence state: re-resolve/inspect state, set LicenceValid condition (true/false with reason/message), and skip EE reconciliation when licence not valid.
Resource gating & image selection
internal/resources/licence/licence.go, internal/resources/payments/init.go
Licence resource creation and payments-EE image selection now depend on LicenceState == LicenceStateValid; invalid/absent state deletes/skips licence ref and avoids EE image.
Stack tests & bootstrap
internal/resources/stacks/licence_test.go, internal/tests/internal/bootstrap.go
New tests for setLicenceCondition and test bootstrap now injects a signed token secret and sets Platform.LicenceState = LicenceStateValid for setup.
Import bumps — pointer helper
api/formance.com/v1beta1/shared.go, internal/core/utils.go, internal/resources/.../application.go, .../databases/init.go, .../jobs/job.go, .../ledgers/reindex.go, .../settings/helpers.go, .../services/services_test.go, internal/tests/internal/matcher_be_owned_by.go
Switched imports from github.com/formancehq/go-libs/v2/pointer.../v4/pointer; no logic changes.
Import bumps — collectionutils & other go-libs
internal/core/env.go, many internal/resources/..., tools/kubectl-stacks/list.go, tools/utils/cmd/*.go, tools/utils/root.go, tools/utils/main.go
Updated numerous go-libs imports from v2 → v4 (collectionutils, bunconnect, bunmigrate, logging, service, etc.); mostly mechanical changes.
Misc small edits
several internal/core/*, internal/resources/*, tools/*
Minor control-flow/logging updates and import path fixes across several files; main functional changes are licence-related as above.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as cmd/main
    participant K8s as Kubernetes API
    participant Core as internal/core/licence
    participant Platform as Platform
    participant Controller as Module Controller

    CLI->>K8s: Read Secret (name) in namespace
    alt Secret with token found
        K8s-->>CLI: Secret (token)
        CLI->>Core: ResolveLicenceState(reader, secretName, namespace)
        Core->>Core: parseFormancePublicKey()\nValidateLicenceToken(token) (RS256 + exp)
        Core-->>CLI: LicenceState, LicenceMessage
    else Secret missing or empty
        CLI-->>CLI: LicenceStateAbsent
    end
    CLI->>Platform: set LicenceNamespace, LicenceState & LicenceMessage
    Controller->>Platform: check LicenceState for EE module
    alt LicenceState == LicenceStateValid
        Controller->>Controller: proceed with reconciliation / create licence ref / select EE image
    else
        Controller->>Controller: set LicenceValid=false condition\nskip EE reconciliation / delete licence ref
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hop through namespaces, sniff each token's seam,

I check the sig, the expiry, and the issuer's gleam.
If RS256 nods and time is kind, I wake the EE dream—
Else I tuck it safe, quiet as a stream.
Tests in my pouch, carrots for the CI team.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(licence): offline licence validation in operator' clearly and concisely describes the main change: adding offline licence validation to the operator.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, detailing the licence validation feature, behavior changes, code modifications, and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/offline-licence-validation
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Add centralized licence JWT validation directly in the operator using
the embedded Formance RSA public key (same as go-libs/v4). This enables
offline licence validation without any outbound network call.

Behaviour by licence state:
- Absent: operator deploys only non-EE modules (unchanged)
- Valid: operator deploys all modules including EE (unchanged)
- Expired/Invalid: operator continues reconciling non-EE modules,
  EE module reconciliation is skipped without deleting anything,
  a LicenceValid=False condition is set on the Stack and EE CRDs

Key changes:
- New LicenceValidator with embedded RSA public key (internal/core/licence.go)
- EE gating in ForModule() — single enforcement point (controllers.go)
- LicenceValid condition on Stack CRDs (stacks/init.go)
- payments-ee image only used when licence is actually valid
- Licence env vars only injected when licence is valid
- Operator remains healthy regardless of licence state
- 14 new unit tests covering JWT validation and condition behaviour
@flemzord flemzord force-pushed the feat/offline-licence-validation branch from c1894f5 to bbebbab Compare March 12, 2026 11:12
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/main.go`:
- Around line 53-72: The code currently falls back to a cluster-wide Secret list
on any reader.Get error (involving reader.Get, types.NamespacedName, secretName
and reader.List), which can mask RBAC/transient failures and mis-validate
duplicates; instead remove the cluster-wide scan and only treat a NotFound as
"missing": after reader.Get(..., secret) if err != nil { if
apierrors.IsNotFound(err) return core.LicenceStateInvalid, fmt.Sprintf("licence
secret %q not found", secretName) else return core.LicenceStateInvalid,
fmt.Sprintf("failed to get secret %q: %v", secretName, err) } — add the import
for k8s.io/apimachinery/pkg/api/errors as apierrors and drop the
corev1.SecretList/list logic.
- Around line 158-163: The current code computes licenceState/message once at
startup (using validateLicenceFromSecret with licenceSecret and
mgr.GetAPIReader()) and stores them on
platform.LicenceState/Platform.LicenceMessage, causing stale state; change the
logic so the licence is re-evaluated either when the Secret changes or during
each EE reconciliation: call validateLicenceFromSecret(...) from the
EnterpriseEdition reconciler (e.g., inside Reconcile in
internal/core/controllers.go or the EE reconciler method) using the
controller-runtime client or mgr.GetAPIReader(), and update
platform.LicenceState and platform.LicenceMessage with the fresh values (or
avoid global caching and use the returned values directly) so renewed/expired
tokens are reflected without restarting.
- Around line 75-80: The code currently only checks presence of the "token" key
but not its value, causing empty tokens to be treated as absent; update the
block that reads secret.Data["token"] (the token variable) to also validate that
the token is non-empty and, if empty, return core.LicenceStateInvalid with a
clear message rather than calling core.ValidateLicenceToken; ensure you use the
same token identifier and call signature (string(token)) when checking
length/emptiness before invoking core.ValidateLicenceToken.

In `@internal/core/controllers.go`:
- Around line 136-154: Replace the current partial licence check in the EE
branch with a full switch on ctx.GetPlatform().LicenceState (use t.IsEE(),
ctx.GetPlatform(), and t.GetConditions().AppendOrReplace as anchors): for
LicenceStateValid set a LicenceValid condition with Status True (clear any prior
false), for LicenceStateAbsent set LicenceValid False with Reason
LicenceStateAbsent and appropriate message, and for other non-valid states set
LicenceValid False with the corresponding state Reason/message; ensure you
respect LicenceSecret semantics (absence -> Absent) and always AppendOrReplace
the "LicenceValid" condition so transitions (Invalid→Valid and Absent)
update/clear previous values. Add regression tests exercising an EE module when
platform LicenceState is Absent and the Invalid→Valid transition to verify
ForObjectController no longer leaves a stale false condition.

In `@tools/utils/cmd/root.go`:
- Around line 8-9: Remove the full environment dump emitted at startup (the code
that iterates os.Environ or calls a helper to print all env vars) to avoid
leaking secrets; locate the startup/init code that logs environment variables
(e.g., in main() or init() where logger or logging is used) and delete that
dump, and if minimal diagnostics are needed replace it with an explicit
allowlist that uses os.LookupEnv for specific keys (example keys: "LOG_LEVEL",
"POD_NAME", "POD_NAMESPACE") and logs only those via logger.Debugf so secrets
are not exposed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2b143f66-84db-4665-a3d4-bc5490b3034b

📥 Commits

Reviewing files that changed from the base of the PR and between b429564 and c1894f5.

⛔ Files ignored due to path filters (6)
  • go.mod is excluded by !**/*.mod
  • go.sum is excluded by !**/*.sum, !**/*.sum
  • tools/kubectl-stacks/go.mod is excluded by !**/*.mod
  • tools/kubectl-stacks/go.sum is excluded by !**/*.sum, !**/*.sum
  • tools/utils/go.mod is excluded by !**/*.mod
  • tools/utils/go.sum is excluded by !**/*.sum, !**/*.sum
📒 Files selected for processing (41)
  • api/formance.com/v1beta1/shared.go
  • cmd/main.go
  • internal/core/controllers.go
  • internal/core/env.go
  • internal/core/licence.go
  • internal/core/licence_test.go
  • internal/core/platform.go
  • internal/core/reconciler.go
  • internal/core/setup.go
  • internal/core/utils.go
  • internal/resources/applications/application.go
  • internal/resources/auths/configuration.go
  • internal/resources/auths/deployment.go
  • internal/resources/auths/init.go
  • internal/resources/benthos/controller.go
  • internal/resources/brokerconsumers/controller.go
  • internal/resources/brokerconsumers/create.go
  • internal/resources/brokers/reconcile.go
  • internal/resources/brokers/utils.go
  • internal/resources/caddy/caddy.go
  • internal/resources/databases/init.go
  • internal/resources/gateways/caddyfile.go
  • internal/resources/gateways/init.go
  • internal/resources/jobs/job.go
  • internal/resources/ledgers/reindex.go
  • internal/resources/licence/licence.go
  • internal/resources/payments/init.go
  • internal/resources/resourcereferences/init.go
  • internal/resources/services/services_test.go
  • internal/resources/settings/helpers.go
  • internal/resources/settings/helpers_test.go
  • internal/resources/settings/resourcerequirements.go
  • internal/resources/stacks/init.go
  • internal/resources/stacks/licence_test.go
  • internal/tests/internal/matcher_be_owned_by.go
  • tools/kubectl-stacks/list.go
  • tools/utils/cmd/database-create.go
  • tools/utils/cmd/database-drop.go
  • tools/utils/cmd/database.go
  • tools/utils/cmd/root.go
  • tools/utils/main.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (4)
internal/core/controllers.go (1)

136-152: ⚠️ Potential issue | 🔴 Critical

Handle Absent and valid transitions explicitly here.

This branch still lets EE modules reconcile when LicenceSecret is empty, which contradicts the PR’s “Absent: EE not deployed” behavior. It also only ever writes LicenceValid=False, so an Invalid/Expired -> Valid transition can leave a stale current-generation false condition behind and ForObjectController will keep the module pending. Please make this a full licence-state flow: skip EE reconciliation for Absent, and clear or replace any previous LicenceValid=False when the licence becomes valid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/controllers.go` around lines 136 - 152, The current branch for
EE licence handling (involving t.IsEE(), ctx.GetPlatform(), LicenceSecret,
LicenceState and LicenceStateValid) incorrectly allows reconciliation when
LicenceSecret is empty and only ever writes LicenceValid=False; modify the logic
to explicitly treat LicenceState==Absent as "EE not deployed" and skip
reconciliation in that case (no conditions written), and when
LicenceState==LicenceStateValid replace/clear any existing LicenceValid=False by
writing a LicenceValid condition with Status metav1.ConditionTrue
(ObservedGeneration from t.GetGeneration(), LastTransitionTime metav1.Now(),
Reason set appropriately and Message from platform.LicenceMessage if present)
using t.GetConditions().AppendOrReplace (matching
v1beta1.ConditionTypeMatch("LicenceValid")) so invalid->valid transitions remove
the stale false condition and allow ForObjectController to proceed.
cmd/main.go (3)

75-80: ⚠️ Potential issue | 🟡 Minor

Treat an empty token value as invalid.

ValidateLicenceToken("") returns LicenceStateAbsent, so a secret with token: "" is currently classified as “no licence” instead of “bad licence secret”. That misstates the condition and can bypass the intended invalid-secret path.

Suggested fix
 token, ok := secret.Data["token"]
-if !ok {
-	return core.LicenceStateInvalid, "licence secret missing 'token' key"
+if !ok || len(token) == 0 {
+	return core.LicenceStateInvalid, "licence secret missing non-empty 'token' key"
 }
 
 return core.ValidateLicenceToken(string(token))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/main.go` around lines 75 - 80, The code currently only checks for
presence of secret.Data["token"] but not for an empty value, causing empty
tokens to be treated as LicenceStateAbsent; update the logic that handles
secret.Data["token"] (the token retrieval before calling
core.ValidateLicenceToken) to also treat an empty byte slice/string as invalid
by returning core.LicenceStateInvalid with a clear message (e.g., "licence
secret 'token' is empty") instead of calling core.ValidateLicenceToken when
token is empty; keep the presence check (ok) and only call
core.ValidateLicenceToken(string(token)) when token is non-empty.

51-72: ⚠️ Potential issue | 🟠 Major

Reject ambiguous cross-namespace secret matches.

Falling back to a cluster-wide SecretList and picking the first name match makes validation nondeterministic when the same secret name exists in multiple namespaces, and it also masks non-NotFound read failures. This should either require an explicit namespace/name input or fail when the lookup is ambiguous instead of silently validating an arbitrary secret.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/main.go` around lines 51 - 72, In validateLicenceFromSecret, stop
silently falling back to the first name match across the cluster: if reader.Get
returns a non-NotFound error, return that error instead of listing; only list
when the original Get is a NotFound, then collect all secrets with s.Name ==
secretName; if zero matches return not found, if multiple matches return an
ambiguous error (include the namespaces) instead of picking one, and only
proceed when exactly one match is found; update error messages to clearly state
ambiguity and prefer accepting explicit "namespace/name" input upstream if
ambiguity is possible.

160-163: ⚠️ Potential issue | 🟠 Major

Refresh licence state after startup.

platform.LicenceState is computed once here and then reused by reconciliation in internal/core/controllers.go and image selection in internal/resources/payments/init.go. A token that expires or is renewed while the operator is running will stay in the old state until restart, so EE gating can drift from the actual secret contents.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/main.go` around lines 160 - 163, The code sets platform.LicenceState and
LicenceMessage once using validateLicenceFromSecret at startup, causing stale EE
gating; change callers to re-evaluate the secret or add a refresh method: either
(A) replace direct reads of platform.LicenceState/Message in
internal/core/controllers.go and internal/resources/payments/init.go with calls
to validateLicenceFromSecret(mgr.GetAPIReader(), licenceSecret) at use-time, or
(B) add a Platform method like RefreshLicence(apiReader, licenceSecret) that
calls validateLicenceFromSecret and updates
Platform.LicenceState/LicenseMessage, and invoke that refresh from reconcile
paths and image-selection logic whenever the secret is read/used so state is
kept current.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@cmd/main.go`:
- Around line 75-80: The code currently only checks for presence of
secret.Data["token"] but not for an empty value, causing empty tokens to be
treated as LicenceStateAbsent; update the logic that handles
secret.Data["token"] (the token retrieval before calling
core.ValidateLicenceToken) to also treat an empty byte slice/string as invalid
by returning core.LicenceStateInvalid with a clear message (e.g., "licence
secret 'token' is empty") instead of calling core.ValidateLicenceToken when
token is empty; keep the presence check (ok) and only call
core.ValidateLicenceToken(string(token)) when token is non-empty.
- Around line 51-72: In validateLicenceFromSecret, stop silently falling back to
the first name match across the cluster: if reader.Get returns a non-NotFound
error, return that error instead of listing; only list when the original Get is
a NotFound, then collect all secrets with s.Name == secretName; if zero matches
return not found, if multiple matches return an ambiguous error (include the
namespaces) instead of picking one, and only proceed when exactly one match is
found; update error messages to clearly state ambiguity and prefer accepting
explicit "namespace/name" input upstream if ambiguity is possible.
- Around line 160-163: The code sets platform.LicenceState and LicenceMessage
once using validateLicenceFromSecret at startup, causing stale EE gating; change
callers to re-evaluate the secret or add a refresh method: either (A) replace
direct reads of platform.LicenceState/Message in internal/core/controllers.go
and internal/resources/payments/init.go with calls to
validateLicenceFromSecret(mgr.GetAPIReader(), licenceSecret) at use-time, or (B)
add a Platform method like RefreshLicence(apiReader, licenceSecret) that calls
validateLicenceFromSecret and updates Platform.LicenceState/LicenseMessage, and
invoke that refresh from reconcile paths and image-selection logic whenever the
secret is read/used so state is kept current.

In `@internal/core/controllers.go`:
- Around line 136-152: The current branch for EE licence handling (involving
t.IsEE(), ctx.GetPlatform(), LicenceSecret, LicenceState and LicenceStateValid)
incorrectly allows reconciliation when LicenceSecret is empty and only ever
writes LicenceValid=False; modify the logic to explicitly treat
LicenceState==Absent as "EE not deployed" and skip reconciliation in that case
(no conditions written), and when LicenceState==LicenceStateValid replace/clear
any existing LicenceValid=False by writing a LicenceValid condition with Status
metav1.ConditionTrue (ObservedGeneration from t.GetGeneration(),
LastTransitionTime metav1.Now(), Reason set appropriately and Message from
platform.LicenceMessage if present) using t.GetConditions().AppendOrReplace
(matching v1beta1.ConditionTypeMatch("LicenceValid")) so invalid->valid
transitions remove the stale false condition and allow ForObjectController to
proceed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 25306f4a-753a-4fd2-95ac-fd86b82a7d3d

📥 Commits

Reviewing files that changed from the base of the PR and between c1894f5 and bbebbab.

⛔ Files ignored due to path filters (2)
  • go.mod is excluded by !**/*.mod
  • go.sum is excluded by !**/*.sum, !**/*.sum
📒 Files selected for processing (9)
  • cmd/main.go
  • internal/core/controllers.go
  • internal/core/licence.go
  • internal/core/licence_test.go
  • internal/core/platform.go
  • internal/resources/licence/licence.go
  • internal/resources/payments/init.go
  • internal/resources/stacks/init.go
  • internal/resources/stacks/licence_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/core/platform.go
  • internal/resources/licence/licence.go

- Re-resolve licence state from Secret on each EE reconciliation,
  not just at startup — prevents stale state when token expires or
  Secret is updated while operator is running
- Handle all licence state transitions in ForModule():
  Valid → write LicenceValid=True (clears stale False condition)
  Absent → skip EE with LicenceValid=False/Absent
  Expired/Invalid → skip EE with LicenceValid=False
- Remove dangerous cluster-wide Secret fallback scan; use explicit
  namespace via --licence-namespace flag (defaults to POD_NAMESPACE)
- Treat empty token value as Invalid, not Absent
- Remove env vars dump from utils startup logs (security)
- Add SetFormancePublicKeyForTest for cross-package test overrides
- Add tests: EmptyToken, SecretNotFound, real Secret-based conditions
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/resources/stacks/init.go (1)

189-217: LicenceStateAbsent falls through to "Unknown" instead of being handled explicitly.

When ResolveLicenceState returns LicenceStateAbsent (e.g., if secretName is somehow empty after the initial check), this falls to the default case and sets reason "Unknown". For consistency with controllers.go (which handles LicenceStateAbsent explicitly), consider adding an explicit case.

However, given the early return at lines 191-194 when LicenceSecret == "", the only way to reach LicenceStateAbsent would be if ResolveLicenceState returned it for a non-empty secret name, which doesn't happen in the current implementation. This is a minor consistency concern.

♻️ Optional: Add explicit case for Absent
 	switch licenceState {
 	case LicenceStateValid:
 		condition.SetStatus(metav1.ConditionTrue).SetReason("Valid").SetMessage("Licence is valid")
 	case LicenceStateExpired:
 		condition.SetStatus(metav1.ConditionFalse).SetReason("Expired").SetMessage("Licence token is expired")
 	case LicenceStateInvalid:
 		msg := "Licence token is invalid"
 		if licenceMessage != "" {
 			msg = licenceMessage
 		}
 		condition.SetStatus(metav1.ConditionFalse).SetReason("Invalid").SetMessage(msg)
+	case LicenceStateAbsent:
+		// Should not reach here since we return early if LicenceSecret is empty
+		condition.SetStatus(metav1.ConditionFalse).SetReason("Absent").SetMessage("No licence configured")
 	default:
 		condition.SetStatus(metav1.ConditionFalse).SetReason("Unknown").SetMessage("Licence state unknown")
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/resources/stacks/init.go` around lines 189 - 217, In
setLicenceCondition, add an explicit case for LicenceStateAbsent (the value
returned by ResolveLicenceState) instead of letting it fall through to default;
update the condition to SetStatus(metav1.ConditionFalse) and SetReason("Absent")
with a clear message (matching the behavior in controllers.go) so
LicenceStateAbsent is handled consistently with other states returned by
ResolveLicenceState; locate this change in the setLicenceCondition function
where the switch on licenceState is implemented and add the new case alongside
LicenceStateValid/Expired/Invalid.
internal/core/licence.go (1)

87-117: Consider caching the parsed public key.

parseFormancePublicKey() is called on every token validation. While the overhead is minimal for PEM parsing, caching the result in a sync.Once would be more efficient, especially given this is called on every EE reconciliation.

♻️ Optional: Cache the parsed public key
+var (
+	parsedPublicKey     *rsa.PublicKey
+	parsedPublicKeyOnce sync.Once
+	parsedPublicKeyErr  error
+)
+
+func getFormancePublicKey() (*rsa.PublicKey, error) {
+	parsedPublicKeyOnce.Do(func() {
+		parsedPublicKey, parsedPublicKeyErr = parseFormancePublicKey()
+	})
+	return parsedPublicKey, parsedPublicKeyErr
+}
+
 func ValidateLicenceToken(token string) (LicenceState, string) {
 	if token == "" {
 		return LicenceStateAbsent, ""
 	}
 
-	rsaPub, err := parseFormancePublicKey()
+	rsaPub, err := getFormancePublicKey()
 	if err != nil {

Note: This optimization would require updating SetFormancePublicKeyForTest to also reset the sync.Once, which adds complexity. Given the current reconciliation frequency, the existing approach is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/licence.go` around lines 87 - 117, ValidateLicenceToken
currently calls parseFormancePublicKey on every invocation; change this by
caching the parsed RSA public key using a package-level variable and sync.Once
(e.g., add a once variable and cached rsaPub used by ValidateLicenceToken) so
parseFormancePublicKey is only run once, and update SetFormancePublicKeyForTest
to reset the cache/once (or provide a test-only setter) so tests can replace the
key; ensure error handling from parseFormancePublicKey is preserved and returned
when the cached init fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/licence.go`:
- Around line 137-146: ResolveLicenceState currently uses context.Background()
which discards caller cancellation/deadline; change its signature to accept a
context.Context parameter (e.g., func ResolveLicenceState(ctx context.Context,
reader client.Reader, secretName string, operatorNamespace string)
(LicenceState, string)) and replace reader.Get(context.Background(), ...) with
reader.Get(ctx, ...). Update all call sites (notably in controllers.go and
cmd/main.go) to pass their existing reconciliation or main context through, and
add/adjust the context import if needed.

---

Nitpick comments:
In `@internal/core/licence.go`:
- Around line 87-117: ValidateLicenceToken currently calls
parseFormancePublicKey on every invocation; change this by caching the parsed
RSA public key using a package-level variable and sync.Once (e.g., add a once
variable and cached rsaPub used by ValidateLicenceToken) so
parseFormancePublicKey is only run once, and update SetFormancePublicKeyForTest
to reset the cache/once (or provide a test-only setter) so tests can replace the
key; ensure error handling from parseFormancePublicKey is preserved and returned
when the cached init fails.

In `@internal/resources/stacks/init.go`:
- Around line 189-217: In setLicenceCondition, add an explicit case for
LicenceStateAbsent (the value returned by ResolveLicenceState) instead of
letting it fall through to default; update the condition to
SetStatus(metav1.ConditionFalse) and SetReason("Absent") with a clear message
(matching the behavior in controllers.go) so LicenceStateAbsent is handled
consistently with other states returned by ResolveLicenceState; locate this
change in the setLicenceCondition function where the switch on licenceState is
implemented and add the new case alongside LicenceStateValid/Expired/Invalid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b8ac89c3-2554-417a-aeb5-06eda43561f5

📥 Commits

Reviewing files that changed from the base of the PR and between bbebbab and f4f8176.

📒 Files selected for processing (8)
  • cmd/main.go
  • internal/core/controllers.go
  • internal/core/licence.go
  • internal/core/licence_test.go
  • internal/core/platform.go
  • internal/resources/stacks/init.go
  • internal/resources/stacks/licence_test.go
  • tools/utils/cmd/root.go

Comment on lines +137 to +146
func ResolveLicenceState(reader client.Reader, secretName string, operatorNamespace string) (LicenceState, string) {
if secretName == "" {
return LicenceStateAbsent, ""
}

secret := &corev1.Secret{}
err := reader.Get(context.Background(), types.NamespacedName{
Name: secretName,
Namespace: operatorNamespace,
}, secret)
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.

⚠️ Potential issue | 🟡 Minor

Pass caller's context instead of context.Background().

The function uses context.Background() for the Kubernetes API call, which loses the caller's cancellation signals and deadlines. Since this is called during reconciliation (which has its own context), the reconciliation's context should be passed through.

🔧 Proposed fix
-func ResolveLicenceState(reader client.Reader, secretName string, operatorNamespace string) (LicenceState, string) {
+func ResolveLicenceState(ctx context.Context, reader client.Reader, secretName string, operatorNamespace string) (LicenceState, string) {
 	if secretName == "" {
 		return LicenceStateAbsent, ""
 	}
 
 	secret := &corev1.Secret{}
-	err := reader.Get(context.Background(), types.NamespacedName{
+	err := reader.Get(ctx, types.NamespacedName{
 		Name:      secretName,
 		Namespace: operatorNamespace,
 	}, secret)

This requires updating callers in controllers.go and cmd/main.go to pass their context.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func ResolveLicenceState(reader client.Reader, secretName string, operatorNamespace string) (LicenceState, string) {
if secretName == "" {
return LicenceStateAbsent, ""
}
secret := &corev1.Secret{}
err := reader.Get(context.Background(), types.NamespacedName{
Name: secretName,
Namespace: operatorNamespace,
}, secret)
func ResolveLicenceState(ctx context.Context, reader client.Reader, secretName string, operatorNamespace string) (LicenceState, string) {
if secretName == "" {
return LicenceStateAbsent, ""
}
secret := &corev1.Secret{}
err := reader.Get(ctx, types.NamespacedName{
Name: secretName,
Namespace: operatorNamespace,
}, secret)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/licence.go` around lines 137 - 146, ResolveLicenceState
currently uses context.Background() which discards caller cancellation/deadline;
change its signature to accept a context.Context parameter (e.g., func
ResolveLicenceState(ctx context.Context, reader client.Reader, secretName
string, operatorNamespace string) (LicenceState, string)) and replace
reader.Get(context.Background(), ...) with reader.Get(ctx, ...). Update all call
sites (notably in controllers.go and cmd/main.go) to pass their existing
reconciliation or main context through, and add/adjust the context import if
needed.

The offline licence validation feature gates EE module reconciliation
behind a valid licence. Integration tests were not configuring any
licence state, causing all EE modules (Auth, Wallets, Orchestration)
to be skipped and their deployments never created, resulting in
timeouts.
@flemzord flemzord requested a review from a team as a code owner March 19, 2026 15:04
The previous fix only set LicenceState without a LicenceSecret,
causing GetLicenceEnvVars to create ResourceReferences to an empty
secret name. This broke EE module reconciliation and caused cascading
failures in non-EE tests.

Now the test bootstrap:
- Generates a test RSA key pair and overrides the embedded public key
- Signs a valid JWT token
- Creates a licence Secret in the test cluster
- Configures Platform with LicenceSecret, LicenceNamespace, and
  LicenceState so all licence checks pass properly
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
internal/tests/internal/bootstrap.go (2)

129-135: Misleading variable name: licenceSecret is actually a Namespace.

The variable licenceSecret is declared as *corev1.Namespace but the name suggests it's a Secret. This creates confusion when reading the code.

♻️ Suggested fix
-	licenceSecret := &corev1.Namespace{
+	licenceNamespace := &corev1.Namespace{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: testLicenceSecretNamespace,
 		},
 	}
 	// default namespace should already exist, ignore error
-	_ = k8sClient.Create(context.Background(), licenceSecret)
+	_ = k8sClient.Create(context.Background(), licenceNamespace)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/tests/internal/bootstrap.go` around lines 129 - 135, The variable
named `licenceSecret` is misleading because it's created as a
`*corev1.Namespace`; rename it to something like `licenceNamespace` (or
`testLicenceNamespace`) and update all references (the declaration and the
`k8sClient.Create(context.Background(), licenceSecret)` call) so the variable
name matches its type (`*corev1.Namespace`) and avoids confusion with Secrets.

142-145: The issuer field is written but never used.

Based on the ResolveLicenceState implementation (which only reads the "token" key) and ValidateLicenceToken (which doesn't validate issuer claims), the issuer field is dead data. Consider removing it to avoid confusion about what the licence validation actually checks.

♻️ Suggested fix
 		Data: map[string][]byte{
 			"token":  []byte(licenceToken),
-			"issuer": []byte("https://license.formance.cloud/keys"),
 		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/tests/internal/bootstrap.go` around lines 142 - 145, The test writes
an unused "issuer" map entry which is dead data because ResolveLicenceState only
reads "token" and ValidateLicenceToken doesn't check issuer claims; remove the
"issuer": []byte("https://license.formance.cloud/keys") entry from the Data map
in the bootstrap test (where licenceToken is used) to avoid confusion, or if
issuer validation is intended, update ValidateLicenceToken and
ResolveLicenceState to parse and validate the issuer claim accordingly—choose
one approach and make the corresponding change so the test and validation code
are consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/tests/internal/bootstrap.go`:
- Around line 129-135: The variable named `licenceSecret` is misleading because
it's created as a `*corev1.Namespace`; rename it to something like
`licenceNamespace` (or `testLicenceNamespace`) and update all references (the
declaration and the `k8sClient.Create(context.Background(), licenceSecret)`
call) so the variable name matches its type (`*corev1.Namespace`) and avoids
confusion with Secrets.
- Around line 142-145: The test writes an unused "issuer" map entry which is
dead data because ResolveLicenceState only reads "token" and
ValidateLicenceToken doesn't check issuer claims; remove the "issuer":
[]byte("https://license.formance.cloud/keys") entry from the Data map in the
bootstrap test (where licenceToken is used) to avoid confusion, or if issuer
validation is intended, update ValidateLicenceToken and ResolveLicenceState to
parse and validate the issuer claim accordingly—choose one approach and make the
corresponding change so the test and validation code are consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9fef6768-4c8f-49f1-9370-a6dcf9d80e79

📥 Commits

Reviewing files that changed from the base of the PR and between 815d20c and 693a7d0.

📒 Files selected for processing (1)
  • internal/tests/internal/bootstrap.go

…okup

The ResourceReference controller finds secrets by filtering on the
formance.com/stack label. Without this label, the licence secret was
invisible to the resource reference reconciler, causing "item not
found: formance-licence" errors that blocked module reconciliation.
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.

1 participant