Skip to content

Add group-based upstream filter for vMCP auth - #6739

Merged
JAORMX merged 11 commits into
stacklok:mainfrom
hellouz818:feature/6675-pr2-filter
Oct 13, 2026
Merged

JAORMX merged 11 commits into
stacklok:mainfrom
hellouz818:feature/6675-pr2-filter

Conversation

@hellouz818

@hellouz818 hellouz818 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

VirtualMCPServer currently walks every configured upstream in order for every user. A user without an account on one of the upstreams (e.g. Salesforce) is forced through that OAuth leg and fails there, failing the whole authorization even though the otherwise-valid identity chain is complete.

This PR introduces a built-in GroupBasedFilter implementing the existing handlers.UpstreamFilter hook. The filter reads a configurable claim (default "groups") from the first upstream's resolved principal and returns the union of UpstreamProviders from every matching rule, with a fallback to defaultUpstreams for valid-but-empty or unmatched groups. If no rule matches and defaultUpstreams is unset, no optional upstreams are selected; the mandatory first upstream remains the only authentication leg. Missing or malformed claims fail the authorization per the filter's fail-closed contract; principal.Claims is treated as read-only.

The new filter is wired through authserver.RunConfig so operator-supplied configuration can turn it on, with static invariants (unknown references, mandatory-first references, two-upstream minimum, duplicates, empty values) rejected both at RunConfig.Validate startup and at filter construction.

  • pkg/authserver/upstreamfilter/ — new package with filter + config + unit tests
  • pkg/authserver/config.go — RunConfig.UpstreamFilter field + Validate() extension
  • pkg/authserver/runner/embeddedauthserver.go — filter construction with typed-nil-safe pattern
  • pkg/authserver/runner/embeddedauthserver_upstreamfilter_test.go — integration coverage for the RunConfig → filter → callback path

Refs #6675

Type of change

  • New feature

Test plan

  • Unit tests (task test) — 24 cases in upstreamfilter plus a multi-group rule table pinning any-of matching; 6 validation cases and JSON and YAML round-trip cases in authserver/config_test.go
  • Integration tests — three cases in authserver/runner build the server from a RunConfig and drive the authorization redirects: a narrowed chain (okta → slack, never redirected to salesforce), a missing claim failing on the first callback, and an unset filter visiting every upstream
  • Linting (task lint-fix) — 0 issues
  • Manual testing — deployed to an internal mcphub deployment; viewer-only user completes the narrowed chain (okta → slack) and successfully invokes slack tools via claude-code (audit event confirmed)

API Compatibility

  • This PR does not break the v1beta1 API.

(The new field is optional and its nil default preserves pre-filter behavior. RunConfig is not part of the v1beta1 CRD surface, but it is not internal either: the vMCP config loader already reads this field from authserver-config.yaml, and the model is published through the OpenAPI document and the Go SDK. CRD exposure follows in #6740.)

Does this introduce a user-facing change?

No CRD-level change yet. The field is reachable through authserver.RunConfig, which the vMCP config loader reads from authserver-config.yaml and which ships in the OpenAPI document and the Go SDK; operator-facing CRD exposure comes in #6740.

Special notes for reviewers

PR 2/3 of #6675.

Open design question (from issue thread)

  • Multi-value rule matching semantics: This PR implements any-of (OR), now pinned by TestFilterUpstreams_MultiGroupRuleMatchesAnyGroup. Change scope is a single predicate (ruleMatches) plus that test if the preferred semantic turns out to be all-of (AND).

@hellouz818 hellouz818 changed the title Feature/6675 pr2 filter Add group-based upstream filter for vMCP auth Oct 2, 2026
@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.31933% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.55%. Comparing base (303ad35) to head (0d66ec9).

Files with missing lines Patch % Lines
pkg/authserver/runner/embeddedauthserver.go 84.61% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6739      +/-   ##
==========================================
+ Coverage   79.48%   79.55%   +0.06%     
==========================================
  Files         813      814       +1     
  Lines       82415    82534     +119     
==========================================
+ Hits        65505    65656     +151     
+ Misses      16905    16873      -32     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hellouz818
hellouz818 force-pushed the feature/6675-pr2-filter branch 2 times, most recently from 7844e3a to d47f086 Compare October 8, 2026 01:39
JWT/OIDC claim values that are string arrays commonly arrive at Go as
either []string (from typed JWT libraries) or []any whose elements are
all strings (from standard encoding/json). The Cedar authorizer decodes
that split inline today, and the upcoming group-based upstream filter
needs the same decoding with a stricter contract.

Add pkg/auth/claims so neither caller has to carry its own copy. The
decoder in this commit is strict by design: nil values, non-array types,
and non-string array elements are errors, so a security-relevant caller
can rely on it to fail closed on a malformed claim. Returned slices
never alias the caller's input.

The next commit moves Cedar onto the same package, which needs a second,
permissive contract to preserve its current behavior.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
The decoder added in the previous commit is strict by design, and Cedar
cannot use it as-is. Cedar reads a present-but-malformed group claim as
"the IdP asserted no usable names" and relies on that to suppress the
fallback to well-known claim names. Decoding it to nil instead would let
an explicitly empty custom claim fall through to "roles" and grant
access the operator did not intend.

Add DecodeStringArrayLenient next to the strict decoder, preserving
Cedar's existing semantics: non-string elements are dropped from []any,
empty or typed-nil []any values produce a non-nil empty slice, and
[]string values retain their nil-ness. Rewrite extractGroups on top of
it, so both contracts live in one place and the difference between them
is documented rather than implicit.

The one deliberate change is that the result no longer aliases a
[]string claim value. authorizeWithClaims appends the role claim onto
the slice returned for the group claim, which could write a role name
into the token's own backing array when that array had spare capacity.

Cover the lenient decoder with a table of every shape where it diverges
from the strict one, and add Cedar cases for []string(nil), []any(nil),
empty arrays, all-non-string elements, and aliasing. The existing test
pinning the present-but-empty fallback semantic passes unchanged.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
VirtualMCPServer currently walks every configured upstream in order
for every user. When a user is missing an account on one of the
upstreams (e.g. Salesforce), the login they are forced through there
fails and the whole authorization fails — even though the
otherwise-valid upstream chain is identity-complete.

Introduce a built-in GroupBasedFilter implementing the existing
handlers.UpstreamFilter hook. The filter reads a configurable claim
(default "groups") from the first upstream's resolved principal and
returns the union of UpstreamProviders from every matching rule, with
a fallback to defaultUpstreams for valid-but-empty or unmatched
groups. Missing or malformed claims fail the authorization per the
filter's fail-closed contract; principal.Claims is treated as
read-only.

Wire the new filter through authserver.RunConfig so operator-supplied
configuration can turn it on, with the same static invariants
(unknown references, mandatory-first references, two-upstream
minimum, duplicates, empty values) rejected both at RunConfig.Validate
startup and at filter construction.

This is step two of three; the matching VirtualMCPServer CRD surface
and conversion follow in a separate commit so each layer is reviewable
independently.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
@hellouz818
hellouz818 force-pushed the feature/6675-pr2-filter branch from d47f086 to f0cc7d7 Compare October 12, 2026 08:12
hellouz818 and others added 2 commits October 12, 2026 17:53
Resolving the main merge conflict in the web editor dropped the comma
after the expected schema count and left a stray blank line, so the SDK
module stopped compiling.

The count itself is correct: the merge base asserted 197 schemas, main
added one and this branch adds two, so the merged contract holds 200.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
JAORMX
JAORMX previously approved these changes Oct 12, 2026
Comment thread pkg/authserver/runner/embeddedauthserver.go
Comment thread pkg/authserver/config_test.go
Comment thread pkg/authserver/upstreamfilter/config.go
Comment thread pkg/authserver/upstreamfilter/filter_test.go
@JAORMX

JAORMX commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

suggestion (non-blocking): Could we narrow “operator-internal” to “not yet exposed through the CRD”? The existing vMCP config loader can already consume this field from authserver-config.yaml, and we are publishing the model through OpenAPI and the Go SDK.

The change is additive, so I am not seeing a compatibility problem. We should just describe the surface accurately.

The operator writes authserver-config.yaml and vMCP reads it back with
gopkg.in/yaml.v3, which uses yaml tags rather than the json tags covered
by the existing round-trip test.

Decode a literal document covering a custom claim name, multiple rules,
and defaultUpstreams, then re-encode and decode it again. The literal
pins the expected YAML key names: a marshal/unmarshal round-trip alone
can still pass if both sides use the same renamed tag. A second subtest
verifies that an unset filter is omitted from YAML and decodes to nil.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
The filter reads its claim from the identity the first upstream
resolves, and not every upstream path populates one. An OIDC upstream
fills the claims map from the validated ID token and a pure OAuth2
upstream from the userinfo response, while identityFromToken and
synthesized identities leave it empty. Login keeps working until the
filter is enabled, and defaultUpstreams does not soften the failure:
an absent claim fails closed rather than falling back.

Record that on the Claim field, and note on the struct why its nested
keys are camelCase where RunConfig's own fields are snake_case. The
field comment is the source of the OpenAPI property description, so
regenerate docs/server.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
Every matching rule in the existing fixture lists a single group, so
none of them distinguishes any-of from all-of: flipping ruleMatches to
require every group leaves the whole suite green.

Add a rule listing two groups and assert that each one selects it on
its own, that both together select it once, and that an unrelated
group falls back. DefaultUpstreams is a different set from the rule's
providers so a fallback cannot be mistaken for a match. Issue stacklok#6675
leaves the preferred semantic open, so this is the test a move to
all-of would have to update.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
The filter reaches the callback handler through RunConfig, and nothing
covered that leg: the existing integration test injects a stub straight
into Config, so dropping upstreamFilter from resolvedCfg leaves the
whole suite green.

Build the server from RunConfig and drive the upstream authorization
redirects and callbacks manually, recording each upstream authorize
endpoint visited.

A viewer visits Okta then Slack and receives an authorization code
without being redirected to Salesforce. A missing claim fails on the
first callback without issuing a code, even with defaultUpstreams
configured. An unset filter visits all configured upstreams and issues
a code.

The helper stops at the client redirect without exchanging the code
for tokens, keeping the test focused on filter wiring and chain
selection.

Upstream secrets go through files because the environment-variable
form cannot be combined with t.Parallel, and the upstream scope list
asks for "groups" because mockoidc only emits that claim when it is
requested.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
The SDK contract carries schema descriptions, so documenting the Claim
field left sdk/go stale and failed the Go SDK verification job. Rerun
task sdk-generate.

Refs: stacklok#6675

Signed-off-by: hellouz818 <hellouz818@gmail.com>
@hellouz818

hellouz818 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

@JAORMX Thanks for the thorough review!

All four suggestions are addressed, with no force-push, so the changes since
your previous review are easy to inspect.

  • f33dbc5 — YAML round-trip for the filter configuration
  • 41f0fd5 — where the group claim comes from, plus regenerated OpenAPI
  • caf98ab — multi-group rule pinning any-of matching
  • 8189ed8 — RunConfig-rooted integration test for the filter wiring
  • aaf5ba8 — regenerated Go SDK with the updated schema descriptions

task test, task lint-fix, and task docs-verify are green. The PR body is
updated for the API-compatibility wording.

Static Analysis is failing on the goreleaser-action pin comment in
.github/workflows/releaser.yml. I confirmed that current main fails
the same zizmor check, and this PR does not modify .github/.

The new commits dismissed the previous approval. Could you take another look
when you have a chance? Thanks!

@JAORMX
JAORMX merged commit d6660c0 into stacklok:main Oct 13, 2026
43 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 13, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants