From db4b2cb5772f3c20b3d71fda689d2426371cbfd0 Mon Sep 17 00:00:00 2001 From: hc-github-team-consul-core Date: Mon, 17 Jul 2023 10:50:21 -0500 Subject: [PATCH] Backport of Use JWT-auth filter in metadata mode & Delegate validation to RBAC filter into release/1.16.x (#18153) ## Backport This PR is auto-generated from #18062 to be assessed for backporting due to the inclusion of the label backport/1.16. The below text is copied from the body of the original PR. --- ### Description - Currently the jwt-auth filter doesn't take into account the service identity when validating jwt-auth, it only takes into account the path and jwt provider during validation. This causes issues when multiple source intentions restrict access to an endpoint with different JWT providers. - To fix these issues, rather than use the JWT auth filter for validation, we use it in metadata mode and allow it to forward the successful validated JWT token payload to the RBAC filter which will make the decisions. This PR ensures requests with and without JWT tokens successfully go through the jwt-authn filter. The filter however only forwards the data for successful/valid tokens. On the RBAC filter level, we check the payload for claims and token issuer + existing rbac rules. ### Testing & Reproduction steps - This test covers a multi level jwt requirements (requirements at top level and permissions level). It also assumes you have envoy running, you have a redis and a sidecar proxy service registered, and have a way to generate jwks with jwt. I mostly use: https://www.scottbrady91.com/tools/jwt for this. - first write your proxy defaults ``` Kind = "proxy-defaults" name = "global" config { protocol = "http" } ``` - Create two providers ``` Kind = "jwt-provider" Name = "auth0" Issuer = "https://ronald.local" JSONWebKeySet = { Local = { JWKS = "eyJrZXlzIjog....." } } ``` ``` Kind = "jwt-provider" Name = "okta" Issuer = "https://ronald.local" JSONWebKeySet = { Local = { JWKS = "eyJrZXlzIjogW3...." } } ``` - add a service intention ``` Kind = "service-intentions" Name = "redis" JWT = { Providers = [ { Name = "okta" }, ] } Sources = [ { Name = "*" Permissions = [{ Action = "allow" HTTP = { PathPrefix = "/workspace" } JWT = { Providers = [ { Name = "okta" VerifyClaims = [ { Path = ["aud"] Value = "my_client_app" }, { Path = ["sub"] Value = "5be86359073c434bad2da3932222dabe" } ] }, ] } }, { Action = "allow" HTTP = { PathPrefix = "/" } JWT = { Providers = [ { Name = "auth0" }, ] } }] } ] ``` - generate 3 jwt tokens: 1 from auth0 jwks, 1 from okta jwks with different claims than `/workspace` expects and 1 with correct claims - connect to your envoy (change service and address as needed) to view logs and potential errors. You can add: `-- --log-level debug` to see what data is being forwarded ``` consul connect envoy -sidecar-for redis1 -grpc-addr 127.0.0.1:8502 ``` - Make the following requests: ``` curl -s -H "Authorization: Bearer $Auth0_TOKEN" --insecure --cert leaf.cert --key leaf.key --cacert connect-ca.pem https://localhost:20000/workspace -v RBAC filter denied curl -s -H "Authorization: Bearer $Okta_TOKEN_with_wrong_claims" --insecure --cert leaf.cert --key leaf.key --cacert connect-ca.pem https://localhost:20000/workspace -v RBAC filter denied curl -s -H "Authorization: Bearer $Okta_TOKEN_with_correct_claims" --insecure --cert leaf.cert --key leaf.key --cacert connect-ca.pem https://localhost:20000/workspace -v Successful request ``` ### TODO * [x] Update test coverage * [ ] update integration tests (follow-up PR) * [x] appropriate backport labels added ---
Overview of commits - 70536f5a38507d7468f62d00dd93a6968a3d9cf3
Co-authored-by: Ronald Ekambi --- agent/xds/jwt_authn.go | 212 +++++------ agent/xds/jwt_authn_test.go | 235 ++---------- agent/xds/listeners.go | 9 +- agent/xds/rbac.go | 334 ++++++++++++------ agent/xds/rbac_test.go | 56 ++- .../jwt_authn/intention-with-path.golden | 13 +- .../testdata/jwt_authn/local-provider.golden | 7 +- ...ltiple-providers-and-one-permission.golden | 52 ++- .../xds/testdata/jwt_authn/no-provider.golden | 1 + .../testdata/jwt_authn/remote-provider.golden | 7 +- .../top-level-provider-with-permission.golden | 30 +- ...jwt-with-one-permission--httpfilter.golden | 58 +-- ...evel-jwt-no-permissions--httpfilter.golden | 16 +- ...th-multiple-permissions--httpfilter.golden | 158 ++++++--- ...jwt-with-one-permission--httpfilter.golden | 80 +++-- .../test/jwtauth/jwt_auth_test.go | 4 +- 16 files changed, 658 insertions(+), 614 deletions(-) create mode 100644 agent/xds/testdata/jwt_authn/no-provider.golden diff --git a/agent/xds/jwt_authn.go b/agent/xds/jwt_authn.go index ba1c17bbc..17b34e5cd 100644 --- a/agent/xds/jwt_authn.go +++ b/agent/xds/jwt_authn.go @@ -13,6 +13,7 @@ import ( envoy_http_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" "github.com/hashicorp/consul/agent/structs" "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/wrapperspb" ) @@ -22,129 +23,149 @@ const ( jwksClusterPrefix = "jwks_cluster" ) -// This is an intermediate JWTProvider form used to associate -// unique payload keys to providers -type jwtAuthnProvider struct { - ComputedName string - Provider *structs.IntentionJWTProvider -} - -func makeJWTAuthFilter(pCE map[string]*structs.JWTProviderConfigEntry, intentions structs.SimplifiedIntentions) (*envoy_http_v3.HttpFilter, error) { +// makeJWTAuthFilter builds jwt filter for envoy. It limits its use to referenced provider rather than every provider. +// +// Eg. If you have three providers: okta, auth0 and fusionAuth and only okta is referenced in your intentions, then this +// will create a jwt-auth filter containing just okta in the list of providers. +func makeJWTAuthFilter(providerMap map[string]*structs.JWTProviderConfigEntry, intentions structs.SimplifiedIntentions) (*envoy_http_v3.HttpFilter, error) { providers := map[string]*envoy_http_jwt_authn_v3.JwtProvider{} - var rules []*envoy_http_jwt_authn_v3.RequirementRule + var jwtRequirements []*envoy_http_jwt_authn_v3.JwtRequirement for _, intention := range intentions { if intention.JWT == nil && !hasJWTconfig(intention.Permissions) { continue } - for _, jwtReq := range collectJWTAuthnProviders(intention) { - if _, ok := providers[jwtReq.ComputedName]; ok { + for _, p := range collectJWTProviders(intention) { + providerName := p.Name + if _, ok := providers[providerName]; ok { continue } - jwtProvider, ok := pCE[jwtReq.Provider.Name] - + providerCE, ok := providerMap[providerName] if !ok { - return nil, fmt.Errorf("provider specified in intention does not exist. Provider name: %s", jwtReq.Provider.Name) + return nil, fmt.Errorf("provider specified in intention does not exist. Provider name: %s", providerName) } - // If intention permissions use HTTP-match criteria with - // VerifyClaims, then generate a clone of the jwt provider with a - // unique key for payload_in_metadata. The RBAC filter relies on - // the key to check the correct claims for the matched request. - envoyCfg, err := buildJWTProviderConfig(jwtProvider, jwtReq.ComputedName) + + envoyCfg, err := buildJWTProviderConfig(providerCE) if err != nil { return nil, err } - providers[jwtReq.ComputedName] = envoyCfg - } - - for k, perm := range intention.Permissions { - if perm.JWT == nil { - continue - } - for _, prov := range perm.JWT.Providers { - rule := buildRouteRule(prov, perm, "/", k) - rules = append(rules, rule) - } - } - - if intention.JWT != nil { - for _, provider := range intention.JWT.Providers { - // The top-level provider applies to all requests. - rule := buildRouteRule(provider, nil, "/", 0) - rules = append(rules, rule) - } + providers[providerName] = envoyCfg + reqs := providerToJWTRequirement(providerCE) + jwtRequirements = append(jwtRequirements, reqs) } } - if len(intentions) == 0 && len(providers) == 0 { - //do not add jwt_authn filter when intentions don't have JWT + if len(jwtRequirements) == 0 { + //do not add jwt_authn filter when intentions don't have JWTs return nil, nil } cfg := &envoy_http_jwt_authn_v3.JwtAuthentication{ Providers: providers, - Rules: rules, + Rules: []*envoy_http_jwt_authn_v3.RequirementRule{ + { + Match: &envoy_route_v3.RouteMatch{ + PathSpecifier: &envoy_route_v3.RouteMatch_Prefix{Prefix: "/"}, + }, + RequirementType: makeJWTRequirementRule(andJWTRequirements(jwtRequirements)), + }, + }, } return makeEnvoyHTTPFilter(jwtEnvoyFilter, cfg) } -func collectJWTAuthnProviders(i *structs.Intention) []*jwtAuthnProvider { - var reqs []*jwtAuthnProvider +func makeJWTRequirementRule(r *envoy_http_jwt_authn_v3.JwtRequirement) *envoy_http_jwt_authn_v3.RequirementRule_Requires { + return &envoy_http_jwt_authn_v3.RequirementRule_Requires{ + Requires: r, + } +} + +// andJWTRequirements combines list of jwt requirements into a single jwt requirement. +func andJWTRequirements(reqs []*envoy_http_jwt_authn_v3.JwtRequirement) *envoy_http_jwt_authn_v3.JwtRequirement { + switch len(reqs) { + case 0: + return nil + case 1: + return reqs[0] + default: + return &envoy_http_jwt_authn_v3.JwtRequirement{ + RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_RequiresAll{ + RequiresAll: &envoy_http_jwt_authn_v3.JwtRequirementAndList{ + Requirements: reqs, + }, + }, + } + } +} + +// providerToJWTRequirement builds the envoy jwtRequirement. +// +// Note: since the rbac filter is in charge of making decisions of allow/denied, this +// requirement uses `allow_missing_or_failed` to ensure it is always satisfied. +func providerToJWTRequirement(provider *structs.JWTProviderConfigEntry) *envoy_http_jwt_authn_v3.JwtRequirement { + return &envoy_http_jwt_authn_v3.JwtRequirement{ + RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_RequiresAny{ + RequiresAny: &envoy_http_jwt_authn_v3.JwtRequirementOrList{ + Requirements: []*envoy_http_jwt_authn_v3.JwtRequirement{ + { + RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_ProviderName{ + ProviderName: provider.Name, + }, + }, + // We use allowMissingOrFailed to allow rbac filter to do the validation + { + RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_AllowMissingOrFailed{ + AllowMissingOrFailed: &emptypb.Empty{}, + }, + }, + }, + }, + }, + } +} + +// collectJWTProviders returns a list of all top level and permission level referenced providers. +func collectJWTProviders(i *structs.Intention) []*structs.IntentionJWTProvider { + // get permission level providers + reqs := getPermissionsProviders(i.Permissions) if i.JWT != nil { - for _, prov := range i.JWT.Providers { - reqs = append(reqs, &jwtAuthnProvider{Provider: prov, ComputedName: makeComputedProviderName(prov.Name, nil, 0)}) - } + // get top level providers + reqs = append(reqs, i.JWT.Providers...) } - reqs = append(reqs, getPermissionsProviders(i.Permissions)...) - return reqs } -func getPermissionsProviders(p []*structs.IntentionPermission) []*jwtAuthnProvider { - var reqs []*jwtAuthnProvider - for k, perm := range p { - if perm.JWT == nil { +func getPermissionsProviders(perms []*structs.IntentionPermission) []*structs.IntentionJWTProvider { + var reqs []*structs.IntentionJWTProvider + for _, p := range perms { + if p.JWT == nil { continue } - for _, prov := range perm.JWT.Providers { - reqs = append(reqs, &jwtAuthnProvider{Provider: prov, ComputedName: makeComputedProviderName(prov.Name, perm, k)}) - } + + reqs = append(reqs, p.JWT.Providers...) } return reqs } -// makeComputedProviderName is used to create names for unique provider per permission -// This is to stop jwt claims cross validation across permissions/providers. +// buildPayloadInMetadataKey is used to create a unique payload key per provider. +// This is to ensure claims are validated/forwarded specifically under the right provider. +// The forwarded payload is used with other data (eg. service identity) by the RBAC filter +// to validate access to resource. // -// eg. If Permission x is the 3rd permission and has a provider of original name okta -// this function will return okta_3 as the computed provider name -func makeComputedProviderName(name string, perm *structs.IntentionPermission, idx int) string { - if perm == nil { - return name - } - return fmt.Sprintf("%s_%d", name, idx) +// eg. With a provider named okta will have a payload key of: jwt_payload_okta +func buildPayloadInMetadataKey(providerName string) string { + return jwtMetadataKeyPrefix + "_" + providerName } -// buildPayloadInMetadataKey is used to create a unique payload key per provider/permissions. -// This is to ensure claims are validated/forwarded specifically under the right permission/path -// and ensure we don't accidentally validate claims from different permissions/providers. -// -// eg. With a provider named okta, the second permission in permission list will have a provider of: -// okta_2 and a payload key of: jwt_payload_okta_2. Whereas an okta provider with no specific permission -// will have a payload key of: jwt_payload_okta -func buildPayloadInMetadataKey(providerName string, perm *structs.IntentionPermission, idx int) string { - return fmt.Sprintf("%s_%s", jwtMetadataKeyPrefix, makeComputedProviderName(providerName, perm, idx)) -} - -func buildJWTProviderConfig(p *structs.JWTProviderConfigEntry, metadataKeySuffix string) (*envoy_http_jwt_authn_v3.JwtProvider, error) { +func buildJWTProviderConfig(p *structs.JWTProviderConfigEntry) (*envoy_http_jwt_authn_v3.JwtProvider, error) { envoyCfg := envoy_http_jwt_authn_v3.JwtProvider{ Issuer: p.Issuer, Audiences: p.Audiences, - PayloadInMetadata: buildPayloadInMetadataKey(metadataKeySuffix, nil, 0), + PayloadInMetadata: buildPayloadInMetadataKey(p.Name), } if p.Forwarding != nil { @@ -262,43 +283,6 @@ func buildJWTRetryPolicy(r *structs.JWKSRetryPolicy) *envoy_core_v3.RetryPolicy return &pol } -func buildRouteRule(provider *structs.IntentionJWTProvider, perm *structs.IntentionPermission, defaultPrefix string, permIdx int) *envoy_http_jwt_authn_v3.RequirementRule { - rule := &envoy_http_jwt_authn_v3.RequirementRule{ - Match: &envoy_route_v3.RouteMatch{ - PathSpecifier: &envoy_route_v3.RouteMatch_Prefix{Prefix: defaultPrefix}, - }, - RequirementType: &envoy_http_jwt_authn_v3.RequirementRule_Requires{ - Requires: &envoy_http_jwt_authn_v3.JwtRequirement{ - RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_ProviderName{ - ProviderName: makeComputedProviderName(provider.Name, perm, permIdx), - }, - }, - }, - } - - if perm != nil && perm.HTTP != nil { - if perm.HTTP.PathPrefix != "" { - rule.Match.PathSpecifier = &envoy_route_v3.RouteMatch_Prefix{ - Prefix: perm.HTTP.PathPrefix, - } - } - - if perm.HTTP.PathExact != "" { - rule.Match.PathSpecifier = &envoy_route_v3.RouteMatch_Path{ - Path: perm.HTTP.PathExact, - } - } - - if perm.HTTP.PathRegex != "" { - rule.Match.PathSpecifier = &envoy_route_v3.RouteMatch_SafeRegex{ - SafeRegex: makeEnvoyRegexMatch(perm.HTTP.PathRegex), - } - } - } - - return rule -} - func hasJWTconfig(p []*structs.IntentionPermission) bool { for _, perm := range p { if perm.JWT != nil { diff --git a/agent/xds/jwt_authn_test.go b/agent/xds/jwt_authn_test.go index b2a7d7ce5..ab8665b1d 100644 --- a/agent/xds/jwt_authn_test.go +++ b/agent/xds/jwt_authn_test.go @@ -9,7 +9,6 @@ import ( "testing" envoy_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" - envoy_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" envoy_http_jwt_authn_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/jwt_authn/v3" "github.com/hashicorp/consul/agent/structs" "github.com/stretchr/testify/require" @@ -173,6 +172,10 @@ func TestMakeJWTAUTHFilters(t *testing.T) { intentions structs.SimplifiedIntentions provider map[string]*structs.JWTProviderConfigEntry }{ + "no-provider": { + intentions: simplified(makeTestIntention(t, ixnOpts{src: "web", action: structs.IntentionActionAllow})), + provider: nil, + }, "remote-provider": { intentions: simplified(makeTestIntention(t, ixnOpts{src: "web", action: structs.IntentionActionAllow, jwt: oktaIntention})), provider: remoteCE, @@ -206,123 +209,45 @@ func TestMakeJWTAUTHFilters(t *testing.T) { } } -func TestMakeComputedProviderName(t *testing.T) { - tests := map[string]struct { - name string - perm *structs.IntentionPermission - idx int - expected string - }{ - "no-permissions": { - name: "okta", - idx: 0, - expected: "okta", - }, - "exact-path-permission": { - name: "auth0", - perm: &structs.IntentionPermission{ - HTTP: &structs.IntentionHTTPPermission{ - PathExact: "admin", - }, - }, - idx: 5, - expected: "auth0_5", - }, - } - - for name, tt := range tests { - tt := tt - t.Run(name, func(t *testing.T) { - reqs := makeComputedProviderName(tt.name, tt.perm, tt.idx) - require.Equal(t, reqs, tt.expected) - }) - } -} - -func TestBuildPayloadInMetadataKey(t *testing.T) { - tests := map[string]struct { - name string - perm *structs.IntentionPermission - permIdx int - expected string - }{ - "no-permissions": { - name: "okta", - expected: "jwt_payload_okta", - }, - "path-prefix-permission": { - name: "auth0", - perm: &structs.IntentionPermission{ - HTTP: &structs.IntentionHTTPPermission{ - PathPrefix: "admin", - }, - }, - permIdx: 4, - expected: "jwt_payload_auth0_4", - }, - } - - for name, tt := range tests { - tt := tt - t.Run(name, func(t *testing.T) { - reqs := buildPayloadInMetadataKey(tt.name, tt.perm, tt.permIdx) - require.Equal(t, reqs, tt.expected) - }) - } -} - -func TestCollectJWTAuthnProviders(t *testing.T) { +func TestCollectJWTProviders(t *testing.T) { tests := map[string]struct { intention *structs.Intention - expected []*jwtAuthnProvider + expected []*structs.IntentionJWTProvider }{ "empty-top-level-jwt-and-empty-permissions": { intention: makeTestIntention(t, ixnOpts{src: "web"}), - expected: []*jwtAuthnProvider{}, + expected: []*structs.IntentionJWTProvider{}, }, "top-level-jwt-and-empty-permissions": { intention: makeTestIntention(t, ixnOpts{src: "web", jwt: oktaIntention}), - expected: []*jwtAuthnProvider{{Provider: &oktaProvider, ComputedName: oktaProvider.Name}}, + expected: []*structs.IntentionJWTProvider{&oktaProvider}, }, "multi-top-level-jwt-and-empty-permissions": { intention: makeTestIntention(t, ixnOpts{src: "web", jwt: multiProviderIntentions}), - expected: []*jwtAuthnProvider{ - {Provider: &oktaProvider, ComputedName: oktaProvider.Name}, - {Provider: &auth0Provider, ComputedName: auth0Provider.Name}, - }, + expected: []*structs.IntentionJWTProvider{&oktaProvider, &auth0Provider}, }, "top-level-jwt-and-one-jwt-permission": { intention: makeTestIntention(t, ixnOpts{src: "web", jwt: auth0Intention, perms: pWithOktaProvider}), - expected: []*jwtAuthnProvider{ - {Provider: &auth0Provider, ComputedName: auth0Provider.Name}, - {Provider: &oktaProvider, ComputedName: "okta_0"}, - }, + expected: []*structs.IntentionJWTProvider{&auth0Provider, &oktaProvider}, }, "top-level-jwt-and-multi-jwt-permissions": { intention: makeTestIntention(t, ixnOpts{src: "web", jwt: fakeIntention, perms: pWithMultiProviders}), - expected: []*jwtAuthnProvider{ - {Provider: &fakeProvider, ComputedName: fakeProvider.Name}, - {Provider: &oktaProvider, ComputedName: "okta_0"}, - {Provider: &auth0Provider, ComputedName: "auth0_0"}, - }, + expected: []*structs.IntentionJWTProvider{&fakeProvider, &oktaProvider, &auth0Provider}, }, "empty-top-level-jwt-and-one-jwt-permission": { intention: makeTestIntention(t, ixnOpts{src: "web", perms: pWithOktaProvider}), - expected: []*jwtAuthnProvider{{Provider: &oktaProvider, ComputedName: "okta_0"}}, + expected: []*structs.IntentionJWTProvider{&oktaProvider}, }, "empty-top-level-jwt-and-multi-jwt-permission": { intention: makeTestIntention(t, ixnOpts{src: "web", perms: pWithMultiProviders}), - expected: []*jwtAuthnProvider{ - {Provider: &oktaProvider, ComputedName: "okta_0"}, - {Provider: &auth0Provider, ComputedName: "auth0_0"}, - }, + expected: []*structs.IntentionJWTProvider{&oktaProvider, &auth0Provider}, }, } for name, tt := range tests { tt := tt t.Run(name, func(t *testing.T) { - reqs := collectJWTAuthnProviders(tt.intention) + reqs := collectJWTProviders(tt.intention) require.ElementsMatch(t, reqs, tt.expected) }) } @@ -331,43 +256,35 @@ func TestCollectJWTAuthnProviders(t *testing.T) { func TestGetPermissionsProviders(t *testing.T) { tests := map[string]struct { perms []*structs.IntentionPermission - expected []*jwtAuthnProvider + expected []*structs.IntentionJWTProvider }{ "empty-permissions": { perms: []*structs.IntentionPermission{}, - expected: []*jwtAuthnProvider{}, + expected: []*structs.IntentionJWTProvider{}, }, "nil-permissions": { perms: nil, - expected: []*jwtAuthnProvider{}, + expected: []*structs.IntentionJWTProvider{}, }, "permissions-with-no-jwt": { perms: []*structs.IntentionPermission{pWithNoJWT}, - expected: []*jwtAuthnProvider{}, + expected: []*structs.IntentionJWTProvider{}, }, "permissions-with-one-jwt": { - perms: []*structs.IntentionPermission{pWithOktaProvider, pWithNoJWT}, - expected: []*jwtAuthnProvider{ - {Provider: &oktaProvider, ComputedName: "okta_0"}, - }, + perms: []*structs.IntentionPermission{pWithOktaProvider, pWithNoJWT}, + expected: []*structs.IntentionJWTProvider{&oktaProvider}, }, "permissions-with-multiple-jwt": { - perms: []*structs.IntentionPermission{pWithMultiProviders, pWithNoJWT}, - expected: []*jwtAuthnProvider{ - {Provider: &auth0Provider, ComputedName: "auth0_0"}, - {Provider: &oktaProvider, ComputedName: "okta_0"}, - }, + perms: []*structs.IntentionPermission{pWithMultiProviders, pWithNoJWT}, + expected: []*structs.IntentionJWTProvider{&auth0Provider, &oktaProvider}, }, } for name, tt := range tests { tt := tt t.Run(name, func(t *testing.T) { - t.Run("getPermissionsProviders", func(t *testing.T) { - p := getPermissionsProviders(tt.perms) - - require.ElementsMatch(t, p, tt.expected) - }) + p := getPermissionsProviders(tt.perms) + require.ElementsMatch(t, p, tt.expected) }) } } @@ -415,7 +332,7 @@ func TestBuildJWTProviderConfig(t *testing.T) { Issuer: fullCE.Issuer, Audiences: fullCE.Audiences, ForwardPayloadHeader: "user-token", - PayloadInMetadata: buildPayloadInMetadataKey(ceRemoteJWKS.Name, nil, 0), + PayloadInMetadata: buildPayloadInMetadataKey(ceRemoteJWKS.Name), PadForwardPayloadHeader: false, Forward: true, JwksSourceSpecifier: &envoy_http_jwt_authn_v3.JwtProvider_LocalJwks{ @@ -433,7 +350,7 @@ func TestBuildJWTProviderConfig(t *testing.T) { expected: &envoy_http_jwt_authn_v3.JwtProvider{ Issuer: fullCE.Issuer, Audiences: fullCE.Audiences, - PayloadInMetadata: buildPayloadInMetadataKey(ceRemoteJWKS.Name, nil, 0), + PayloadInMetadata: buildPayloadInMetadataKey(ceRemoteJWKS.Name), JwksSourceSpecifier: &envoy_http_jwt_authn_v3.JwtProvider_RemoteJwks{ RemoteJwks: &envoy_http_jwt_authn_v3.RemoteJwks{ HttpUri: &envoy_core_v3.HttpUri{ @@ -453,7 +370,7 @@ func TestBuildJWTProviderConfig(t *testing.T) { for name, tt := range tests { tt := tt t.Run(name, func(t *testing.T) { - res, err := buildJWTProviderConfig(tt.ce, tt.ce.GetName()) + res, err := buildJWTProviderConfig(tt.ce) if tt.expectedError != "" { require.Error(t, err) @@ -620,104 +537,6 @@ func TestBuildJWTRetryPolicy(t *testing.T) { } } -func TestBuildRouteRule(t *testing.T) { - var ( - pWithExactPath = &structs.IntentionPermission{ - Action: structs.IntentionActionAllow, - HTTP: &structs.IntentionHTTPPermission{ - PathExact: "/exact-match", - }, - } - pWithRegex = &structs.IntentionPermission{ - Action: structs.IntentionActionAllow, - HTTP: &structs.IntentionHTTPPermission{ - PathRegex: "p([a-z]+)ch", - }, - } - ) - tests := map[string]struct { - provider *structs.IntentionJWTProvider - perm *structs.IntentionPermission - route string - expected *envoy_http_jwt_authn_v3.RequirementRule - }{ - "permission-nil": { - provider: &oktaProvider, - perm: nil, - route: "/my-route", - expected: &envoy_http_jwt_authn_v3.RequirementRule{ - Match: &envoy_route_v3.RouteMatch{PathSpecifier: &envoy_route_v3.RouteMatch_Prefix{Prefix: "/my-route"}}, - RequirementType: &envoy_http_jwt_authn_v3.RequirementRule_Requires{ - Requires: &envoy_http_jwt_authn_v3.JwtRequirement{ - RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_ProviderName{ - ProviderName: oktaProvider.Name, - }, - }, - }, - }, - }, - "permission-with-path-prefix": { - provider: &oktaProvider, - perm: pWithOktaProvider, - route: "/my-route", - expected: &envoy_http_jwt_authn_v3.RequirementRule{ - Match: &envoy_route_v3.RouteMatch{PathSpecifier: &envoy_route_v3.RouteMatch_Prefix{ - Prefix: pWithMultiProviders.HTTP.PathPrefix, - }}, - RequirementType: &envoy_http_jwt_authn_v3.RequirementRule_Requires{ - Requires: &envoy_http_jwt_authn_v3.JwtRequirement{ - RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_ProviderName{ - ProviderName: makeComputedProviderName(oktaProvider.Name, pWithMultiProviders, 0), - }, - }, - }, - }, - }, - "permission-with-exact-path": { - provider: &oktaProvider, - perm: pWithExactPath, - route: "/", - expected: &envoy_http_jwt_authn_v3.RequirementRule{ - Match: &envoy_route_v3.RouteMatch{PathSpecifier: &envoy_route_v3.RouteMatch_Path{ - Path: pWithExactPath.HTTP.PathExact, - }}, - RequirementType: &envoy_http_jwt_authn_v3.RequirementRule_Requires{ - Requires: &envoy_http_jwt_authn_v3.JwtRequirement{ - RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_ProviderName{ - ProviderName: makeComputedProviderName(oktaProvider.Name, pWithExactPath, 0), - }, - }, - }, - }, - }, - "permission-with-regex": { - provider: &oktaProvider, - perm: pWithRegex, - route: "/", - expected: &envoy_http_jwt_authn_v3.RequirementRule{ - Match: &envoy_route_v3.RouteMatch{PathSpecifier: &envoy_route_v3.RouteMatch_SafeRegex{ - SafeRegex: makeEnvoyRegexMatch(pWithRegex.HTTP.PathRegex), - }}, - RequirementType: &envoy_http_jwt_authn_v3.RequirementRule_Requires{ - Requires: &envoy_http_jwt_authn_v3.JwtRequirement{ - RequiresType: &envoy_http_jwt_authn_v3.JwtRequirement_ProviderName{ - ProviderName: makeComputedProviderName(oktaProvider.Name, pWithRegex, 0), - }, - }, - }, - }, - }, - } - - for name, tt := range tests { - tt := tt - t.Run(name, func(t *testing.T) { - res := buildRouteRule(tt.provider, tt.perm, tt.route, 0) - require.Equal(t, res, tt.expected) - }) - } -} - func TestHasJWTconfig(t *testing.T) { tests := map[string]struct { perms []*structs.IntentionPermission diff --git a/agent/xds/listeners.go b/agent/xds/listeners.go index 6e67cd1c5..71e5b285e 100644 --- a/agent/xds/listeners.go +++ b/agent/xds/listeners.go @@ -1291,6 +1291,7 @@ func (s *ResourceGenerator) makeInboundListener(cfgSnap *proxycfg.ConfigSnapshot partition: cfgSnap.ProxyID.PartitionOrDefault(), }, cfgSnap.ConnectProxy.InboundPeerTrustBundles, + cfgSnap.JWTProviders, ) if err != nil { return nil, err @@ -1364,9 +1365,9 @@ func (s *ResourceGenerator) makeInboundListener(cfgSnap *proxycfg.ConfigSnapshot logger: s.Logger, } if useHTTPFilter { - jwtFilter, jwtFilterErr := makeJWTAuthFilter(cfgSnap.JWTProviders, cfgSnap.ConnectProxy.Intentions) - if jwtFilterErr != nil { - return nil, jwtFilterErr + jwtFilter, err := makeJWTAuthFilter(cfgSnap.JWTProviders, cfgSnap.ConnectProxy.Intentions) + if err != nil { + return nil, err } rbacFilter, err := makeRBACHTTPFilter( cfgSnap.ConnectProxy.Intentions, @@ -1377,6 +1378,7 @@ func (s *ResourceGenerator) makeInboundListener(cfgSnap *proxycfg.ConfigSnapshot partition: cfgSnap.ProxyID.PartitionOrDefault(), }, cfgSnap.ConnectProxy.InboundPeerTrustBundles, + cfgSnap.JWTProviders, ) if err != nil { return nil, err @@ -1844,6 +1846,7 @@ func (s *ResourceGenerator) makeFilterChainTerminatingGateway(cfgSnap *proxycfg. partition: cfgSnap.ProxyID.PartitionOrDefault(), }, nil, // TODO(peering): verify intentions w peers don't apply to terminatingGateway + cfgSnap.JWTProviders, ) if err != nil { return nil, err diff --git a/agent/xds/rbac.go b/agent/xds/rbac.go index 4cb77ad7f..f38525abb 100644 --- a/agent/xds/rbac.go +++ b/agent/xds/rbac.go @@ -28,7 +28,10 @@ func makeRBACNetworkFilter( localInfo rbacLocalInfo, peerTrustBundles []*pbpeering.PeeringTrustBundle, ) (*envoy_listener_v3.Filter, error) { - rules := makeRBACRules(intentions, intentionDefaultAllow, localInfo, false, peerTrustBundles) + rules, err := makeRBACRules(intentions, intentionDefaultAllow, localInfo, false, peerTrustBundles, nil) + if err != nil { + return nil, err + } cfg := &envoy_network_rbac_v3.RBAC{ StatPrefix: "connect_authz", @@ -42,8 +45,12 @@ func makeRBACHTTPFilter( intentionDefaultAllow bool, localInfo rbacLocalInfo, peerTrustBundles []*pbpeering.PeeringTrustBundle, + providerMap map[string]*structs.JWTProviderConfigEntry, ) (*envoy_http_v3.HttpFilter, error) { - rules := makeRBACRules(intentions, intentionDefaultAllow, localInfo, true, peerTrustBundles) + rules, err := makeRBACRules(intentions, intentionDefaultAllow, localInfo, true, peerTrustBundles, providerMap) + if err != nil { + return nil, err + } cfg := &envoy_http_rbac_v3.RBAC{ Rules: rules, @@ -56,7 +63,8 @@ func intentionListToIntermediateRBACForm( localInfo rbacLocalInfo, isHTTP bool, trustBundlesByPeer map[string]*pbpeering.PeeringTrustBundle, -) []*rbacIntention { + providerMap map[string]*structs.JWTProviderConfigEntry, +) ([]*rbacIntention, error) { sort.Sort(structs.IntentionPrecedenceSorter(intentions)) // Omit any lower-precedence intentions that share the same source. @@ -73,10 +81,13 @@ func intentionListToIntermediateRBACForm( continue } - rixn := intentionToIntermediateRBACForm(ixn, localInfo, isHTTP, trustBundle) + rixn, err := intentionToIntermediateRBACForm(ixn, localInfo, isHTTP, trustBundle, providerMap) + if err != nil { + return nil, err + } rbacIxns = append(rbacIxns, rixn) } - return rbacIxns + return rbacIxns, nil } func removeSourcePrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction, localInfo rbacLocalInfo) []*rbacIntention { @@ -216,7 +227,8 @@ func intentionToIntermediateRBACForm( localInfo rbacLocalInfo, isHTTP bool, bundle *pbpeering.PeeringTrustBundle, -) *rbacIntention { + providerMap map[string]*structs.JWTProviderConfigEntry, +) (*rbacIntention, error) { rixn := &rbacIntention{ Source: rbacService{ ServiceName: ixn.SourceServiceName(), @@ -233,36 +245,41 @@ func intentionToIntermediateRBACForm( } if isHTTP && ixn.JWT != nil { - var c []*JWTInfo + var jwts []*JWTInfo for _, prov := range ixn.JWT.Providers { - if len(prov.VerifyClaims) > 0 { - c = append(c, makeJWTInfos(prov, nil, 0)) + jwtProvider, ok := providerMap[prov.Name] + + if !ok { + return nil, fmt.Errorf("provider specified in intention does not exist. Provider name: %s", prov.Name) } + jwts = append(jwts, newJWTInfo(prov, jwtProvider)) } - if len(c) > 0 { - rixn.jwtInfos = c - } + + rixn.jwtInfos = jwts } if len(ixn.Permissions) > 0 { if isHTTP { rixn.Action = intentionActionLayer7 rixn.Permissions = make([]*rbacPermission, 0, len(ixn.Permissions)) - for k, perm := range ixn.Permissions { + for _, perm := range ixn.Permissions { rbacPerm := rbacPermission{ Definition: perm, Action: intentionActionFromString(perm.Action), Perm: convertPermission(perm), } + if perm.JWT != nil { - var c []*JWTInfo + var jwts []*JWTInfo for _, prov := range perm.JWT.Providers { - if len(prov.VerifyClaims) > 0 { - c = append(c, makeJWTInfos(prov, perm, k)) + jwtProvider, ok := providerMap[prov.Name] + if !ok { + return nil, fmt.Errorf("provider specified in intention does not exist. Provider name: %s", prov.Name) } + jwts = append(jwts, newJWTInfo(prov, jwtProvider)) } - if len(c) > 0 { - rbacPerm.jwtInfos = c + if len(jwts) > 0 { + rbacPerm.jwtInfos = jwts } } rixn.Permissions = append(rixn.Permissions, &rbacPerm) @@ -275,18 +292,24 @@ func intentionToIntermediateRBACForm( rixn.Action = intentionActionFromString(ixn.Action) } - return rixn + return rixn, nil } -func makeJWTInfos(p *structs.IntentionJWTProvider, perm *structs.IntentionPermission, permKey int) *JWTInfo { - return &JWTInfo{Claims: p.VerifyClaims, MetadataPayloadKey: buildPayloadInMetadataKey(p.Name, perm, permKey)} +func newJWTInfo(p *structs.IntentionJWTProvider, ce *structs.JWTProviderConfigEntry) *JWTInfo { + return &JWTInfo{ + Provider: p, + Issuer: ce.Issuer, + } } type intentionAction int type JWTInfo struct { - Claims []*structs.IntentionJWTClaimVerification - MetadataPayloadKey string + // Provider issuer + // this information is coming from the config entry + Issuer string + // Provider is the intention provider + Provider *structs.IntentionJWTProvider } const ( @@ -341,26 +364,32 @@ type rbacIntention struct { } func (r *rbacIntention) FlattenPrincipal(localInfo rbacLocalInfo) *envoy_rbac_v3.Principal { + var principal *envoy_rbac_v3.Principal if !localInfo.expectXFCC { - return r.flattenPrincipalFromCert() - + principal = r.flattenPrincipalFromCert() } else if r.Source.Peer == "" { // NOTE: ixnSourceMatches should enforce that all of Source and NotSources // are peered or not-peered, so we only need to look at the Source element. - return r.flattenPrincipalFromCert() // intention is not relevant to peering + principal = r.flattenPrincipalFromCert() // intention is not relevant to peering + } else { + // If this intention is an L7 peered one, then it is exclusively resolvable + // using XFCC, rather than the TLS SAN field. + fromXFCC := r.flattenPrincipalFromXFCC() + + // Use of the XFCC one is gated on coming directly from our own gateways. + gwIDPattern := makeSpiffeMeshGatewayPattern(localInfo.trustDomain, localInfo.partition) + + principal = andPrincipals([]*envoy_rbac_v3.Principal{ + authenticatedPatternPrincipal(gwIDPattern), + fromXFCC, + }) } - // If this intention is an L7 peered one, then it is exclusively resolvable - // using XFCC, rather than the TLS SAN field. - fromXFCC := r.flattenPrincipalFromXFCC() + if len(r.jwtInfos) == 0 { + return principal + } - // Use of the XFCC one is gated on coming directly from our own gateways. - gwIDPattern := makeSpiffeMeshGatewayPattern(localInfo.trustDomain, localInfo.partition) - - return andPrincipals([]*envoy_rbac_v3.Principal{ - authenticatedPatternPrincipal(gwIDPattern), - fromXFCC, - }) + return addJWTPrincipal(principal, r.jwtInfos) } func (r *rbacIntention) flattenPrincipalFromCert() *envoy_rbac_v3.Principal { @@ -417,17 +446,47 @@ type rbacPermission struct { ComputedPermission *envoy_rbac_v3.Permission } +// Flatten ensure the permission rules, not-rules, and jwt validation rules are merged into a single computed permission. +// +// Details on JWTInfo section: +// For each JWTInfo (AKA provider required), this builds 1 single permission that validates that the jwt has +// the right issuer (`iss`) field and validates the claims (if any). +// +// After generating a single permission per info, it combines all the info permissions into a single OrPermission. +// This orPermission is then attached to initial computed permission for jwt payload and claims validation. func (p *rbacPermission) Flatten() *envoy_rbac_v3.Permission { - if len(p.NotPerms) == 0 { - return p.Perm + computedPermission := p.Perm + if len(p.NotPerms) == 0 && len(p.jwtInfos) == 0 { + return computedPermission } - parts := make([]*envoy_rbac_v3.Permission, 0, len(p.NotPerms)+1) - parts = append(parts, p.Perm) - for _, notPerm := range p.NotPerms { - parts = append(parts, notPermission(notPerm)) + if len(p.NotPerms) != 0 { + parts := make([]*envoy_rbac_v3.Permission, 0, len(p.NotPerms)+1) + parts = append(parts, p.Perm) + for _, notPerm := range p.NotPerms { + parts = append(parts, notPermission(notPerm)) + } + computedPermission = andPermissions(parts) } - return andPermissions(parts) + + if len(p.jwtInfos) == 0 { + return computedPermission + } + + var jwtPerms []*envoy_rbac_v3.Permission + for _, info := range p.jwtInfos { + payloadKey := buildPayloadInMetadataKey(info.Provider.Name) + claimsPermission := jwtInfosToPermission(info.Provider.VerifyClaims, payloadKey) + issuerPermission := segmentToPermission(pathToSegments([]string{"iss"}, payloadKey), info.Issuer) + + perm := andPermissions([]*envoy_rbac_v3.Permission{ + issuerPermission, claimsPermission, + }) + jwtPerms = append(jwtPerms, perm) + } + + jwtPerm := orPermissions(jwtPerms) + return andPermissions([]*envoy_rbac_v3.Permission{computedPermission, jwtPerm}) } // simplifyNotSourceSlice will collapse NotSources elements together if any element is @@ -526,7 +585,8 @@ func makeRBACRules( localInfo rbacLocalInfo, isHTTP bool, peerTrustBundles []*pbpeering.PeeringTrustBundle, -) *envoy_rbac_v3.RBAC { + providerMap map[string]*structs.JWTProviderConfigEntry, +) (*envoy_rbac_v3.RBAC, error) { // TODO(banks,rb): Implement revocation list checking? // TODO(peering): mkeeler asked that these maps come from proxycfg instead of @@ -546,7 +606,10 @@ func makeRBACRules( } // First build up just the basic principal matches. - rbacIxns := intentionListToIntermediateRBACForm(intentions, localInfo, isHTTP, trustBundlesByPeer) + rbacIxns, err := intentionListToIntermediateRBACForm(intentions, localInfo, isHTTP, trustBundlesByPeer, providerMap) + if err != nil { + return nil, err + } // Normalize: if we are in default-deny then all intentions must be allows and vice versa intentionDefaultAction := intentionActionFromBool(intentionDefaultAllow) @@ -574,10 +637,6 @@ func makeRBACRules( var principalsL4 []*envoy_rbac_v3.Principal for i, rbacIxn := range rbacIxns { - var infos []*JWTInfo - if isHTTP { - infos = collectJWTInfos(rbacIxn) - } if rbacIxn.Action == intentionActionLayer7 { if len(rbacIxn.Permissions) == 0 { panic("invalid state: L7 intention has no permissions") @@ -587,10 +646,6 @@ func makeRBACRules( } rbacPrincipals := optimizePrincipals([]*envoy_rbac_v3.Principal{rbacIxn.ComputedPrincipal}) - if len(infos) > 0 { - claimsPrincipal := jwtInfosToPrincipals(infos) - rbacPrincipals = combineBasePrincipalWithJWTPrincipals(rbacPrincipals, claimsPrincipal) - } // For L7: we should generate one Policy per Principal and list all of the Permissions policy := &envoy_rbac_v3.Policy{ Principals: rbacPrincipals, @@ -603,11 +658,6 @@ func makeRBACRules( } else { // For L4: we should generate one big Policy listing all Principals principalsL4 = append(principalsL4, rbacIxn.ComputedPrincipal) - // Append JWT principals to list of principals - if len(infos) > 0 { - claimsPrincipal := jwtInfosToPrincipals(infos) - principalsL4 = combineBasePrincipalWithJWTPrincipals(principalsL4, claimsPrincipal) - } } } if len(principalsL4) > 0 { @@ -620,59 +670,74 @@ func makeRBACRules( if len(rbac.Policies) == 0 { rbac.Policies = nil } - return rbac + return rbac, nil } -// combineBasePrincipalWithJWTPrincipals ensure each RBAC/Network principal is associated with -// the JWT principal -func combineBasePrincipalWithJWTPrincipals(p []*envoy_rbac_v3.Principal, cp *envoy_rbac_v3.Principal) []*envoy_rbac_v3.Principal { - res := make([]*envoy_rbac_v3.Principal, 0) +// addJWTPrincipal ensure the passed RBAC/Network principal is associated with +// a JWT principal when JWTs validation is required. +// +// For each jwtInfo, this builds a first principal that validates that the jwt has the right issuer (`iss`). +// It collects all the claims principal and combines them into a single principal using jwtClaimsToPrincipals. +// It then combines the issuer principal and the claims principal into a single principal. +// +// After generating a single principal per info, it combines all the info principals into a single jwt OrPrincipal. +// This orPrincipal is then attached to the RBAC/NETWORK principal for jwt payload validation. +func addJWTPrincipal(principal *envoy_rbac_v3.Principal, infos []*JWTInfo) *envoy_rbac_v3.Principal { + if len(infos) == 0 { + return principal + } + jwtPrincipals := make([]*envoy_rbac_v3.Principal, 0, len(infos)) + for _, info := range infos { + payloadKey := buildPayloadInMetadataKey(info.Provider.Name) - for _, principal := range p { - if principal != nil && cp != nil { - p := andPrincipals([]*envoy_rbac_v3.Principal{principal, cp}) - res = append(res, p) + // build jwt provider issuer principal + segments := pathToSegments([]string{"iss"}, payloadKey) + p := segmentToPrincipal(segments, info.Issuer) + + // add jwt provider claims principal if any + if cp := jwtClaimsToPrincipals(info.Provider.VerifyClaims, payloadKey); cp != nil { + p = andPrincipals([]*envoy_rbac_v3.Principal{p, cp}) } + jwtPrincipals = append(jwtPrincipals, p) } - return res + + // make jwt principals into 1 single principal + jwtFinalPrincipal := orPrincipals(jwtPrincipals) + + if principal == nil { + return jwtFinalPrincipal + } + + return andPrincipals([]*envoy_rbac_v3.Principal{principal, jwtFinalPrincipal}) } -// collectJWTInfos extracts all the collected JWTInfos top level infos -// and permission level infos and returns them as a single array -func collectJWTInfos(rbacIxn *rbacIntention) []*JWTInfo { - infos := make([]*JWTInfo, 0, len(rbacIxn.jwtInfos)) - - if len(rbacIxn.jwtInfos) > 0 { - infos = append(infos, rbacIxn.jwtInfos...) - } - for _, perm := range rbacIxn.Permissions { - infos = append(infos, perm.jwtInfos...) - } - - return infos -} - -func jwtInfosToPrincipals(c []*JWTInfo) *envoy_rbac_v3.Principal { +func jwtClaimsToPrincipals(claims []*structs.IntentionJWTClaimVerification, payloadkey string) *envoy_rbac_v3.Principal { ps := make([]*envoy_rbac_v3.Principal, 0) - for _, jwtInfo := range c { - if jwtInfo != nil { - for _, claim := range jwtInfo.Claims { - ps = append(ps, jwtClaimToPrincipal(claim, jwtInfo.MetadataPayloadKey)) - } - } + for _, claim := range claims { + ps = append(ps, jwtClaimToPrincipal(claim, payloadkey)) + } + switch len(ps) { + case 0: + return nil + case 1: + return ps[0] + default: + return andPrincipals(ps) } - return orPrincipals(ps) } -// jwtClaimToPrincipal takes in a payloadkey which is the metadata key. This key is generated by using provider name, -// permission index with a jwt_payload prefix. See buildPayloadInMetadataKey in agent/xds/jwt_authn.go +// jwtClaimToPrincipal takes in a payloadkey which is the metadata key. This key is generated by using provider name +// and a jwt_payload prefix. See buildPayloadInMetadataKey in agent/xds/jwt_authn.go // // This uniquely generated payloadKey is the first segment in the path to validate the JWT claims. The subsequent segments // come from the Path included in the IntentionJWTClaimVerification param. func jwtClaimToPrincipal(c *structs.IntentionJWTClaimVerification, payloadKey string) *envoy_rbac_v3.Principal { segments := pathToSegments(c.Path, payloadKey) + return segmentToPrincipal(segments, c.Value) +} +func segmentToPrincipal(segments []*envoy_matcher_v3.MetadataMatcher_PathSegment, v string) *envoy_rbac_v3.Principal { return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_Metadata{ Metadata: &envoy_matcher_v3.MetadataMatcher{ @@ -682,7 +747,41 @@ func jwtClaimToPrincipal(c *structs.IntentionJWTClaimVerification, payloadKey st MatchPattern: &envoy_matcher_v3.ValueMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Exact{ - Exact: c.Value, + Exact: v, + }, + }, + }, + }, + }, + }, + } +} + +func jwtInfosToPermission(claims []*structs.IntentionJWTClaimVerification, payloadkey string) *envoy_rbac_v3.Permission { + ps := make([]*envoy_rbac_v3.Permission, 0, len(claims)) + + for _, claim := range claims { + ps = append(ps, jwtClaimToPermission(claim, payloadkey)) + } + return andPermissions(ps) +} + +func jwtClaimToPermission(c *structs.IntentionJWTClaimVerification, payloadKey string) *envoy_rbac_v3.Permission { + segments := pathToSegments(c.Path, payloadKey) + return segmentToPermission(segments, c.Value) +} + +func segmentToPermission(segments []*envoy_matcher_v3.MetadataMatcher_PathSegment, v string) *envoy_rbac_v3.Permission { + return &envoy_rbac_v3.Permission{ + Rule: &envoy_rbac_v3.Permission_Metadata{ + Metadata: &envoy_matcher_v3.MetadataMatcher{ + Filter: jwtEnvoyFilter, + Path: segments, + Value: &envoy_matcher_v3.ValueMatcher{ + MatchPattern: &envoy_matcher_v3.ValueMatcher_StringMatch{ + StringMatch: &envoy_matcher_v3.StringMatcher{ + MatchPattern: &envoy_matcher_v3.StringMatcher_Exact{ + Exact: v, }, }, }, @@ -837,22 +936,32 @@ func countWild(src rbacService) int { } func andPrincipals(ids []*envoy_rbac_v3.Principal) *envoy_rbac_v3.Principal { - return &envoy_rbac_v3.Principal{ - Identifier: &envoy_rbac_v3.Principal_AndIds{ - AndIds: &envoy_rbac_v3.Principal_Set{ - Ids: ids, + switch len(ids) { + case 1: + return ids[0] + default: + return &envoy_rbac_v3.Principal{ + Identifier: &envoy_rbac_v3.Principal_AndIds{ + AndIds: &envoy_rbac_v3.Principal_Set{ + Ids: ids, + }, }, - }, + } } } func orPrincipals(ids []*envoy_rbac_v3.Principal) *envoy_rbac_v3.Principal { - return &envoy_rbac_v3.Principal{ - Identifier: &envoy_rbac_v3.Principal_OrIds{ - OrIds: &envoy_rbac_v3.Principal_Set{ - Ids: ids, + switch len(ids) { + case 1: + return ids[0] + default: + return &envoy_rbac_v3.Principal{ + Identifier: &envoy_rbac_v3.Principal_OrIds{ + OrIds: &envoy_rbac_v3.Principal_Set{ + Ids: ids, + }, }, - }, + } } } @@ -1206,3 +1315,20 @@ func andPermissions(perms []*envoy_rbac_v3.Permission) *envoy_rbac_v3.Permission } } } + +func orPermissions(perms []*envoy_rbac_v3.Permission) *envoy_rbac_v3.Permission { + switch len(perms) { + case 0: + return anyPermission() + case 1: + return perms[0] + default: + return &envoy_rbac_v3.Permission{ + Rule: &envoy_rbac_v3.Permission_OrRules{ + OrRules: &envoy_rbac_v3.Permission_Set{ + Rules: perms, + }, + }, + } + } +} diff --git a/agent/xds/rbac_test.go b/agent/xds/rbac_test.go index 76f4467bf..6bc360287 100644 --- a/agent/xds/rbac_test.go +++ b/agent/xds/rbac_test.go @@ -451,10 +451,11 @@ func TestRemoveIntentionPrecedence(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - rbacIxns := intentionListToIntermediateRBACForm(tt.intentions, testLocalInfo, tt.http, testPeerTrustBundle) + rbacIxns, err := intentionListToIntermediateRBACForm(tt.intentions, testLocalInfo, tt.http, testPeerTrustBundle, nil) intentionDefaultAction := intentionActionFromBool(tt.intentionDefaultAllow) rbacIxns = removeIntentionPrecedence(rbacIxns, intentionDefaultAction, testLocalInfo) + require.NoError(t, err) require.Equal(t, tt.expect, rbacIxns) }) } @@ -529,6 +530,10 @@ func TestMakeRBACNetworkAndHTTPFilters(t *testing.T) { {Path: []string{"perms", "role"}, Value: "admin"}, }, } + testJWTProviderConfigEntry = map[string]*structs.JWTProviderConfigEntry{ + "okta": {Name: "okta", Issuer: "mytest.okta-issuer"}, + "auth0": {Name: "auth0", Issuer: "mytest.auth0-issuer"}, + } jwtRequirement = &structs.IntentionJWTRequirement{ Providers: []*structs.IntentionJWTProvider{ &oktaWithClaims, @@ -922,7 +927,7 @@ func TestMakeRBACNetworkAndHTTPFilters(t *testing.T) { }) }) t.Run("http filter", func(t *testing.T) { - filter, err := makeRBACHTTPFilter(tt.intentions, tt.intentionDefaultAllow, testLocalInfo, testPeerTrustBundle) + filter, err := makeRBACHTTPFilter(tt.intentions, tt.intentionDefaultAllow, testLocalInfo, testPeerTrustBundle, testJWTProviderConfigEntry) require.NoError(t, err) t.Run("current", func(t *testing.T) { @@ -1202,7 +1207,7 @@ func TestPathToSegments(t *testing.T) { } } -func TestJwtClaimToPrincipal(t *testing.T) { +func TestJWTClaimsToPrincipals(t *testing.T) { var ( firstClaim = structs.IntentionJWTClaimVerification{ Path: []string{"perms"}, @@ -1234,7 +1239,7 @@ func TestJwtClaimToPrincipal(t *testing.T) { Identifier: &envoy_rbac_v3.Principal_Metadata{ Metadata: &envoy_matcher_v3.MetadataMatcher{ Filter: jwtEnvoyFilter, - Path: pathToSegments(secondClaim.Path, "second-key"), + Path: pathToSegments(secondClaim.Path, payloadKey), Value: &envoy_matcher_v3.ValueMatcher{ MatchPattern: &envoy_matcher_v3.ValueMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ @@ -1249,38 +1254,21 @@ func TestJwtClaimToPrincipal(t *testing.T) { } ) tests := map[string]struct { - jwtInfos []*JWTInfo - expected *envoy_rbac_v3.Principal + claims []*structs.IntentionJWTClaimVerification + metadataPayloadKey string + expected *envoy_rbac_v3.Principal }{ - "single-jwt-info": { - jwtInfos: []*JWTInfo{ - { - Claims: []*structs.IntentionJWTClaimVerification{&firstClaim}, - MetadataPayloadKey: payloadKey, - }, - }, - expected: &envoy_rbac_v3.Principal{ - Identifier: &envoy_rbac_v3.Principal_OrIds{ - OrIds: &envoy_rbac_v3.Principal_Set{ - Ids: []*envoy_rbac_v3.Principal{&firstPrincipal}, - }, - }, - }, + "single-claim": { + claims: []*structs.IntentionJWTClaimVerification{&firstClaim}, + metadataPayloadKey: payloadKey, + expected: &firstPrincipal, }, - "multiple-jwt-info": { - jwtInfos: []*JWTInfo{ - { - Claims: []*structs.IntentionJWTClaimVerification{&firstClaim}, - MetadataPayloadKey: payloadKey, - }, - { - Claims: []*structs.IntentionJWTClaimVerification{&secondClaim}, - MetadataPayloadKey: "second-key", - }, - }, + "multiple-claims": { + claims: []*structs.IntentionJWTClaimVerification{&firstClaim, &secondClaim}, + metadataPayloadKey: payloadKey, expected: &envoy_rbac_v3.Principal{ - Identifier: &envoy_rbac_v3.Principal_OrIds{ - OrIds: &envoy_rbac_v3.Principal_Set{ + Identifier: &envoy_rbac_v3.Principal_AndIds{ + AndIds: &envoy_rbac_v3.Principal_Set{ Ids: []*envoy_rbac_v3.Principal{&firstPrincipal, &secondPrincipal}, }, }, @@ -1291,7 +1279,7 @@ func TestJwtClaimToPrincipal(t *testing.T) { for name, tt := range tests { tt := tt t.Run(name, func(t *testing.T) { - principal := jwtInfosToPrincipals(tt.jwtInfos) + principal := jwtClaimsToPrincipals(tt.claims, tt.metadataPayloadKey) require.Equal(t, principal, tt.expected) }) } diff --git a/agent/xds/testdata/jwt_authn/intention-with-path.golden b/agent/xds/testdata/jwt_authn/intention-with-path.golden index 6e925758c..3a66e2dcf 100644 --- a/agent/xds/testdata/jwt_authn/intention-with-path.golden +++ b/agent/xds/testdata/jwt_authn/intention-with-path.golden @@ -3,9 +3,9 @@ "typedConfig": { "@type": "type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication", "providers": { - "okta_0": { + "okta": { "issuer": "test-issuer", - "payloadInMetadata": "jwt_payload_okta_0", + "payloadInMetadata": "jwt_payload_okta", "remoteJwks": { "httpUri": { "uri": "https://example-okta.com/.well-known/jwks.json", @@ -21,10 +21,15 @@ "rules": [ { "match": { - "prefix": "some-special-path" + "prefix": "/" }, "requires": { - "providerName": "okta_0" + "requiresAny": { + "requirements": [ + {"providerName": "okta"}, + {"allowMissingOrFailed": {}} + ] + } } } ] diff --git a/agent/xds/testdata/jwt_authn/local-provider.golden b/agent/xds/testdata/jwt_authn/local-provider.golden index 9efda0042..528c0556a 100644 --- a/agent/xds/testdata/jwt_authn/local-provider.golden +++ b/agent/xds/testdata/jwt_authn/local-provider.golden @@ -17,7 +17,12 @@ "prefix": "/" }, "requires": { - "providerName": "okta" + "requiresAny": { + "requirements": [ + {"providerName": "okta"}, + {"allowMissingOrFailed": {}} + ] + } } } ] diff --git a/agent/xds/testdata/jwt_authn/multiple-providers-and-one-permission.golden b/agent/xds/testdata/jwt_authn/multiple-providers-and-one-permission.golden index ca9a99265..7c970bde4 100644 --- a/agent/xds/testdata/jwt_authn/multiple-providers-and-one-permission.golden +++ b/agent/xds/testdata/jwt_authn/multiple-providers-and-one-permission.golden @@ -17,20 +17,6 @@ } } }, - "okta_0": { - "issuer": "test-issuer", - "payloadInMetadata": "jwt_payload_okta_0", - "remoteJwks": { - "httpUri": { - "uri": "https://example-okta.com/.well-known/jwks.json", - "cluster": "jwks_cluster_okta", - "timeout": "1s" - }, - "asyncFetch": { - "fastListener": true - } - } - }, "auth0": { "issuer": "another-issuer", "payloadInMetadata": "jwt_payload_auth0", @@ -47,28 +33,32 @@ } }, "rules": [ - { - "match": { - "prefix": "some-special-path" - }, - "requires": { - "providerName": "okta_0" - } - }, { "match": { "prefix": "/" }, "requires": { - "providerName": "okta" - } - }, - { - "match": { - "prefix": "/" - }, - "requires": { - "providerName": "auth0" + "requiresAll": { + "requirements": [ + { + "requiresAny": { + "requirements": [ + {"providerName": "okta"}, + {"allowMissingOrFailed": {}} + ] + } + }, + { + "requiresAny": { + "requirements": [ + {"providerName": "auth0"}, + {"allowMissingOrFailed": {}} + ] + } + } + ] + } + } } ] diff --git a/agent/xds/testdata/jwt_authn/no-provider.golden b/agent/xds/testdata/jwt_authn/no-provider.golden new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/agent/xds/testdata/jwt_authn/no-provider.golden @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/agent/xds/testdata/jwt_authn/remote-provider.golden b/agent/xds/testdata/jwt_authn/remote-provider.golden index 6116a58ce..3a66e2dcf 100644 --- a/agent/xds/testdata/jwt_authn/remote-provider.golden +++ b/agent/xds/testdata/jwt_authn/remote-provider.golden @@ -24,7 +24,12 @@ "prefix": "/" }, "requires": { - "providerName": "okta" + "requiresAny": { + "requirements": [ + {"providerName": "okta"}, + {"allowMissingOrFailed": {}} + ] + } } } ] diff --git a/agent/xds/testdata/jwt_authn/top-level-provider-with-permission.golden b/agent/xds/testdata/jwt_authn/top-level-provider-with-permission.golden index 6eed6793d..1b0ded7fc 100644 --- a/agent/xds/testdata/jwt_authn/top-level-provider-with-permission.golden +++ b/agent/xds/testdata/jwt_authn/top-level-provider-with-permission.golden @@ -16,37 +16,21 @@ "fastListener": true } } - }, - "okta_0": { - "issuer": "test-issuer", - "payloadInMetadata": "jwt_payload_okta_0", - "remoteJwks": { - "httpUri": { - "uri": "https://example-okta.com/.well-known/jwks.json", - "cluster": "jwks_cluster_okta", - "timeout": "1s" - }, - "asyncFetch": { - "fastListener": true - } - } } }, "rules": [ - { - "match": { - "prefix": "some-special-path" - }, - "requires": { - "providerName": "okta_0" - } - }, { "match": { "prefix": "/" }, "requires": { - "providerName": "okta" + "requiresAny": { + "requirements": [ + {"providerName": "okta"}, + {"allowMissingOrFailed": {}} + ] + } + } } ] diff --git a/agent/xds/testdata/rbac/empty-top-level-jwt-with-one-permission--httpfilter.golden b/agent/xds/testdata/rbac/empty-top-level-jwt-with-one-permission--httpfilter.golden index cd5c35bab..f5eb4bdbc 100644 --- a/agent/xds/testdata/rbac/empty-top-level-jwt-with-one-permission--httpfilter.golden +++ b/agent/xds/testdata/rbac/empty-top-level-jwt-with-one-permission--httpfilter.golden @@ -7,35 +7,37 @@ "consul-intentions-layer7-0": { "permissions": [ { - "urlPath": { - "path": { - "prefix": "some-path" - } - } - } - ], - "principals": [ - { - "andIds": { - "ids": [ + "andRules": { + "rules": [ { - "authenticated": { - "principalName": { - "safeRegex": { - "googleRe2": {}, - "regex": "^spiffe://test.consul/ns/default/dc/[^/]+/svc/web$" - } + "urlPath": { + "path": { + "prefix": "some-path" } } }, { - "orIds": { - "ids": [ - { + "andRules": { + "rules": [ + { "metadata": { - "filter":"envoy.filters.http.jwt_authn", + "filter": "envoy.filters.http.jwt_authn", "path": [ - {"key": "jwt_payload_okta_0"}, + {"key": "jwt_payload_okta"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.okta-issuer" + } + } + } + }, + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_okta"}, {"key": "roles"} ], "value": { @@ -51,6 +53,18 @@ ] } } + ], + "principals": [ + { + "authenticated": { + "principalName": { + "safeRegex": { + "googleRe2": {}, + "regex": "^spiffe://test.consul/ns/default/dc/[^/]+/svc/web$" + } + } + } + } ] } } diff --git a/agent/xds/testdata/rbac/top-level-jwt-no-permissions--httpfilter.golden b/agent/xds/testdata/rbac/top-level-jwt-no-permissions--httpfilter.golden index 35b3792e6..efa9293f3 100644 --- a/agent/xds/testdata/rbac/top-level-jwt-no-permissions--httpfilter.golden +++ b/agent/xds/testdata/rbac/top-level-jwt-no-permissions--httpfilter.golden @@ -27,8 +27,22 @@ } }, { - "orIds": { + "andIds": { "ids": [ + { + "metadata": { + "filter":"envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_okta"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.okta-issuer" + } + } + } + }, { "metadata": { "filter":"envoy.filters.http.jwt_authn", diff --git a/agent/xds/testdata/rbac/top-level-jwt-with-multiple-permissions--httpfilter.golden b/agent/xds/testdata/rbac/top-level-jwt-with-multiple-permissions--httpfilter.golden index 409a3a4bd..6ce0662e3 100644 --- a/agent/xds/testdata/rbac/top-level-jwt-with-multiple-permissions--httpfilter.golden +++ b/agent/xds/testdata/rbac/top-level-jwt-with-multiple-permissions--httpfilter.golden @@ -7,30 +7,112 @@ "consul-intentions-layer7-0": { "permissions": [ { - "urlPath": { - "path": { - "exact": "/v1/secret" - } + "andRules": { + "rules": [ + { + "urlPath": { + "path": { + "exact": "/v1/secret" + } + } + }, + { + "andRules": { + "rules": [ + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_auth0"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.auth0-issuer" + } + } + } + }, + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_auth0"}, + {"key": "perms"}, + {"key": "role"} + ], + "value": { + "stringMatch": { + "exact": "admin" + } + } + } + } + ] + } + } + ] } }, { "andRules": { "rules": [ { - "urlPath": { - "path": { - "exact": "/v1/admin" - } + "andRules": { + "rules": [ + { + "urlPath": { + "path": { + "exact": "/v1/admin" + } + } + }, + { + "notRule": { + "urlPath": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] } }, { - "notRule": { - "urlPath": { - "path": { - "exact": "/v1/secret" - } - } - } + "andRules": { + "rules": [ + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_auth0"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.auth0-issuer" + } + } + } + }, + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_auth0"}, + {"key": "perms"}, + {"key": "role"} + ], + "value": { + "stringMatch": { + "exact": "admin" + } + } + } + } + ] + } } ] } @@ -53,8 +135,22 @@ } }, { - "orIds": { + "andIds": { "ids": [ + { + "metadata": { + "filter":"envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_okta"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.okta-issuer" + } + } + } + }, { "metadata": { "filter":"envoy.filters.http.jwt_authn", @@ -68,36 +164,6 @@ } } } - }, - { - "metadata": { - "filter":"envoy.filters.http.jwt_authn", - "path": [ - {"key": "jwt_payload_auth0_0"}, - {"key": "perms"}, - {"key": "role"} - ], - "value": { - "stringMatch": { - "exact": "admin" - } - } - } - }, - { - "metadata": { - "filter":"envoy.filters.http.jwt_authn", - "path": [ - {"key": "jwt_payload_auth0_1"}, - {"key": "perms"}, - {"key": "role"} - ], - "value": { - "stringMatch": { - "exact": "admin" - } - } - } } ] } diff --git a/agent/xds/testdata/rbac/top-level-jwt-with-one-permission--httpfilter.golden b/agent/xds/testdata/rbac/top-level-jwt-with-one-permission--httpfilter.golden index edf027f3e..36ba23c29 100644 --- a/agent/xds/testdata/rbac/top-level-jwt-with-one-permission--httpfilter.golden +++ b/agent/xds/testdata/rbac/top-level-jwt-with-one-permission--httpfilter.golden @@ -7,10 +7,51 @@ "consul-intentions-layer7-0": { "permissions": [ { - "urlPath": { - "path": { - "exact": "/v1/secret" - } + "andRules": { + "rules": [ + { + "urlPath": { + "path": { + "exact": "/v1/secret" + } + } + }, + { + "andRules": { + "rules": [ + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_auth0"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.auth0-issuer" + } + } + } + }, + { + "metadata": { + "filter": "envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_auth0"}, + {"key": "perms"}, + {"key": "role"} + ], + "value": { + "stringMatch": { + "exact": "admin" + } + } + } + } + ] + } + } + ] } }, { @@ -53,8 +94,22 @@ } }, { - "orIds": { + "andIds": { "ids": [ + { + "metadata": { + "filter":"envoy.filters.http.jwt_authn", + "path": [ + {"key": "jwt_payload_okta"}, + {"key": "iss"} + ], + "value": { + "stringMatch": { + "exact": "mytest.okta-issuer" + } + } + } + }, { "metadata": { "filter":"envoy.filters.http.jwt_authn", @@ -68,21 +123,6 @@ } } } - }, - { - "metadata": { - "filter":"envoy.filters.http.jwt_authn", - "path": [ - {"key": "jwt_payload_auth0_0"}, - {"key": "perms"}, - {"key": "role"} - ], - "value": { - "stringMatch": { - "exact": "admin" - } - } - } } ] } diff --git a/test/integration/consul-container/test/jwtauth/jwt_auth_test.go b/test/integration/consul-container/test/jwtauth/jwt_auth_test.go index 37c846d0a..498bdcedf 100644 --- a/test/integration/consul-container/test/jwtauth/jwt_auth_test.go +++ b/test/integration/consul-container/test/jwtauth/jwt_auth_test.go @@ -76,8 +76,8 @@ func TestJWTAuthConnectService(t *testing.T) { configureIntentions(t, cluster) baseURL := fmt.Sprintf("http://localhost:%d", clientPort) - // fails without jwt headers - doRequest(t, baseURL, http.StatusUnauthorized, "") + // TODO(roncodingenthusiast): update test to reflect jwt-auth filter in metadata mode + doRequest(t, baseURL, http.StatusOK, "") // succeeds with jwt doRequest(t, baseURL, http.StatusOK, jwt) }