From e7fc9c124764122ca93547cf8690181bca943375 Mon Sep 17 00:00:00 2001 From: Nakul Date: Wed, 13 May 2026 12:11:06 +0530 Subject: [PATCH] Support auth for OCI helm templates --- cyclops-ctrl/pkg/auth/templates_test.go | 33 ++++++- cyclops-ctrl/pkg/template/oci.go | 115 ++++++++++++++++-------- cyclops-ctrl/pkg/template/oci_test.go | 94 +++++++++++++++++++ cyclops-ctrl/pkg/template/template.go | 7 +- 4 files changed, 209 insertions(+), 40 deletions(-) create mode 100644 cyclops-ctrl/pkg/template/oci_test.go diff --git a/cyclops-ctrl/pkg/auth/templates_test.go b/cyclops-ctrl/pkg/auth/templates_test.go index f0484ca3..ae5968f8 100644 --- a/cyclops-ctrl/pkg/auth/templates_test.go +++ b/cyclops-ctrl/pkg/auth/templates_test.go @@ -82,6 +82,19 @@ var _ = Describe("Templates resolver", func() { }, }, }, + { + Spec: v1alpha1.TemplateAuthRuleSpec{ + Repo: "oci://registry.example.com/my-org", + Username: apiv1.SecretKeySelector{ + LocalObjectReference: apiv1.LocalObjectReference{Name: "oci-secret-name"}, + Key: "username", + }, + Password: apiv1.SecretKeySelector{ + LocalObjectReference: apiv1.LocalObjectReference{Name: "oci-secret-name"}, + Key: "token", + }, + }, + }, } testCases := []testCase{ @@ -154,7 +167,7 @@ var _ = Describe("Templates resolver", func() { }, }, { - description: "fetches no matching template auth rules", + description: "fetches matching template auth rule", in: caseInput{ repo: "https://github.com/my-org/my-team", mockCalls: func() { @@ -171,6 +184,24 @@ var _ = Describe("Templates resolver", func() { returnsError: false, }, }, + { + description: "fetches matching OCI template auth rule", + in: caseInput{ + repo: "oci://registry.example.com/my-org", + mockCalls: func() { + k8sClient.On("ListTemplateAuthRules").Return(tars, nil) + k8sClient.On("GetTemplateAuthRuleSecret", "oci-secret-name", "username").Return("my-oci-username", nil) + k8sClient.On("GetTemplateAuthRuleSecret", "oci-secret-name", "token").Return("my-oci-token", nil) + }, + }, + out: caseOutput{ + credentials: &Credentials{ + Username: "my-oci-username", + Password: "my-oci-token", + }, + returnsError: false, + }, + }, } for _, t := range testCases { diff --git a/cyclops-ctrl/pkg/template/oci.go b/cyclops-ctrl/pkg/template/oci.go index c4c8de30..684487fa 100644 --- a/cyclops-ctrl/pkg/template/oci.go +++ b/cyclops-ctrl/pkg/template/oci.go @@ -12,16 +12,21 @@ import ( cyclopsv1alpha1 "github.com/cyclops-ui/cyclops/cyclops-ctrl/api/v1alpha1" "github.com/cyclops-ui/cyclops/cyclops-ctrl/internal/models" + "github.com/cyclops-ui/cyclops/cyclops-ctrl/pkg/auth" ) func (r Repo) LoadOCIHelmChart(repo, chart, version, resolvedVersion string) (*models.Template, error) { var err error strictVersion := version + creds, err := r.credResolver.RepoAuthCredentials(repo) + if err != nil { + return nil, err + } if len(resolvedVersion) > 0 { strictVersion = resolvedVersion } else if !isValidVersion(version) { - strictVersion, err = getOCIStrictVersion(repo, chart, version) + strictVersion, err = getOCIStrictVersion(repo, chart, version, creds) if err != nil { return nil, err } @@ -33,7 +38,7 @@ func (r Repo) LoadOCIHelmChart(repo, chart, version, resolvedVersion string) (*m } var tgzData []byte - tgzData, err = loadOCIHelmChartBytes(repo, chart, version) + tgzData, err = loadOCIHelmChartBytes(repo, chart, version, creds) if err != nil { return nil, err } @@ -59,8 +64,13 @@ func (r Repo) LoadOCIHelmChart(repo, chart, version, resolvedVersion string) (*m func (r Repo) LoadOCIHelmChartInitialValues(repo, chart, version string) (map[string]interface{}, error) { var err error strictVersion := version + creds, err := r.credResolver.RepoAuthCredentials(repo) + if err != nil { + return nil, err + } + if !isValidVersion(version) { - strictVersion, err = getOCIStrictVersion(repo, chart, version) + strictVersion, err = getOCIStrictVersion(repo, chart, version, creds) if err != nil { return nil, err } @@ -71,7 +81,7 @@ func (r Repo) LoadOCIHelmChartInitialValues(repo, chart, version string) (map[st return cached, nil } - tgzData, err := loadOCIHelmChartBytes(repo, chart, version) + tgzData, err := loadOCIHelmChartBytes(repo, chart, version, creds) if err != nil { return nil, err } @@ -91,34 +101,34 @@ func (r Repo) LoadOCIHelmChartInitialValues(repo, chart, version string) (map[st return initial, nil } -func loadOCIHelmChartBytes(repo, chart, version string) ([]byte, error) { +func loadOCIHelmChartBytes(repo, chart, version string, creds *auth.Credentials) ([]byte, error) { var err error if !isValidVersion(version) { - version, err = getOCIStrictVersion(repo, chart, version) + version, err = getOCIStrictVersion(repo, chart, version, creds) if err != nil { return nil, err } } - token, err := authorizeOCI(repo, chart, version) + token, err := authorizeOCI(repo, chart, version, creds) if err != nil { return nil, err } - digest, err := fetchDigest(repo, chart, version, token) + digest, err := fetchDigest(repo, chart, version, token, creds) if err != nil { return nil, err } - contentDigest, err := fetchContentDigest(repo, chart, digest, token) + contentDigest, err := fetchContentDigest(repo, chart, digest, token, creds) if err != nil { return nil, err } - return loadOCITar(repo, chart, contentDigest, token) + return loadOCITar(repo, chart, contentDigest, token, creds) } -func loadOCITar(repo, chart, digest, token string) ([]byte, error) { +func loadOCITar(repo, chart, digest, token string, creds *auth.Credentials) ([]byte, error) { bURL, err := blobURL(repo, chart, digest) if err != nil { return nil, err @@ -131,9 +141,7 @@ func loadOCITar(repo, chart, digest, token string) ([]byte, error) { req.Header.Set("User-Agent", "Helm/3.13.3") req.Header.Set("Accept", "application/vnd.cncf.helm.config.v1+json, */*") - if len(token) != 0 { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) - } + setOCIRegistryAuth(req, token, creds) client := &http.Client{} resp, err := client.Do(req) @@ -145,7 +153,7 @@ func loadOCITar(repo, chart, digest, token string) ([]byte, error) { return ioutil.ReadAll(resp.Body) } -func fetchContentDigest(repo, chart, digest, token string) (string, error) { +func fetchContentDigest(repo, chart, digest, token string, creds *auth.Credentials) (string, error) { dURL, err := contentDigestURL(repo, chart, digest) if err != nil { return "", err @@ -158,9 +166,7 @@ func fetchContentDigest(repo, chart, digest, token string) (string, error) { req.Header.Set("User-Agent", "Helm/3.13.3") req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, */*") - if len(token) != 0 { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) - } + setOCIRegistryAuth(req, token, creds) client := &http.Client{} resp, err := client.Do(req) @@ -198,7 +204,7 @@ func fetchContentDigest(repo, chart, digest, token string) (string, error) { return ct.Layers[0].Digest, nil } -func fetchDigest(repo, chart, version, token string) (string, error) { +func fetchDigest(repo, chart, version, token string, creds *auth.Credentials) (string, error) { dURL, err := digestURL(repo, chart, version) if err != nil { return "", err @@ -211,9 +217,7 @@ func fetchDigest(repo, chart, version, token string) (string, error) { req.Header.Set("User-Agent", "Helm/3.13.3") req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, */*") - if len(token) != 0 { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) - } + setOCIRegistryAuth(req, token, creds) client := &http.Client{} resp, err := client.Do(req) @@ -225,8 +229,8 @@ func fetchDigest(repo, chart, version, token string) (string, error) { return resp.Header.Get("docker-content-digest"), nil } -func getOCIStrictVersion(repo, chart, version string) (string, error) { - allTags, err := GetOCIChartTags(repo, chart) +func getOCIStrictVersion(repo, chart, version string, creds *auth.Credentials) (string, error) { + allTags, err := getOCIChartTags(repo, chart, creds) if err != nil { return "", err } @@ -235,7 +239,11 @@ func getOCIStrictVersion(repo, chart, version string) (string, error) { } func GetOCIChartTags(repo, chart string) ([]string, error) { - token, err := authorizeOCITags(repo, chart) + return getOCIChartTags(repo, chart, nil) +} + +func getOCIChartTags(repo, chart string, creds *auth.Credentials) ([]string, error) { + token, err := authorizeOCITags(repo, chart, creds) if err != nil { return nil, err } @@ -254,9 +262,7 @@ func GetOCIChartTags(repo, chart string) ([]string, error) { } req.Header.Set("User-Agent", "Helm/3.13.3") - if len(token) != 0 { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) - } + setOCIRegistryAuth(req, token, creds) resp, err := client.Do(req) if err != nil { @@ -303,7 +309,7 @@ func GetOCIChartTags(repo, chart string) ([]string, error) { return allTags, err } -func authorizeOCI(repo, chart, version string) (string, error) { +func authorizeOCI(repo, chart, version string, creds *auth.Credentials) (string, error) { // region head dURL, err := digestURL(repo, chart, version) if err != nil { @@ -319,6 +325,7 @@ func authorizeOCI(repo, chart, version string) (string, error) { req.Header.Set("User-Agent", "Helm/3.13.3") req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, */*") + setOCIRegistryAuth(req, "", creds) resp, err := client.Do(req) if err != nil { @@ -355,6 +362,7 @@ func authorizeOCI(repo, chart, version string) (string, error) { req.Header.Set("User-Agent", "Helm/3.13.3") req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + setOCITokenAuth(req, creds) resp, err = client.Do(req) if err != nil { @@ -367,20 +375,17 @@ func authorizeOCI(repo, chart, version string) (string, error) { return "", err } - var ar struct { - Token string `json:"token"` - } - - if err := json.Unmarshal(responseBody, &ar); err != nil { + token, err := parseOCITokenResponse(responseBody) + if err != nil { return "", err } - return ar.Token, nil + return token, nil // endregion } -func authorizeOCITags(repo, chart string) (string, error) { +func authorizeOCITags(repo, chart string, creds *auth.Credentials) (string, error) { // region head tURL, err := tagsURL(repo, chart) if err != nil { @@ -393,6 +398,8 @@ func authorizeOCITags(repo, chart string) (string, error) { if err != nil { return "", err } + req.Header.Set("User-Agent", "Helm/3.13.3") + setOCIRegistryAuth(req, "", creds) resp, err := client.Do(req) if err != nil { @@ -428,6 +435,7 @@ func authorizeOCITags(repo, chart string) (string, error) { req.Header.Set("User-Agent", "Helm/3.13.3") req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + setOCITokenAuth(req, creds) resp, err = client.Do(req) if err != nil { @@ -440,17 +448,48 @@ func authorizeOCITags(repo, chart string) (string, error) { return "", err } + token, err := parseOCITokenResponse(responseBody) + if err != nil { + return "", err + } + + return token, nil + + // endregion +} + +func parseOCITokenResponse(responseBody []byte) (string, error) { var ar struct { - Token string `json:"token"` + Token string `json:"token"` + AccessToken string `json:"access_token"` } if err := json.Unmarshal(responseBody, &ar); err != nil { return "", err } + if len(ar.Token) == 0 { + return ar.AccessToken, nil + } + return ar.Token, nil +} - // endregion +func setOCIRegistryAuth(req *http.Request, token string, creds *auth.Credentials) { + if len(token) != 0 { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) + return + } + + if creds != nil { + req.SetBasicAuth(creds.Username, creds.Password) + } +} + +func setOCITokenAuth(req *http.Request, creds *auth.Credentials) { + if creds != nil { + req.SetBasicAuth(creds.Username, creds.Password) + } } func parseAuthenticateHeader(header string) (realm, service, scope string) { diff --git a/cyclops-ctrl/pkg/template/oci_test.go b/cyclops-ctrl/pkg/template/oci_test.go new file mode 100644 index 00000000..86fb9882 --- /dev/null +++ b/cyclops-ctrl/pkg/template/oci_test.go @@ -0,0 +1,94 @@ +package template + +import ( + "net/http" + "testing" + + "github.com/cyclops-ui/cyclops/cyclops-ctrl/pkg/auth" +) + +func TestSetOCIRegistryAuthUsesBearerTokenFirst(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://registry.example.com/v2/chart/manifests/1.0.0", nil) + if err != nil { + t.Fatal(err) + } + + setOCIRegistryAuth(req, "registry-token", &auth.Credentials{ + Username: "template-user", + Password: "template-pass", + }) + + if got, want := req.Header.Get("Authorization"), "Bearer registry-token"; got != want { + t.Fatalf("Authorization header = %q, want %q", got, want) + } +} + +func TestSetOCIRegistryAuthFallsBackToTemplateBasicAuth(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://registry.example.com/v2/chart/blobs/sha256:abc", nil) + if err != nil { + t.Fatal(err) + } + + setOCIRegistryAuth(req, "", &auth.Credentials{ + Username: "template-user", + Password: "template-pass", + }) + + username, password, ok := req.BasicAuth() + if !ok { + t.Fatal("expected request to use HTTP Basic auth") + } + + if username != "template-user" || password != "template-pass" { + t.Fatalf("Basic auth = %q/%q, want template-user/template-pass", username, password) + } +} + +func TestSetOCIRegistryAuthLeavesPublicRequestUnauthenticated(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://registry.example.com/v2/chart/tags/list", nil) + if err != nil { + t.Fatal(err) + } + + setOCIRegistryAuth(req, "", nil) + + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization header = %q, want empty", got) + } + + if _, _, ok := req.BasicAuth(); ok { + t.Fatal("did not expect HTTP Basic auth on public request") + } +} + +func TestSetOCITokenAuthUsesTemplateBasicAuth(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://registry.example.com/token", nil) + if err != nil { + t.Fatal(err) + } + + setOCITokenAuth(req, &auth.Credentials{ + Username: "template-user", + Password: "template-pass", + }) + + username, password, ok := req.BasicAuth() + if !ok { + t.Fatal("expected token request to use HTTP Basic auth") + } + + if username != "template-user" || password != "template-pass" { + t.Fatalf("Basic auth = %q/%q, want template-user/template-pass", username, password) + } +} + +func TestOCIAuthorizationTokenResponseAcceptsAccessToken(t *testing.T) { + token, err := parseOCITokenResponse([]byte(`{"access_token":"registry-access-token"}`)) + if err != nil { + t.Fatal(err) + } + + if got, want := token, "registry-access-token"; got != want { + t.Fatalf("token = %q, want %q", got, want) + } +} diff --git a/cyclops-ctrl/pkg/template/template.go b/cyclops-ctrl/pkg/template/template.go index dc84fb81..70b50699 100644 --- a/cyclops-ctrl/pkg/template/template.go +++ b/cyclops-ctrl/pkg/template/template.go @@ -183,7 +183,12 @@ func (r Repo) assumeTemplateSourceType(repo string) (cyclopsv1alpha1.TemplateSou func (r Repo) GetTemplateRevisions(repo, path string) ([]string, error) { if registry.IsOCI(repo) { - return GetOCIChartTags(repo, path) + creds, err := r.credResolver.RepoAuthCredentials(repo) + if err != nil { + return nil, err + } + + return getOCIChartTags(repo, path, creds) } if !gitproviders2.IsGitHubSource(repo) {