Skip to content

Commit d7cba67

Browse files
authored
Merge pull request #1160 from abhijith-darshan/feat/gh_app_tls
Add support for mTLS to GitHub App transport
2 parents c2a0355 + 4eae0d3 commit d7cba67

6 files changed

Lines changed: 209 additions & 27 deletions

File tree

docs/spec/v1beta3/providers.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1504,10 +1504,15 @@ stringData:
15041504
#### GitHub App
15051505

15061506
To use Github App authentication, make sure the GitHub App is registered and
1507-
installed with the necessary permissions and the github app secret is created as
1507+
installed with the necessary permissions and the GitHub app secret is created as
15081508
described
15091509
[here](https://fluxcd.io/flux/components/source/gitrepositories/#github).
15101510

1511+
**NOTE:** (For GitHub Enterprise Server) If the GitHub Server uses a private CA, the CA certificate can be referenced either via `.spec.certSecretRef`
1512+
as described [here](#certificate-secret-reference) or the CA certificate can be added in the GitHub App secret referenced via `.spec.secretRef`.
1513+
If the `.spec.secretRef` contains `tls.crt`, `tls.key` then mutual TLS configuration will be automatically enabled. Omit these keys if the GitHub server does not support mutual TLS.
1514+
If both secret references are specified, then the CA specified in `.spec.certSecretRef` takes precedence over the CA specified in the GitHub App secret.
1515+
15111516
#### Setting up a GitHub workflow
15121517

15131518
To trigger a GitHub Actions workflow when a Flux Kustomization finishes reconciling,
@@ -1802,6 +1807,11 @@ permissions to update the commit status and the github app secret is created as
18021807
described
18031808
[here](https://fluxcd.io/flux/components/source/gitrepositories/#github).
18041809

1810+
**NOTE:** (For GitHub Enterprise Server) If the GitHub Server uses a private CA, the CA certificate can be referenced either via `.spec.certSecretRef`
1811+
as described [here](#certificate-secret-reference) or the CA certificate can be added in the GitHub App secret referenced via `.spec.secretRef`.
1812+
If the `.spec.secretRef` contains `tls.crt`, `tls.key` then mutual TLS configuration will be automatically enabled. Omit these keys if the GitHub server does not support mutual TLS.
1813+
If both secret references are specified, then the CA specified in `.spec.certSecretRef` takes precedence over the CA specified in the GitHub App secret.
1814+
18051815
#### GitLab
18061816

18071817
When `.spec.type` is set to `gitlab`, the referenced secret must contain a key called `token` with the value set to a

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ require (
2121
github.com/fluxcd/pkg/apis/meta v1.18.0
2222
github.com/fluxcd/pkg/auth v0.27.0
2323
github.com/fluxcd/pkg/cache v0.10.0
24-
github.com/fluxcd/pkg/git v0.34.0
24+
github.com/fluxcd/pkg/git v0.35.0
2525
github.com/fluxcd/pkg/masktoken v0.7.0
2626
github.com/fluxcd/pkg/runtime v0.80.0
2727
github.com/fluxcd/pkg/ssa v0.51.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ github.com/fluxcd/pkg/auth v0.27.0 h1:DFsizUxt9ZDAc+z7+o7jcbtfaxRH55MRD/wdU4CXNC
142142
github.com/fluxcd/pkg/auth v0.27.0/go.mod h1:YEAHpBFuW5oLlH9ekuJaQdnJ2Q3A7Ny8kha3WY7QMnY=
143143
github.com/fluxcd/pkg/cache v0.10.0 h1:M+OGDM4da1cnz7q+sZSBtkBJHpiJsLnKVmR9OdMWxEY=
144144
github.com/fluxcd/pkg/cache v0.10.0/go.mod h1:pPXRzQUDQagsCniuOolqVhnAkbNgYOg8d2cTliPs7ME=
145-
github.com/fluxcd/pkg/git v0.34.0 h1:qTViWkfpEDnjzySyKRKliqUeGj/DznqlkmPhaDNIsFY=
146-
github.com/fluxcd/pkg/git v0.34.0/go.mod h1:F9Asm3MlLW4uZx3FF92+bqho+oktdMdnTn/QmXe56NE=
145+
github.com/fluxcd/pkg/git v0.35.0 h1:mAauhsdfxNW4yQdXviVlvcN/uCGGG0+6p5D1+HFZI9w=
146+
github.com/fluxcd/pkg/git v0.35.0/go.mod h1:F9Asm3MlLW4uZx3FF92+bqho+oktdMdnTn/QmXe56NE=
147147
github.com/fluxcd/pkg/masktoken v0.7.0 h1:pitmyOg2pUVdW+nn2Lk/xqm2TaA08uxvOC0ns3sz6bM=
148148
github.com/fluxcd/pkg/masktoken v0.7.0/go.mod h1:Lc1uoDjO1GY6+YdkK+ZqqBIBWquyV58nlSJ5S1N1IYU=
149149
github.com/fluxcd/pkg/runtime v0.80.0 h1:vknT2vdQSGTFnAhz4xGk2ZXUWCrXh3whsISStgA57Go=

internal/notifier/github_helpers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ func getRepoInfoAndGithubClient(addr string, token string, tlsConfig *tls.Config
8080
return nil, err
8181
}
8282

83+
if tlsConfig != nil {
84+
// add TLS config to get installation token
85+
githubOpts = append(githubOpts, github.WithTLSConfig(tlsConfig))
86+
}
87+
8388
client, err := github.New(githubOpts...)
8489
if err != nil {
8590
return nil, err

internal/server/event_handlers.go

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package server
1818

1919
import (
2020
"context"
21+
"crypto/tls"
2122
"errors"
2223
"fmt"
2324
"net/http"
@@ -307,15 +308,8 @@ func createCommitStatus(ctx context.Context, provider *apiv1beta3.Provider, even
307308

308309
// extractAuthFromSecret processes notification-controller specific keys (address, proxy, headers)
309310
// then uses runtime/secrets to handle standard authentication keys (token, username, password, etc.).
310-
func extractAuthFromSecret(ctx context.Context, kubeClient client.Client, provider *apiv1beta3.Provider) ([]notifier.Option, map[string][]byte, error) {
311+
func extractAuthFromSecret(ctx context.Context, secret *corev1.Secret) ([]notifier.Option, map[string][]byte, error) {
311312
options := []notifier.Option{}
312-
313-
secretName := types.NamespacedName{Namespace: provider.Namespace, Name: provider.Spec.SecretRef.Name}
314-
var secret corev1.Secret
315-
if err := kubeClient.Get(ctx, secretName, &secret); err != nil {
316-
return nil, nil, fmt.Errorf("failed to read secret: %w", err)
317-
}
318-
319313
if val, ok := secret.Data["address"]; ok {
320314
if len(val) > 2048 {
321315
return nil, nil, fmt.Errorf("invalid address in secret: address exceeds maximum length of %d bytes", 2048)
@@ -325,7 +319,7 @@ func extractAuthFromSecret(ctx context.Context, kubeClient client.Client, provid
325319
if val, ok := secret.Data["proxy"]; ok {
326320
deprecatedProxy := strings.TrimSpace(string(val))
327321
if _, err := url.Parse(deprecatedProxy); err != nil {
328-
return nil, nil, fmt.Errorf("invalid 'proxy' in secret '%s'", secretName.String())
322+
return nil, nil, fmt.Errorf("invalid 'proxy' in secret '%s/%s'", secret.Namespace, secret.Name)
329323
}
330324
log.FromContext(ctx).Error(nil, "warning: specifying proxy with 'proxy' key in the referenced secret is deprecated, use spec.proxySecretRef with 'address' key instead. Support for the 'proxy' key will be removed in v1.")
331325
options = append(options, notifier.WithProxyURL(deprecatedProxy))
@@ -339,7 +333,7 @@ func extractAuthFromSecret(ctx context.Context, kubeClient client.Client, provid
339333
options = append(options, notifier.WithHeaders(headers))
340334
}
341335

342-
authMethods, err := secrets.AuthMethodsFromSecret(ctx, &secret)
336+
authMethods, err := secrets.AuthMethodsFromSecret(ctx, secret)
343337
if err == nil && authMethods != nil {
344338
if authMethods.HasTokenAuth() {
345339
options = append(options, notifier.WithToken(string(authMethods.Token)))
@@ -394,9 +388,15 @@ func createNotifier(ctx context.Context, kubeClient client.Client, provider *api
394388
webhook := provider.Spec.Address
395389
var token string
396390
var secretData map[string][]byte
391+
var providerCertSecret, providerSecret *corev1.Secret
392+
var err error
397393

398394
if provider.Spec.SecretRef != nil {
399-
secretOptions, sData, err := extractAuthFromSecret(ctx, kubeClient, provider)
395+
providerSecret, err = getSecret(ctx, kubeClient, provider.Spec.SecretRef.Name, provider.GetNamespace())
396+
if err != nil {
397+
return nil, "", err
398+
}
399+
secretOptions, sData, err := extractAuthFromSecret(ctx, providerSecret)
400400
if err != nil {
401401
return nil, "", err
402402
}
@@ -416,29 +416,32 @@ func createNotifier(ctx context.Context, kubeClient client.Client, provider *api
416416
}
417417

418418
if provider.Spec.ProxySecretRef != nil {
419-
secretRef := types.NamespacedName{
420-
Name: provider.Spec.ProxySecretRef.Name,
421-
Namespace: provider.GetNamespace(),
419+
proxySecret, err := getSecret(ctx, kubeClient, provider.Spec.ProxySecretRef.Name, provider.GetNamespace())
420+
if err != nil {
421+
return nil, "", err
422422
}
423-
proxyURL, err := secrets.ProxyURLFromSecretRef(ctx, kubeClient, secretRef)
423+
proxyURL, err := secrets.ProxyURLFromSecret(ctx, proxySecret)
424424
if err != nil {
425425
return nil, "", fmt.Errorf("failed to get proxy URL: %w", err)
426426
}
427427
options = append(options, notifier.WithProxyURL(proxyURL.String()))
428428
}
429429

430430
if provider.Spec.CertSecretRef != nil {
431-
secretRef := types.NamespacedName{
432-
Name: provider.Spec.CertSecretRef.Name,
433-
Namespace: provider.GetNamespace(),
434-
}
435-
tlsConfig, err := secrets.TLSConfigFromSecretRef(ctx, kubeClient, secretRef)
431+
providerCertSecret, err = getSecret(ctx, kubeClient, provider.Spec.CertSecretRef.Name, provider.GetNamespace())
436432
if err != nil {
437-
return nil, "", fmt.Errorf("failed to get TLS config: %w", err)
433+
return nil, "", err
438434
}
439-
options = append(options, notifier.WithTLSConfig(tlsConfig))
440435
}
441436

437+
tlsConfig, err := getTLSConfigForProvider(ctx, providerCertSecret, providerSecret, provider.Spec.Type)
438+
if err != nil {
439+
return nil, "", err
440+
}
441+
442+
if tlsConfig != nil {
443+
options = append(options, notifier.WithTLSConfig(tlsConfig))
444+
}
442445
if webhook != "" {
443446
options = append(options, notifier.WithURL(webhook))
444447
}
@@ -451,6 +454,34 @@ func createNotifier(ctx context.Context, kubeClient client.Client, provider *api
451454
return sender, token, nil
452455
}
453456

457+
// getTLSConfigForProvider - retrieves the TLS configuration from the provider's certSecretRef or secretRef.
458+
func getTLSConfigForProvider(ctx context.Context, providerCertSecret, providerSecret *corev1.Secret, providerType string) (tlsConfig *tls.Config, err error) {
459+
// providerCertSecret takes precedence over providerSecret as it is explicitly specified for TLS configuration
460+
if providerCertSecret != nil {
461+
tlsConfig, err = secrets.TLSConfigFromSecret(ctx, providerCertSecret)
462+
if err != nil {
463+
return nil, fmt.Errorf("failed to get TLS config: %w", err)
464+
}
465+
return
466+
}
467+
// if providerCertSecret is not specified, and if the provider is a git provider then
468+
// attempt to get TLS config from providerSecret if ca.crt exists
469+
if isGitProvider(providerType) && providerSecret != nil {
470+
authMethods, err := secrets.AuthMethodsFromSecret(ctx, providerSecret)
471+
if err != nil {
472+
return nil, fmt.Errorf("failed to get TLS config: %w", err)
473+
}
474+
// only proceed to create TLS config if ca.crt exists in the secret
475+
if authMethods != nil && authMethods.HasTLS() {
476+
tlsConfig, err = secrets.TLSConfigFromSecret(ctx, providerSecret)
477+
if err != nil {
478+
return nil, fmt.Errorf("failed to get TLS config: %w", err)
479+
}
480+
}
481+
}
482+
return
483+
}
484+
454485
// eventMatchesAlertSource returns if a given event matches with the given alert
455486
// source configuration and severity.
456487
func (s *EventServer) eventMatchesAlertSource(ctx context.Context, event *eventv1.Event, alert *apiv1beta3.Alert, source apiv1.CrossNamespaceObjectReference) bool {
@@ -608,3 +639,12 @@ func excludeInternalMetadata(event *eventv1.Event) {
608639
delete(event.Metadata, key)
609640
}
610641
}
642+
643+
func getSecret(ctx context.Context, c client.Client, name, namespace string) (*corev1.Secret, error) {
644+
secret := &corev1.Secret{}
645+
ref := types.NamespacedName{Name: name, Namespace: namespace}
646+
if err := c.Get(ctx, ref, secret); err != nil {
647+
return nil, fmt.Errorf("failed to get secret '%s': %w", ref.String(), err)
648+
}
649+
return secret, nil
650+
}

internal/server/event_handlers_test.go

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import (
4040
"k8s.io/apimachinery/pkg/runtime"
4141
"k8s.io/client-go/tools/record"
4242
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
43-
log "sigs.k8s.io/controller-runtime/pkg/log"
43+
"sigs.k8s.io/controller-runtime/pkg/log"
4444

4545
eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
4646
"github.com/fluxcd/pkg/apis/meta"
@@ -1518,6 +1518,133 @@ func Test_excludeInternalMetadata(t *testing.T) {
15181518
}
15191519
}
15201520

1521+
func TestGetTLSConfigForProvider(t *testing.T) {
1522+
g := NewWithT(t)
1523+
ctx := context.Background()
1524+
1525+
// Reuse your existing helper.
1526+
caCert, clientCert, clientKey := generateTestCertificates(t)
1527+
1528+
// Expected TLS pieces.
1529+
caPool := x509.NewCertPool()
1530+
caPool.AppendCertsFromPEM(caCert)
1531+
1532+
clientCertPair, err := tls.X509KeyPair(clientCert, clientKey)
1533+
if err != nil {
1534+
t.Fatalf("failed to create client cert pair: %v", err)
1535+
}
1536+
1537+
getSecret := func(name string, data map[string][]byte) *corev1.Secret {
1538+
return &corev1.Secret{
1539+
ObjectMeta: metav1.ObjectMeta{Name: name},
1540+
Data: data,
1541+
}
1542+
}
1543+
1544+
tests := []struct {
1545+
name string
1546+
providerType string
1547+
providerCertSecret *corev1.Secret
1548+
providerSecret *corev1.Secret
1549+
wantErr bool
1550+
wantTLSConfig *tls.Config
1551+
}{
1552+
{
1553+
name: "no secrets returns nil TLS config",
1554+
providerType: apiv1beta3.GitHubProvider,
1555+
},
1556+
{
1557+
name: "providerCertSecret in ca.crt with valid CA",
1558+
providerType: apiv1beta3.GitHubProvider,
1559+
providerCertSecret: getSecret("cert-secret", map[string][]byte{"ca.crt": caCert}),
1560+
wantTLSConfig: &tls.Config{RootCAs: caPool},
1561+
},
1562+
{
1563+
name: "providerCertSecret precedence over providerSecret",
1564+
providerType: apiv1beta3.GitHubProvider,
1565+
providerCertSecret: getSecret("cert-secret", map[string][]byte{"ca.crt": caCert}),
1566+
// Intentionally invalid providerSecret to prove precedence is honored.
1567+
providerSecret: getSecret("ignored", map[string][]byte{"ca.crt": []byte("not-a-cert")}),
1568+
wantTLSConfig: &tls.Config{RootCAs: caPool},
1569+
},
1570+
{
1571+
name: "providerCertSecret with invalid CA returns error",
1572+
providerType: apiv1beta3.GitHubProvider,
1573+
providerCertSecret: getSecret("cert-secret", map[string][]byte{"ca.crt": []byte("bogus")}),
1574+
wantErr: true,
1575+
},
1576+
{
1577+
name: "providerSecret in ca.crt with valid CA (git provider)",
1578+
providerType: apiv1beta3.GitHubProvider,
1579+
providerSecret: getSecret("git-secret", map[string][]byte{"ca.crt": caCert}),
1580+
wantTLSConfig: &tls.Config{RootCAs: caPool},
1581+
},
1582+
{
1583+
name: "providerSecret without ca.crt (git provider) returns nil TLS config",
1584+
providerType: apiv1beta3.GitHubProvider,
1585+
providerSecret: getSecret("git-secret-no-ca", map[string][]byte{"foo": []byte("bar")}),
1586+
},
1587+
{
1588+
name: "providerSecret in ca.crt with invalid CA (git provider) returns error",
1589+
providerType: apiv1beta3.GitHubProvider,
1590+
providerSecret: getSecret("git-secret", map[string][]byte{"ca.crt": []byte("aaa")}),
1591+
wantErr: true,
1592+
},
1593+
{
1594+
name: "providerSecret ignored for non-git provider even if ca.crt present",
1595+
providerType: apiv1beta3.SlackProvider,
1596+
providerSecret: getSecret("non-git", map[string][]byte{"ca.crt": caCert}),
1597+
},
1598+
{
1599+
name: "providerCertSecret with (mTLS)",
1600+
providerType: apiv1beta3.SlackProvider,
1601+
providerCertSecret: getSecret("cert-secret", map[string][]byte{"ca.crt": caCert, "tls.crt": clientCert, "tls.key": clientKey}),
1602+
wantTLSConfig: &tls.Config{RootCAs: caPool, Certificates: []tls.Certificate{clientCertPair}},
1603+
},
1604+
{
1605+
name: "providerSecret with (mTLS)",
1606+
providerType: apiv1beta3.GitHubProvider,
1607+
providerSecret: getSecret("cert-secret", map[string][]byte{"ca.crt": caCert, "tls.crt": clientCert, "tls.key": clientKey}),
1608+
wantTLSConfig: &tls.Config{RootCAs: caPool, Certificates: []tls.Certificate{clientCertPair}},
1609+
},
1610+
}
1611+
1612+
for _, tt := range tests {
1613+
tt := tt
1614+
t.Run(tt.name, func(t *testing.T) {
1615+
got, err := getTLSConfigForProvider(ctx, tt.providerCertSecret, tt.providerSecret, tt.providerType)
1616+
if tt.wantErr {
1617+
g.Expect(err).To(HaveOccurred())
1618+
return
1619+
}
1620+
g.Expect(err).ToNot(HaveOccurred())
1621+
1622+
if tt.wantTLSConfig == nil {
1623+
g.Expect(got).To(BeNil(), "expected nil TLS config")
1624+
return
1625+
}
1626+
1627+
g.Expect(got).ToNot(BeNil(), "expected non-nil TLS config")
1628+
1629+
// RootCAs presence matches expectation.
1630+
if tt.wantTLSConfig.RootCAs != nil {
1631+
g.Expect(got.RootCAs).ToNot(BeNil())
1632+
} else {
1633+
g.Expect(got.RootCAs).To(BeNil())
1634+
}
1635+
1636+
// Certificates (mTLS) presence matches expectation.
1637+
g.Expect(got.Certificates).To(HaveLen(len(tt.wantTLSConfig.Certificates)))
1638+
if len(tt.wantTLSConfig.Certificates) > 0 {
1639+
// Basic sanity: leaf cert is parsable; avoids brittle struct equality.
1640+
g.Expect(got.Certificates[0].Certificate).ToNot(BeNil())
1641+
_, parseErr := x509.ParseCertificate(got.Certificates[0].Certificate[0])
1642+
g.Expect(parseErr).ToNot(HaveOccurred())
1643+
}
1644+
})
1645+
}
1646+
}
1647+
15211648
// generateTestCertificates generates test certificates for mTLS testing.
15221649
// TODO: Move to pkg/runtime/secrets test helpers after mTLS implementation is complete
15231650
func generateTestCertificates(t *testing.T) (caCert, clientCert, clientKey []byte) {

0 commit comments

Comments
 (0)