diff --git a/.gitattributes b/.gitattributes index 8a936351a..3880cde0f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,7 @@ app/controlplane/pkg/data/ent/migrate/** linguist-generated=false app/controlplane/pkg/data/ent/migrate/** linguist-detectable=true app/controlplane/pkg/data/ent/schema/* linguist-generated=false app/controlplane/pkg/data/ent/schema/* linguist-detectable=true -app/controlplane/api/gen/jsonschema/** linguist-generated=true \ No newline at end of file +app/controlplane/api/gen/jsonschema/** linguist-generated=true +*.pb.go linguist-generated=true +*.pb.validate.go linguist-generated=true +wire_gen.go linguist-generated=true \ No newline at end of file diff --git a/app/artifact-cas/internal/server/grpc_test.go b/app/artifact-cas/internal/server/grpc_test.go index 632efa583..fcc7b5281 100644 --- a/app/artifact-cas/internal/server/grpc_test.go +++ b/app/artifact-cas/internal/server/grpc_test.go @@ -97,7 +97,7 @@ func TestJWTAuthFunc(t *testing.T) { b, err := robotaccount.NewBuilder(opts...) require.NoError(t, err) - token, err := b.GenerateJWT("backend-type", "secret-id", tc.audience, robotaccount.Downloader, 0) + token, err := b.GenerateJWT("backend-type", "secret-id", tc.audience, robotaccount.Downloader, 0, "org-id") require.NoError(t, err) // add bearer token to context diff --git a/app/artifact-cas/internal/service/bytestream.go b/app/artifact-cas/internal/service/bytestream.go index ba462bfd0..025bebc34 100644 --- a/app/artifact-cas/internal/service/bytestream.go +++ b/app/artifact-cas/internal/service/bytestream.go @@ -64,7 +64,7 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro defer span.End() // Get auth info and check that it's an uploader token - info, err := infoFromAuth(ctx) + info, err := casJWT.InfoFromAuth(ctx) if err != nil { return err } @@ -145,7 +145,7 @@ func (s *ByteStreamService) Read(req *bytestream.ReadRequest, stream bytestream. ctx := stream.Context() ctx, span := otelx.Start(ctx, byteStreamTracer, "ByteStreamService.Read") defer span.End() - info, err := infoFromAuth(ctx) + info, err := casJWT.InfoFromAuth(ctx) if err != nil { return err } diff --git a/app/artifact-cas/internal/service/download.go b/app/artifact-cas/internal/service/download.go index 6100f8082..afd0ac0b5 100644 --- a/app/artifact-cas/internal/service/download.go +++ b/app/artifact-cas/internal/service/download.go @@ -52,7 +52,7 @@ func (s *DownloadService) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ctx, span := otelx.Start(ctx, downloadTracer, "DownloadService.ServeHTTP") defer span.End() - auth, err := infoFromAuth(ctx) + auth, err := casJWT.InfoFromAuth(ctx) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return diff --git a/app/artifact-cas/internal/service/resource.go b/app/artifact-cas/internal/service/resource.go index 2c6924bd4..6360d307d 100644 --- a/app/artifact-cas/internal/service/resource.go +++ b/app/artifact-cas/internal/service/resource.go @@ -19,6 +19,7 @@ import ( "context" v1 "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/otelx" sl "github.com/chainloop-dev/chainloop/pkg/servicelogger" @@ -43,7 +44,7 @@ func (s *ResourceService) Describe(ctx context.Context, req *v1.ResourceServiceD ctx, span := otelx.Start(ctx, resourceTracer, "ResourceService.Describe") defer span.End() - info, err := infoFromAuth(ctx) + info, err := casJWT.InfoFromAuth(ctx) if err != nil { return nil, err } diff --git a/app/artifact-cas/internal/service/service.go b/app/artifact-cas/internal/service/service.go index ed25510aa..acc1f453b 100644 --- a/app/artifact-cas/internal/service/service.go +++ b/app/artifact-cas/internal/service/service.go @@ -21,12 +21,10 @@ import ( "fmt" "syscall" - casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/servicelogger" kerrors "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" - "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/google/wire" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -104,30 +102,3 @@ func isClientDisconnect(err error) bool { return false } - -// Extract the JWT claims from the context, note that the JWT verification has happened in the middleware -func infoFromAuth(ctx context.Context) (*casJWT.Claims, error) { - rawClaims, ok := jwt.FromContext(ctx) - if !ok { - return nil, kerrors.Unauthorized("cas", "missing authentication information") - } - - claims, ok := rawClaims.(*casJWT.Claims) - if !ok { - return nil, kerrors.Unauthorized("cas", "invalid authentication information") - } - - if claims.StoredSecretID == "" { - return nil, kerrors.Unauthorized("cas", "missing secret reference") - } - - if claims.BackendType == "" { - return nil, kerrors.Unauthorized("cas", "missing backend type") - } - - if claims.Role != casJWT.Uploader && claims.Role != casJWT.Downloader { - return nil, kerrors.Unauthorized("cas", "invalid role") - } - - return claims, nil -} diff --git a/app/artifact-cas/internal/service/service_test.go b/app/artifact-cas/internal/service/service_test.go index 149479355..acf5017bf 100644 --- a/app/artifact-cas/internal/service/service_test.go +++ b/app/artifact-cas/internal/service/service_test.go @@ -24,90 +24,15 @@ import ( "syscall" "testing" - casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" "github.com/chainloop-dev/chainloop/pkg/blobmanager/mocks" kerrors "github.com/go-kratos/kratos/v2/errors" - jwtm "github.com/go-kratos/kratos/v2/middleware/auth/jwt" - "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -func TestInfoFromAuth(t *testing.T) { - testCases := []struct { - name string - // input - claims jwt.Claims - wantErr bool - }{ - { - name: "valid claims downloader", - claims: &casJWT.Claims{ - Role: casJWT.Downloader, - StoredSecretID: "test", - BackendType: "backend-type", - }, - }, - { - name: "valid claims uploader", - claims: &casJWT.Claims{ - Role: casJWT.Uploader, - StoredSecretID: "test", - BackendType: "backend-type", - }, - }, - { - name: "invalid role", - claims: &casJWT.Claims{ - Role: "invalid", - StoredSecretID: "test", - BackendType: "backend-type", - }, - wantErr: true, - }, - { - name: "missing secretID", - claims: &casJWT.Claims{ - Role: "test", - BackendType: "backend-type", - }, - wantErr: true, - }, - { - name: "missing role", - claims: &casJWT.Claims{ - StoredSecretID: "test", - BackendType: "backend-type", - }, - wantErr: true, - }, - { - name: "missing backend type", - claims: &casJWT.Claims{ - StoredSecretID: "test", - Role: "test", - }, - wantErr: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - info, err := infoFromAuth(jwtm.NewContext(context.Background(), tc.claims)) - if tc.wantErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - assert.Equal(t, tc.claims, info) - }) - } -} - func TestLoadBackend(t *testing.T) { testCases := []struct { name string diff --git a/app/controlplane/internal/dispatcher/dispatcher.go b/app/controlplane/internal/dispatcher/dispatcher.go index 49305e134..704bb6b2d 100644 --- a/app/controlplane/internal/dispatcher/dispatcher.go +++ b/app/controlplane/internal/dispatcher/dispatcher.go @@ -92,7 +92,7 @@ func (d *FanOutDispatcher) Run(ctx context.Context, opts *RunOpts) error { } // 2. Hydrate the dispatch queue with the actual inputs - if err := d.loadInputs(ctx, queue, opts.Envelope, opts.DownloadBackendType, opts.DownloadSecretName); err != nil { + if err := d.loadInputs(ctx, queue, opts.Envelope, opts.DownloadBackendType, opts.DownloadSecretName, opts.OrgID); err != nil { return fmt.Errorf("loading materials: %w", err) } @@ -198,7 +198,7 @@ func (d *FanOutDispatcher) initDispatchQueue(ctx context.Context, orgID, workflo } // Load the inputs for the dispatchItem, both materials and attestation -func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, att *dsse.Envelope, backendType, secretName string) error { +func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, att *dsse.Envelope, backendType, secretName, orgID string) error { if att == nil { return fmt.Errorf("attestation is nil") } @@ -252,8 +252,12 @@ func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, if item.plugin.IsSubscribedTo(material.Type) { // It's a downloadable and has not been downloaded yet if !downloaded && material.Hash != nil && material.UploadedToCAS { + orgUUID, err := uuid.Parse(orgID) + if err != nil { + return fmt.Errorf("parsing org id: %w", err) + } buf := bytes.NewBuffer(nil) - if err := d.casClient.Download(ctx, backendType, secretName, buf, material.Hash.String()); err != nil { + if err := d.casClient.Download(ctx, backendType, secretName, orgUUID, buf, material.Hash.String()); err != nil { return fmt.Errorf("downloading from CAS: %w", err) } diff --git a/app/controlplane/internal/dispatcher/dispatcher_test.go b/app/controlplane/internal/dispatcher/dispatcher_test.go index 0a10c1112..ff971c7aa 100644 --- a/app/controlplane/internal/dispatcher/dispatcher_test.go +++ b/app/controlplane/internal/dispatcher/dispatcher_test.go @@ -29,6 +29,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" mockedSDK "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1/mocks" "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" "github.com/secure-systems-lab/go-securesystemslib/dsse" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" @@ -57,7 +58,7 @@ func (s *dispatcherTestSuite) TestLoadInputsEnvelope() { s.ociIntegrationBackend.(*mockedSDK.FanOut).On("IsSubscribedTo", "SBOM_CYCLONEDX_JSON").Return(false) s.ociIntegrationBackend.(*mockedSDK.FanOut).On("String").Return("mocked-integration") - err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name") + err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name", uuid.NewString()) assert.NoError(s.T(), err) // Only one integration is registered @@ -101,14 +102,14 @@ func (s *dispatcherTestSuite) TestLoadInputsMaterials() { require.NoError(s.T(), err) // Simulate SBOM download - s.casClient.On("Download", mock.Anything, "backend-type", "secret-name", mock.Anything, mock.Anything). + s.casClient.On("Download", mock.Anything, "backend-type", "secret-name", mock.Anything, mock.Anything, mock.Anything). Return(nil).Run(func(args mock.Arguments) { buf := bytes.NewBuffer([]byte("SBOM Content")) - _, err := io.Copy(args.Get(3).(io.Writer), buf) + _, err := io.Copy(args.Get(4).(io.Writer), buf) s.NoError(err) }) - err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name") + err = s.dispatcher.loadInputs(context.TODO(), queue, envelope, "backend-type", "secret-name", uuid.NewString()) assert.NoError(s.T(), err) require.Len(s.T(), queue, 3) diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 54d318618..4091b2792 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -494,7 +494,7 @@ func (s *AttestationService) GetUploadCreds(ctx context.Context, req *cpAPI.Atte // Return the backend information and associated credentials (if applicable) resp := &cpAPI.AttestationServiceGetUploadCredsResponse_Result{Backend: bizCASBackendToPb(backend)} if backend.SecretName != "" { - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Uploader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/casbackend.go b/app/controlplane/internal/service/casbackend.go index 8763e3c3b..958ea0deb 100644 --- a/app/controlplane/internal/service/casbackend.go +++ b/app/controlplane/internal/service/casbackend.go @@ -21,6 +21,7 @@ import ( pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/go-kratos/kratos/v2/errors" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -66,6 +67,12 @@ func (s *CASBackendService) Create(ctx context.Context, req *pb.CASBackendServic return nil, err } + // Managed-only providers (currently AWS-S3-ACCESS-POINT) are reserved + if isManagedOnlyProvider(req.Provider) { + return nil, errors.BadRequest("invalid CAS backend", + "managed CAS backends cannot be created via this API") + } + backendP, ok := s.providers[req.Provider] if !ok { return nil, errors.BadRequest("invalid CAS backend", "invalid CAS backend") @@ -193,13 +200,25 @@ func (s *CASBackendService) Revalidate(ctx context.Context, req *pb.CASBackendSe } func bizCASBackendToPb(in *biz.CASBackend) *pb.CASBackendItem { + // Managed backends hide both Location (AP ARN) and Provider + // (AWS-S3-ACCESS-POINT) from API clients — both are implementation + // details that tenants don't need to know. The DB and biz layer + // still carry the real values; only the wire format is sanitized. + // See biz.CASBackendManagedLocationDisplay / + // biz.CASBackendManagedProviderDisplay. + location := in.Location + provider := string(in.Provider) + if in.Managed { + location = biz.CASBackendManagedLocationDisplay + provider = biz.CASBackendManagedProviderDisplay + } r := &pb.CASBackendItem{ - Id: in.ID.String(), Location: in.Location, Description: in.Description, + Id: in.ID.String(), Location: location, Description: in.Description, Name: in.Name, CreatedAt: timestamppb.New(*in.CreatedAt), UpdatedAt: timestamppb.New(*in.UpdatedAt), ValidatedAt: timestamppb.New(*in.ValidatedAt), - Provider: string(in.Provider), + Provider: provider, Default: in.Default, Fallback: in.Fallback, IsInline: in.Inline, @@ -225,3 +244,8 @@ func bizCASBackendToPb(in *biz.CASBackend) *pb.CASBackendItem { return r } + +// isManagedOnlyProvider returns true when the supplied provider is managed +func isManagedOnlyProvider(id string) bool { + return id == s3accesspoint.ProviderID +} diff --git a/app/controlplane/internal/service/casbackend_test.go b/app/controlplane/internal/service/casbackend_test.go new file mode 100644 index 000000000..11d5c7842 --- /dev/null +++ b/app/controlplane/internal/service/casbackend_test.go @@ -0,0 +1,95 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +// TestBizCASBackendToPb_HidesManagedDetails guards the rule that +// managed-backend implementation details (AP ARN, provider ID) never +// leak to API clients. Non-managed rows keep their original Location +// and Provider; managed rows are rewritten to stable placeholders. +// Regression-prevention only — both fields are otherwise straightforward +// to map. +func TestBizCASBackendToPb_HidesManagedDetails(t *testing.T) { + now := time.Now() + realLocation := "arn:aws:s3:us-east-1:471112941097:accesspoint/chainloop-org-dev" + realProvider := biz.CASBackendProvider("AWS-S3-ACCESS-POINT") + + base := biz.CASBackend{ + ID: uuid.New(), + Name: "backend", + Location: realLocation, + CreatedAt: &now, + UpdatedAt: &now, + ValidatedAt: &now, + Provider: realProvider, + } + + t.Run("non-managed exposes location and provider verbatim", func(t *testing.T) { + in := base + in.Managed = false + got := bizCASBackendToPb(&in) + assert.Equal(t, realLocation, got.Location, + "non-managed rows must surface their real location") + assert.Equal(t, string(realProvider), got.Provider, + "non-managed rows must surface their real provider") + assert.False(t, got.IsManaged) + }) + + t.Run("managed replaces location and provider with placeholders", func(t *testing.T) { + in := base + in.Managed = true + got := bizCASBackendToPb(&in) + assert.Equal(t, biz.CASBackendManagedLocationDisplay, got.Location, + "managed rows must never leak the underlying AP ARN") + assert.Equal(t, biz.CASBackendManagedProviderDisplay, got.Provider, + "managed rows must never leak the backing provider ID") + assert.True(t, got.IsManaged) + }) +} + +// TestIsManagedOnlyProvider locks down which provider IDs are reserved +// for the platform reconciler. If a new managed provider is added but +// this list isn't updated, users would be able to create the row +// directly via CASBackendService.Create — a privilege escalation against +// the managed-CAS trust model. +func TestIsManagedOnlyProvider(t *testing.T) { + tests := []struct { + id string + expected bool + }{ + {s3accesspoint.ProviderID, true}, + {"AWS-S3", false}, + {"OCI", false}, + {"AzureBlob", false}, + {"INLINE", false}, + {"", false}, + {"unknown-provider", false}, + } + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + assert.Equal(t, tc.expected, isManagedOnlyProvider(tc.id)) + }) + } +} diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index 9729100fa..cfec6038b 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -149,7 +149,7 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS return nil, errors.BadRequest("invalid argument", "cannot upload or download artifacts from an inline CAS backend") } - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: role, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID} t, err := s.casUC.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/casredirect.go b/app/controlplane/internal/service/casredirect.go index 431c5a6a8..9f0dc136c 100644 --- a/app/controlplane/internal/service/casredirect.go +++ b/app/controlplane/internal/service/casredirect.go @@ -126,7 +126,7 @@ func (s *CASRedirectService) GetDownloadURL(ctx context.Context, req *pb.GetDown // 2- add authentication token to the query params ?t=[token] if backend.SecretName != "" { - ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes} + ref := &biz.CASCredsOpts{BackendType: string(backend.Provider), SecretPath: backend.SecretName, Role: casJWT.Downloader, MaxBytes: backend.Limits.MaxBytes, OrgID: backend.OrganizationID} t, err := s.casCredsUseCase.GenerateTemporaryCredentials(ref) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/workflowrun.go b/app/controlplane/internal/service/workflowrun.go index 27d1e51b4..4934ed6e5 100644 --- a/app/controlplane/internal/service/workflowrun.go +++ b/app/controlplane/internal/service/workflowrun.go @@ -109,7 +109,7 @@ func (s *WorkflowRunService) resolvePolicyEvaluations( } var buf bytes.Buffer - if err := s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, &buf, digest); err != nil { + if err := s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, &buf, digest); err != nil { return nil, fmt.Errorf("downloading policy eval bundle: %w", err) } diff --git a/app/controlplane/pkg/biz/attestation.go b/app/controlplane/pkg/biz/attestation.go index 19967c477..bf23186b1 100644 --- a/app/controlplane/pkg/biz/attestation.go +++ b/app/controlplane/pkg/biz/attestation.go @@ -49,7 +49,7 @@ func (uc *AttestationUseCase) UploadAttestationToCAS(ctx context.Context, conten ctx, span := otelx.Start(ctx, attestationTracer, "AttestationUseCase.UploadAttestationToCAS") defer span.End() - if err := uc.CASClient.Upload(ctx, string(backend.Provider), backend.SecretName, bytes.NewBuffer(content), fmt.Sprintf("attestation-%s.json", workflowRunID), digest.String()); err != nil { + if err := uc.Upload(ctx, string(backend.Provider), backend.SecretName, backend.OrganizationID, bytes.NewBuffer(content), fmt.Sprintf("attestation-%s.json", workflowRunID), digest.String()); err != nil { otelx.RecordError(span, err) return err } diff --git a/app/controlplane/pkg/biz/casbackend.go b/app/controlplane/pkg/biz/casbackend.go index 89ef89132..508b0694f 100644 --- a/app/controlplane/pkg/biz/casbackend.go +++ b/app/controlplane/pkg/biz/casbackend.go @@ -29,6 +29,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" @@ -47,8 +48,54 @@ const ( MinCASBackendMaxBytes int64 = 10 * 1024 * 1024 // 10MB minimum errMsgCredentialsAccess = "Failed to access CAS backend credentials in external Secrets Manager" errMsgCredentialsFormat = "Invalid CAS backend credentials format from external Secrets Manager" + + // CASBackendManagedLocationDisplay is the placeholder substituted for + // CASBackend.Location whenever a managed backend is exposed beyond + // the controlplane's trust boundary (API responses, audit events). + // The real ARN remains in the DB so PerformValidation, the platform + // reconciler, and forensic joins by CASBackendID still work — only + // the wire-format Location is sanitized. + CASBackendManagedLocationDisplay = "managed by Chainloop" + + // CASBackendManagedProviderDisplay is the placeholder substituted + // for CASBackend.Provider on managed backends. The underlying + // provider ID ("AWS-S3-ACCESS-POINT" today, possibly other managed + // providers tomorrow) is itself an implementation detail that + // tenants shouldn't see; "Chainloop" tells them everything they + // need to know about ownership without revealing the backing + // technology. + CASBackendManagedProviderDisplay = "Chainloop" ) +// displayLocation returns the location string we expose outside the +// controlplane's trust boundary. Managed backends get a stable +// placeholder; everything else passes through verbatim. Use this for +// any path that emits a CASBackend.Location to API clients or to the +// audit event bus. +func displayLocation(b *CASBackend) string { + if b != nil && b.Managed { + return CASBackendManagedLocationDisplay + } + if b == nil { + return "" + } + return b.Location +} + +// displayProvider returns the provider string we expose outside the +// controlplane's trust boundary. Managed backends report a generic +// "Chainloop" provider name so the specific backing technology stays +// internal. Non-managed backends pass through their provider ID. +func displayProvider(b *CASBackend) string { + if b == nil { + return "" + } + if b.Managed { + return CASBackendManagedProviderDisplay + } + return string(b.Provider) +} + var CASBackendInlineDescription = "Embed artifacts content in the attestation (fallback)" type CASBackendValidationStatus string @@ -410,8 +457,8 @@ func (uc *CASBackendUseCase) Create(ctx context.Context, orgID, name, location, CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, CASBackendDescription: description, @@ -532,8 +579,8 @@ func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id string, descr CASBackendBase: &events.CASBackendBase{ CASBackendID: &after.ID, CASBackendName: after.Name, - Provider: string(after.Provider), - Location: after.Location, + Provider: displayProvider(after), + Location: displayLocation(after), Default: after.Default, }, NewDescription: description, @@ -642,8 +689,8 @@ func (uc *CASBackendUseCase) SoftDelete(ctx context.Context, orgID, id string) e CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, }, &orgUUID) @@ -691,8 +738,8 @@ func (uc *CASBackendUseCase) Delete(ctx context.Context, id string) error { CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, }, &backend.OrganizationID) @@ -781,8 +828,8 @@ func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) e CASBackendBase: &events.CASBackendBase{ CASBackendID: &backend.ID, CASBackendName: backend.Name, - Provider: string(backend.Provider), - Location: backend.Location, + Provider: displayProvider(backend), + Location: displayLocation(backend), Default: backend.Default, }, PreviousStatus: string(previousStatus), @@ -827,7 +874,7 @@ func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) e // Implements https://pkg.go.dev/entgo.io/ent/schema/field#EnumValues func (CASBackendProvider) Values() (kinds []string) { - for _, s := range []CASBackendProvider{azureblob.ProviderID, oci.ProviderID, CASBackendInline, s3.ProviderID} { + for _, s := range []CASBackendProvider{azureblob.ProviderID, oci.ProviderID, CASBackendInline, s3.ProviderID, s3accesspoint.ProviderID} { kinds = append(kinds, string(s)) } diff --git a/app/controlplane/pkg/biz/casclient.go b/app/controlplane/pkg/biz/casclient.go index 6cae2b7a2..98f7856de 100644 --- a/app/controlplane/pkg/biz/casclient.go +++ b/app/controlplane/pkg/biz/casclient.go @@ -30,6 +30,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" ) var casClientTracer = otelx.Tracer("chainloop-controlplane", "biz/casclient") @@ -45,11 +46,11 @@ type CASClientUseCase struct { } type CASUploader interface { - Upload(ctx context.Context, backendType, secretID string, content io.Reader, filename, digest string) error + Upload(ctx context.Context, backendType, secretID string, orgID uuid.UUID, content io.Reader, filename, digest string) error } type CASDownloader interface { - Download(ctx context.Context, backendType, secretID string, w io.Writer, digest string) error + Download(ctx context.Context, backendType, secretID string, orgID uuid.UUID, w io.Writer, digest string) error } type CASClient interface { @@ -102,14 +103,14 @@ func NewCASClientUseCase(credsProvider *CASCredentialsUseCase, config *conf.Boot } // The secretID is embedded in the JWT token and is used to identify the secret by the CAS server -func (uc *CASClientUseCase) Upload(ctx context.Context, backendType, secretID string, content io.Reader, filename, digest string) error { +func (uc *CASClientUseCase) Upload(ctx context.Context, backendType, secretID string, orgID uuid.UUID, content io.Reader, filename, digest string) error { ctx, span := otelx.Start(ctx, casClientTracer, "CASClientUseCase.Upload") defer span.End() uc.logger.Infow("msg", "upload initialized", "filename", filename, "digest", digest) // client with temporary set of credentials - client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Uploader}) + client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Uploader, OrgID: orgID}) if err != nil { return fmt.Errorf("failed to create cas client: %w", err) } @@ -125,13 +126,13 @@ func (uc *CASClientUseCase) Upload(ctx context.Context, backendType, secretID st return nil } -func (uc *CASClientUseCase) Download(ctx context.Context, backendType, secretID string, w io.Writer, digest string) error { +func (uc *CASClientUseCase) Download(ctx context.Context, backendType, secretID string, orgID uuid.UUID, w io.Writer, digest string) error { ctx, span := otelx.Start(ctx, casClientTracer, "CASClientUseCase.Download") defer span.End() uc.logger.Infow("msg", "download initialized", "digest", digest) - client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Downloader}) + client, closeFn, err := uc.casAPIClient(&CASCredsOpts{BackendType: backendType, SecretPath: secretID, Role: casJWT.Downloader, OrgID: orgID}) if err != nil { return fmt.Errorf("failed to create cas client: %w", err) } diff --git a/app/controlplane/pkg/biz/cascredentials.go b/app/controlplane/pkg/biz/cascredentials.go index 6e9b3bef2..41e1360bb 100644 --- a/app/controlplane/pkg/biz/cascredentials.go +++ b/app/controlplane/pkg/biz/cascredentials.go @@ -16,11 +16,13 @@ package biz import ( + "fmt" "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt" robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" + "github.com/google/uuid" ) type CASCredentialsUseCase struct { @@ -48,8 +50,16 @@ type CASCredsOpts struct { SecretPath string // path to for example the OCI secret in the vault Role robotaccount.Role MaxBytes int64 + // OrgID identifies the org the CAS backend belongs to. Required for + // every CAS JWT — managed providers (e.g. AWS-S3-ACCESS-POINT) need + // it to scope per-tenant STS sessions, and non-managed providers + // still carry it for audit traceability. + OrgID uuid.UUID } func (uc *CASCredentialsUseCase) GenerateTemporaryCredentials(backendRef *CASCredsOpts) (string, error) { - return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes) + if backendRef.OrgID == uuid.Nil { + return "", fmt.Errorf("org id is required") + } + return uc.jwtBuilder.GenerateJWT(backendRef.BackendType, backendRef.SecretPath, jwt.CASAudience, backendRef.Role, backendRef.MaxBytes, backendRef.OrgID.String()) } diff --git a/app/controlplane/pkg/biz/mocks/CASClient.go b/app/controlplane/pkg/biz/mocks/CASClient.go index ba51e6129..66ebacc70 100644 --- a/app/controlplane/pkg/biz/mocks/CASClient.go +++ b/app/controlplane/pkg/biz/mocks/CASClient.go @@ -6,6 +6,7 @@ import ( context "context" io "io" + "github.com/google/uuid" mock "github.com/stretchr/testify/mock" ) @@ -14,17 +15,17 @@ type CASClient struct { mock.Mock } -// Download provides a mock function with given fields: ctx, backendType, secretID, w, digest -func (_m *CASClient) Download(ctx context.Context, backendType string, secretID string, w io.Writer, digest string) error { - ret := _m.Called(ctx, backendType, secretID, w, digest) +// Download provides a mock function with given fields: ctx, backendType, secretID, orgID, w, digest +func (_m *CASClient) Download(ctx context.Context, backendType string, secretID string, orgID uuid.UUID, w io.Writer, digest string) error { + ret := _m.Called(ctx, backendType, secretID, orgID, w, digest) if len(ret) == 0 { panic("no return value specified for Download") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, io.Writer, string) error); ok { - r0 = rf(ctx, backendType, secretID, w, digest) + if rf, ok := ret.Get(0).(func(context.Context, string, string, uuid.UUID, io.Writer, string) error); ok { + r0 = rf(ctx, backendType, secretID, orgID, w, digest) } else { r0 = ret.Error(0) } @@ -32,17 +33,17 @@ func (_m *CASClient) Download(ctx context.Context, backendType string, secretID return r0 } -// Upload provides a mock function with given fields: ctx, backendType, secretID, content, filename, digest -func (_m *CASClient) Upload(ctx context.Context, backendType string, secretID string, content io.Reader, filename string, digest string) error { - ret := _m.Called(ctx, backendType, secretID, content, filename, digest) +// Upload provides a mock function with given fields: ctx, backendType, secretID, orgID, content, filename, digest +func (_m *CASClient) Upload(ctx context.Context, backendType string, secretID string, orgID uuid.UUID, content io.Reader, filename string, digest string) error { + ret := _m.Called(ctx, backendType, secretID, orgID, content, filename, digest) if len(ret) == 0 { panic("no return value specified for Upload") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, string, string) error); ok { - r0 = rf(ctx, backendType, secretID, content, filename, digest) + if rf, ok := ret.Get(0).(func(context.Context, string, string, uuid.UUID, io.Reader, string, string) error); ok { + r0 = rf(ctx, backendType, secretID, orgID, content, filename, digest) } else { r0 = ret.Error(0) } diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 084081cc1..be88d561e 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -708,7 +708,7 @@ func (uc *WorkflowRunUseCase) downloadBundleFromCAS(ctx context.Context, digest } var buf bytes.Buffer - if err := uc.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, &buf, digest); err != nil { + if err := uc.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, mapping.CASBackend.OrganizationID, &buf, digest); err != nil { return nil, fmt.Errorf("downloading attestation bundle: %w", err) } diff --git a/app/controlplane/pkg/data/ent/casbackend/casbackend.go b/app/controlplane/pkg/data/ent/casbackend/casbackend.go index 8fe9e8c68..f2f465b3d 100644 --- a/app/controlplane/pkg/data/ent/casbackend/casbackend.go +++ b/app/controlplane/pkg/data/ent/casbackend/casbackend.go @@ -136,7 +136,7 @@ var ( // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr biz.CASBackendProvider) error { switch pr { - case "AzureBlob", "OCI", "INLINE", "AWS-S3": + case "AzureBlob", "OCI", "INLINE", "AWS-S3", "AWS-S3-ACCESS-POINT": return nil default: return fmt.Errorf("casbackend: invalid enum value for provider field: %q", pr) diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 7f1870ae0..85e4bc67c 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -94,7 +94,7 @@ var ( {Name: "id", Type: field.TypeUUID, Unique: true}, {Name: "location", Type: field.TypeString}, {Name: "name", Type: field.TypeString}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"AzureBlob", "OCI", "INLINE", "AWS-S3"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"AzureBlob", "OCI", "INLINE", "AWS-S3", "AWS-S3-ACCESS-POINT"}}, {Name: "description", Type: field.TypeString, Nullable: true}, {Name: "secret_name", Type: field.TypeString}, {Name: "created_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"}, diff --git a/go.mod b/go.mod index 251963644..e1e183342 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( entgo.io/ent v0.14.6-0.20251003170342-01063ef6395c github.com/adrg/xdg v0.4.0 github.com/aws/aws-sdk-go-v2 v1.41.5 - github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.6 github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 @@ -49,7 +49,7 @@ require ( github.com/testcontainers/testcontainers-go v0.40.0 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.1 - golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.42.0 google.golang.org/api v0.272.0 @@ -72,7 +72,7 @@ require ( github.com/casbin/casbin/v2 v2.103.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/extism/go-sdk v1.7.1 - github.com/go-git/go-git/v6 v6.0.0-alpha.2 // recommended path: https://github.com/go-git/go-git/issues/1943#issuecomment-4232656963 + github.com/go-git/go-git/v6 v6.0.0-alpha.3 // recommended path: https://github.com/go-git/go-git/issues/1943#issuecomment-4232656963 github.com/google/go-github/v66 v66.0.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 @@ -193,8 +193,8 @@ require ( github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect - github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -268,7 +268,7 @@ require ( github.com/package-url/packageurl-go v0.1.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect - github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/xattr v0.4.9 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -348,7 +348,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -366,7 +366,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsouza/fake-gcs-server v1.47.6 - github.com/go-git/go-git/v5 v5.18.0 // indirect + github.com/go-git/go-git/v5 v5.19.0 // indirect github.com/go-kratos/aegis v0.2.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -454,13 +454,13 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.50.0 - golang.org/x/mod v0.34.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.1 // indirect diff --git a/go.sum b/go.sum index 6b8e97fb9..e6fba39bf 100644 --- a/go.sum +++ b/go.sum @@ -447,19 +447,19 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= -github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= -github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d h1:bLMI9z4mKkfQO383+O3fkP4xdWQcMdnn5fFBMwaBC1M= -github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d/go.mod h1:LLeMBFApkgIKwMzirxpU9XB7NvO2HdTw5FXmeP1M6c8= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= +github.com/go-git/go-git-fixtures/v6 v6.0.0-20260422085740-0c07409f52ec h1:FpCNUs50xfQyJJs31uO3mDnqU855OhzAzfkkTgE6/DI= +github.com/go-git/go-git-fixtures/v6 v6.0.0-20260422085740-0c07409f52ec/go.mod h1:F1SpxOny2UYXu62DzjEH4UqBjk4AoGs27cA8I9buK+o= github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= -github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= -github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= -github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= -github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= +github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= +github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.3 h1:lJGritJ5AcC0X7buV0lReZ4cEHqcKB3Ab2ZjD3Ku+Ss= +github.com/go-git/go-git/v6 v6.0.0-alpha.3/go.mod h1:DGnqu+twdAgtDx/4tQTWFrVE1an+2ACph3W9yOfSJZM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -1108,8 +1108,8 @@ github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= -github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= -github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -1522,8 +1522,8 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1552,8 +1552,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1702,8 +1702,8 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1788,8 +1788,8 @@ golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/robotaccount/cas/robotaccount.go b/internal/robotaccount/cas/robotaccount.go index d21dc7af2..223f69b58 100644 --- a/internal/robotaccount/cas/robotaccount.go +++ b/internal/robotaccount/cas/robotaccount.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,12 +16,15 @@ package robotaccount import ( + "context" "crypto/ecdsa" "errors" "fmt" "os" "time" + kerrors "github.com/go-kratos/kratos/v2/errors" + kratosjwt "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/golang-jwt/jwt/v4" ) @@ -38,6 +41,11 @@ type Claims struct { StoredSecretID string `json:"secret-id"` // path to the OCI secret in the vault BackendType string `json:"backend"` // backend to use, i.e OCI MaxBytes int64 `json:"maxbytes"` // max bytes to upload + // OrgID identifies the authenticated org this token was minted for. + // Managed providers (e.g. AWS-S3-ACCESS-POINT) require it to scope + // per-tenant STS sessions; the non-managed providers ignore it but + // it is still carried for audit traceability. + OrgID string `json:"org-id"` } type Role string @@ -103,7 +111,12 @@ func NewBuilder(opts ...NewOpt) (*Builder, error) { return b, nil } -func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role, maxBytes int64) (string, error) { +// GenerateJWT mints a CAS token. All fields are required, including +// orgID — managed providers (e.g. AWS-S3-ACCESS-POINT) need it to scope +// per-tenant STS sessions and other providers still record it for +// audit. The token always carries the CAS audience and a short expiry +// window. +func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role, maxBytes int64, orgID string) (string, error) { if backendType == "" { return "", fmt.Errorf("backend type is required") } @@ -116,6 +129,10 @@ func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role return "", fmt.Errorf("audience is required") } + if orgID == "" { + return "", fmt.Errorf("org id is required") + } + if role != Downloader && role != Uploader { return "", fmt.Errorf("invalid role") } @@ -126,6 +143,7 @@ func (ra *Builder) GenerateJWT(backendType, secretID, audience string, role Role StoredSecretID: secretID, // Identifier for the backend, i.e OCI BackendType: backendType, + OrgID: orgID, RegisteredClaims: jwt.RegisteredClaims{ Issuer: ra.issuer, Audience: jwt.ClaimStrings{audience}, @@ -173,3 +191,30 @@ func (c *Claims) CheckRole(r Role) error { return nil } + +// InfoFromAuth extracts the JWT claims from the context, note that the JWT verification has happened in the middleware +func InfoFromAuth(ctx context.Context) (*Claims, error) { + rawClaims, ok := kratosjwt.FromContext(ctx) + if !ok { + return nil, kerrors.Unauthorized("cas", "missing authentication information") + } + + claims, ok := rawClaims.(*Claims) + if !ok { + return nil, kerrors.Unauthorized("cas", "invalid authentication information") + } + + if claims.StoredSecretID == "" { + return nil, kerrors.Unauthorized("cas", "missing secret reference") + } + + if claims.BackendType == "" { + return nil, kerrors.Unauthorized("cas", "missing backend type") + } + + if claims.Role != Uploader && claims.Role != Downloader { + return nil, kerrors.Unauthorized("cas", "invalid role") + } + + return claims, nil +} diff --git a/internal/robotaccount/cas/robotaccount_test.go b/internal/robotaccount/cas/robotaccount_test.go index 0f9e2d7de..d627a5633 100644 --- a/internal/robotaccount/cas/robotaccount_test.go +++ b/internal/robotaccount/cas/robotaccount_test.go @@ -16,10 +16,12 @@ package robotaccount import ( + "context" "os" "testing" "time" + jwtmiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -150,7 +152,7 @@ func TestGenerateJWT(t *testing.T) { ) require.NoError(t, err) - token, err := b.GenerateJWT("OCI", "secret-id", JWTAudience, Uploader, 123) + token, err := b.GenerateJWT("OCI", "secret-id", JWTAudience, Uploader, 123, "org-uuid") assert.NoError(t, err) assert.NotEmpty(t, token) @@ -167,12 +169,85 @@ func TestGenerateJWT(t *testing.T) { assert.Equal(t, "my-issuer", claims.Issuer) assert.Contains(t, claims.Audience, "artifact-cas.chainloop") assert.Equal(t, claims.MaxBytes, int64(123)) + assert.Equal(t, "org-uuid", claims.OrgID) assert.WithinDuration(t, time.Now(), claims.ExpiresAt.Time, 10*time.Second) } // load key for verification func loadPublicKey(rawKey []byte) jwt.Keyfunc { - return func(token *jwt.Token) (interface{}, error) { + return func(_ *jwt.Token) (any, error) { return jwt.ParseECPublicKeyFromPEM(rawKey) } } + +func TestInfoFromAuth(t *testing.T) { + testCases := []struct { + name string + // input + claims jwt.Claims + wantErr bool + }{ + { + name: "valid claims downloader", + claims: &Claims{ + Role: Downloader, + StoredSecretID: "test", + BackendType: "backend-type", + }, + }, + { + name: "valid claims uploader", + claims: &Claims{ + Role: Uploader, + StoredSecretID: "test", + BackendType: "backend-type", + }, + }, + { + name: "invalid role", + claims: &Claims{ + Role: "invalid", + StoredSecretID: "test", + BackendType: "backend-type", + }, + wantErr: true, + }, + { + name: "missing secretID", + claims: &Claims{ + Role: "test", + BackendType: "backend-type", + }, + wantErr: true, + }, + { + name: "missing role", + claims: &Claims{ + StoredSecretID: "test", + BackendType: "backend-type", + }, + wantErr: true, + }, + { + name: "missing backend type", + claims: &Claims{ + StoredSecretID: "test", + Role: "test", + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info, err := InfoFromAuth(jwtmiddleware.NewContext(context.Background(), tc.claims)) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.claims, info) + }) + } +} diff --git a/pkg/blobmanager/loader/loader.go b/pkg/blobmanager/loader/loader.go index 576091145..c32482601 100644 --- a/pkg/blobmanager/loader/loader.go +++ b/pkg/blobmanager/loader/loader.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -20,18 +20,26 @@ import ( "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" "github.com/chainloop-dev/chainloop/pkg/credentials" ) +// LoadProviders builds the registry of CAS backend providers consumed by +// both the controlplane and the artifact-cas binaries. All providers are +// registered unconditionally — the s3accesspoint provider has no +// deployment-level config of its own (everything per-tenant lives in the +// secret blob), so on-prem deployments without managed CAS simply never +// have managed rows and the provider is dormant. func LoadProviders(creader credentials.Reader) backends.Providers { - // Initialize CAS backend providers ociProvider := oci.NewBackendProvider(creader) azureBlobProvider := azureblob.NewBackendProvider(creader) s3Provider := s3.NewBackendProvider(creader) + apProvider := s3accesspoint.NewBackendProvider(creader) return backends.Providers{ ociProvider.ID(): ociProvider, azureBlobProvider.ID(): azureBlobProvider, s3Provider.ID(): s3Provider, + apProvider.ID(): apProvider, } } diff --git a/pkg/blobmanager/loader/loader_test.go b/pkg/blobmanager/loader/loader_test.go new file mode 100644 index 000000000..0980585bf --- /dev/null +++ b/pkg/blobmanager/loader/loader_test.go @@ -0,0 +1,42 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/chainloop-dev/chainloop/pkg/blobmanager/azureblob" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/oci" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3" + "github.com/chainloop-dev/chainloop/pkg/blobmanager/s3accesspoint" +) + +type stubReader struct{} + +func (stubReader) ReadCredentials(_ context.Context, _ string, _ any) error { return nil } + +func TestLoadProviders_AllRegistered(t *testing.T) { + t.Parallel() + + ps := LoadProviders(stubReader{}) + assert.Contains(t, ps, oci.ProviderID) + assert.Contains(t, ps, azureblob.ProviderID) + assert.Contains(t, ps, s3.ProviderID) + assert.Contains(t, ps, s3accesspoint.ProviderID) +} diff --git a/pkg/blobmanager/s3accesspoint/backend.go b/pkg/blobmanager/s3accesspoint/backend.go new file mode 100644 index 000000000..33a42d829 --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/backend.go @@ -0,0 +1,368 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3accesspoint + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/smithy-go" + pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" +) + +const ( + annotationNameAuthor = "author" + annotationNameFilename = "filename" +) + +// ErrMissingRequestingOrg is returned when a request reaches the backend +// without an org UUID in its context. The backend fails closed in this +// case rather than minting a session with a default/empty name that would +// be useless against an AP policy condition. +var ErrMissingRequestingOrg = errors.New("s3accesspoint: requesting org missing from claims") + +// Backend is the per-tenant uploader/downloader. One *Backend instance is +// bound to one access point; the actual AWS credentials are minted +// per-request via STS using the org UUID found in the request context. +type Backend struct { + creds *Credentials + + // stsClient is built once at construction using the pod's ambient + // IAM identity. The credential chain (IRSA → IMDS → env → + // ~/.aws/credentials) picks up the identity automatically. + stsClient *sts.Client + + // s3Client uses a custom CredentialsProvider that mints a scoped + // session per request (cached in-process per requesting-org so back- + // to-back uploads from the same org reuse the token). Bucket is + // always the AP ARN; the SDK accepts an ARN there directly. + s3Client *s3.Client +} + +var _ backend.UploaderDownloader = (*Backend)(nil) + +// NewBackend constructs a *Backend wired to an STS-backed credentials +// provider. ctx is used only for the initial AWS config load (DNS lookups, +// IMDS, IRSA token reads); it is not retained for later operations. +func NewBackend(ctx context.Context, creds *Credentials) (*Backend, error) { + if err := creds.Validate(); err != nil { + return nil, err + } + + // Load the pod's ambient AWS identity once. Subsequent SDK calls + // reuse the resulting config; no per-request credential lookup + // against the pod identity is necessary. + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(creds.Region)) + if err != nil { + return nil, fmt.Errorf("loading aws config: %w", err) + } + + stsClient := sts.NewFromConfig(awsCfg) + + // The per-request credential provider closes over creds so it can + // build the session policy from the AP ARN and key prefix every time + // AWS asks for fresh credentials. NewCredentialsCache handles + // proactive refresh and concurrent-call deduplication. + // + // In dev mode we hand the provider the ambient credentials so it can + // return them directly without calling STS. The provider still + // enforces the requesting-org context discipline. + credProvider := aws.NewCredentialsCache(&sessionCredentialsProvider{ + stsClient: stsClient, + ambientCreds: awsCfg.Credentials, + useAmbientForRetrieve: devModeEnabled(), + creds: creds, + }) + + s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.Credentials = credProvider + }) + + return &Backend{ + creds: creds, + stsClient: stsClient, + s3Client: s3Client, + }, nil +} + +// keyFor builds the bucket-level S3 key for a digest. Every tenant's +// objects live under a prefix derived from the requesting org carried in +// ctx, so two tenants pushing the same digest don't collide at the bucket +// layer. The function fails closed when the org is missing — same +// invariant the credentials provider enforces, just surfaced earlier +// with a clearer error. +func (b *Backend) keyFor(ctx context.Context, digest string) (string, error) { + claims, err := robotaccount.InfoFromAuth(ctx) + if err != nil { + return "", err + } + if claims.OrgID == "" { + return "", ErrMissingRequestingOrg + } + return fmt.Sprintf("%s/sha256:%s", claims.OrgID, digest), nil +} + +func (b *Backend) Exists(ctx context.Context, digest string) (bool, error) { + _, err := b.Describe(ctx, digest) + if err != nil && backend.IsNotFound(err) { + return false, nil + } + return err == nil, err +} + +func (b *Backend) Upload(ctx context.Context, r io.Reader, resource *pb.CASResource) error { + key, err := b.keyFor(ctx, resource.Digest) + if err != nil { + return err + } + uploader := manager.NewUploader(b.s3Client) + _, err = uploader.Upload(ctx, &s3.PutObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + Body: r, + Metadata: map[string]string{ + annotationNameAuthor: backend.AuthorAnnotation, + annotationNameFilename: resource.FileName, + }, + }) + if err != nil { + return fmt.Errorf("uploading to access point: %w", err) + } + return nil +} + +func (b *Backend) Describe(ctx context.Context, digest string) (*pb.CASResource, error) { + key, err := b.keyFor(ctx, digest) + if err != nil { + return nil, err + } + resp, err := b.s3Client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + ChecksumMode: s3types.ChecksumModeEnabled, + }) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" { + return nil, backend.NewErrNotFound("artifact") + } + return nil, fmt.Errorf("reading from access point: %w", err) + } + + // Integrity check: when S3 returned a checksum, make sure the digest + // the caller asked for matches the server's recorded value. + if resp.ChecksumSHA256 != nil && *resp.ChecksumSHA256 != hexSha256ToBinaryB64(digest) { + return nil, fmt.Errorf("failed to validate integrity of object, got=%s, want=%s", + *resp.ChecksumSHA256, hexSha256ToBinaryB64(digest)) + } + + author, ok := resp.Metadata[annotationNameAuthor] + if !ok || author != backend.AuthorAnnotation { + return nil, errors.New("asset not uploaded by Chainloop") + } + fileName, ok := resp.Metadata[annotationNameFilename] + if !ok { + return nil, errors.New("couldn't find file metadata") + } + + var size int64 + if resp.ContentLength != nil { + size = *resp.ContentLength + } + return &pb.CASResource{FileName: fileName, Size: size, Digest: digest}, nil +} + +func (b *Backend) Download(ctx context.Context, w io.Writer, digest string) error { + exists, err := b.Exists(ctx, digest) + if err != nil { + return err + } else if !exists { + return backend.NewErrNotFound("artifact") + } + + key, err := b.keyFor(ctx, digest) + if err != nil { + return err + } + downloader := manager.NewDownloader(b.s3Client, func(d *manager.Downloader) { + // Force sequential downloads so the fakeWriterAt below can + // safely ignore the offset argument. + d.Concurrency = 1 + }) + _, err = downloader.Download(ctx, fakeWriterAt{w}, &s3.GetObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + }) + return err +} + +// CheckWritePermissions verifies that the calling org can actually mint a +// scoped session and put/get an object through its AP. Unlike the regular +// s3 backend's variant this MUST be invoked with a context carrying the org +func (b *Backend) CheckWritePermissions(ctx context.Context) error { + info, err := robotaccount.InfoFromAuth(ctx) + if err != nil { + return err + } + if info.OrgID == "" { + return ErrMissingRequestingOrg + } + const testObject = "healthcheck" + key := fmt.Sprintf("%s/%s", info.OrgID, testObject) + + if _, err := b.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Body: strings.NewReader("healthcheckdata"), + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("writing healthcheck object: %w", err) + } + if _, err := b.s3Client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(b.creds.AccessPointARN), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("reading healthcheck object: %w", err) + } + return nil +} + +// sessionCredentialsProvider implements aws.CredentialsProvider. Each +// Retrieve call extracts the requesting org from ctx, builds a session +// policy that scopes the resulting credentials to one AP + one key +// prefix, and calls sts:AssumeRole. +// +// The aws.NewCredentialsCache wrapper around this provider takes care of +// reusing the temporary credentials across consecutive calls until the +// expiration window approaches. +type sessionCredentialsProvider struct { + stsClient *sts.Client + + // ambientCreds is the SDK-default credentials provider captured from + // awsCfg at construction time. Only consulted when + // useAmbientForRetrieve is true (dev mode). + ambientCreds aws.CredentialsProvider + // useAmbientForRetrieve short-circuits Retrieve to return the pod's + // ambient AWS credentials directly without calling sts:AssumeRole. + // DEV ONLY — see DevModeEnvVar. + useAmbientForRetrieve bool + + creds *Credentials +} + +// Retrieve is called by the AWS SDK before every signed request. It must +// be cheap to call (the cache wrapper deduplicates concurrent misses and +// caches valid creds until ExpiresIn). +func (p *sessionCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + info, err := robotaccount.InfoFromAuth(ctx) + if err != nil { + return aws.Credentials{}, err + } + if info.OrgID == "" { + return aws.Credentials{}, ErrMissingRequestingOrg + } + + // Dev mode: skip the per-request AssumeRole entirely and use the + // SDK's default credential chain directly. + if p.useAmbientForRetrieve { + if p.ambientCreds == nil { + return aws.Credentials{}, errors.New("s3accesspoint: dev mode requested but no ambient credentials available") + } + return p.ambientCreds.Retrieve(ctx) + } + + // Session policy intersects with the base role's permissions; even + // if the role grants accesspoint/*, this session can only touch the + // caller's AP and prefix. The prefix is the requesting-org UUID + // straight from ctx — same source as the session name — so a + // tampered AccessPointARN in the secret blob can't widen the prefix + // scope to escape into another tenant's namespace. + sessionPolicy := buildSessionPolicy(p.creds.AccessPointARN, info.OrgID) + + out, err := p.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: aws.String(p.creds.BaseRoleARN), + RoleSessionName: aws.String(roleSessionName(info.OrgID)), + Policy: aws.String(sessionPolicy), + DurationSeconds: aws.Int32(int32(SessionDuration.Seconds())), + }) + if err != nil { + return aws.Credentials{}, fmt.Errorf("sts:AssumeRole for org %s: %w", info.OrgID, err) + } + if out.Credentials == nil { + return aws.Credentials{}, errors.New("sts:AssumeRole returned no credentials") + } + + return aws.Credentials{ + AccessKeyID: aws.ToString(out.Credentials.AccessKeyId), + SecretAccessKey: aws.ToString(out.Credentials.SecretAccessKey), + SessionToken: aws.ToString(out.Credentials.SessionToken), + Source: "s3accesspoint", + CanExpire: true, + Expires: aws.ToTime(out.Credentials.Expiration), + }, nil +} + +// roleSessionName binds the AssumeRole session to the requesting org. +// AWS limits session names to 64 chars and a restricted character set; a +// "cas-" string is well within that. +func roleSessionName(orgUUID string) string { + return "cas-" + orgUUID +} + +// buildSessionPolicy returns an IAM policy document that allows only the +// operations the backend actually performs, and only against this +// tenant's AP + key prefix. The Resource ARNs use the AP form +// "${apARN}/object/${keyPrefix}/*". +func buildSessionPolicy(apARN, keyPrefix string) string { + // Minimal, hand-written JSON — keeping it small reduces request + // payload (STS limits session policies to 2048 chars by default). + return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:GetObjectAttributes"],"Resource":"%s/object/%s/*"}]}`, + apARN, keyPrefix) +} + +// hexSha256ToBinaryB64 decodes the hex sha and re-encodes as base64. S3 +// returns the recorded checksum in base64 form; comparing it to a hex +// digest needs this conversion. +func hexSha256ToBinaryB64(hexString string) string { + decoded, err := hex.DecodeString(hexString) + if err != nil { + return "" + } + return base64.StdEncoding.EncodeToString(decoded) +} + +// fakeWriterAt wraps an io.Writer so the SDK's WriterAt-shaped +// downloader can be driven by a regular writer. Safe only when +// concurrency is forced to 1. +type fakeWriterAt struct { + w io.Writer +} + +func (fw fakeWriterAt) WriteAt(p []byte, _ int64) (int, error) { + return fw.w.Write(p) +} diff --git a/pkg/blobmanager/s3accesspoint/backend_test.go b/pkg/blobmanager/s3accesspoint/backend_test.go new file mode 100644 index 000000000..587101373 --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/backend_test.go @@ -0,0 +1,210 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3accesspoint + +import ( + "bytes" + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + pb "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1" + robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" + jwtmiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBackend_FailClosedWithoutRequestingOrg(t *testing.T) { + b := newTestBackend(t) + ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + + t.Run("upload", func(t *testing.T) { + err := b.Upload(ctx, bytes.NewReader([]byte("data")), + &pb.CASResource{Digest: "deadbeef", FileName: "x.txt"}) + assertFailedClosed(t, err) + }) + + t.Run("download", func(t *testing.T) { + // Download calls Exists -> Describe -> HeadObject, which goes + // through the credentials provider and trips the fail-closed + // path before any AWS call is made. + err := b.Download(ctx, &bytes.Buffer{}, "deadbeef") + assertFailedClosed(t, err) + }) + + t.Run("describe", func(t *testing.T) { + _, err := b.Describe(ctx, "deadbeef") + assertFailedClosed(t, err) + }) + + t.Run("check-write", func(t *testing.T) { + // CheckWritePermissions has its own pre-flight assertion that + // short-circuits without consulting the credentials provider at + // all, which is both faster and gives a cleaner error message + // to operators staring at config. + err := b.CheckWritePermissions(ctx) + require.ErrorIs(t, err, ErrMissingRequestingOrg) + }) +} + +// TestBackend_KeyDerivedFromRequestingOrg verifies the bucket-layer +// isolation property: every object the backend reads or writes is +// addressed under a prefix derived from the requesting org in ctx. +// One Backend invoked with two different ctx-orgs must produce distinct +// keys for the same digest, and an empty ctx must error out. +func TestBackend_KeyDerivedFromRequestingOrg(t *testing.T) { + t.Parallel() + + b := &Backend{creds: &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-a", + }} + digest := "deadbeef" + + ctxA := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{OrgID: "org-A", StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + keyA, err := b.keyFor(ctxA, digest) + require.NoError(t, err) + ctxB := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{OrgID: "org-B", StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + keyB, err := b.keyFor(ctxB, digest) + require.NoError(t, err) + + assert.Equal(t, "org-A/sha256:deadbeef", keyA) + assert.Equal(t, "org-B/sha256:deadbeef", keyB) + assert.NotEqual(t, keyA, keyB, "same digest must produce distinct keys across tenants") + + ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + _, err = b.keyFor(ctx, digest) + require.ErrorIs(t, err, ErrMissingRequestingOrg) +} + +// TestSessionPolicy_ScopesToTenantPrefix locks down the session-policy +// generator: the IAM policy minted at AssumeRole time must reference +// both the AP ARN and the tenant key prefix, so a leaked token can't +// touch keys outside its tenant's namespace. +func TestSessionPolicy_ScopesToTenantPrefix(t *testing.T) { + t.Parallel() + + policy := buildSessionPolicy("arn:aws:s3:us-east-1:111:accesspoint/ap-a", "org/A") + + assert.Contains(t, policy, `"arn:aws:s3:us-east-1:111:accesspoint/ap-a/object/org/A/*"`, + "policy Resource must be the AP ARN + tenant prefix") + assert.NotContains(t, policy, `"*"`, + "session policy must not wildcard the Resource") + assert.Contains(t, policy, `"s3:GetObject"`) + assert.Contains(t, policy, `"s3:PutObject"`) +} + +// TestRoleSessionName_DerivedFromOrg pins the session-name shape that +// the AP resource policy condition depends on. Changing the format here +// without updating the AP-side IaC will lock every tenant out. +func TestRoleSessionName_DerivedFromOrg(t *testing.T) { + t.Parallel() + + assert.Equal(t, "cas-abc-123", roleSessionName("abc-123")) +} + +// TestSessionCredentialsProvider_DevModeShortCircuit verifies that the +// dev-mode bypass calls the ambient credentials provider instead of STS, +// and crucially that the missing-org fail-closed check still fires even +// in dev mode — so developers don't accidentally let an obvious bug +// through. +func TestSessionCredentialsProvider_DevModeShortCircuit(t *testing.T) { + t.Parallel() + + ambient := &countingCredsProvider{ + creds: aws.Credentials{AccessKeyID: "AKDEV", SecretAccessKey: "secret", Source: "test"}, + } + p := &sessionCredentialsProvider{ + ambientCreds: ambient, + useAmbientForRetrieve: true, + creds: &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:111:accesspoint/ap-a", + }, + // stsClient deliberately nil; if dev mode short-circuits properly + // it should never be touched. A non-nil pointer here would mask + // regressions. + } + + t.Run("returns ambient credentials when org is set", func(t *testing.T) { + ctx := jwtmiddleware.NewContext(context.Background(), &robotaccount.Claims{OrgID: "org-A", StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader}) + got, err := p.Retrieve(ctx) + require.NoError(t, err) + assert.Equal(t, "AKDEV", got.AccessKeyID) + assert.Equal(t, 1, ambient.calls) + }) + + t.Run("still fails closed without requesting org", func(t *testing.T) { + ambient.calls = 0 + _, err := p.Retrieve(jwtmiddleware.NewContext(context.Background(), + &robotaccount.Claims{StoredSecretID: "foo", BackendType: "BT", Role: robotaccount.Downloader})) + require.ErrorIs(t, err, ErrMissingRequestingOrg) + assert.Equal(t, 0, ambient.calls, "ambient provider must not be hit when org is missing") + }) +} + +// countingCredsProvider is the minimum aws.CredentialsProvider needed to +// observe whether the dev-mode short-circuit invoked it. Used in the +// dev-mode test above and nowhere else. +type countingCredsProvider struct { + creds aws.Credentials + calls int +} + +func (c *countingCredsProvider) Retrieve(_ context.Context) (aws.Credentials, error) { + c.calls++ + return c.creds, nil +} + +// --- helpers ----------------------------------------------------------- + +// newTestBackend constructs a fully wired *Backend that uses static dummy +// AWS credentials so LoadDefaultConfig doesn't reach out to IMDS/SSO. The +// resulting STS client would only be invoked if a test path slipped past +// the fail-closed guard — in which case the dummy creds would still +// trigger a fast, deterministic failure rather than a real AWS call. +func newTestBackend(t *testing.T) *Backend { + t.Helper() + return backendForCreds(t, &Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", + Region: "us-east-1", + BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", + }) +} + +func backendForCreds(t *testing.T, creds *Credentials) *Backend { + t.Helper() + t.Setenv("AWS_ACCESS_KEY_ID", "test") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_REGION", "us-east-1") + // EC2_METADATA_SERVICE_ENDPOINT to a bogus host stops the SDK from + // trying IMDS during config load when no static creds are picked + // up — defensive in case the env-var pickup order changes. + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + b, err := NewBackend(context.Background(), creds) + require.NoError(t, err) + return b +} + +// assertFailedClosed checks that an error originated from the +// missing-context guard, whether returned directly or wrapped by the +// AWS SDK credential-chain machinery. +func assertFailedClosed(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + require.Containsf(t, err.Error(), ErrMissingRequestingOrg.Error(), + "expected fail-closed missing-org error, got %q", err) +} diff --git a/pkg/blobmanager/s3accesspoint/provider.go b/pkg/blobmanager/s3accesspoint/provider.go new file mode 100644 index 000000000..6dab7d1d6 --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/provider.go @@ -0,0 +1,202 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package s3accesspoint implements a CAS backend that targets a single AWS +// S3 Access Point per tenant. Multiple tenants share one physical bucket; +// per-tenant isolation is provided by: +// +// 1. The Access Point's resource policy, which gates who can address the AP +// and may further restrict s3:prefix. +// 2. A per-request sts:AssumeRole that mints a scoped session whose +// RoleSessionName is derived from the authenticated requesting org. The AP's +// resource policy enforces a StringEquals on aws:userid so that a +// session minted for org A cannot read or write to org B's AP — even if +// org A's secret blob has been tampered with to point at org B's ARN. +// 3. A per-tenant key prefix derived from the requesting org UUID: every +// object is keyed as /sha256: and the AssumeRole +// session policy's Resource is scoped to ${apARN}/object//*. +// The prefix shares its source of truth with the session name, so a +// tampered secret cannot reroute a tenant's writes into a different +// namespace. +// +// The session name MUST come from the request context, not from the secret +// blob: a secrets-store compromise alone must not let an attacker reroute +// uploads to another tenant's AP. +package s3accesspoint + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "time" + + backend "github.com/chainloop-dev/chainloop/pkg/blobmanager" + "github.com/chainloop-dev/chainloop/pkg/credentials" +) + +// ProviderID is the stable identifier used by the CASBackend table's enum +// and by every other place that needs to disambiguate this provider from +// the regular s3 one. +const ProviderID = "AWS-S3-ACCESS-POINT" + +// SessionDuration is the STS token lifetime. STS allows up to 12h; 1h keeps +// blast radius of a leaked token small while still giving the credential +// cache useful reuse across consecutive uploads. +const SessionDuration = time.Hour + +// DevModeEnvVar when set to a truthy value, short-circuits sts:AssumeRole +// and routes S3 calls through whatever ambient AWS identity the SDK's +// default credential chain produced (env vars, ~/.aws/credentials, instance +// profile, IRSA, …). The fail-closed check on a missing requesting-org +// context is still enforced. +// +// DEV ONLY. This bypasses the per-tenant isolation guarantees that the +// AssumeRole + session-policy + AP-policy chain provides; objects +// addressed via this backend are limited only by whatever the developer's +// IAM identity allows. NEVER set this in a multi-tenant deployment. +const DevModeEnvVar = "CHAINLOOP_S3_ACCESS_POINT_DEV_MODE" + +// devModeEnabled reads DevModeEnvVar and returns true for the usual truthy +// spellings. Kept as a package-level function so tests can swap the env +// var with t.Setenv. +func devModeEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(DevModeEnvVar))) + switch v { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// Credentials is the per-tenant blob stashed in the secrets manager under +// CASBackend.SecretName. Despite the name it carries no access keys — only +// tenant-identifying coordinates used to construct a scoped S3 client. +// +// The per-tenant key prefix is intentionally NOT a field here: it's +// derived at request time from the authenticated requesting org carried +// in ctx via org claim. Both the bucket-layer key namespace and +// the AssumeRole session-name binding therefore come from the same +// untamperable source, so a secrets-store compromise that rewrites this +// blob still can't reroute a tenant's writes into another tenant's +// namespace. +type Credentials struct { + // AccessPointARN, e.g. + // arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org- + // The provider passes this string verbatim as the Bucket parameter on + // every S3 SDK call. + AccessPointARN string + // Region the AP lives in. + Region string + // BaseRoleARN is the IAM role assumed via STS to mint per-request, + // per-tenant scoped credentials. Stored per-tenant (not per-deployment) + // so a single chainloop install can serve tenants across multiple AWS + // accounts without a config change. Required unless DevModeEnvVar is + // set on the running binary. + BaseRoleARN string +} + +func (c *Credentials) Validate() error { + if c == nil { + return fmt.Errorf("%w: nil credentials", backend.ErrValidation) + } + if c.AccessPointARN == "" { + return fmt.Errorf("%w: missing access_point_arn", backend.ErrValidation) + } + if !strings.HasPrefix(c.AccessPointARN, "arn:aws:s3:") || !strings.Contains(c.AccessPointARN, ":accesspoint/") { + return fmt.Errorf("%w: access_point_arn %q is not an S3 access point ARN", backend.ErrValidation, c.AccessPointARN) + } + if c.Region == "" { + return fmt.Errorf("%w: missing region", backend.ErrValidation) + } + if !devModeEnabled() { + if c.BaseRoleARN == "" { + return fmt.Errorf("%w: missing base_role_arn", backend.ErrValidation) + } + if !strings.HasPrefix(c.BaseRoleARN, "arn:aws:iam::") { + return fmt.Errorf("%w: base_role_arn %q is not a valid IAM role ARN", backend.ErrValidation, c.BaseRoleARN) + } + } + return nil +} + +// BackendProvider implements backend.Provider for the access-point-backed +// managed CAS. Construction takes only the credentials reader; everything +// the provider needs at request time lives in the per-tenant secret blob. +type BackendProvider struct { + cReader credentials.Reader +} + +var _ backend.Provider = (*BackendProvider)(nil) + +// NewBackendProvider constructs the provider. A nil credentials reader is +// a programmer error and surfaces as a startup failure. +func NewBackendProvider(cReader credentials.Reader) *BackendProvider { + if devModeEnabled() { + log.Printf("WARNING: s3accesspoint provider running with %s=true; sts:AssumeRole is bypassed and per-tenant isolation is NOT enforced — DEV USE ONLY", DevModeEnvVar) + } + return &BackendProvider{cReader: cReader} +} + +func (p *BackendProvider) ID() string { + return ProviderID +} + +// FromCredentials reads the per-tenant Credentials blob from the secrets +// manager and constructs a *Backend bound to that tenant's AP. +// +// The returned UploaderDownloader is safe to reuse across requests; each +// request must enrich its context with org claim so the STS-minted +// session name matches the AP's resource-policy condition. +func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string) (backend.UploaderDownloader, error) { + creds := &Credentials{} + if err := p.cReader.ReadCredentials(ctx, secretName, creds); err != nil { + return nil, err + } + if err := creds.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials retrieved from storage: %w", err) + } + return NewBackend(ctx, creds) +} + +// ValidateAndExtractCredentials decodes credsJSON into a Credentials struct +// and optionally cross-checks it against the location passed by the caller. +// This is invoked when a managed row is being created or revalidated; the +// returned value is what gets persisted in the secrets manager by upstream +// callers. +// +// Unlike the regular s3 provider, this does NOT exercise live S3 +// permissions during validation: the credentials by themselves can't be +// tested without a request-context org UUID, so a +// proper end-to-end check belongs in the upload path. PerformValidation in +// the controlplane still calls this method for managed rows; it will +// succeed as long as the blob is well-formed. +func (p *BackendProvider) ValidateAndExtractCredentials(location string, credsJSON []byte) (any, error) { + var creds Credentials + if err := json.Unmarshal(credsJSON, &creds); err != nil { + return nil, fmt.Errorf("unmarshaling credentials: %w", err) + } + if err := creds.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials: %w", err) + } + if location != "" && location != creds.AccessPointARN { + return nil, fmt.Errorf("%w: location %q does not match access_point_arn %q", + backend.ErrValidation, location, creds.AccessPointARN) + } + return &creds, nil +} diff --git a/pkg/blobmanager/s3accesspoint/provider_test.go b/pkg/blobmanager/s3accesspoint/provider_test.go new file mode 100644 index 000000000..ccc1d9488 --- /dev/null +++ b/pkg/blobmanager/s3accesspoint/provider_test.go @@ -0,0 +1,154 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3accesspoint + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validCreds is reused across the unit tests as a known-good baseline. +// Each test case clones and mutates it so we can express what's missing +// rather than what's present. +func validCreds() Credentials { + return Credentials{ + AccessPointARN: "arn:aws:s3:us-east-1:123456789012:accesspoint/chainloop-org-abc", + Region: "us-east-1", + BaseRoleARN: "arn:aws:iam::123456789012:role/chainloop-cas-tenant", + } +} + +func TestCredentials_Validate(t *testing.T) { + tests := []struct { + name string + mutate func(*Credentials) + wantErr string + }{ + {"happy", func(*Credentials) {}, ""}, + { + name: "missing arn", + mutate: func(c *Credentials) { c.AccessPointARN = "" }, + wantErr: "missing access_point_arn", + }, + { + name: "not an AP arn", + mutate: func(c *Credentials) { c.AccessPointARN = "arn:aws:s3:::some-bucket" }, + wantErr: "not an S3 access point ARN", + }, + { + name: "missing region", + mutate: func(c *Credentials) { c.Region = "" }, + wantErr: "missing region", + }, + { + name: "missing base role arn", + mutate: func(c *Credentials) { c.BaseRoleARN = "" }, + wantErr: "missing base_role_arn", + }, + { + name: "malformed base role arn", + mutate: func(c *Credentials) { c.BaseRoleARN = "not-an-arn" }, + wantErr: "not a valid IAM role ARN", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := validCreds() + tc.mutate(&c) + err := c.Validate() + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + assert.ErrorContains(t, err, tc.wantErr) + }) + } +} + +// In dev mode the base role requirement is relaxed because nothing on the +// hot path will actually call sts:AssumeRole. AccessPointARN and Region +// remain mandatory — the SDK needs the latter to construct any S3 client +// at all. +func TestCredentials_Validate_DevModeRelaxesBaseRoleARN(t *testing.T) { + // Without dev mode: empty base role rejected. + c := validCreds() + c.BaseRoleARN = "" + require.ErrorContains(t, c.Validate(), "missing base_role_arn") + + // With dev mode: empty base role accepted. + t.Setenv(DevModeEnvVar, "true") + require.NoError(t, c.Validate()) + + // AccessPointARN is still mandatory in dev mode. + c2 := validCreds() + c2.AccessPointARN = "" + require.ErrorContains(t, c2.Validate(), "missing access_point_arn") +} + +func TestValidateAndExtractCredentials(t *testing.T) { + t.Parallel() + + good := validCreds() + goodJSON, _ := json.Marshal(good) + wrongLocation := good.AccessPointARN + "-tampered" + + tests := []struct { + name string + location string + body []byte + wantErr string + }{ + {"valid no location", "", goodJSON, ""}, + {"valid matching location", good.AccessPointARN, goodJSON, ""}, + {"location mismatch", wrongLocation, goodJSON, "does not match access_point_arn"}, + {"malformed JSON", "", []byte("{not json"), "unmarshaling"}, + {"missing field", "", []byte(`{"AccessPointARN":""}`), "missing access_point_arn"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &BackendProvider{cReader: stubReader{}} + out, err := p.ValidateAndExtractCredentials(tc.location, tc.body) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + assert.Nil(t, out) + return + } + require.NoError(t, err) + creds, ok := out.(*Credentials) + require.True(t, ok, "expected *Credentials, got %T", out) + assert.Equal(t, good.AccessPointARN, creds.AccessPointARN) + assert.Equal(t, good.Region, creds.Region) + assert.Equal(t, good.BaseRoleARN, creds.BaseRoleARN) + }) + } +} + +func TestNewBackendProvider(t *testing.T) { + t.Parallel() + + p := NewBackendProvider(stubReader{}) + assert.Equal(t, ProviderID, p.ID()) +} + +// stubReader is the minimal credentials.Reader implementation needed to +// exercise constructor wiring; the unit tests never invoke it. +type stubReader struct{} + +func (stubReader) ReadCredentials(_ context.Context, _ string, _ any) error { return nil }