Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions docs/04-Modules/12-Connectivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,23 @@ During reconciliation, the Operator may adopt same-name ownerless delegated Conn

The Operator provisions a cluster-scoped `ledger.formance.com/Credentials` named `connectivity-<stack>`, selected onto the stack's ledger Cluster with `spec.selector.matchLabels[formance.com/stack]`, with superuser mode disabled and exactly the scope set Connectivity's ingestion path requires:

- `ledger:AccountRead` — cursor `GetAccount`
- `ledger:LedgerWrite` — `CreateLedger`, `CreateIndex`, `SaveNumscript`
- `ledger:TransactionWrite` — plugin transaction actions and cursor save in `Apply`
- `ledger:MetadataWrite` — plugin metadata actions and cursor save in `Apply`
- `ledger:QueryWrite` — `CreatePreparedQuery`
- `ledger:ReadAccount` — cursor `GetAccount`
- `ledger:WriteLedger` — `CreateLedger`, `CreateIndex`, `SaveNumscript`
- `ledger:WriteTransaction` — plugin transaction actions in `Apply`
- `ledger:WriteMetadata` — plugin metadata actions and cursor save in `Apply`
- `ledger:WriteQuery` — `CreatePreparedQuery`

The scope list is owned by the integration contract (see the [Ledger capability matrix](https://github.com/formancehq/connectivity/blob/main/docs/architecture/ledger-capabilities.md)); it is not configurable through the Connectivity CRD and is updated only when Connectivity's actual Ledger calls change. The delegated binding uses the supported `spec.auth.keyIdSecretKeyRef` and `spec.auth.secretKeyRef`, referencing `key-id` and `seed.hex` in the same Ledger-distributed Secret, with `spec.auth.subject=connectivity`. Reconciliation replaces the auth map to remove old inline or bundle fields. The Connectivity operator watches both Secret keys and restarts Core when either changes. The Operator creates no credential bundle or additional signing Secret.
The scope list is owned by the integration contract (see the [Ledger capability matrix](https://github.com/formancehq/connectivity/blob/main/docs/architecture/core/ledger-capabilities.md)); it is not configurable through the Connectivity CRD and follows the scopes enforced by the supported Ledger version. The delegated binding uses the supported `spec.auth.keyIdSecretKeyRef` and `spec.auth.secretKeyRef`, referencing `key-id` and `seed.hex` in the same Ledger-distributed Secret, with `spec.auth.subject=connectivity`. Reconciliation replaces the auth map to remove old inline or bundle fields. The Connectivity operator watches both Secret keys and restarts Core when either changes. The Operator creates no credential bundle or additional signing Secret.

An existing superuser Credentials converges to the narrowed spec on the next reconciliation, retaining its key ID and distributed Secret. After any credential update, the Operator waits for `status.phase=Ready` and `status.observedGeneration` to match `metadata.generation` before proceeding with delegation. This status confirms credential distribution; Ledger Cluster reconciliation applies the grants separately. Deploy a compatible Connectivity Core that emits scoped tokens with these scopes before narrowing the registered key; older Core versions that emit `superuser=true` will fail authentication after the change. Before releasing this Operator change, exercise the migration with an existing credential against a real Ledger, verify continued ingestion and denial of unrelated privileges, and record the exact Core, Operator and Ledger revisions. Kubernetes readiness alone does not prove authorization.
An existing Credentials with RC.1 scope names (`ledger:AccountRead`, `ledger:LedgerWrite`, `ledger:TransactionWrite`, `ledger:MetadataWrite`, `ledger:QueryWrite`) or superuser grants converges to the canonical spec on the next reconciliation, retaining its key ID and distributed Secret. After any credential update, the Operator waits for `status.phase=Ready` and `status.observedGeneration` to match `metadata.generation` before proceeding with delegation. This status confirms credential distribution; Ledger Cluster reconciliation applies the grants separately. Deploy a compatible Connectivity Core that emits scoped tokens with these scopes before narrowing the registered key; older Core versions that emit `superuser=true` will fail authentication after the change. Before releasing this Operator change, exercise the migration with an existing credential against a real Ledger, verify continued ingestion and denial of unrelated privileges, and record the exact Core, Operator and Ledger revisions. Kubernetes readiness alone does not prove authorization.

If Ledger reports authorization errors, check the Credentials' current observed generation, `spec.superuser=false`, the five scopes, and the deployed Core token behavior. A version or scope mismatch requires compatible Core/Operator versions. Rolling back the Operator restores the previous superuser grants on reconciliation; it widens privileges and must follow the deployment approval process.
### Migrating RC.1 scope names

Coordinate this Operator with a Connectivity Core that claims the canonical scopes above and Ledger RC.2 or later. A Core that still claims the RC.1 names cannot authenticate with a key narrowed to the new scopes; the canonical Core cannot use RC.1's granular authorization contract. Existing manually supplied credentials and bundles must be migrated by their owner as well.

Reconciliation updates `Credentials.spec.scopes` in place and waits for its new observed generation. Before resuming ingestion, verify that the Ledger Cluster controller has applied the five canonical grants to the existing registered key, then exercise a real authenticated provisioning and ingestion call. Credentials readiness alone confirms distribution and does not prove that the registered Ledger grants have converged. Preserve the existing key and Secret; a scope rename does not require key rotation.

If Ledger reports authorization errors, check the Credentials' current observed generation, `spec.superuser=false`, the five scopes, the registered key grants, and the deployed Core token behavior. A version or scope mismatch requires compatible Core/Operator versions. A rollback must restore matching Core token claims, Operator-desired grants and Ledger authorization semantics together; rolling back only the scope list can interrupt ingestion. Any rollback to a superuser-producing Operator widens privileges and requires deployment approval.

The rollout proof intentionally matches the Connectivity operator's supported rendering contract: `spec.api.auth` contains exactly `issuer` and `checkScopes`; the `api` container receives literal `AUTH_ENABLED`, `AUTH_ISSUER`, and `AUTH_CHECK_SCOPES` values; and a ClusterIP Service selects and routes to that container. These assumptions match the minimum supported Connectivity operator (`v1.0.0-alpha.1`). A future operator version that changes this schema or rendering remains fail closed: the public route stays revoked with a `ConnectivityAPIPending` condition until this integration is updated.

Expand Down
4 changes: 2 additions & 2 deletions helm/crds/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: "3.21.1"
version: "3.21.2"
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "v3.21.1"
appVersion: "v3.21.2"
6 changes: 3 additions & 3 deletions helm/operator/Chart.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
dependencies:
- name: operator-crds
repository: file://../crds
version: 3.21.1
digest: sha256:0a231f2904ca1cd10d842c81d508268d907c12fb00ad427f463f0a26b126160c
generated: "2026-11-05T17:02:27.569014+01:00"
version: 3.21.2
digest: sha256:b173eebe1025cdd58f7e30d032884fc750b9dda67d79a7c33b44b9e9b5e0f1ad
generated: "2026-11-06T10:50:45.138035+01:00"
4 changes: 2 additions & 2 deletions helm/operator/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: "3.21.1"
version: "3.21.2"
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "v3.21.1"
appVersion: "v3.21.2"
dependencies:
- name: operator-crds
version: "3.X"
Expand Down
24 changes: 12 additions & 12 deletions internal/resources/connectivities/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,19 @@ var (
connectivityAvailable bool

// connectivityLedgerScopes is the exact set of Ledger v3 scopes Connectivity's
// ingestion path requires for its Core calls: AccountRead (cursor GetAccount),
// LedgerWrite (CreateLedger, CreateIndex, SaveNumscript), TransactionWrite and
// MetadataWrite (plugin transaction/metadata actions and cursor save in Apply),
// and QueryWrite (CreatePreparedQuery). Discovery is public. The list is owned
// by the integration contract, not user configuration; update it only when
// Core's actual Ledger calls change. See the capability matrix in
// connectivity's docs/architecture/ledger-capabilities.md.
// ingestion path requires for its Core calls: ReadAccount (cursor GetAccount),
// WriteLedger (CreateLedger, CreateIndex, SaveNumscript), WriteTransaction and
// WriteMetadata (plugin transaction/metadata actions and cursor save in Apply),
// and WriteQuery (CreatePreparedQuery). Discovery is public. The list is owned
// by the integration contract, not user configuration; update it when
// Core's actual Ledger calls or Ledger scope names change. See connectivity's
// capability matrix in docs/architecture/core/ledger-capabilities.md.
connectivityLedgerScopes = []string{
"ledger:AccountRead",
"ledger:LedgerWrite",
"ledger:TransactionWrite",
"ledger:MetadataWrite",
"ledger:QueryWrite",
"ledger:ReadAccount",
"ledger:WriteLedger",
"ledger:WriteTransaction",
"ledger:WriteMetadata",
"ledger:WriteQuery",
}

// ledgerCredentialsGVK is the cluster-scoped ledger.formance.com/Credentials
Expand Down
75 changes: 75 additions & 0 deletions internal/resources/connectivities/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,81 @@ func TestEnsureLedgerCredentialsConvergesSuperuserCredentialToScoped(t *testing.
}
}

// Scope renames must migrate an existing scoped credential in place, without
// trusting the Ready status for its previous grants or rotating the signing key.
func TestEnsureLedgerCredentialsMigratesLegacyScopes(t *testing.T) {
existing := readyLedgerCredentialsForStack1(1, 1)
existing.SetUID(types.UID("existing-credential"))
legacyScopes := []string{
"ledger:AccountRead", "ledger:LedgerWrite", "ledger:TransactionWrite",
"ledger:MetadataWrite", "ledger:QueryWrite",
}
_ = unstructured.SetNestedStringSlice(existing.Object, legacyScopes, "spec", "scopes")
ctx := newCredsTestContext(t, existing)
stack := &v1beta1.Stack{}
stack.Name = "stack1"

keyID, secret, ready, err := ensureLedgerCredentials(ctx, stack)
if err != nil {
t.Fatal(err)
}
if ready || keyID != "" || secret != "" {
t.Fatal("legacy Ready status must not authorize delegation after scope migration")
}
got := &unstructured.Unstructured{}
got.SetGroupVersionKind(ledgerCredentialsGVK)
if err := ctx.GetClient().Get(ctx, client.ObjectKeyFromObject(existing), got); err != nil {
t.Fatal(err)
}
scopes, _, _ := unstructured.NestedStringSlice(got.Object, "spec", "scopes")
want := []string{
"ledger:ReadAccount", "ledger:WriteLedger", "ledger:WriteTransaction",
"ledger:WriteMetadata", "ledger:WriteQuery",
}
if !slices.Equal(scopes, want) {
t.Fatalf("migrated scopes = %v, want %v", scopes, want)
}
if got.GetUID() != existing.GetUID() {
t.Fatal("scope migration must preserve the existing credential identity")
}

// Fake clients do not increment generation on spec changes; model the API
// server increment before checking the unobserved and observed states.
got.SetGeneration(2)
if err := ctx.GetClient().Update(ctx, got); err != nil {
t.Fatal(err)
}
keyID, secret, ready, err = ensureLedgerCredentials(ctx, stack)
if err != nil {
t.Fatal(err)
}
if ready || keyID != "" || secret != "" {
t.Fatal("migrated credential must wait for the new observedGeneration")
}
_ = unstructured.SetNestedField(got.Object, int64(2), "status", "observedGeneration")
if err := ctx.GetClient().Update(ctx, got); err != nil {
t.Fatal(err)
}

for range 2 {
keyID, secret, ready, err = ensureLedgerCredentials(ctx, stack)
if err != nil {
t.Fatal(err)
}
if !ready || keyID != "2ba721b2866686f6" || secret != "ledger-connectivity-stack1-credentials-keys" {
t.Fatalf("migrated credential changed identity: ready=%v keyID=%q secret=%q", ready, keyID, secret)
}
after := &unstructured.Unstructured{}
after.SetGroupVersionKind(ledgerCredentialsGVK)
if err := ctx.GetClient().Get(ctx, client.ObjectKeyFromObject(existing), after); err != nil {
t.Fatal(err)
}
if after.GetResourceVersion() != got.GetResourceVersion() {
t.Fatal("reconciling canonical scopes must not update an already-converged credential")
}
}
}

// A Ready phase observed for an older generation proves nothing about the
// current spec: whoever changed the spec (this operator or otherwise), the
// ledger operator must re-observe the new generation before the credential is
Expand Down
Loading