Chore/merge release 4.7.0 to main - #1995
Merged
Merged
Conversation
Merges main into develop for the 4.6.2 release.
…n-extended threshold/weight parsing on the RPC ledger backend (#1987) ### Description `StellarRpc.getAccount()` reads an account's `med_threshold` and master-key weight from the on-chain XDR `Thresholds` field, which the Java SDK models as a signed `byte[]` even though the wire type is `uint8`. The code widened each byte with a plain `(int)`/`(long)` cast, which in Java sign-extends: any byte in `[128,255]` becomes negative (`200` → `-56`, `255` → `-1`). `Sep10Service` then compares that value against the summed SEP-10 challenge signer weights via the SDK's `Sep10Challenge.verifyChallengeTransactionThreshold`, which does a signed `if_icmpge`. A negative threshold makes that comparison unconditionally true, so on accounts with `med_threshold >= 128` (institutional custody, issuers, or any account using per-signer weights like `50/100/255` instead of `1/2/3`), a single listed signer of any weight — including a master key the owner deliberately demoted to weight 0 after a compromise — is enough to obtain a valid SEP-10 JWT for that account. The same cast applied to the master weight produces the mirror-image bug: any single-signer account whose owner raised their master weight to `>= 128` is permanently locked out, with the anchor returning a negative weight in the rejection message. This only affects the RPC ledger backend (`stellar_network.type: rpc`); Horizon is unaffected because `AccountResponse.Thresholds` is already deserialized from JSON into correctly-ranged `int`s. RPC is not the schema default, but it is what SDF's own shipped reference profile sets and is required for Soroban/SEP-45 support, so this isn't an edge-case configuration. The fix is a single masking helper (`b & 0xFF`), applied at all four points the raw bytes are widened. No other change is needed: once the parsed value is guaranteed non-negative by construction, both the bypass and the lockout resolve as a direct consequence, and no defense-in-depth range check or signer-list change is required. **Changes** - [x] `StellarRpc.getAccount`: added `unsignedByte(byte)` (`b & 0xFF`) and used it for the three threshold bytes and the master-key signer weight, replacing the sign-extending `(int)`/`(long)` casts. - [x] `Sep10RpcThresholdSignExtensionTest.kt` (new): wires the real `StellarRpc` + `Sep10Service` + pinned Stellar SDK together (only the Soroban-RPC network call itself is stubbed, same boundary `StellarRpcTest` already stubs) and runs real, freshly-signed SEP-10 challenges through them, so the SDK's actual threshold-comparison bytecode decides pass/fail. Covers: the parsing defect in isolation, a below-threshold co-signer bypass, a revoked weight-0 master-key bypass, the owner-lockout case, a control case proving the check itself works, and a 7-value parameterized sweep (`0, 1, 127, 128, 200, 254, 255`) asserting no `uint8` byte value ever parses as negative. **Acceptance Criteria** - [x] On the RPC ledger backend, an account with `med_threshold = 200` rejects a SEP-10 challenge signed only by a single weight-100 co-signer. - [x] The same account rejects a challenge signed only by a revoked weight-0 master key. - [x] A plain single-signer account with master weight 200 and `med_threshold = 100` authenticates successfully with its own master key (no lockout). - [x] `StellarRpc.getAccount()` returns non-negative `low`/`medium`/`high` thresholds and a non-negative master-signer weight for every possible on-chain byte value (`0`–`255`). - [x] Existing correctly-rejected cases (weight below threshold, both bytes `<128`) are unaffected. ### Context [HackerOne #3893785](https://hackerone.com/reports/3893785) ### Testing - Unit: `./gradlew :core:test --tests "org.stellar.anchor.ledger.Sep10RpcThresholdSignExtensionTest"` - Unit: `./gradlew :core:test --tests "org.stellar.anchor.ledger.StellarRpcTest"` (no regressions in existing coverage) - Integration: `./gradlew :core:test :platform:test :core:spotlessCheck` (full suite, confirms no regressions elsewhere) ### Documentation N/A ### Known limitations N/A
…tion in SEP-10/SEP-45 takes the whole anchor SEP API offline from one host (#1990) ### Description `Sep10Service.createChallenge` and both `Sep45Service` call sites (`createArgsFromRequest`, `verifyArguments`) fetch the wallet-supplied `client_domain`'s `stellar.toml` synchronously, on the servlet request thread, before authorization is checked. The fetch has no bulkhead: `ClientDomainHelper.fetchSigningKeyFromClientDomain` can block for up to ~30s (a 15s OkHttp call timeout, doubled by the HTTPS→HTTP retry off mainnet), and nothing caps how many of these can run concurrently. `/auth` and `/sep45/auth` are both unauthenticated by design (SEP-10 issues the JWT, so it can't require one), and the shipped default (`sep10.client_attribution_required: false`, and SEP-45 had no allow-list option at all) means any public hostname is accepted with zero preconditions. An unauthenticated attacker pointing `client_domain` at a host that just never answers can park a servlet worker per request; ~20 req/s (3.2 KB/s) exhausts the default 200-thread Tomcat pool and takes down every SEP endpoint on that connector. The project's own Helm chart makes this worse: `livenessProbe`/`readinessProbe` hit `/health` on the same connector/pool, so starvation escalates to actual pod destruction and a crash-loop that outlives the attack. Correcting only the allow-list gap (giving SEP-45 the same opt-in `client_allow_list` SEP-10 already has) does not close this: the DoS doesn't depend on the domain being disallowed. Any allow-listed domain — or any domain the attacker can influence the DNS/routing of — can still park a thread if it's slow to respond. The actual fix has to bound *concurrency*, not *which domains are trusted*. So this PR does both: extends the allow-list to SEP-45 (closes the confirmed SSRF-scope gap, report claim (ii)), and moves the `client_domain` fetch off the servlet thread onto a small, bounded, reject-not-queue executor (closes the thread-exhaustion DoS, report claim (iii), the report's headline finding). Report claim (i) — that the existing SEP-10 allow-list guard "checks the wrong getter" — is not addressed here because it isn't a bug: the current guard matches the deliberate, already-shipped, already-tested design from the prior SEP-10 SSRF fix (`0979bd19`, #3824178), which was itself reverted from a stricter draft specifically to avoid breaking operators who configure `clients:` for unrelated reasons. **Changes** - [x] `ClientDomainHelper.java`: added a static, bounded `ThreadPoolExecutor` (`core=4, max=8, keepAlive=60s, SynchronousQueue`, `AbortPolicy`) and a new `fetchSigningKeyFromClientDomainBounded(clientDomain, allowHttpRetry)` that submits the existing unbounded fetch to it and waits at most 2.5s via `Future.get`. `RejectedExecutionException`/`TimeoutException` (pool full or timed out) and `ExecutionException` (unwrapped to the original `SepException` when that's the cause) both surface as a generic `SepException("client_domain resolution unavailable")` — no behavior change for the normal-latency case, since the timeout is well above any real anchor's expected TOML fetch time. - [x] `Sep10Service.java`: `createChallenge` and the package-private `fetchSigningKeyFromClientDomain` wrapper (renamed `fetchSigningKeyFromClientDomainBounded`) now call the bounded method instead of the direct one. - [x] `Sep45Service.java`: both call sites switched to the bounded method the same way. Added `validateClientDomainAllowed(clientDomain)`, called before either fetch — mirrors `Sep10Service.validateChallengeRequestClient`'s opt-in branch exactly: only enforced when `sep45Config.getClientAllowList()` is explicitly non-empty, so operators who never set the new field see no behavior change. - [x] `Sep45Config.java` (interface): added `getClientAllowList()` / `getAllowedClientDomains()`. - [x] `PropertySep45Config.java`: added `clientAllowList` field and a `ClientService` dependency (new constructor param); `getAllowedClientDomains()` derives from the `clients:` section using the identical logic `PropertySep10Config` already uses (falls back to all non-custodial clients' domains when the list is unset); `validate()` rejects any allow-list entry that doesn't name a configured client. - [x] `SepBeans.java`: `sep45Config(...)` bean now takes and passes `ClientService`. - [x] `anchor-config-default-values.yaml` / `anchor-config-schema-v1.yaml`: documented `sep45.client_allow_list`, mirroring the existing `sep10.client_allow_list` entry. - [x] `ClientDomainHelperTest.kt`: added tests proving (a) a normal, unsaturated call still fails with the ordinary fetch error, not the bulkhead one, and (b) once the bounded pool's `maximumPoolSize` is saturated by blocking tasks, a further call rejects in well under the 2.5s bound rather than hanging. - [x] `Sep10ServiceTest.kt`: updated the two references to the renamed `fetchSigningKeyFromClientDomainBounded`. - [x] `Sep45ServiceTest.kt`: added three tests mirroring `Sep10ServiceTest`'s allow-list coverage — rejects an out-of-list `client_domain` without ever calling the fetch; allows any `client_domain` when no explicit list is set; allows an unlisted `client_domain` when `clients:` exists only for unrelated configuration. - [x] `Sep45ConfigTest.kt`: updated the `PropertySep45Config` constructor call for the new `ClientService` param; added allow-list derivation and validation tests mirroring `Sep10ConfigTest`. **Acceptance Criteria** - [x] A `client_domain` pointed at a host that never responds fails in ~2.5s, not ~15–30s. - [x] 20 concurrent requests against such a host never block the servlet/Tomcat thread pool: `/health` and other SEP endpoints keep responding normally throughout. - [x] Of those 20, only as many as the bounded pool's capacity (≤8) actually wait out the 2.5s bound; the rest are rejected immediately. - [x] SEP-45 with an explicit `sep45.client_allow_list` configured rejects a `client_domain` outside that list with `SepNotAuthorizedException`, before any outbound fetch is attempted. - [x] SEP-45 with no explicit `client_allow_list` behaves exactly as before this PR (no regression for existing deployments). - [x] SEP-10's existing allow-list behavior is unchanged. ### Context [HackerOne #3903968](https://hackerone.com/reports/3903968) ### Testing - Unit: `./gradlew :core:test --tests "org.stellar.anchor.sep10.Sep10ServiceTest" --tests "org.stellar.anchor.sep45.Sep45ServiceTest" --tests "org.stellar.anchor.util.ClientDomainHelperTest"` - Unit: `./gradlew :platform:test --tests "org.stellar.anchor.platform.config.Sep45ConfigTest" --tests "org.stellar.anchor.platform.config.Sep10ConfigTest"` - Full regression: `./gradlew :core:test :platform:test` ### Documentation N/A ### Known limitations N/A
… requests to arbitrary attacker-controlled hosts (SSRF) (#1991) ### Description The ANCHOR-1236 fix (PR #1984) closed the missing-allow-list gap but never touched `ClientDomainHelper.validateDomainNotPrivateNetwork`, the private-network guard that runs on mainnet before fetching a `client_domain`'s `stellar.toml`. Follow-up review on report 3824178 found that guard doesn't actually protect the fetch it's meant to gate: it resolves the hostname once (`InetAddress.getAllByName`) to decide pass/fail, but the fetch itself goes through OkHttp's default client, which resolves the hostname again, independently, at connect time. An attacker who controls DNS for their own domain can return a public address for the check and an internal address for the fetch — the two resolutions never have to agree. Fixing just the resolution race isn't enough either: the same four `InetAddress` predicates the guard relies on (`isLoopbackAddress`, `isSiteLocalAddress`, `isLinkLocalAddress`, `isAnyLocalAddress`) never recognized carrier-grade NAT (`100.64.0.0/10`, notably used by AWS EKS/VPC-CNI for pod ranges) or IPv6 unique-local addresses (`fc00::/7` — distinct from the deprecated `fec0::/10` IPv6 site-local range `isSiteLocalAddress` actually checks). Those ranges pass the guard with a single static DNS answer, no rebinding needed. Both gaps are fixed together in `ClientDomainHelper`, since they're independent ways of reaching the same outcome (an internal-network fetch on mainnet) and share the same underlying address classifier. **Changes** - [x] `ClientDomainHelper.isNonPublicAddress`: new shared classifier, extending the existing four checks with `100.64.0.0/10`, `fc00::/7`, and — since we were touching this anyway — the related `192.0.0.0/24` (IETF protocol assignments) and `198.18.0.0/15` (benchmarking) reserved ranges. `validateDomainNotPrivateNetwork` now delegates to it, so its existing behavior/tests are unchanged beyond the wider coverage. - [x] `ClientDomainHelper.pinnedValidatingDns`: new `okhttp3.Dns` implementation that resolves and validates a hostname in one step — `Dns.SYSTEM.lookup`, then `isNonPublicAddress` against the same result, then returns those exact addresses to OkHttp. `fetchSigningKeyFromClientDomain` builds this once (mainnet only) and threads it through to the actual connection, so there's no second, independent resolution to disagree with the one that was validated. Non-mainnet callers are unaffected — `dns` stays `null` and the fetch uses the plain, unparameterized path exactly as before. - [x] `NetUtil.fetch` / `OkHttpUtil.buildClient`: new overloads accepting a `Dns`, additive only — the existing no-arg/`Dns`-less overloads are untouched and still used by every other caller in the codebase. - [x] `Sep1Helper.readToml`: new `Dns`-accepting overload alongside the existing one, same reasoning. - [x] `ClientDomainHelperTest.kt`: added coverage for the four new ranges (including boundary cases just outside the CGNAT range, and a public-IPv6 sanity check to guard against false positives), plus an end-to-end test that runs a real loopback HTTP server and confirms `fetchSigningKeyFromClientDomain(..., allowHttpRetry=false)` can't reach it — proves the new `Dns` wiring is actually connected through `NetUtil`/`Sep1Helper`/`OkHttpUtil`, not just that the classifier rejects the string in isolation. **Acceptance Criteria** - [x] On mainnet, a `client_domain` that resolves to `100.64.0.0/10` or `fc00::/7` is rejected before any fetch, the same as the existing loopback/RFC-1918/link-local ranges. - [x] On mainnet, a `client_domain` whose DNS answer differs between the validation lookup and the fetch's connection lookup cannot reach an address that would have failed validation — the fetch always connects to the address that was actually checked. - [x] Non-mainnet (`allowHttpRetry=true`) behavior is unchanged: no private-network validation, same permissive fetch path as before. - [x] SEP-45 (`Sep45Service`, which calls the same `ClientDomainHelper.fetchSigningKeyFromClientDomain`) gets both fixes automatically, with no SEP-45-specific change needed. ### Context [HackerOne #3824178](https://hackerone.com/reports/3824178) ### Testing - Unit: `./gradlew :core:test --tests "org.stellar.anchor.util.ClientDomainHelperTest"` - Unit: `./gradlew :core:test --tests "org.stellar.anchor.util.NetUtilTest" --tests "org.stellar.anchor.util.SepHelperTest" --tests "org.stellar.anchor.util.OkHttpUtilTest"` - Regression: `./gradlew :core:test --tests "org.stellar.anchor.sep10.Sep10ServiceTest" --tests "org.stellar.anchor.sep45.Sep45ServiceTest"` - Full module: `./gradlew :core:test :platform:test :lib-util:test` ### Documentation N/A ### Known limitations N/A
…h a REST management API and JWT-carried client attribution (#1992) ### Description Raised by an anchor running the Anchor Platform: their partner-config YAML has grown to ~1,200 lines, and every change requires a rolling restart on their end. They asked whether config could move to the AP database instead — either AP checking the DB directly per request, or AP refreshing an in-memory cache every ~60 minutes — so onboarding a new partner (their stated goal is to automate onboarding as much as possible) or updating an existing one no longer needs a restart on their end. Client (wallet) definitions — SEP-10 signing keys, SEP-24 domains, callback URLs, destination accounts — are configured today via `clients.type: file|inline|json|yaml`, parsed once into an in-memory list at startup. Every lookup is a linear scan over that list, onboarding or revoking a single wallet means editing one shared YAML/JSON file, and any change requires a full restart. That breaks down past a few hundred clients: at 600+ entries the file becomes unwieldy to hand-edit safely, and there is no way to add or revoke a client without redeploying. Three designs were prototyped before this one: an in-memory cache refreshed on a timer/signal (the anchor's 60-minute-refresh suggestion; largest diff, up to an hour of staleness), a purely live DB lookup on every request (the anchor's other suggestion — AP checks the DB directly; simplest, but a DB round trip on every SEP-10/24/6/31/12 call), and this one — DB-backed live lookup, but only at SEP-10/SEP-45 auth time. This PR implements the third: SEP-10/SEP-45 resolve the client once via `ClientFinder` against the database and stamp the result into a new `client_name` JWT claim; SEP-6/12/24/31 read `token.getClientName()` directly instead of each doing their own lookup. That removes the per-request database traffic the live-lookup-only design would add on every SEP-6/12/24/31 call, at the cost of client changes not taking effect for a session until its JWT expires (see Known limitations). A full REST API on the platform server lets anchors manage clients without a restart: `PUT`/`GET`/`DELETE /clients/{name}`, `GET /clients` (all), `GET /clients/custodial` / `GET /clients/non-custodial` (filtered by type), plus incremental `POST`/`DELETE /clients/{name}/signing-keys/{key}` and `/destination-accounts/{account}`. The incremental endpoints exist specifically for high-volume clients — one anchor's production config has a single custodial client with 600+ destination accounts — so adding or revoking one wallet doesn't require resending the entire list. Setting `clients.type: db` with `clients.value` still pointing at an existing file/yaml/json source auto-imports those clients into the database on every startup (upsert, not destructive), so an anchor can cut over without hand-transcribing every entry; switching `clients.type` away from `db` afterward does not delete the imported rows. **Changes** - [x] `ClientsConfig.java`: adds a `db` `ClientsConfigType`. (No separate migration flag — see below.) - [x] `WebAuthJwt.java`: adds a `client_name` claim field, populated from the decoded JWT. - [x] `JwtService.encode`: emits the `CLIENT_NAME` claim when `token.getClientName()` is set (the claim key already existed for the SEP-24 interactive/more-info JWTs; this reuses it for the SEP-10/45 web-auth JWT). - [x] `Sep10Service.generateWebAuthJwt`: resolves `clientFinder.getClientName(clientDomain, account)` once, at token-issuance time, and stamps it onto the token before encoding. `validateChallenge`/`generateWebAuthJwt` now declare `throws SepException` (was `SepValidationException`) so a `SepNotAuthorizedException` from client resolution propagates and the login is refused, instead of silently issuing a token with no client name. - [x] `Sep45Service.validate`: same resolve-and-stamp for the SEP-45 contract-account flow; adds a `ClientFinder` dependency. - [x] `Sep6Service`, `Sep24Service`, `Sep12Service`, `Sep31Service`: drop their own `ClientFinder`/`ClientService` lookups; each now reads `token.getClientName()` (or `Context.get().getWebAuthJwt().getClientName()` for SEP-31) directly, since SEP-10/45 already vetted it. - [x] `V32__client_config.sql` (new): creates `client_config`, `client_domain`, `client_signing_key`, `client_destination_account`, with unique indexes on `domain` and `signing_key` — a hard constraint the file-based config never enforced. - [x] `JdbcClientConfig.java` (new): JPA entity for the tables above. - [x] `JdbcClientConfigRepo.java` (new): Spring Data repo with indexed `findByDomain`/`findBySigningKey`/`findByType` queries, `@EntityGraph`-annotated so `signingKeys`/`domains`/`destinationAccounts` are eagerly fetched (avoids a `LazyInitializationException` when a config bean reads them outside a request-scoped Hibernate session). - [x] `JdbcClientService.java` (new): `ClientService` implementation backed directly by `JdbcClientConfigRepo` — every lookup is a live, indexed query, no in-memory copy. - [x] `PropertyClientsConfig.java`: `type: db` auto-detects and parses `clients.value` (file path, inline YAML, or inline JSON) into `items` for import, without clearing any items already bound directly; no separate migration flag. - [x] `PropertySep10Config.java`: `clientService` field marked `transient` — it's a JDK dynamic proxy (inherent to any Spring Data repo/service, not specific to `@Transactional`), and Gson would otherwise try to reflectively serialize it during debug logging and fail on a JPMS `InaccessibleObjectException`. - [x] `ClientConfigController.java`, `ClientConfigRequest.java`, `ClientConfigResponse.java` (new): `PUT`/`GET`/`GET all`/`DELETE /clients/{name}`, `GET /clients/custodial`, `GET /clients/non-custodial`, and `POST`/`DELETE /clients/{name}/signing-keys/{key}` + `/destination-accounts/{account}`, behind the same auth as the rest of the Platform API (see Known limitations). - [x] `ClientConfigService.java` (new): validates and upserts/gets/lists/deletes clients, plus the incremental add/remove-signing-key and add/remove-destination-account operations (rejecting removal of a custodial client's last signing key); translates a `DataIntegrityViolationException` (duplicate domain/signing key) into a clean 400 instead of a raw SQL error. - [x] `ClientConfigImportRunner.java` (new): a `CommandLineRunner`, gated only on `clients.type=db`, that imports every client currently resolvable from `clients.value` into the database on each startup (upsert — safe to run repeatedly), logging and skipping any individual client that fails to import rather than aborting startup. - [x] `ClientsBeans.clientService`: returns `JdbcClientService` when `clients.type: db`, otherwise the existing `DefaultClientService`. - [x] `PlatformServerBeans.java`: registers `ClientConfigService` and the conditional `ClientConfigImportRunner`. - [x] `SepBeans.java`: drops `ClientFinder`/`ClientService` constructor args from the SEP-6/12/24/31 beans; adds `sep45` to `ClientFinder`'s enabled-SEPs list and wires it into the `Sep45Service` bean. - [x] `Sep10Controller.validateChallenge`: throws clause widened to `SepException`. - [x] `TestProfileRunner.kt`: fixes a test-harness bug where inline `clients.value` content (JSON/YAML, not a file path) was unconditionally treated as a classpath resource and crashed with `RuntimeException("Resource ... not found")`; now falls back to treating it as inline content with a warning log. - [x] `anchor-config-default-values.yaml`: documents the new `type: db` option and its auto-import behavior (corrected from an earlier draft of this doc that described a since-removed `migrate_from_file_on_startup` flag). - [x] `Sep10ServiceTest.kt`, `Sep45ServiceTest.kt`: new cases asserting `client_name` is stamped onto the JWT, and that a `ClientFinder` authorization failure propagates instead of issuing a token with no client name. - [x] `Sep12ServiceTest.kt`, `Sep24ServiceTest.kt`, `Sep31ServiceTest.kt`, `Sep6ServiceTest.kt`: updated to set/read `token.getClientName()` directly instead of mocking `ClientFinder`. - [x] `JdbcClientServiceTest.kt` (new, 8 tests): domain/signing-key/name resolution, domain-preferred-over-account-match precedence, custodial-vs-noncustodial filtering. - [x] `PropertyClientsConfigTest.kt` (new, 5 tests): `type: db` with no value, with a YAML/JSON string, with a file path, and with items already bound directly — none of the four clear or duplicate the item list. - [x] `ClientConfigImportRunnerTest.kt` (new, 3 tests): upserts every item, continues past a single item's import failure, no-ops on an empty list. - [x] `ClientConfigServiceTest.kt` (29 tests): full coverage of validation, upsert-conflict handling, list/get/delete, and the incremental signing-key/destination-account add/remove operations including the custodial-last-key protection and cross-client uniqueness conflicts. - [x] `ClientConfigApiTests.kt` (essential-tests, 19 tests): real-HTTP coverage against a live Postgres for the full `/clients` REST surface — CRUD lifecycle, 404s, validation (missing signing keys, no domain on a noncustodial client, malformed callback URL, unknown type, domain/signing-key collisions across clients), the four incremental endpoints (additive without disturbing existing entries, 404 on an unknown sub-resource, cross-client conflict, last-signing-key protection), and the two type-filtered list endpoints. - [x] `ClientsDbAttributionTests.kt` + `ClientsDbTestSuite.kt` (new, extended-tests, 4 tests): the one thing essential-tests can't cover, since it only runs against the default profile's `clients.type: file`. A new `clients-db` service-runner profile (`clients.type: db`, wired into `sub_extended_tests.yml`) boots a real server with the DB-backed path active, and this suite runs an actual SEP-10 challenge/validate against it, decodes the resulting JWT, and asserts `client_name` is attributed correctly on create, follows a signing key moved between clients, stays unset for an unregistered key, and disappears after the client is deleted. **Acceptance Criteria** - [x] `PUT /clients/{name}` with a valid custodial or noncustodial payload creates the client and returns it; a second `PUT` to the same name updates rather than duplicates. - [x] `GET /clients/{name}` and `GET /clients` reflect what's stored; `GET /clients/{unknown}` returns 404. `GET /clients/custodial` and `GET /clients/non-custodial` return only clients of the matching type. - [x] `PUT` with a signing key or domain already used by another client, a noncustodial client with no domain, a custodial client with no signing keys, a malformed callback URL, or an unrecognized type all return 400 without creating/mutating a row. - [x] `POST /clients/{name}/signing-keys/{key}` and `/destination-accounts/{account}` add without disturbing existing entries; the corresponding `DELETE` removes only the targeted entry; both 404 on an unknown client or an entry that isn't present; adding a signing key already used by another client returns 400; removing a custodial client's last signing key returns 400. - [x] `DELETE /clients/{name}` removes the client and its domain/signing-key/destination-account rows; a subsequent `GET` returns 404. - [x] With `clients.type: db`, a SEP-10 or SEP-45 login for a signing key/domain that matches a client in the database stamps that client's name into the `client_name` JWT claim — verified end-to-end (real HTTP, real Postgres) via `ClientsDbAttributionTests`, not just at the unit level. - [x] With `sep10.client_attribution_required: true`, a login for a signing key/domain not in the database (or not on the allow list) is rejected outright, not issued a token with a null client name. - [x] SEP-6/12/24/31 requests authenticated with such a token attribute the resulting transaction/event to that `client_name`, without making their own database call to resolve it. - [x] With `clients.type: db` and `clients.value` pointing at an existing file/yaml/json source, every client there is upserted into the database on startup, and startup does not fail if one client fails to import. - [x] Existing `clients.type: file|inline|json|yaml` deployments are unaffected — `clients.type: db` is opt-in, and switching away from it afterward does not delete previously-imported/managed rows. ### Context Origin ask (anchor operator, paraphrased): their ~1,200-line partner-config YAML requires a rolling restart on every change; they asked for either AP-checks-DB-directly or a ~60-minute AP-side cache refresh, with automating partner onboarding as the underlying goal. A separate concrete follow-up from the same anchor: one of their custodial clients has 600+ destination accounts, and they didn't want to resend the full list to add or remove a single one — that's what the incremental signing-key/destination-account endpoints are for. ### Testing - Unit: `./gradlew :core:test :platform:test` (includes `ClientConfigServiceTest`, `JdbcClientServiceTest`, `PropertyClientsConfigTest`, `ClientConfigImportRunnerTest`) - Integration: `./gradlew runEssentialTests` (includes the expanded `ClientConfigApiTests`, real HTTP against a live Postgres) - Extended: `TEST_PROFILE_NAME=clients-db ./gradlew startServersWithTestProfile` then `./gradlew :extended-tests:test --tests org.stellar.anchor.platform.suite.ClientsDbTestSuite` (real SEP-10 auth against a DB-backed server, wired into `sub_extended_tests.yml` for CI) - Manual: full `PUT`/`GET`/`GET all`/incremental-endpoint round trips against a running server, verified against a live Postgres, including a full clean-restart migration of an existing file-based config into the database. ### Documentation `anchor-config-default-values.yaml` updated with the `clients.type: db` option, its auto-import behavior, and the two new incremental-endpoint / type-filtered-list capabilities. ### Known limitations N/A
### Description This bumps the version to 4.7.0 ### Context Release ### Testing `./gradlew test` ### Documentation N/A ### Known limitations N/A
amandagonsalves
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Merges release/4.7.0 into main for the 4.7.0 release.
Context
N/A
Testing
./gradlew testDocumentation
N/A
Known limitations
N/A