diff --git a/app/artifact-cas/api/cas/v1/status_http.pb.go b/app/artifact-cas/api/cas/v1/status_http.pb.go index d01da6c96..8cdf8edb1 100644 --- a/app/artifact-cas/api/cas/v1/status_http.pb.go +++ b/app/artifact-cas/api/cas/v1/status_http.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-http. DO NOT EDIT. // versions: -// - protoc-gen-go-http v2.6.2 +// - protoc-gen-go-http v2.6.3 // - protoc (unknown) // source: cas/v1/status.proto diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go index 575657a44..441ee6a9e 100644 --- a/app/controlplane/cmd/main.go +++ b/app/controlplane/cmd/main.go @@ -28,6 +28,9 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/server" "github.com/chainloop-dev/chainloop/app/controlplane/plugins" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" + backends "github.com/chainloop-dev/chainloop/internal/blobmanager" + "github.com/chainloop-dev/chainloop/internal/blobmanager/oci" + "github.com/chainloop-dev/chainloop/internal/credentials" credsConfig "github.com/chainloop-dev/chainloop/internal/credentials/api/credentials/v1" "github.com/chainloop-dev/chainloop/internal/servicelogger" @@ -167,6 +170,15 @@ func maskArgs(keyvals []interface{}) { } } +func loadCASBackendProviders(creader credentials.Reader) backends.Providers { + // Initialize CAS backend providers + // For now we only have OCI as a backend provider + p := oci.NewBackendProvider(creader) + return backends.Providers{ + p.ID(): p, + } +} + func initSentry(c *conf.Bootstrap, logger log.Logger) (cleanupFunc func(), err error) { cleanupFunc = func() { sentry.Flush(2 * time.Second) diff --git a/app/controlplane/cmd/wire.go b/app/controlplane/cmd/wire.go index 9c9f3a633..114a9624a 100644 --- a/app/controlplane/cmd/wire.go +++ b/app/controlplane/cmd/wire.go @@ -28,8 +28,6 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/server" "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" - backend "github.com/chainloop-dev/chainloop/internal/blobmanager" - "github.com/chainloop-dev/chainloop/internal/blobmanager/oci" "github.com/chainloop-dev/chainloop/internal/credentials" "github.com/go-kratos/kratos/v2/log" "github.com/google/wire" @@ -42,10 +40,9 @@ func wireApp(*conf.Bootstrap, credentials.ReaderWriter, log.Logger, sdk.Availabl server.ProviderSet, data.ProviderSet, biz.ProviderSet, + loadCASBackendProviders, service.ProviderSet, - wire.Bind(new(backend.Provider), new(*oci.BackendProvider)), wire.Bind(new(biz.CASClient), new(*biz.CASClientUseCase)), - oci.NewBackendProvider, serviceOpts, wire.Value([]biz.CASClientOpts{}), wire.FieldsOf(new(*conf.Bootstrap), "Server", "Auth", "Data", "CasServer"), diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 7c5222a71..eb525270b 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -14,7 +14,6 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/server" "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" - "github.com/chainloop-dev/chainloop/internal/blobmanager/oci" "github.com/chainloop-dev/chainloop/internal/credentials" "github.com/go-kratos/kratos/v2/log" ) @@ -32,8 +31,8 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l membershipUseCase := biz.NewMembershipUseCase(membershipRepo, logger) organizationRepo := data.NewOrganizationRepo(dataData, logger) casBackendRepo := data.NewCASBackendRepo(dataData, logger) - backendProvider := oci.NewBackendProvider(readerWriter) - casBackendUseCase := biz.NewCASBackendUseCase(casBackendRepo, readerWriter, backendProvider, logger) + providers := loadCASBackendProviders(readerWriter) + casBackendUseCase := biz.NewCASBackendUseCase(casBackendRepo, readerWriter, providers, logger) integrationRepo := data.NewIntegrationRepo(dataData, logger) integrationAttachmentRepo := data.NewIntegrationAttachmentRepo(dataData, logger) workflowRepo := data.NewWorkflowRepo(dataData, logger) @@ -116,7 +115,7 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l ociRepositoryService := service.NewOCIRepositoryService(casBackendUseCase, v2...) integrationsService := service.NewIntegrationsService(integrationUseCase, workflowUseCase, availablePlugins, v2...) organizationService := service.NewOrganizationService(membershipUseCase, v2...) - casBackendService := service.NewCASBackendService(casBackendUseCase, v2...) + casBackendService := service.NewCASBackendService(casBackendUseCase, providers, v2...) opts := &server.Opts{ UserUseCase: userUseCase, RobotAccountUseCase: robotAccountUseCase, diff --git a/app/controlplane/internal/biz/casbackend.go b/app/controlplane/internal/biz/casbackend.go index ef47113e8..aa8b2f56e 100644 --- a/app/controlplane/internal/biz/casbackend.go +++ b/app/controlplane/internal/biz/casbackend.go @@ -18,15 +18,12 @@ package biz import ( "context" "encoding/json" - "errors" "fmt" "io" "time" backend "github.com/chainloop-dev/chainloop/internal/blobmanager" - "github.com/chainloop-dev/chainloop/internal/blobmanager/oci" "github.com/chainloop-dev/chainloop/internal/credentials" - "github.com/chainloop-dev/chainloop/internal/ociauth" "github.com/chainloop-dev/chainloop/internal/servicelogger" "github.com/go-kratos/kratos/v2/log" "github.com/google/uuid" @@ -47,7 +44,7 @@ type CASBackend struct { ID uuid.UUID Location, Description, SecretName string CreatedAt, ValidatedAt *time.Time - OrganizationID string + OrganizationID uuid.UUID ValidationStatus CASBackendValidationStatus // OCI, S3, ... Provider CASBackendProvider @@ -56,6 +53,7 @@ type CASBackend struct { } type CASBackendOpts struct { + OrgID uuid.UUID Location, SecretName, Description string Provider CASBackendProvider Default bool @@ -63,7 +61,6 @@ type CASBackendOpts struct { type CASBackendCreateOpts struct { *CASBackendOpts - OrgID uuid.UUID } type CASBackendUpdateOpts struct { @@ -85,23 +82,23 @@ type CASBackendRepo interface { type CASBackendReader interface { FindDefaultBackend(ctx context.Context, orgID string) (*CASBackend, error) - FindByID(ctx context.Context, ID string) (*CASBackend, error) + FindByIDInOrg(ctx context.Context, OrgID, ID string) (*CASBackend, error) PerformValidation(ctx context.Context, ID string) error } type CASBackendUseCase struct { - repo CASBackendRepo - logger *log.Helper - credsRW credentials.ReaderWriter - ociBackendProvider backend.Provider + repo CASBackendRepo + logger *log.Helper + credsRW credentials.ReaderWriter + providers backend.Providers } -func NewCASBackendUseCase(repo CASBackendRepo, credsRW credentials.ReaderWriter, p backend.Provider, l log.Logger) *CASBackendUseCase { +func NewCASBackendUseCase(repo CASBackendRepo, credsRW credentials.ReaderWriter, providers backend.Providers, l log.Logger) *CASBackendUseCase { if l == nil { l = log.NewStdLogger(io.Discard) } - return &CASBackendUseCase{repo, servicelogger.ScopedHelper(l, "biz/CASBackend"), credsRW, p} + return &CASBackendUseCase{repo, servicelogger.ScopedHelper(l, "biz/CASBackend"), credsRW, providers} } func (uc *CASBackendUseCase) List(ctx context.Context, orgID string) ([]*CASBackend, error) { @@ -119,16 +116,7 @@ func (uc *CASBackendUseCase) FindDefaultBackend(ctx context.Context, orgID strin return nil, NewErrInvalidUUID(err) } - return uc.repo.FindDefaultBackend(ctx, orgUUID) -} - -func (uc *CASBackendUseCase) FindByID(ctx context.Context, id string) (*CASBackend, error) { - backendUUID, err := uuid.Parse(id) - if err != nil { - return nil, NewErrInvalidUUID(err) - } - - backend, err := uc.repo.FindByID(ctx, backendUUID) + backend, err := uc.repo.FindDefaultBackend(ctx, orgUUID) if err != nil { return nil, err } else if backend == nil { @@ -138,75 +126,49 @@ func (uc *CASBackendUseCase) FindByID(ctx context.Context, id string) (*CASBacke return backend, nil } -func validateAndExtractCredentials(provider CASBackendProvider, location string, credsJSON []byte) (any, error) { - // TODO: (miguel) this logic (marshalling from struct + validation) will be moved to the actual backend implementation - // This endpoint will support other backends in the future - if provider != CASBackendOCI { - return nil, NewErrValidation(errors.New("unsupported provider")) - } - - var ociConfig = struct { - Password string `json:"password"` - Username string `json:"username"` - }{} - - if err := json.Unmarshal(credsJSON, &ociConfig); err != nil { - return nil, NewErrValidation(err) - } - - // Create and validate credentials - k, err := ociauth.NewCredentials(location, ociConfig.Username, ociConfig.Password) +func (uc *CASBackendUseCase) FindByIDInOrg(ctx context.Context, orgID, id string) (*CASBackend, error) { + orgUUID, err := uuid.Parse(orgID) if err != nil { - return nil, NewErrValidation(err) + return nil, NewErrInvalidUUID(err) } - // Check credentials - b, err := oci.NewBackend(location, &oci.RegistryOptions{Keychain: k}) + backendUUID, err := uuid.Parse(id) if err != nil { - return nil, fmt.Errorf("checking credentials: %w", err) - } - - if err := b.CheckWritePermissions(context.TODO()); err != nil { - return nil, NewErrValidation(fmt.Errorf("wrong credentials: %w", err)) + return nil, NewErrInvalidUUID(err) } - // Validate and store the secret in the external secrets manager - creds := &credentials.OCIKeypair{Repo: location, Username: ociConfig.Username, Password: ociConfig.Password} - if err := creds.Validate(); err != nil { - return nil, NewErrValidation(err) + backend, err := uc.repo.FindByIDInOrg(ctx, orgUUID, backendUUID) + if err != nil { + return nil, err + } else if backend == nil { + return nil, NewErrNotFound("CAS Backend") } - return creds, nil + return backend, nil } -func (uc *CASBackendUseCase) Create(ctx context.Context, orgID, location, description string, provider CASBackendProvider, credsJSON []byte, defaultB bool) (*CASBackend, error) { +func (uc *CASBackendUseCase) Create(ctx context.Context, orgID, location, description string, provider CASBackendProvider, creds any, defaultB bool) (*CASBackend, error) { orgUUID, err := uuid.Parse(orgID) if err != nil { return nil, NewErrInvalidUUID(err) } - // Validate and store the secret in the external secrets manager - creds, err := validateAndExtractCredentials(provider, location, credsJSON) - if err != nil { - return nil, NewErrValidation(err) - } - secretName, err := uc.credsRW.SaveCredentials(ctx, orgID, creds) if err != nil { return nil, fmt.Errorf("storing the credentials: %w", err) } return uc.repo.Create(ctx, &CASBackendCreateOpts{ - OrgID: orgUUID, CASBackendOpts: &CASBackendOpts{ Location: location, SecretName: secretName, Provider: provider, Default: defaultB, Description: description, + OrgID: orgUUID, }, }) } // Update will update credentials, description or default status -func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id, description string, credsJSON []byte, defaultB bool) (*CASBackend, error) { +func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id, description string, creds any, defaultB bool) (*CASBackend, error) { orgUUID, err := uuid.Parse(orgID) if err != nil { return nil, NewErrInvalidUUID(err) @@ -226,13 +188,7 @@ func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id, description var secretName string // We want to rotate credentials - if credsJSON != nil { - // Validate and store the secret in the external secrets manager - creds, err := validateAndExtractCredentials(repo.Provider, repo.Location, credsJSON) - if err != nil { - return nil, NewErrValidation(err) - } - + if creds != nil { secretName, err = uc.credsRW.SaveCredentials(ctx, orgID, creds) if err != nil { return nil, fmt.Errorf("storing the credentials: %w", err) @@ -242,13 +198,11 @@ func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id, description return uc.repo.Update(ctx, &CASBackendUpdateOpts{ ID: uuid, CASBackendOpts: &CASBackendOpts{ - SecretName: secretName, Default: defaultB, Description: description, + SecretName: secretName, Default: defaultB, Description: description, OrgID: orgUUID, }, }) } -// TODO(miguel): we need to think about the update mechanism and add some guardrails -// for example, we might only allow updating credentials but not the repository itself or the provider // Deprecated: use Create and update methods separately instead func (uc *CASBackendUseCase) CreateOrUpdate(ctx context.Context, orgID, name, username, password string, provider CASBackendProvider, defaultB bool) (*CASBackend, error) { orgUUID, err := uuid.Parse(orgID) @@ -284,10 +238,10 @@ func (uc *CASBackendUseCase) CreateOrUpdate(ctx context.Context, orgID, name, us } return uc.repo.Create(ctx, &CASBackendCreateOpts{ - OrgID: orgUUID, CASBackendOpts: &CASBackendOpts{ Location: name, SecretName: secretName, Provider: provider, Default: defaultB, + OrgID: orgUUID, }, }) } @@ -353,8 +307,6 @@ func (CASBackendValidationStatus) Values() (kinds []string) { } // Validate that the repository is valid and reachable -// TODO: run this process periodically in the background -// TODO: we need to support other kinds of repositories this is for the OCI type func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) (err error) { validationStatus := CASBackendValidationFailed @@ -370,10 +322,9 @@ func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) ( return NewErrNotFound("CAS Backend") } - // Currently this code is just for OCI repositories - if backend.Provider != CASBackendOCI { - uc.logger.Warnw("msg", "validation not supported for this provider", "ID", id, "provider", backend.Provider) - return nil + provider, ok := uc.providers[string(backend.Provider)] + if !ok { + return fmt.Errorf("CAS backend provider not found: %s", backend.Provider) } defer func() { @@ -390,14 +341,21 @@ func (uc *CASBackendUseCase) PerformValidation(ctx context.Context, id string) ( }() // 1 - Retrieve the credentials from the external secrets manager - b, err := uc.ociBackendProvider.FromCredentials(ctx, backend.SecretName) - if err != nil { + var creds any + if err := uc.credsRW.ReadCredentials(ctx, backend.SecretName, &creds); err != nil { uc.logger.Infow("msg", "credentials not found or invalid", "ID", id) return nil } - // 2 - Perform a write validation - if err = b.CheckWritePermissions(context.TODO()); err != nil { + credsJSON, err := json.Marshal(creds) + if err != nil { + uc.logger.Infow("msg", "credentials invalid", "ID", id) + return nil + } + + // 2 - run validation + _, err = provider.ValidateAndExtractCredentials(backend.Location, credsJSON) + if err != nil { uc.logger.Infow("msg", "permissions validation failed", "ID", id) return nil } diff --git a/app/controlplane/internal/biz/casbackend_integration_test.go b/app/controlplane/internal/biz/casbackend_integration_test.go index 45fa4d622..6e200cb78 100644 --- a/app/controlplane/internal/biz/casbackend_integration_test.go +++ b/app/controlplane/internal/biz/casbackend_integration_test.go @@ -22,12 +22,166 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz" "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz/testhelpers" creds "github.com/chainloop-dev/chainloop/internal/credentials/mocks" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) +const location = "my-location" +const description = "my-description" +const backendType = biz.CASBackendOCI + +func (s *CASBackendIntegrationTestSuite) TestCreate() { + assert := assert.New(s.T()) + orgID := s.orgOne.ID + + s.Run("non-existing org", func() { + _, err := s.CASBackend.Create( + context.TODO(), uuid.NewString(), location, description, backendType, nil, true, + ) + assert.Error(err) + }) + + s.Run("create default", func() { + b, err := s.CASBackend.Create(context.TODO(), orgID, location, description, backendType, nil, true) + assert.NoError(err) + + if diff := cmp.Diff(&biz.CASBackend{ + Location: location, + Description: description, + SecretName: "stored-OCI-secret", + Provider: backendType, + ValidationStatus: "OK", + Default: true, + }, b, + cmpopts.IgnoreFields(biz.CASBackend{}, "CreatedAt", "ID", "ValidatedAt", "OrganizationID"), + ); diff != "" { + assert.Failf("mismatch (-want +got):\n%s", diff) + } + }) +} +func (s *CASBackendIntegrationTestSuite) TestCreateOverride() { + assert := assert.New(s.T()) + + // When a new default backend is created, the previous default should be overridden + b1, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, location, description, backendType, nil, true) + assert.NoError(err) + assert.True(b1.Default) + b2, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, "another-location", description, backendType, nil, true) + assert.NoError(err) + assert.True(b2.Default) + + // Check that the first one is no longer default + b1, err = s.TestingUseCases.CASBackend.FindByIDInOrg(context.TODO(), s.orgNoBackend.ID, b1.ID.String()) + assert.NoError(err) + assert.False(b1.Default) +} + +func (s *CASBackendIntegrationTestSuite) TestUpdate() { + assert := assert.New(s.T()) + + s.Run("overrides previous backends", func() { + // When a new default backend is set, the previous default should be overridden + defaultB, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, location, description, backendType, nil, true) + assert.NoError(err) + assert.True(defaultB.Default) + nonDefaultB, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, "another-location", description, backendType, nil, false) + assert.NoError(err) + assert.False(nonDefaultB.Default) + + // Update the non-default to be default + nonDefaultB, err = s.TestingUseCases.CASBackend.Update(context.TODO(), s.orgNoBackend.ID, nonDefaultB.ID.String(), "", nil, true) + assert.NoError(err) + assert.True(nonDefaultB.Default) + + // Check that the first one is no longer default + defaultB, err = s.TestingUseCases.CASBackend.FindByIDInOrg(context.TODO(), s.orgNoBackend.ID, defaultB.ID.String()) + assert.NoError(err) + assert.False(defaultB.Default) + }) + + s.Run("can update only the description", func() { + // When a new default backend is set, the previous default should be overridden + defaultB, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, location, description, backendType, nil, true) + assert.NoError(err) + assert.Equal(description, defaultB.Description) + + // Update the description + defaultB, err = s.TestingUseCases.CASBackend.Update(context.TODO(), s.orgNoBackend.ID, defaultB.ID.String(), "updated desc", nil, true) + assert.NoError(err) + assert.Equal("updated desc", defaultB.Description) + assert.True(defaultB.Default) + }) + + s.Run("can update only the status", func() { + // When a new default backend is set, the previous default should be overridden + defaultB, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, location, description, backendType, nil, true) + assert.NoError(err) + assert.Equal(description, defaultB.Description) + + // update the status + defaultB, err = s.TestingUseCases.CASBackend.Update(context.TODO(), s.orgNoBackend.ID, defaultB.ID.String(), description, nil, false) + assert.NoError(err) + assert.Equal(description, defaultB.Description) + assert.False(defaultB.Default) + }) + + s.Run("can rotate credentials", func() { + // When a new default backend is set, the previous default should be overridden + defaultB, err := s.CASBackend.Create(context.TODO(), s.orgNoBackend.ID, location, description, backendType, nil, true) + assert.NoError(err) + assert.Equal(description, defaultB.Description) + + // update the secret + creds := struct{}{} + ctx := context.TODO() + s.credsWriter.Mock = mock.Mock{} + s.credsWriter.On("SaveCredentials", ctx, s.orgNoBackend.ID, creds).Return("new-secret", nil) + defaultB, err = s.TestingUseCases.CASBackend.Update(ctx, s.orgNoBackend.ID, defaultB.ID.String(), description, creds, true) + assert.NoError(err) + assert.Equal(description, defaultB.Description) + assert.Equal("new-secret", defaultB.SecretName) + assert.True(defaultB.Default) + }) +} + +func (s *CASBackendIntegrationTestSuite) TestSoftDelete() { + assert := assert.New(s.T()) + ctx := context.TODO() + + backends, err := s.TestingUseCases.CASBackend.List(ctx, s.orgTwo.ID) + assert.NoError(err) + // There are two backends + require.Len(s.T(), backends, 2) + + // We are going to delete the default one + toDelete := backends[1].ID + assert.True(backends[1].Default) + + // Delete it + err = s.TestingUseCases.CASBackend.SoftDelete(ctx, s.orgTwo.ID, toDelete.String()) + assert.NoError(err) + + // there is one left + backends, err = s.TestingUseCases.CASBackend.List(ctx, s.orgTwo.ID) + assert.NoError(err) + // There is one backend + require.Len(s.T(), backends, 1) + assert.Equal(backends[0].ID, s.casBackend3.ID) + + // the deleted one can not be found by ID either + _, err = s.TestingUseCases.CASBackend.FindByIDInOrg(ctx, s.orgTwo.ID, toDelete.String()) + assert.ErrorAs(err, &biz.ErrNotFound{}) + + // the deleted one can not be found by as default + _, err = s.TestingUseCases.CASBackend.FindDefaultBackend(ctx, s.orgTwo.ID) + assert.ErrorAs(err, &biz.ErrNotFound{}) +} + func (s *CASBackendIntegrationTestSuite) TestList() { testCases := []struct { name string @@ -52,9 +206,10 @@ func (s *CASBackendIntegrationTestSuite) TestList() { }, }, { - name: "backend 2 in org", + name: "2 backends in org", orgID: s.orgTwo.ID, expectedResult: []*biz.CASBackend{ + s.casBackend3, s.casBackend2, }, }, @@ -76,23 +231,25 @@ func (s *CASBackendIntegrationTestSuite) SetupTest() { assert := assert.New(s.T()) ctx := context.Background() // OCI repository credentials - credsWriter := creds.NewReaderWriter(s.T()) - credsWriter.On( + s.credsWriter = creds.NewReaderWriter(s.T()) + s.credsWriter.On( "SaveCredentials", ctx, mock.Anything, mock.Anything, ).Return("stored-OCI-secret", nil) - s.TestingUseCases = testhelpers.NewTestingUseCases(s.T(), testhelpers.WithCredsReaderWriter(credsWriter)) + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T(), testhelpers.WithCredsReaderWriter(s.credsWriter)) - s.orgOne, err = s.Organization.Create(ctx, "testing org 1") + s.orgOne, err = s.Organization.Create(ctx, "testing org 1 with one backend") assert.NoError(err) - s.orgTwo, err = s.Organization.Create(ctx, "testing org 2") + s.orgTwo, err = s.Organization.Create(ctx, "testing org 2 with 2 backends") assert.NoError(err) - s.orgNoBackend, err = s.Organization.Create(ctx, "testing org 3") + s.orgNoBackend, err = s.Organization.Create(ctx, "testing org 3, no backends") assert.NoError(err) - s.casBackend1, err = s.CASBackend.CreateOrUpdate(ctx, s.orgOne.ID, "backend 1", "username", "pass", biz.CASBackendOCI, true) + s.casBackend1, err = s.CASBackend.Create(ctx, s.orgOne.ID, "my-location", "backend 1 description", biz.CASBackendOCI, nil, true) + assert.NoError(err) + s.casBackend2, err = s.CASBackend.Create(ctx, s.orgTwo.ID, "my-location 2", "backend 2 description", biz.CASBackendOCI, nil, true) assert.NoError(err) - s.casBackend2, err = s.CASBackend.CreateOrUpdate(ctx, s.orgTwo.ID, "backend 2", "username", "pass", biz.CASBackendOCI, true) + s.casBackend3, err = s.CASBackend.Create(ctx, s.orgTwo.ID, "my-location 3", "backend 3 description", biz.CASBackendOCI, nil, false) assert.NoError(err) } @@ -102,8 +259,9 @@ func TestCASBackendUseCase(t *testing.T) { type CASBackendIntegrationTestSuite struct { testhelpers.UseCasesEachTestSuite - orgTwo, orgOne, orgNoBackend *biz.Organization - casBackend1, casBackend2 *biz.CASBackend + orgTwo, orgOne, orgNoBackend *biz.Organization + casBackend1, casBackend2, casBackend3 *biz.CASBackend + credsWriter *creds.ReaderWriter } func TestIntegrationCASBackend(t *testing.T) { diff --git a/app/controlplane/internal/biz/casbackend_test.go b/app/controlplane/internal/biz/casbackend_test.go index ac4032a4b..d3f83e4fa 100644 --- a/app/controlplane/internal/biz/casbackend_test.go +++ b/app/controlplane/internal/biz/casbackend_test.go @@ -22,6 +22,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz" bizMocks "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz/mocks" + backends "github.com/chainloop-dev/chainloop/internal/blobmanager" blobM "github.com/chainloop-dev/chainloop/internal/blobmanager/mocks" "github.com/chainloop-dev/chainloop/internal/credentials" credentialsM "github.com/chainloop-dev/chainloop/internal/credentials/mocks" @@ -55,7 +56,7 @@ func (s *casBackendTestSuite) TestFindDefaultBackendNotFound() { s.repo.On("FindDefaultBackend", ctx, s.validUUID).Return(nil, nil) repo, err := s.useCase.FindDefaultBackend(ctx, s.validUUID.String()) - assert.NoError(err) + assert.ErrorAs(err, &biz.ErrNotFound{}) assert.Nil(repo) } @@ -109,8 +110,8 @@ func (s *casBackendTestSuite) TestSaveDefaultBackendOk() { newRepo := &biz.CASBackend{} s.repo.On("Create", ctx, &biz.CASBackendCreateOpts{ - OrgID: s.validUUID, CASBackendOpts: &biz.CASBackendOpts{ + OrgID: s.validUUID, Location: repo, SecretName: "secret-key", Default: true, Provider: biz.CASBackendOCI, }, }).Return(newRepo, nil) @@ -140,19 +141,18 @@ func (s *casBackendTestSuite) TestPerformValidation() { t.Run("proper provider credentials missing, set validation status => invalid", func(t *testing.T) { s.repo.On("FindByID", mock.Anything, s.validUUID).Return(validRepo, nil) s.repo.On("UpdateValidationStatus", mock.Anything, s.validUUID, biz.CASBackendValidationFailed).Return(nil) - s.backendProvider.On("FromCredentials", mock.Anything, mock.Anything).Return(nil, credentials.ErrNotFound) + + s.credsRW.On("ReadCredentials", mock.Anything, mock.Anything, mock.Anything).Return(credentials.ErrNotFound) err := s.useCase.PerformValidation(context.Background(), s.validUUID.String()) assert.NoError(err) s.resetMock() }) t.Run("invalid credentials, set validation status => invalid", func(t *testing.T) { - b := blobM.NewUploaderDownloader(t) - s.repo.On("FindByID", mock.Anything, s.validUUID).Return(validRepo, nil) s.repo.On("UpdateValidationStatus", mock.Anything, s.validUUID, biz.CASBackendValidationFailed).Return(nil) - s.backendProvider.On("FromCredentials", mock.Anything, mock.Anything).Return(b, nil) - b.On("CheckWritePermissions", mock.Anything).Return(errors.New("invalid credentials")) + s.credsRW.On("ReadCredentials", mock.Anything, mock.Anything, mock.Anything).Return(nil) + s.backendProvider.On("ValidateAndExtractCredentials", validRepo.Location, mock.Anything).Return(nil, errors.New("invalid credentials")) err := s.useCase.PerformValidation(context.Background(), s.validUUID.String()) assert.NoError(err) @@ -160,12 +160,10 @@ func (s *casBackendTestSuite) TestPerformValidation() { }) t.Run("valid credentials, set validation status => ok", func(t *testing.T) { - b := blobM.NewUploaderDownloader(t) - s.repo.On("FindByID", mock.Anything, s.validUUID).Return(validRepo, nil) s.repo.On("UpdateValidationStatus", mock.Anything, s.validUUID, biz.CASBackendValidationOK).Return(nil) - s.backendProvider.On("FromCredentials", mock.Anything, mock.Anything).Return(b, nil) - b.On("CheckWritePermissions", mock.Anything).Return(nil) + s.credsRW.On("ReadCredentials", mock.Anything, mock.Anything, mock.Anything).Return(nil) + s.backendProvider.On("ValidateAndExtractCredentials", validRepo.Location, mock.Anything).Return(nil, nil) err := s.useCase.PerformValidation(context.Background(), s.validUUID.String()) assert.NoError(err) @@ -190,5 +188,9 @@ func (s *casBackendTestSuite) SetupTest() { s.repo = bizMocks.NewCASBackendRepo(s.T()) s.credsRW = credentialsM.NewReaderWriter(s.T()) s.backendProvider = blobM.NewProvider(s.T()) - s.useCase = biz.NewCASBackendUseCase(s.repo, s.credsRW, s.backendProvider, nil) + s.useCase = biz.NewCASBackendUseCase(s.repo, s.credsRW, + backends.Providers{ + "OCI": s.backendProvider, + }, nil, + ) } diff --git a/app/controlplane/internal/biz/mocks/CASBackendReader.go b/app/controlplane/internal/biz/mocks/CASBackendReader.go index 8ca1a72f4..50f28ddfa 100644 --- a/app/controlplane/internal/biz/mocks/CASBackendReader.go +++ b/app/controlplane/internal/biz/mocks/CASBackendReader.go @@ -15,25 +15,25 @@ type CASBackendReader struct { mock.Mock } -// FindByID provides a mock function with given fields: ctx, ID -func (_m *CASBackendReader) FindByID(ctx context.Context, ID string) (*biz.CASBackend, error) { - ret := _m.Called(ctx, ID) +// FindByIDInOrg provides a mock function with given fields: ctx, OrgID, ID +func (_m *CASBackendReader) FindByIDInOrg(ctx context.Context, OrgID string, ID string) (*biz.CASBackend, error) { + ret := _m.Called(ctx, OrgID, ID) var r0 *biz.CASBackend var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*biz.CASBackend, error)); ok { - return rf(ctx, ID) + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*biz.CASBackend, error)); ok { + return rf(ctx, OrgID, ID) } - if rf, ok := ret.Get(0).(func(context.Context, string) *biz.CASBackend); ok { - r0 = rf(ctx, ID) + if rf, ok := ret.Get(0).(func(context.Context, string, string) *biz.CASBackend); ok { + r0 = rf(ctx, OrgID, ID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*biz.CASBackend) } } - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, ID) + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, OrgID, ID) } else { r1 = ret.Error(1) } diff --git a/app/controlplane/internal/biz/organization.go b/app/controlplane/internal/biz/organization.go index 7523b1eb8..eb6056336 100644 --- a/app/controlplane/internal/biz/organization.go +++ b/app/controlplane/internal/biz/organization.go @@ -104,7 +104,7 @@ func (uc *OrganizationUseCase) Delete(ctx context.Context, id string) error { // Delete the associated repository // Currently there is only one repository per organization ociRepository, err := uc.casBackendUseCase.FindDefaultBackend(ctx, org.ID) - if err != nil { + if err != nil && !IsNotFound(err) { return err } diff --git a/app/controlplane/internal/biz/organization_integration_test.go b/app/controlplane/internal/biz/organization_integration_test.go index 387520fd9..577c6f351 100644 --- a/app/controlplane/internal/biz/organization_integration_test.go +++ b/app/controlplane/internal/biz/organization_integration_test.go @@ -67,8 +67,8 @@ func (s *OrgIntegrationTestSuite) TestDeleteOrg() { assert.Empty(integrations) ociRepo, err := s.CASBackend.FindDefaultBackend(ctx, s.org.ID) - assert.NoError(err) assert.Nil(ociRepo) + assert.ErrorAs(err, &biz.ErrNotFound{}) workflows, err := s.Workflow.List(ctx, s.org.ID) assert.NoError(err) diff --git a/app/controlplane/internal/biz/testhelpers/database.go b/app/controlplane/internal/biz/testhelpers/database.go index 4ef117dae..401ca5a0f 100644 --- a/app/controlplane/internal/biz/testhelpers/database.go +++ b/app/controlplane/internal/biz/testhelpers/database.go @@ -30,6 +30,8 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf" "github.com/chainloop-dev/chainloop/app/controlplane/internal/data" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" + backends "github.com/chainloop-dev/chainloop/internal/blobmanager" + backendsm "github.com/chainloop-dev/chainloop/internal/blobmanager/mocks" "github.com/chainloop-dev/chainloop/internal/credentials" creds "github.com/chainloop-dev/chainloop/internal/credentials/mocks" robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" @@ -66,6 +68,7 @@ type TestingUseCases struct { type newTestingOpts struct { credsReaderWriter credentials.ReaderWriter integrations sdk.AvailablePlugins + providers backends.Providers } type NewTestingUCOpt func(*newTestingOpts) @@ -88,7 +91,12 @@ func WithRegisteredIntegration(i sdk.FanOut) NewTestingUCOpt { func NewTestingUseCases(t *testing.T, opts ...NewTestingUCOpt) *TestingUseCases { // default args - newArgs := &newTestingOpts{credsReaderWriter: creds.NewReaderWriter(t), integrations: make(sdk.AvailablePlugins, 0)} + newArgs := &newTestingOpts{credsReaderWriter: creds.NewReaderWriter(t), + integrations: make(sdk.AvailablePlugins, 0), + providers: backends.Providers{ + "OCI": backendsm.NewProvider(t), + }, + } // Overrides for _, opt := range opts { @@ -100,7 +108,7 @@ func NewTestingUseCases(t *testing.T, opts ...NewTestingUCOpt) *TestingUseCases testData, _, err := WireTestData(db, t, log, newArgs.credsReaderWriter, &robotaccount.Builder{}, &conf.Auth{ GeneratedJwsHmacSecret: "test", CasRobotAccountPrivateKeyPath: "./testdata/test-key.ec.pem", - }, newArgs.integrations) + }, newArgs.integrations, newArgs.providers) assert.NoError(t, err) // Run DB migrations for testing diff --git a/app/controlplane/internal/biz/testhelpers/wire.go b/app/controlplane/internal/biz/testhelpers/wire.go index 5478c0a83..f4d919a02 100644 --- a/app/controlplane/internal/biz/testhelpers/wire.go +++ b/app/controlplane/internal/biz/testhelpers/wire.go @@ -25,8 +25,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf" "github.com/chainloop-dev/chainloop/app/controlplane/internal/data" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" - backend "github.com/chainloop-dev/chainloop/internal/blobmanager" - "github.com/chainloop-dev/chainloop/internal/blobmanager/oci" + backends "github.com/chainloop-dev/chainloop/internal/blobmanager" "github.com/chainloop-dev/chainloop/internal/credentials" robotaccount "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" "github.com/go-kratos/kratos/v2/log" @@ -35,14 +34,11 @@ import ( ) // wireTestData init testing data -func WireTestData(*TestDatabase, *testing.T, log.Logger, credentials.ReaderWriter, *robotaccount.Builder, *conf.Auth, sdk.AvailablePlugins) (*TestingUseCases, func(), error) { +func WireTestData(*TestDatabase, *testing.T, log.Logger, credentials.ReaderWriter, *robotaccount.Builder, *conf.Auth, sdk.AvailablePlugins, backends.Providers) (*TestingUseCases, func(), error) { panic( wire.Build( data.ProviderSet, biz.ProviderSet, - wire.Bind(new(backend.Provider), new(*oci.BackendProvider)), - wire.Bind(new(credentials.Reader), new(credentials.ReaderWriter)), - oci.NewBackendProvider, wire.Struct(new(TestingUseCases), "*"), newConfData, ), diff --git a/app/controlplane/internal/biz/testhelpers/wire_gen.go b/app/controlplane/internal/biz/testhelpers/wire_gen.go index 784bbab31..c621a9d42 100644 --- a/app/controlplane/internal/biz/testhelpers/wire_gen.go +++ b/app/controlplane/internal/biz/testhelpers/wire_gen.go @@ -11,7 +11,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf" "github.com/chainloop-dev/chainloop/app/controlplane/internal/data" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" - "github.com/chainloop-dev/chainloop/internal/blobmanager/oci" + "github.com/chainloop-dev/chainloop/internal/blobmanager" "github.com/chainloop-dev/chainloop/internal/credentials" "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" "github.com/go-kratos/kratos/v2/log" @@ -25,7 +25,7 @@ import ( // Injectors from wire.go: // wireTestData init testing data -func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, readerWriter credentials.ReaderWriter, builder *robotaccount.Builder, auth *conf.Auth, availablePlugins sdk.AvailablePlugins) (*TestingUseCases, func(), error) { +func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, readerWriter credentials.ReaderWriter, builder *robotaccount.Builder, auth *conf.Auth, availablePlugins sdk.AvailablePlugins, providers backend.Providers) (*TestingUseCases, func(), error) { confData := newConfData(testDatabase, t) dataData, cleanup, err := data.NewData(confData, logger) if err != nil { @@ -34,8 +34,7 @@ func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, r membershipRepo := data.NewMembershipRepo(dataData, logger) membershipUseCase := biz.NewMembershipUseCase(membershipRepo, logger) casBackendRepo := data.NewCASBackendRepo(dataData, logger) - backendProvider := oci.NewBackendProvider(readerWriter) - casBackendUseCase := biz.NewCASBackendUseCase(casBackendRepo, readerWriter, backendProvider, logger) + casBackendUseCase := biz.NewCASBackendUseCase(casBackendRepo, readerWriter, providers, logger) integrationRepo := data.NewIntegrationRepo(dataData, logger) integrationAttachmentRepo := data.NewIntegrationAttachmentRepo(dataData, logger) workflowRepo := data.NewWorkflowRepo(dataData, logger) diff --git a/app/controlplane/internal/data/casbackend.go b/app/controlplane/internal/data/casbackend.go index 110fd5da1..f4fdde41d 100644 --- a/app/controlplane/internal/data/casbackend.go +++ b/app/controlplane/internal/data/casbackend.go @@ -58,7 +58,7 @@ func (r *CASBackendRepo) List(ctx context.Context, orgID uuid.UUID) ([]*biz.CASB } func (r *CASBackendRepo) FindDefaultBackend(ctx context.Context, orgID uuid.UUID) (*biz.CASBackend, error) { - backend, err := orgScopedQuery(r.data.db, orgID).QueryCasBackends(). + backend, err := orgScopedQuery(r.data.db, orgID).QueryCasBackends().WithOrganization(). Where(casbackend.Default(true), casbackend.DeletedAtIsNil()). Only(ctx) if err != nil && !ent.IsNotFound(err) { @@ -110,7 +110,24 @@ func (r *CASBackendRepo) Create(ctx context.Context, opts *biz.CASBackendCreateO } func (r *CASBackendRepo) Update(ctx context.Context, opts *biz.CASBackendUpdateOpts) (*biz.CASBackend, error) { - updateChain := r.data.db.CASBackend.UpdateOneID(opts.ID).SetDefault(opts.Default) + tx, err := r.data.db.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create transaction: %w", err) + } + + // 1 - unset default backend for all the other backends in the org + if opts.Default { + if err := tx.CASBackend.Update(). + Where(casbackend.HasOrganizationWith(organization.ID(opts.OrgID))). + Where(casbackend.Default(true)). + SetDefault(false). + Exec(ctx); err != nil { + return nil, fmt.Errorf("failed to clear previous default backend: %w", err) + } + } + + // 2 - Chain the list of updates + updateChain := tx.CASBackend.UpdateOneID(opts.ID).SetDefault(opts.Default) // If description is provided we set it if opts.Description != "" { updateChain = updateChain.SetDescription(opts.Description) @@ -126,6 +143,11 @@ func (r *CASBackendRepo) Update(ctx context.Context, opts *biz.CASBackendUpdateO return nil, err } + // 3 - commit the transaction + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + return r.FindByID(ctx, backend.ID) } @@ -193,7 +215,7 @@ func entCASBackendToBiz(backend *ent.CASBackend) *biz.CASBackend { } if org := backend.Edges.Organization; org != nil { - r.OrganizationID = org.ID.String() + r.OrganizationID = org.ID } return r diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index c1d3b0598..ddc8ccb07 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -120,9 +120,9 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer // find the default CAS backend to associate the workflow backend, err := s.casUC.FindDefaultBackend(context.Background(), robotAccount.OrgID) - if err != nil { + if err != nil && !biz.IsNotFound(err) { return nil, fmt.Errorf("failed to find default CAS backend: %w", err) - } else if backend == nil { + } else if err != nil { return nil, errors.NotFound("not found", "default CAS backend not found") } @@ -252,7 +252,7 @@ func (s *AttestationService) GetUploadCreds(ctx context.Context, req *cpAPI.Atte if req.WorkflowRunId == "" { s.log.Warn("DEPRECATED: using main repository to get upload creds") repo, err := s.casUC.FindDefaultBackend(ctx, wf.OrgID.String()) - if err != nil { + if err != nil && !biz.IsNotFound(err) { return nil, sl.LogAndMaskErr(err, s.log) } else if repo == nil { return nil, errors.NotFound("not found", "main repository not found") diff --git a/app/controlplane/internal/service/casbackend.go b/app/controlplane/internal/service/casbackend.go index 516eeaf9f..188af08df 100644 --- a/app/controlplane/internal/service/casbackend.go +++ b/app/controlplane/internal/service/casbackend.go @@ -20,6 +20,7 @@ import ( pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz" + backend "github.com/chainloop-dev/chainloop/internal/blobmanager" sl "github.com/chainloop-dev/chainloop/internal/servicelogger" "github.com/go-kratos/kratos/v2/errors" "google.golang.org/protobuf/types/known/timestamppb" @@ -29,13 +30,15 @@ type CASBackendService struct { pb.UnimplementedCASBackendServiceServer *service - uc *biz.CASBackendUseCase + uc *biz.CASBackendUseCase + providers backend.Providers } -func NewCASBackendService(uc *biz.CASBackendUseCase, opts ...NewOpt) *CASBackendService { +func NewCASBackendService(uc *biz.CASBackendUseCase, providers backend.Providers, opts ...NewOpt) *CASBackendService { return &CASBackendService{ - service: newService(opts...), - uc: uc, + service: newService(opts...), + uc: uc, + providers: providers, } } @@ -64,13 +67,24 @@ func (s *CASBackendService) Create(ctx context.Context, req *pb.CASBackendServic return nil, err } + backendP, ok := s.providers[req.Provider] + if !ok { + return nil, errors.BadRequest("invalid CAS backend", "invalid CAS backend") + } + credsJSON, err := req.Credentials.MarshalJSON() if err != nil { return nil, errors.BadRequest("invalid config", "config is invalid") } + // Validate and extract the credentials so they can be stored in the next step + creds, err := backendP.ValidateAndExtractCredentials(req.Location, credsJSON) + if err != nil { + return nil, errors.BadRequest("invalid config", err.Error()) + } + // For now we only support one backend which is set as default - res, err := s.uc.Create(ctx, currentOrg.ID, req.Location, req.Description, biz.CASBackendOCI, credsJSON, req.Default) + res, err := s.uc.Create(ctx, currentOrg.ID, req.Location, req.Description, biz.CASBackendOCI, creds, req.Default) if err != nil && biz.IsErrValidation(err) { return nil, errors.BadRequest("invalid CAS backend", err.Error()) } else if err != nil { @@ -86,17 +100,37 @@ func (s *CASBackendService) Update(ctx context.Context, req *pb.CASBackendServic return nil, err } - var credsJSON []byte + // find the backend to update + backend, err := s.uc.FindByIDInOrg(ctx, currentOrg.ID, req.Id) + if err != nil && biz.IsNotFound(err) { + return nil, errors.NotFound("CAS backend", "not found") + } else if err != nil { + return nil, sl.LogAndMaskErr(err, s.log) + } + + // if we are updating credentials we need to validate them + // to do so we load a backend provider and call ValidateAndExtractCredentials + var creds any if req.Credentials != nil { - credsJSON, err = req.Credentials.MarshalJSON() + backendP, ok := s.providers[string(backend.Provider)] + if !ok { + return nil, errors.BadRequest("invalid CAS backend", "invalid CAS backend") + } + + credsJSON, err := req.Credentials.MarshalJSON() if err != nil { return nil, errors.BadRequest("invalid config", "config is invalid") } + + // Validate and extract the credentials so they can be stored in the next step + creds, err = backendP.ValidateAndExtractCredentials(backend.Location, credsJSON) + if err != nil { + return nil, errors.BadRequest("invalid config", err.Error()) + } } // For now we only support one backend which is set as default - res, err := s.uc.Update(ctx, currentOrg.ID, req.Id, req.Description, credsJSON, req.Default) - + res, err := s.uc.Update(ctx, currentOrg.ID, req.Id, req.Description, creds, req.Default) switch { case err != nil && biz.IsErrValidation(err): return nil, errors.BadRequest("invalid CAS backend", err.Error()) diff --git a/app/controlplane/internal/service/cascredential.go b/app/controlplane/internal/service/cascredential.go index 81a75e9ec..98be1c844 100644 --- a/app/controlplane/internal/service/cascredential.go +++ b/app/controlplane/internal/service/cascredential.go @@ -59,11 +59,9 @@ func (s *CASCredentialsService) Get(ctx context.Context, req *pb.CASCredentialsS // Get repository to provide the secret name repo, err := s.ociUC.FindDefaultBackend(ctx, currentOrg.ID) - if err != nil { + if err != nil && !biz.IsNotFound(err) { return nil, sl.LogAndMaskErr(err, s.log) - } - - if repo == nil { + } else if repo == nil { return nil, errors.NotFound("not found", "main repository not found") } diff --git a/app/controlplane/internal/service/context.go b/app/controlplane/internal/service/context.go index 7c0ce84db..08ce55323 100644 --- a/app/controlplane/internal/service/context.go +++ b/app/controlplane/internal/service/context.go @@ -52,9 +52,10 @@ func (s *ContextService) Current(ctx context.Context, _ *pb.ContextServiceCurren } repo, err := s.uc.FindDefaultBackend(ctx, currentOrg.ID) - if err != nil { + if err != nil && !biz.IsNotFound(err) { return nil, sl.LogAndMaskErr(err, s.log) } + if repo != nil { res.CurrentOciRepo = bizOCIRepoToPb(repo) } diff --git a/app/controlplane/internal/usercontext/orgrequirements_middleware.go b/app/controlplane/internal/usercontext/orgrequirements_middleware.go index 36df04f3e..54b67324b 100644 --- a/app/controlplane/internal/usercontext/orgrequirements_middleware.go +++ b/app/controlplane/internal/usercontext/orgrequirements_middleware.go @@ -37,23 +37,23 @@ func CheckOrgRequirements(uc biz.CASBackendReader) middleware.Middleware { // 1 - Figure out main repository for this organization repo, err := uc.FindDefaultBackend(ctx, org.ID) - if err != nil { - return nil, fmt.Errorf("checking for repositories in the org: %w", err) + if err != nil && !biz.IsNotFound(err) { + return nil, fmt.Errorf("checking for CAS backends in the org: %w", err) } else if repo == nil { - return nil, v1.ErrorOciRepositoryErrorReasonRequired("your organization does not have an OCI repository configured yet") + return nil, v1.ErrorOciRepositoryErrorReasonRequired("your organization does not have an CAS Backend configured yet") } // 2 - Perform a validation if needed if shouldRevalidate(repo) { - repo, err = validateRepo(ctx, uc, repo) + repo, err = validateCASBackend(ctx, uc, repo) if err != nil { - return nil, fmt.Errorf("validating repository: %w", err) + return nil, fmt.Errorf("validating CAS backend: %w", err) } } // 2 - compare the status if repo.ValidationStatus != biz.CASBackendValidationOK { - return nil, v1.ErrorOciRepositoryErrorReasonInvalid("your OCI repository can't be reached") + return nil, v1.ErrorOciRepositoryErrorReasonInvalid("your CAS backend can't be reached") } return handler(ctx, req) @@ -62,16 +62,16 @@ func CheckOrgRequirements(uc biz.CASBackendReader) middleware.Middleware { } // validateRepoIfNeeded will re-run a validation and return the updated repository -func validateRepo(ctx context.Context, uc biz.CASBackendReader, repo *biz.CASBackend) (*biz.CASBackend, error) { +func validateCASBackend(ctx context.Context, uc biz.CASBackendReader, repo *biz.CASBackend) (*biz.CASBackend, error) { // re-run the validation if err := uc.PerformValidation(ctx, repo.ID.String()); err != nil { return nil, fmt.Errorf("performing validation: %w", err) } // Reload repository to get the updated validation status - repo, err := uc.FindByID(ctx, repo.ID.String()) + repo, err := uc.FindByIDInOrg(ctx, repo.OrganizationID.String(), repo.ID.String()) if err != nil { - return nil, fmt.Errorf("reloading repository: %w", err) + return nil, fmt.Errorf("reloading CAS backend: %w", err) } return repo, nil diff --git a/app/controlplane/internal/usercontext/orgrequirements_middleware_test.go b/app/controlplane/internal/usercontext/orgrequirements_middleware_test.go index 027ca8656..9ea497ca0 100644 --- a/app/controlplane/internal/usercontext/orgrequirements_middleware_test.go +++ b/app/controlplane/internal/usercontext/orgrequirements_middleware_test.go @@ -72,15 +72,15 @@ func TestShouldRevalidate(t *testing.T) { } } -func TestValidateRepo(t *testing.T) { +func TestValidateCASBackend(t *testing.T) { ctx := context.Background() assert := assert.New(t) - repo := &biz.CASBackend{ID: uuid.New(), ValidatedAt: toTimePtr(time.Now())} + repo := &biz.CASBackend{ID: uuid.New(), OrganizationID: uuid.New(), ValidatedAt: toTimePtr(time.Now())} t.Run("validation error", func(t *testing.T) { useCase := mocks.NewCASBackendReader(t) useCase.On("PerformValidation", ctx, repo.ID.String()).Return(errors.New("validation error")) - got, err := validateRepo(ctx, useCase, repo) + got, err := validateCASBackend(ctx, useCase, repo) assert.Error(err) assert.Nil(got) }) @@ -90,8 +90,8 @@ func TestValidateRepo(t *testing.T) { useCase.On("PerformValidation", ctx, repo.ID.String()).Return(nil) want := &biz.CASBackend{ID: repo.ID, ValidatedAt: toTimePtr(time.Now())} - useCase.On("FindByID", ctx, repo.ID.String()).Return(want, nil) - got, err := validateRepo(ctx, useCase, repo) + useCase.On("FindByIDInOrg", ctx, repo.OrganizationID.String(), repo.ID.String()).Return(want, nil) + got, err := validateCASBackend(ctx, useCase, repo) assert.NoError(err) assert.Equal(want, got) }) diff --git a/internal/blobmanager/backend.go b/internal/blobmanager/backend.go index a16d406f7..b413bc892 100644 --- a/internal/blobmanager/backend.go +++ b/internal/blobmanager/backend.go @@ -44,5 +44,12 @@ type Downloader interface { // Provider is an interface that allows to create a backend from a secret type Provider interface { + // Provider identifier + ID() string + // retrieve a downloader/uploader from a secret FromCredentials(ctx context.Context, secretName string) (UploaderDownloader, error) + // validate and extract credentials from raw json + ValidateAndExtractCredentials(location string, credsJSON []byte) (any, error) } + +type Providers map[string]Provider diff --git a/internal/blobmanager/mocks/Provider.go b/internal/blobmanager/mocks/Provider.go index 0cac8edcb..e681f7dff 100644 --- a/internal/blobmanager/mocks/Provider.go +++ b/internal/blobmanager/mocks/Provider.go @@ -1,18 +1,3 @@ -// -// Copyright 2023 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. - // Code generated by mockery v2.20.0. DO NOT EDIT. package mocks @@ -56,6 +41,46 @@ func (_m *Provider) FromCredentials(ctx context.Context, secretName string) (bac return r0, r1 } +// ID provides a mock function with given fields: +func (_m *Provider) ID() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// ValidateAndExtractCredentials provides a mock function with given fields: location, credsJSON +func (_m *Provider) ValidateAndExtractCredentials(location string, credsJSON []byte) (interface{}, error) { + ret := _m.Called(location, credsJSON) + + var r0 interface{} + var r1 error + if rf, ok := ret.Get(0).(func(string, []byte) (interface{}, error)); ok { + return rf(location, credsJSON) + } + if rf, ok := ret.Get(0).(func(string, []byte) interface{}); ok { + r0 = rf(location, credsJSON) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(interface{}) + } + } + + if rf, ok := ret.Get(1).(func(string, []byte) error); ok { + r1 = rf(location, credsJSON) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + type mockConstructorTestingTNewProvider interface { mock.TestingT Cleanup(func()) diff --git a/internal/blobmanager/oci/provider.go b/internal/blobmanager/oci/provider.go index 861200174..cb5eeab1d 100644 --- a/internal/blobmanager/oci/provider.go +++ b/internal/blobmanager/oci/provider.go @@ -17,6 +17,7 @@ package oci import ( "context" + "encoding/json" "fmt" backend "github.com/chainloop-dev/chainloop/internal/blobmanager" @@ -34,6 +35,10 @@ func NewBackendProvider(cReader credentials.Reader) *BackendProvider { return &BackendProvider{cReader: cReader} } +func (p *BackendProvider) ID() string { + return "OCI" +} + func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string) (backend.UploaderDownloader, error) { creds := &credentials.OCIKeypair{} if err := p.cReader.ReadCredentials(ctx, secretName, creds); err != nil { @@ -51,3 +56,34 @@ func (p *BackendProvider) FromCredentials(ctx context.Context, secretName string return NewBackend(creds.Repo, &RegistryOptions{Keychain: k}) } + +func (p *BackendProvider) ValidateAndExtractCredentials(location string, credsJSON []byte) (any, error) { + var creds credentials.OCIKeypair + if err := json.Unmarshal(credsJSON, &creds); err != nil { + return nil, fmt.Errorf("unmarshaling credentials: %w", err) + } + + // We are currently storing the repo location in the secret as well + creds.Repo = location + if err := creds.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials: %w", err) + } + + // Create and validate credentials + k, err := ociauth.NewCredentials(location, creds.Username, creds.Password) + if err != nil { + return nil, fmt.Errorf("creating credentials: %w", err) + } + + // Check credentials + b, err := NewBackend(location, &RegistryOptions{Keychain: k}) + if err != nil { + return nil, fmt.Errorf("checking credentials: %w", err) + } + + if err := b.CheckWritePermissions(context.TODO()); err != nil { + return nil, fmt.Errorf("checking write permissions: %w", err) + } + + return creds, nil +}