Skip to content

feat: add ClientCertPrincipal to SecurityPolicy authorization - #9936

Open
joshuaecook wants to merge 6 commits into
envoyproxy:mainfrom
joshuaecook:clientcert-principal
Open

feat: add ClientCertPrincipal to SecurityPolicy authorization#9936
joshuaecook wants to merge 6 commits into
envoyproxy:mainfrom
joshuaecook:clientcert-principal

Conversation

@joshuaecook

Copy link
Copy Markdown

What type of PR is this?

feat

What this PR does / why we need it:

Adds clientCert as a principal type under SecurityPolicy.authorization.rules[].principal, authorizing requests against the client certificate presented during mTLS. This gives per-route access control keyed on the workload or service identity carried in the peer certificate.

Match types map to Envoy's native SSL matching inputs:

Field Envoy input Matches
subject SubjectInput Subject DN as an RFC 4514 string; supports RegularExpression
subjectAltNames.uris UriSanInput URI SANs, such as SPIFFE IDs
subjectAltNames.dnsNames DnsSanInput DNS SANs

emailAddresses, ipAddresses, and otherNames have no native Envoy input. A CEL rule rejects them at admission rather than letting translation silently drop the matcher.

The translator lowers a principal to AND(subject, OR(uris…, dnsNames…)). The Subject DN, when set, must match, and the certificate must carry at least one of the listed URI or DNS identities. URI and DNS SANs share a single OR group rather than AND-combining across types, since a workload certificate carries a URI SAN and a service certificate a DNS SAN, rarely both.

mTLS is a prerequisite. clientCert matches the certificate from the TLS handshake, which is present only when a ClientTrafficPolicy sets spec.tls.clientValidation.caCertificateRefs on the listener. Without mTLS, no certificate is presented and the principal never matches.

clientCert applies to HTTPRoute and GRPCRoute targets. Attaching it to a TCPRoute sets Accepted=False.

Which issue(s) this PR fixes:

Fixes #5392

Release Notes: Yes

Signed-off-by: Joshua E Cook <jec@apple.com>
Signed-off-by: Joshua E Cook <jec@apple.com>
…zation

Signed-off-by: Joshua E Cook <jec@apple.com>
…ed principal

Signed-off-by: Joshua E Cook <jec@apple.com>
Signed-off-by: Joshua E Cook <jec@apple.com>
@joshuaecook
joshuaecook requested a review from a team as a code owner September 2, 2026 23:40
@netlify

netlify Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploy Preview for cerulean-figolla-1f9435 ready!

Name Link
🔨 Latest commit eeba0c4
🔍 Latest deploy log https://app.netlify.com/projects/cerulean-figolla-1f9435/deploys/6a98f29dbcf2590008b77b26
😎 Deploy Preview https://deploy-preview-9936--cerulean-figolla-1f9435.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 875d46157f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread internal/gatewayapi/securitypolicy.go Outdated
return nil, fmt.Errorf("unable to translate authorization rule: %w", err)
}

irPrincipal.ClientCert = rule.Principal.ClientCert

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard the client certificate copy with the principal check

A CEL-only authorization rule is valid because AuthorizationRule requires principal or cel, but this unconditional dereference panics whenever such a rule omits principal. This regresses every valid CEL-only SecurityPolicy during gateway translation; copy ClientCert inside the existing rule.Principal != nil block.

Useful? React with 👍 / 👎.

@zhaohuabing zhaohuabing Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I’d prefer to reject the combination of an unverified certificate and ClientCertPrincipal authorization, since allowing it could introduce a security issue.

Comment on lines +1009 to +1010
if len(san.URIs) == 0 && len(san.DNSNames) == 0 {
return predicates, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject empty SAN sets instead of dropping the constraint

When subjectAltNames: {} is supplied, the new CEL validation accepts it because the object is present, but this branch silently contributes no certificate predicate. In a principal that also contains a CIDR, header, or JWT condition, the intended certificate requirement is therefore erased and the rule can authorize without matching any certificate identity; an empty SAN object should be rejected rather than treated as absent.

Useful? React with 👍 / 👎.

Comment on lines +1108 to +1112
case egv1a1.StringMatchRegularExpression:
return &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_SafeRegex{
SafeRegex: &matcherv3.RegexMatcher{
Regex: sm.Value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate client certificate regular expressions

For a client-certificate StringMatch with type: RegularExpression, admission only validates the enum and nonempty value, and this conversion embeds the value without compiling it. A malformed expression such as [ consequently reaches Envoy as an invalid RegexMatcher, causing the generated route configuration to be rejected instead of surfacing a SecurityPolicy validation error; validate it with the same regex helper used for authorization path expressions before producing xDS.

Useful? React with 👍 / 👎.

| `jwt` | _[JWTPrincipal](#jwtprincipal)_ | false | | JWT authorize the request based on the JWT claims and scopes.<br />Note: in order to use JWT claims for authorization, you must configure the<br />JWT authentication in the same `SecurityPolicy`. |
| `headers` | _[AuthorizationHeaderMatch](#authorizationheadermatch) array_ | false | | Headers authorize the request based on user identity extracted from custom headers.<br />If multiple headers are specified, all headers must match for the rule to match. |
| `clientIPGeoLocations` | _[ClientIPGeoLocation](#clientipgeolocation) array_ | false | | ClientIPGeoLocations authorizes the request based on geolocation metadata derived from the client IP.<br />This field is supported for HTTPRoute and GRPCRoute authorization.<br />It is not supported for TCPRoute targets.<br />If multiple entries are specified, one of the ClientIPGeoLocation entries must match for the rule to match.<br />The client IP is inferred from the X-Forwarded-For header, a custom header, or the<br />direct downstream connection source address (the TCP peer of the connection terminated by Envoy).<br />You can use the `ClientIPDetection` field in the `ClientTrafficPolicy` to configure the client IP detection. |
| `clientCert` | _[ClientCertPrincipal](#clientcertprincipal)_ | false | | ClientCert authorizes the request based on the client certificate<br />presented during the mutual TLS handshake.<br />This principal requires that mTLS is configured for the gateway listener<br />receiving the request, via a `ClientTrafficPolicy` whose<br />`spec.tls.clientValidation.caCertificateRefs` validates the presented<br />client certificate. Without mTLS configured, no client certificate is<br />available and this principal will never match.<br />At least one of `subject` or `subjectAltNames` must be specified. When<br />both are set, both must match for the principal to match. Within<br />`subjectAltNames`, any matching SAN entry across DNS, email, IP, URI,<br />or otherNames satisfies the principal.<br />This principal is supported for HTTPRoute and GRPCRoute authorization<br />targets. It is not applicable to TCPRoute targets. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop documenting unsupported certificate SAN types

The generated API reference tells users that email, IP, and OtherName SAN entries satisfy clientCert, while the API validations and translator added in this commit explicitly reject all three and support only URI and DNS SANs. Users following this reference will create policies rejected at admission, so regenerate or correct this description to list only the supported SAN types.

Useful? React with 👍 / 👎.

- Guard the ClientCert copy in buildAuthorization with the existing
  rule.Principal != nil check. AuthorizationRule allows a CEL-only rule
  (no principal), and the unconditional dereference panicked gateway-wide
  translation for any SecurityPolicy containing one.
- Reject an empty subjectAltNames{} object via a new CEL rule instead of
  silently treating it as "no SAN constraint" during translation, which
  could over-authorize a rule that ANDs clientCert with another condition.
- Validate RegularExpression StringMatch values (subject, uris, dnsNames)
  at admission using the existing regex helper, so a malformed pattern
  fails SecurityPolicy validation instead of reaching xDS as an invalid
  RegexMatcher and rejecting the whole route configuration.
- Regenerate the CRD bundles and API reference docs to match, correcting
  stale doc text that claimed emailAddresses/ipAddresses/otherNames SANs
  satisfy the principal, and fold in the AND/OR semantics wording raised
  in the earlier internal review (subject AND subjectAltNames group; uris
  and dnsNames OR-combine into one group).

Also fixes two pre-existing, unrelated defects surfaced while getting the
test/cel-validation module to actually build and pass (it never had, since
`go build` skips _test.go files and this was the first time `go test` ran
against it in this fork):
- api/v1alpha1.Principal is a pointer field; several existing test cases
  constructed it as a value, which failed to compile.
- The CEL message for Principal's own emptiness check gained "clientCert"
  when this feature was added; one pre-existing test's expected string
  was never updated to match.

Signed-off-by: Joshua E Cook <jec@apple.com>
@joshuaecook

Copy link
Copy Markdown
Author

@codex review again please, I have addressed your findings in the latest commit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eeba0c4390

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// +kubebuilder:validation:XValidation:rule="!has(self.subjectAltNames) || !has(self.subjectAltNames.emailAddresses)",message="emailAddresses is not supported in clientCert; use uris or dnsNames"
// +kubebuilder:validation:XValidation:rule="!has(self.subjectAltNames) || !has(self.subjectAltNames.ipAddresses)",message="ipAddresses is not supported in clientCert; use uris or dnsNames"
// +kubebuilder:validation:XValidation:rule="!has(self.subjectAltNames) || !has(self.subjectAltNames.otherNames)",message="otherNames is not supported in clientCert; use uris or dnsNames"
// +kubebuilder:validation:XValidation:rule="!has(self.subjectAltNames) || has(self.subjectAltNames.uris) || has(self.subjectAltNames.dnsNames)",message="subjectAltNames must specify at least one of uris or dnsNames"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require nonempty SAN matcher lists

The new validation rejects subjectAltNames: {}, but explicitly setting uris: [] or dnsNames: [] still makes the corresponding has(...) expression true because the field is present. Fresh evidence beyond the earlier finding is that these empty arrays have no MinItems constraint, while buildClientCertPredicate treats both zero-length lists as no SAN predicate; when this principal also contains a header, CIDR, or subject condition, authorization can therefore succeed without any SAN match. Check size(...) > 0 rather than field presence.

Useful? React with 👍 / 👎.

Comment on lines +978 to +979
if clientCert.Subject != nil {
subjectInputAny, err := proto.ToAnyWithValidation(&sslinput.SubjectInput{})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refuse unverified certificates as authorization identities

When the listener's ClientTrafficPolicy uses mode: Request or mode: RequireAny, the existing TLS translation sets ACCEPT_UNTRUSTED, yet this predicate directly authorizes against the presented certificate's subject/SAN without checking that its chain was verified. An attacker can consequently present a self-signed certificate containing an allowed identity and satisfy a clientCert rule, despite the new API describing this as matching a validated peer certificate. Reject this combination or add a verified-certificate requirement before trusting these fields.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.33%. Comparing base (3cb03be) to head (eeba0c4).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
internal/xds/translator/authorization.go 93.51% 7 Missing ⚠️
internal/gatewayapi/securitypolicy.go 95.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9936      +/-   ##
==========================================
+ Coverage   81.31%   81.33%   +0.01%     
==========================================
  Files         263      263              
  Lines       40870    40998     +128     
==========================================
+ Hits        33233    33345     +112     
- Misses       7637     7653      +16     

☔ 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

// supported and will be rejected with a validation error.
//
// This principal is supported for HTTPRoute and GRPCRoute authorization
// targets. It is not applicable to TCPRoute targets.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this could work at L4 too, as a follow-up.

TypedConfig: uriSanInputAny,
},
Matcher: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_ValueMatch{
ValueMatch: stringMatcher,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

On the SAN matchers: envoy.matching.inputs.uri_san and dns_san collapse all SANs of that type into one comma-joined string, and the list on the EG API side is the set of accepted values.

For a cert with URI:spiffe://ns/foo/sa/a and URI:spiffe://ns/foo/sa/b, the input value is spiffe://ns/foo/sa/a,spiffe://ns/foo/sa/b, so this doesn't match:

  uris:
    - value: "spiffe://ns/foo/sa/a"   # Exact

Prefix only matches the first SAN, Suffix only the last.

Could the translator absorb this, lowering Exact/Prefix/Suffix on uris/dnsNames to
regexes anchored on the comma boundaries? internal/utils/regex does something
similar already for :path. RegularExpression probably can't be wrapped safely and
would just need a doc note.

Worth a 2-SAN cert in the testdata either way — the current e2e cert has a single
SAN, so this wouldn't show up there.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@zhaohuabing thanks so much for your review and feedback. I think you are correct that this may not handle certs with multiple SANs.

As I understand the practical usage of multiple SANs is to provide multiple a/k/a identifiers for the caller, so that the same the same cert could be presented to different services, and each service may know the peer under a different name. I don't know if there's a use case where a single service would require the peer to present a 2-SAN cert.

Your suggestion to lower the string matching operators into anchored regexes makes sense to me for the common case, where Exact X is translated to a regex (^|,)X(,|$) and similarly for prefix & suffix.

As for the RegularExpression operator, I think passing it through with a clear doc note that explains how it matches against the comma-spliced value is a fair compromise. This at least gives an escape hatch for the uncommon case of requiring a 2-SAN presentation.

If you agree with this approach, I'll update this PR.

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.

Support XFFC validation as First class api in SecurityPolicy

3 participants