Skip to content

feat(auth): restrict API keys to upstreams by group-labels - #397

Open
selfuryon wants to merge 2 commits into
drpcorg:mainfrom
selfuryon:feat/key-upstream-filter
Open

selfuryon wants to merge 2 commits into
drpcorg:mainfrom
selfuryon:feat/key-upstream-filter

Conversation

@selfuryon

Copy link
Copy Markdown

Closes #391

What

Local API keys can restrict which upstreams serve them via settings.upstreams.group-labels. Only upstreams carrying at least one of the listed group-labels are used for the key's requests and subscriptions:

auth:
  key-management:
    - id: partner-a
      type: local
      local:
        key: "..."
        settings:
          upstreams:
            group-labels: [archive, fast]

It is a filter, not a balancer: among the admitted upstreams the chain's strategy (rating / base / label-balancing) still picks as usual, and excluded upstreams are never used — not as a fallback, not on a retry or hedge.

How

  • LocalKey.PostCheckSetting adds an internal RequestGroupLabelSelector to every upstream request (new protocol.SelectorAppender, implemented by the JSON-RPC, REST and gRPC holders). A request that cannot carry it is rejected instead of being served unrestricted.
  • compileSelector turns it into a GroupLabelMatcher that resolves an upstream's group-labels through the supervisor, so every strategy applies it.
  • The selector is part of the subscription aggregation key (keys with different restrictions never share an upstream subscription), but not of the cache key (LabelCacheKey treats it as unconstrained — it chooses which nodes answer, not what they answer).
  • Locally-synthesized newHeads / newPendingTransactions merge every upstream of the chain, so — as logs already did — a selector-bearing subscription now takes the node-backed path. drpc_pendingTransactions has no such path and fails with a client error when selectors are present.
  • Config validation: labels must be non-empty and unique, and each must be carried by at least one configured upstream (a typo fails at startup instead of leaving the key with no upstream).

Also fixed

The integrity retry (IntegrityRequestProcessor.handleResponse) re-sent stale eth_blockNumber / eth_getBlockByNumber answers to higher upstreams through a strategy without matchers, so it ignored the request's selectors — both a client's own and the new key restriction. It now applies the selector matchers while keeping the height order. Separate commit: fix(flow): honor request selectors on the integrity retry.

Behavior changes

  • Emerald NativeSubscribe clients that send effective selectors on newHeads / newPendingTransactions now get them honored via a node-backed subscription; previously the selectors were silently ignored by the local source.
  • Requests with selectors that hit the integrity retry are no longer retried on upstreams the selectors exclude.

Not covered

  • drpc-managed keys: their settings come from the dRPC platform, which has no such field.
  • The emerald gRPC API does not use key-management at all; documented in 03-auth.md.

Testing

  • Unit tests for config validation, selector key / cache key, AppendSelectors, GroupLabelMatcher, LocalKey restriction (incl. fail-closed), resolveSource bypass of local sources, subscription key separation.
  • createStrategy end-to-end with a real chain supervisor: requests and subscriptions only ever land on the admitted upstream.
  • Integrity retry test: fails without the fix (answer comes from the higher full node), passes with it.
  • go test -race ./... and golangci-lint v2.14.0 pass locally (except caches/*_e2e, which need Docker).

Docs: docs/nodecore/03-auth.md (new "Upstream restriction" section) and docs/nodecore/13-subscriptions.md.

🤖 Generated with Claude Code

A local key can now pin its traffic to a subset of nodes with
settings.upstreams.group-labels: only upstreams carrying at least one of
the labels serve the key's requests and subscriptions. It is a filter,
not a balancer - rating, base or label-balancing still picks among the
admitted upstreams.

The key adds a RequestGroupLabelSelector to every upstream request in
PostCheckSetting, so it rides the existing selector routing: all
strategies apply it as a matcher, retries and hedges included, and the
subscription aggregation key separates differently restricted keys. It
does not enter the cache key - it chooses which nodes answer, not what
they answer. A request type that cannot carry the selector is rejected
instead of served unrestricted.

Locally-synthesized newHeads and newPendingTransactions merge every
upstream of the chain, so, like logs already did, a selector-bearing
subscription now takes the node-backed path. drpc_pendingTransactions
has no such path and fails with selectors.

Every configured label must exist on some upstream, so a typo fails at
startup instead of leaving the key with no upstream.
When an eth_blockNumber/eth_getBlockByNumber answer looks stale, the
integrity processor re-sends the request to higher upstreams through a
SpecificOrderUpstreamStrategy built without matchers. That retry
ignored the request's selectors, so a client's selector - or an API
key's upstream restriction - could be escaped to an excluded node.

Build the selector matchers for the retry too, keeping the handler's
height order.
@mxssl
mxssl requested a review from KirillPamPam October 4, 2026 20:46
// validateKeyUpstreams rejects a key filter naming a group-label no upstream
// carries: such a key would silently be served by nothing, and a typo is far
// likelier than an intent to disable the key.
func (a *AuthConfig) validateKeyUpstreams(upstreamConfig *UpstreamConfig) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is already func (a *AuthConfig) validate, let's reuse it instead of creating a new one

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is a method func (l *LocalKeyConfig) validate() where we can add a validation of KeySettingsConfig with new Upstreams

return err
}
if a.AuthConfig != nil {
if err := a.AuthConfig.validateKeyUpstreams(a.UpstreamConfig); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is already a validation of AuthConfig above, let's reuse it

// A selector-bearing request takes the node-backed path instead, where the
// strategy routes on its selectors.
routed := hasEffectiveSelectors(request.Selectors())
if settings.NewHeads && !routed && isNewHeadsRequest(request) && localNewHeadsAvailable(chain, supervisor) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I suggest do not fix local subs so far. There should be a more complex fix that we will add as soon as possible. As a workaround local subs can be disabled via LocalSubscriptionsConfig until this fix

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.

Restrict API keys to a subset of upstreams (key-level node filter)

2 participants