From 094a947821f40eea843b4b3fe91342b227a5f0c6 Mon Sep 17 00:00:00 2001 From: Kevin Abatan Date: Fri, 3 Jan 2025 17:14:02 +0100 Subject: [PATCH 1/3] feat: adding feature repository, model & db_model --- docker-compose.yml | 5 +- models/feature.go | 31 +++++++ models/feature_availability.go | 38 +++++++++ models/organization_entitlements.go | 26 ++++++ repositories/dbmodels/db_feature.go | 52 ++++++++++++ .../dbmodels/db_organization_entitlements.go | 62 ++++++++++++++ repositories/feature_repository.go | 84 +++++++++++++++++++ 7 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 models/feature.go create mode 100644 models/feature_availability.go create mode 100644 models/organization_entitlements.go create mode 100644 repositories/dbmodels/db_feature.go create mode 100644 repositories/dbmodels/db_organization_entitlements.go create mode 100644 repositories/feature_repository.go diff --git a/docker-compose.yml b/docker-compose.yml index 72912eb7..d71cd3d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ services: db: container_name: postgres - image: europe-west1-docker.pkg.dev/marble-infra/marble/postgresql-db:latest # custom image of postgres 15 with pg_cron extension added + #image: europe-west1-docker.pkg.dev/marble-infra/marble/postgresql-db:latest # custom image of postgres 15 with pg_cron extension added + image: postgres:15.2-alpine shm_size: 1g restart: always environment: @@ -14,7 +15,7 @@ services: volumes: - postgres-db:/data/postgres healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres" ] + test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 2s timeout: 1s retries: 5 diff --git a/models/feature.go b/models/feature.go new file mode 100644 index 00000000..e2162cd4 --- /dev/null +++ b/models/feature.go @@ -0,0 +1,31 @@ +package models + +import "time" + +type Feature struct { + Id string + Name string + Slug string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type CreateFeatureInput struct { + Id string + Name string +} + +type CreateFeatureAttributes struct { + Name string +} + +type UpdateFeatureInput struct { + Id string + Name string +} + +type UpdateFeatureAttributes struct { + Id string + Name string +} diff --git a/models/feature_availability.go b/models/feature_availability.go new file mode 100644 index 00000000..719e02a5 --- /dev/null +++ b/models/feature_availability.go @@ -0,0 +1,38 @@ +package models + +type FeatureAvailability int + +const ( + Enable FeatureAvailability = iota + Disable + Test + UnknownFeatureAvailability +) + +var ValidFeaturesAvailability = []FeatureAvailability{Enable, Disable, Test} + +// Provide a string value for each outcome +func (f FeatureAvailability) String() string { + switch f { + case Enable: + return "enable" + case Disable: + return "disable" + case Test: + return "test" + } + return "unknown" +} + +// Provide an Outcome from a string value +func FeatureAvailabilityFrom(s string) FeatureAvailability { + switch s { + case "enable": + return Enable + case "disable": + return Disable + case "test": + return Test + } + return UnknownFeatureAvailability +} diff --git a/models/organization_entitlements.go b/models/organization_entitlements.go new file mode 100644 index 00000000..da7d0c2c --- /dev/null +++ b/models/organization_entitlements.go @@ -0,0 +1,26 @@ +package models + +import "time" + +type OrganizationEntitlement struct { + Id string + OrganizationId string + FeatureId string + Availability FeatureAvailability + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateOrganizationEntitlementInput struct { + Id string + OrganizationId string + FeatureId string + Availability FeatureAvailability +} + +type UpdateOrganizationEntitlementInput struct { + Id string + OrganizationId string + FeatureId string + Availability FeatureAvailability +} diff --git a/repositories/dbmodels/db_feature.go b/repositories/dbmodels/db_feature.go new file mode 100644 index 00000000..2dfcce81 --- /dev/null +++ b/repositories/dbmodels/db_feature.go @@ -0,0 +1,52 @@ +package dbmodels + +import ( + "time" + + "github.com/checkmarble/marble-backend/models" + "github.com/checkmarble/marble-backend/utils" +) + +const TABLE_FEATURES = "features" + +var SelectFeatureColumn = utils.ColumnList[models.Feature]() + +type DBFeature struct { + Id string `db:"id"` + Name string `db:"name"` + Slug string `db:"slug"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func AdaptFeature(db DBFeature) (models.Feature, error) { + return models.Feature{ + Id: db.Id, + Name: db.Name, + Slug: db.Slug, + }, nil +} + +type DBFeatureCreateInput struct { + Id string `db:"id"` + Name string `db:"name"` +} + +func AdaptCreateFeatureInput(db DBFeatureCreateInput) models.CreateFeatureInput { + return models.CreateFeatureInput{ + Id: db.Id, + Name: db.Name, + } +} + +type DBFeatureUpdateInput struct { + Id string `db:"id"` + Name string `db:"name"` +} + +func AdaptUpdateFeatureInput(db DBFeatureUpdateInput) models.UpdateFeatureInput { + return models.UpdateFeatureInput{ + Id: db.Id, + Name: db.Name, + } +} diff --git a/repositories/dbmodels/db_organization_entitlements.go b/repositories/dbmodels/db_organization_entitlements.go new file mode 100644 index 00000000..4ecc07f8 --- /dev/null +++ b/repositories/dbmodels/db_organization_entitlements.go @@ -0,0 +1,62 @@ +package dbmodels + +import ( + "time" + + "github.com/checkmarble/marble-backend/models" + "github.com/checkmarble/marble-backend/utils" +) + +const TABLE_ORGANIZATION_ENTITLEMENTS = "organization_entitlements" + +var SelectOrganizationEntitlementColumn = utils.ColumnList[models.OrganizationEntitlement]() + +type DBOrganizationEntitlement struct { + Id string `db:"id"` + OrganizationId string `db:"id"` + FeatureId string `db:"feature_id"` + Access string `db:"access"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func AdaptOrganizationEntitlement(db DBOrganizationEntitlement) (models.OrganizationEntitlement, error) { + return models.OrganizationEntitlement{ + Id: db.Id, + OrganizationId: db.OrganizationId, + FeatureId: db.FeatureId, + Availability: models.FeatureAvailabilityFrom(db.Access), + }, nil +} + +type DBOrganizationEntitlementCreateInput struct { + Id string `db:"id"` + OrganizationId string `db:"organization_id"` + FeatureId string `db:"feature_id"` + Access string `db:"access"` +} + +func AdaptOrganizationEntitlementCreateInput(db DBOrganizationEntitlementCreateInput) models.CreateOrganizationEntitlementInput { + return models.CreateOrganizationEntitlementInput{ + Id: db.Id, + OrganizationId: db.OrganizationId, + FeatureId: db.FeatureId, + Availability: models.FeatureAvailabilityFrom(db.Access), + } +} + +type DBOrganizationEntitlementUpdateInput struct { + Id string `db:"id"` + OrganizationId string `db:"organization_id"` + FeatureId string `db:"feature_id"` + Access string `db:"access"` +} + +func AdaptOrganizationEntitlementUpdateInput(db DBOrganizationEntitlementUpdateInput) models.UpdateOrganizationEntitlementInput { + return models.UpdateOrganizationEntitlementInput{ + Id: db.Id, + OrganizationId: db.OrganizationId, + FeatureId: db.FeatureId, + Availability: models.FeatureAvailabilityFrom(db.Access), + } +} diff --git a/repositories/feature_repository.go b/repositories/feature_repository.go new file mode 100644 index 00000000..9018de52 --- /dev/null +++ b/repositories/feature_repository.go @@ -0,0 +1,84 @@ +package repositories + +import ( + "context" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/checkmarble/marble-backend/models" + "github.com/checkmarble/marble-backend/repositories/dbmodels" +) + +func (repo *MarbleDbRepository) ListFeatures(ctx context.Context, exec Executor) ([]models.Feature, error) { + if err := validateMarbleDbExecutor(exec); err != nil { + return nil, err + } + query := NewQueryBuilder(). + Select(dbmodels.SelectFeatureColumn...). + From(fmt.Sprintf("%s AS t", dbmodels.TABLE_FEATURES)). + Where(squirrel.Eq{"deleted_at": nil}). + OrderBy("created_at DESC") + + return SqlToListOfModels(ctx, exec, query, dbmodels.AdaptFeature) +} + +func (repo *MarbleDbRepository) CreateFeature(ctx context.Context, exec Executor, + attributes models.CreateFeatureAttributes, newFeatureId string, +) error { + if err := validateMarbleDbExecutor(exec); err != nil { + return err + } + + err := ExecBuilder( + ctx, + exec, + NewQueryBuilder().Insert(dbmodels.TABLE_FEATURES). + Columns(dbmodels.SelectFeatureColumn...). + Values(newFeatureId, attributes.Name), + ) + return err +} + +func (repo *MarbleDbRepository) UpdateFeature(ctx context.Context, exec Executor, attributes models.UpdateFeatureAttributes) error { + if err := validateMarbleDbExecutor(exec); err != nil { + return err + } + + query := NewQueryBuilder().Update(dbmodels.TABLE_FEATURES).Where(squirrel.Eq{ + "id": attributes.Id, + }).Set("updated_at", squirrel.Expr("NOW()")) + + if attributes.Name != "" { + query = query.Set("name", attributes.Name) + } + err := ExecBuilder(ctx, exec, query) + return err +} + +func (repo *MarbleDbRepository) GetFeatureById(ctx context.Context, exec Executor, featureId string) (models.Feature, error) { + if err := validateMarbleDbExecutor(exec); err != nil { + return models.Feature{}, err + } + + return SqlToModel( + ctx, + exec, + NewQueryBuilder().Select(dbmodels.SelectFeatureColumn...). + From(dbmodels.TABLE_FEATURES). + Where(squirrel.Eq{"deleted_at": nil}). + Where(squirrel.Eq{"id": featureId}), + dbmodels.AdaptFeature, + ) +} + +func (repo *MarbleDbRepository) SoftDeleteFeature(ctx context.Context, exec Executor, featureId string) error { + if err := validateMarbleDbExecutor(exec); err != nil { + return err + } + query := NewQueryBuilder().Update(dbmodels.TABLE_FEATURES).Where(squirrel.Eq{"id": featureId}) + query = query.Set("deleted_at", squirrel.Expr("NOW()")) + query = query.Set("updated_at", squirrel.Expr("NOW()")) + + err := ExecBuilder(ctx, exec, query) + return err +} From 0d69c6d8cce5f81b67683d40b6b48912c6c4b05a Mon Sep 17 00:00:00 2001 From: Kevin Abatan Date: Mon, 6 Jan 2025 17:53:29 +0100 Subject: [PATCH 2/3] feat: addding handlers for feature crud --- api/handle_feature.go | 120 +++++++++++++++ api/routes.go | 6 + dto/feature_dto.go | 29 ++++ models/events.go | 3 + models/permission.go | 4 + repositories/dbmodels/db_feature.go | 24 --- usecases/feature_usecase.go | 143 ++++++++++++++++++ .../security/enforce_security_features.go | 39 +++++ usecases/usecases_with_creds.go | 15 ++ 9 files changed, 359 insertions(+), 24 deletions(-) create mode 100644 api/handle_feature.go create mode 100644 dto/feature_dto.go create mode 100644 usecases/feature_usecase.go create mode 100644 usecases/security/enforce_security_features.go diff --git a/api/handle_feature.go b/api/handle_feature.go new file mode 100644 index 00000000..f6490a67 --- /dev/null +++ b/api/handle_feature.go @@ -0,0 +1,120 @@ +package api + +import ( + "net/http" + + "github.com/checkmarble/marble-backend/dto" + "github.com/checkmarble/marble-backend/models" + "github.com/checkmarble/marble-backend/pure_utils" + "github.com/checkmarble/marble-backend/usecases" + "github.com/checkmarble/marble-backend/utils" + "github.com/gin-gonic/gin" +) + +func handleListFeatures(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + usecase := usecasesWithCreds(ctx, uc).NewFeatureUseCase() + features, err := usecase.ListAllFeatures(ctx) + + if presentError(ctx, c, err) { + return + } + c.JSON(http.StatusOK, gin.H{"features": pure_utils.Map(features, dto.AdaptFeatureDto)}) + } +} + +func handleCreateFeature(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + var data dto.CreateFeatureBody + if err := c.ShouldBindJSON(&data); err != nil { + c.Status(http.StatusBadRequest) + return + } + + usecase := usecasesWithCreds(ctx, uc).NewFeatureUseCase() + feature, err := usecase.CreateFeature(ctx, models.CreateFeatureAttributes{ + Name: data.Name, + }) + + if presentError(ctx, c, err) { + return + } + c.JSON(http.StatusCreated, gin.H{"feature": dto.AdaptFeatureDto(feature)}) + } +} + +type FeatureUriInput struct { + FeatureId string `uri:"feature_id" binding:"required,uuid"` +} + +func handleGetFeature(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + var featureInput FeatureUriInput + if err := c.ShouldBindUri(&featureInput); err != nil { + c.Status(http.StatusBadRequest) + return + } + + usecase := usecasesWithCreds(ctx, uc).NewFeatureUseCase() + feature, err := usecase.GetFeatureById(ctx, featureInput.FeatureId) + + if presentError(ctx, c, err) { + return + } + c.JSON(http.StatusOK, gin.H{"feature": dto.AdaptFeatureDto(feature)}) + } +} + +func handleUpdateFeature(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + var featureInput FeatureUriInput + if err := c.ShouldBindUri(&featureInput); err != nil { + c.Status(http.StatusBadRequest) + return + } + + var data dto.UpdateFeatureBody + if err := c.ShouldBindJSON(&data); err != nil { + c.Status(http.StatusBadRequest) + return + } + + usecase := usecasesWithCreds(ctx, uc).NewFeatureUseCase() + feature, err := usecase.UpdateFeature(ctx, models.UpdateFeatureAttributes{ + Name: data.Name, + }) + + if presentError(ctx, c, err) { + return + } + c.JSON(http.StatusOK, gin.H{"feature": dto.AdaptFeatureDto(feature)}) + } +} + +func handleDeleteFeature(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + organizationId, err := utils.OrganizationIdFromRequest(c.Request) + if presentError(ctx, c, err) { + return + } + + var featureInput FeatureUriInput + if err := c.ShouldBindUri(&featureInput); err != nil { + c.Status(http.StatusBadRequest) + return + } + + usecase := usecasesWithCreds(ctx, uc).NewFeatureUseCase() + err = usecase.DeleteFeature(ctx, organizationId, featureInput.FeatureId) + + if presentError(ctx, c, err) { + return + } + c.Status(http.StatusNoContent) + } +} diff --git a/api/routes.go b/api/routes.go index 4e494658..33aff1f6 100644 --- a/api/routes.go +++ b/api/routes.go @@ -202,4 +202,10 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut router.DELETE("/webhooks/:webhook_id", tom, handleDeleteWebhook(uc)) router.GET("/rule-snoozes/:rule_snooze_id", tom, handleGetSnoozesById(uc)) + + router.GET("/features", tom, handleListFeatures(uc)) + router.GET("/features/:feature_id", tom, handleGetFeature(uc)) + router.POST("/features", tom, handleCreateFeature(uc)) + router.PATCH("/features/:feature_id", tom, handleUpdateFeature(uc)) + router.DELETE("/features/:feature_id", tom, handleDeleteFeature(uc)) } diff --git a/dto/feature_dto.go b/dto/feature_dto.go new file mode 100644 index 00000000..1911acea --- /dev/null +++ b/dto/feature_dto.go @@ -0,0 +1,29 @@ +package dto + +import ( + "time" + + "github.com/checkmarble/marble-backend/models" +) + +type APIFeature struct { + Id string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +func AdaptFeatureDto(f models.Feature) APIFeature { + return APIFeature{ + Id: f.Id, + Name: f.Name, + CreatedAt: f.CreatedAt, + } +} + +type CreateFeatureBody struct { + Name string `json:"name" binding:"required"` +} + +type UpdateFeatureBody struct { + Name string `json:"name"` +} diff --git a/models/events.go b/models/events.go index b8607f9c..6ec987b9 100644 --- a/models/events.go +++ b/models/events.go @@ -26,6 +26,9 @@ const ( AnalyticsTagCreated AnalyticsEvent = "Created a Tag" AnalyticsTagUpdated AnalyticsEvent = "Updated a Tag" AnalyticsTagDeleted AnalyticsEvent = "Deleted a Tag" + AnalyticsFeatureCreated AnalyticsEvent = "Created a Feature" + AnalyticsFeatureUpdated AnalyticsEvent = "Updated a Feature" + AnalyticsFeatureDeleted AnalyticsEvent = "Deleted a Feature" AnalyticsUserCreated AnalyticsEvent = "Created a User" AnalyticsUserUpdated AnalyticsEvent = "Updated a User" AnalyticsUserDeleted AnalyticsEvent = "Deleted a User" diff --git a/models/permission.go b/models/permission.go index 5c087e29..5bba6866 100644 --- a/models/permission.go +++ b/models/permission.go @@ -56,6 +56,10 @@ const ( TAG_CREATE TAG_UPDATE TAG_DELETE + FEATURE_READ + FEATURE_CREATE + FEATURE_UPDATE + FEATURE_DELETE ) func (r Permission) String() (string, error) { diff --git a/repositories/dbmodels/db_feature.go b/repositories/dbmodels/db_feature.go index 2dfcce81..129f0fd1 100644 --- a/repositories/dbmodels/db_feature.go +++ b/repositories/dbmodels/db_feature.go @@ -26,27 +26,3 @@ func AdaptFeature(db DBFeature) (models.Feature, error) { Slug: db.Slug, }, nil } - -type DBFeatureCreateInput struct { - Id string `db:"id"` - Name string `db:"name"` -} - -func AdaptCreateFeatureInput(db DBFeatureCreateInput) models.CreateFeatureInput { - return models.CreateFeatureInput{ - Id: db.Id, - Name: db.Name, - } -} - -type DBFeatureUpdateInput struct { - Id string `db:"id"` - Name string `db:"name"` -} - -func AdaptUpdateFeatureInput(db DBFeatureUpdateInput) models.UpdateFeatureInput { - return models.UpdateFeatureInput{ - Id: db.Id, - Name: db.Name, - } -} diff --git a/usecases/feature_usecase.go b/usecases/feature_usecase.go new file mode 100644 index 00000000..3cd4b3fd --- /dev/null +++ b/usecases/feature_usecase.go @@ -0,0 +1,143 @@ +package usecases + +import ( + "context" + + "github.com/checkmarble/marble-backend/models" + "github.com/checkmarble/marble-backend/repositories" + "github.com/checkmarble/marble-backend/usecases/executor_factory" + "github.com/checkmarble/marble-backend/usecases/security" + "github.com/checkmarble/marble-backend/usecases/tracking" + + "github.com/cockroachdb/errors" + "github.com/google/uuid" +) + +type FeatureUseCaseRepository interface { + ListFeatures(ctx context.Context, exec repositories.Executor) ([]models.Feature, error) + CreateFeature(ctx context.Context, exec repositories.Executor, + attributes models.CreateFeatureAttributes, newFeatureId string) error + UpdateFeature(ctx context.Context, exec repositories.Executor, + attributes models.UpdateFeatureAttributes) error + GetFeatureById(ctx context.Context, exec repositories.Executor, featureId string) (models.Feature, error) + SoftDeleteFeature(ctx context.Context, exec repositories.Executor, featureId string) error +} + +type FeatureUseCase struct { + enforceSecurity security.EnforceSecurityFeatures + transactionFactory executor_factory.TransactionFactory + executorFactory executor_factory.ExecutorFactory + repository FeatureUseCaseRepository +} + +func (usecase *FeatureUseCase) ListAllFeatures(ctx context.Context) ([]models.Feature, error) { + features, err := usecase.repository.ListFeatures( + ctx, + usecase.executorFactory.NewExecutor()) + if err != nil { + return nil, err + } + + for _, t := range features { + if err := usecase.enforceSecurity.ReadFeature(t); err != nil { + return nil, err + } + } + return features, err +} + +func (usecase *FeatureUseCase) CreateFeature(ctx context.Context, + attributes models.CreateFeatureAttributes, +) (models.Feature, error) { + if err := usecase.enforceSecurity.CreateFeature(); err != nil { + return models.Feature{}, err + } + + feature, err := executor_factory.TransactionReturnValue(ctx, + usecase.transactionFactory, func(tx repositories.Transaction) (models.Feature, error) { + newFeatureId := uuid.NewString() + if err := usecase.repository.CreateFeature(ctx, tx, attributes, newFeatureId); err != nil { + if repositories.IsUniqueViolationError(err) { + return models.Feature{}, errors.Wrap(models.ConflictError, + "There is already a feature by this name") + } + return models.Feature{}, err + } + return usecase.repository.GetFeatureById(ctx, tx, newFeatureId) + }) + if err != nil { + return models.Feature{}, err + } + + tracking.TrackEvent(ctx, models.AnalyticsFeatureCreated, map[string]interface{}{ + "feature_id": feature.Id, + }) + + return feature, err +} + +func (usecase *FeatureUseCase) GetFeatureById(ctx context.Context, featureId string) (models.Feature, error) { + t, err := usecase.repository.GetFeatureById(ctx, usecase.executorFactory.NewExecutor(), featureId) + if err != nil { + return models.Feature{}, err + } + if err := usecase.enforceSecurity.ReadFeature(t); err != nil { + return models.Feature{}, err + } + return t, nil +} + +func (usecase *FeatureUseCase) UpdateFeature(ctx context.Context, + attributes models.UpdateFeatureAttributes, +) (models.Feature, error) { + feature, err := executor_factory.TransactionReturnValue(ctx, usecase.transactionFactory, func( + tx repositories.Transaction, + ) (models.Feature, error) { + feature, err := usecase.repository.GetFeatureById(ctx, tx, attributes.Id) + if err != nil { + return models.Feature{}, err + } + if err := usecase.enforceSecurity.UpdateFeature(feature); err != nil { + return models.Feature{}, err + } + + if err := usecase.repository.UpdateFeature(ctx, tx, attributes); err != nil { + return models.Feature{}, err + } + return usecase.repository.GetFeatureById(ctx, tx, attributes.Id) + }) + if err != nil { + return models.Feature{}, err + } + + tracking.TrackEvent(ctx, models.AnalyticsFeatureUpdated, map[string]interface{}{ + "feature_id": feature.Id, + }) + + return feature, err +} + +func (usecase *FeatureUseCase) DeleteFeature(ctx context.Context, organizationId, featureId string) error { + err := executor_factory.TransactionFactory.Transaction(usecase.transactionFactory, ctx, func(tx repositories.Transaction) error { + t, err := usecase.repository.GetFeatureById(ctx, tx, featureId) + if err != nil { + return err + } + if err := usecase.enforceSecurity.DeleteFeature(t); err != nil { + return err + } + if err := usecase.repository.SoftDeleteFeature(ctx, tx, featureId); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + + tracking.TrackEvent(ctx, models.AnalyticsFeatureDeleted, map[string]interface{}{ + "feature_id": featureId, + }) + + return nil +} diff --git a/usecases/security/enforce_security_features.go b/usecases/security/enforce_security_features.go new file mode 100644 index 00000000..bdb5256f --- /dev/null +++ b/usecases/security/enforce_security_features.go @@ -0,0 +1,39 @@ +package security + +import ( + "errors" + + "github.com/checkmarble/marble-backend/models" +) + +type EnforceSecurityFeatures interface { + EnforceSecurity + ReadFeature(feature models.Feature) error + CreateFeature() error + UpdateFeature(feature models.Feature) error + DeleteFeature(feature models.Feature) error +} + +func (e *EnforceSecurityImpl) ReadFeature(feature models.Feature) error { + return errors.Join( + e.Permission(models.FEATURE_READ), + ) +} + +func (e *EnforceSecurityImpl) CreateFeature() error { + return errors.Join( + e.Permission(models.FEATURE_CREATE), + ) +} + +func (e *EnforceSecurityImpl) UpdateFeature(feature models.Feature) error { + return errors.Join( + e.Permission(models.FEATURE_UPDATE), + ) +} + +func (e *EnforceSecurityImpl) DeleteFeature(feature models.Feature) error { + return errors.Join( + e.Permission(models.FEATURE_DELETE), + ) +} diff --git a/usecases/usecases_with_creds.go b/usecases/usecases_with_creds.go index e253b8f8..1cd9a5a9 100644 --- a/usecases/usecases_with_creds.go +++ b/usecases/usecases_with_creds.go @@ -91,6 +91,12 @@ func (usecases *UsecasesWithCreds) NewEnforceTagSecurity() security.EnforceSecur } } +func (usecases *UsecasesWithCreds) NewEnforceFeatureSecurity() security.EnforceSecurityFeatures { + return &security.EnforceSecurityImpl{ + Credentials: usecases.Credentials, + } +} + func (usecases *UsecasesWithCreds) NewDecisionUsecase() DecisionUsecase { return DecisionUsecase{ enforceSecurity: usecases.NewEnforceDecisionSecurity(), @@ -324,6 +330,15 @@ func (usecases *UsecasesWithCreds) NewTagUseCase() TagUseCase { } } +func (usecases UsecasesWithCreds) NewFeatureUseCase() FeatureUseCase { + return FeatureUseCase{ + enforceSecurity: usecases.NewEnforceFeatureSecurity(), + transactionFactory: usecases.NewTransactionFactory(), + executorFactory: usecases.NewExecutorFactory(), + repository: &usecases.Repositories.MarbleDbRepository, + } +} + func (usecases *UsecasesWithCreds) NewApiKeyUseCase() ApiKeyUseCase { return ApiKeyUseCase{ executorFactory: usecases.NewExecutorFactory(), From d8fb70539d52894ca8e0a65da1b9bb61e50b51e1 Mon Sep 17 00:00:00 2001 From: Kevin Abatan Date: Tue, 7 Jan 2025 17:03:34 +0100 Subject: [PATCH 3/3] feat: adding all handlers for feature access & sso check --- api/handle_license.go | 17 ++++++++ api/handle_organization.go | 40 +++++++++++++++++++ api/routes.go | 4 ++ dto/organization_entitlement_dto.go | 32 +++++++++++++++ models/feature.go | 1 - repositories/dbmodels/db_feature.go | 1 - .../dbmodels/db_organization_entitlements.go | 14 +++---- .../20250107094245_add_feature_table.sql | 18 +++++++++ ...26_add_organization_entitlements_table.sql | 27 +++++++++++++ repositories/organization_repository.go | 40 +++++++++++++++++++ usecases/organization_usecase.go | 22 ++++++++++ .../security/enforce_security_organization.go | 14 +++++++ 12 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 dto/organization_entitlement_dto.go create mode 100644 repositories/migrations/20250107094245_add_feature_table.sql create mode 100644 repositories/migrations/20250107094526_add_organization_entitlements_table.sql diff --git a/api/handle_license.go b/api/handle_license.go index 88af8f12..f9d94dec 100644 --- a/api/handle_license.go +++ b/api/handle_license.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "os" "strings" "github.com/gin-gonic/gin" @@ -98,3 +99,19 @@ func handleValidateLicense(uc usecases.Usecases) func(c *gin.Context) { c.JSON(http.StatusOK, dto.AdaptLicenseValidationDto(licenseValidation)) } } + +func handleIsSSOEnabled(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + licenseKey := os.Getenv("LICENSE_KEY") + + usecase := uc.NewLicenseUsecase() + licenseValidation, err := usecase.ValidateLicense(ctx, strings.TrimPrefix(licenseKey, "/")) + if presentError(ctx, c, err) { + return + } + c.JSON(http.StatusOK, gin.H{ + "is_sso_enabled": licenseValidation.Sso, + }) + } +} diff --git a/api/handle_organization.go b/api/handle_organization.go index 4ef40b56..3319d526 100644 --- a/api/handle_organization.go +++ b/api/handle_organization.go @@ -102,3 +102,43 @@ func handleDeleteOrganization(uc usecases.Usecases) func(c *gin.Context) { c.Status(http.StatusNoContent) } } + +func handleGetOrganizationEntitlements(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + organizationID := c.Param("organization_id") + + usecase := usecasesWithCreds(ctx, uc).NewOrganizationUseCase() + entitlements, err := usecase.GetOrganizationEntitlements(ctx, organizationID) + if presentError(ctx, c, err) { + return + } + + c.JSON(http.StatusOK, gin.H{ + "entitlements": pure_utils.Map(entitlements, dto.AdaptOrganizationEntitlementDto), + }) + } +} + +func handleUpdateOrganizationEntitlements(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + organizationID := c.Param("organization_id") + var data dto.UpdateOrganizationEntitlementBodyDto + if err := c.ShouldBindJSON(&data); err != nil { + c.Status(http.StatusBadRequest) + return + } + + usecase := usecasesWithCreds(ctx, uc).NewOrganizationUseCase() + err := usecase.UpdateOrganizationEntitlements(ctx, organizationID, models.UpdateOrganizationEntitlementInput{ + FeatureId: data.FeatureId, + Availability: models.FeatureAvailabilityFrom(data.Availability), + OrganizationId: organizationID, + }) + if presentError(ctx, c, err) { + return + } + c.Status(http.StatusNoContent) + } +} diff --git a/api/routes.go b/api/routes.go index 33aff1f6..15da3bda 100644 --- a/api/routes.go +++ b/api/routes.go @@ -27,6 +27,7 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut r.GET("/liveness", tom, handleLivenessProbe(uc)) r.POST("/token", tom, tokenHandler.GenerateToken) r.GET("/validate-license/*license_key", tom, handleValidateLicense(uc)) + r.GET("/is-sso-enabled", tom, handleIsSSOEnabled(uc)) router := r.Use(auth.Middleware) @@ -128,6 +129,9 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut router.GET("/organizations/:organization_id", tom, handleGetOrganization(uc)) router.PATCH("/organizations/:organization_id", tom, handlePatchOrganization(uc)) router.DELETE("/organizations/:organization_id", tom, handleDeleteOrganization(uc)) + router.GET("/organizations/:organization_id/entitlements", tom, handleGetOrganizationEntitlements(uc)) + router.PATCH("/organizations/:organization_id/entitlements", tom, + handleUpdateOrganizationEntitlements(uc)) router.GET("/partners", tom, handleListPartners(uc)) router.POST("/partners", tom, handleCreatePartner(uc)) diff --git a/dto/organization_entitlement_dto.go b/dto/organization_entitlement_dto.go new file mode 100644 index 00000000..074ed399 --- /dev/null +++ b/dto/organization_entitlement_dto.go @@ -0,0 +1,32 @@ +package dto + +import ( + "time" + + "github.com/checkmarble/marble-backend/models" +) + +type APIOrganizationEntitlement struct { + Id string `json:"id"` + OrganizationId string `json:"organization_id"` + FeatureId string `json:"feature_id"` + Availability string `json:"availability"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func AdaptOrganizationEntitlementDto(f models.OrganizationEntitlement) APIOrganizationEntitlement { + return APIOrganizationEntitlement{ + Id: f.Id, + OrganizationId: f.OrganizationId, + FeatureId: f.FeatureId, + Availability: f.Availability.String(), + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + } +} + +type UpdateOrganizationEntitlementBodyDto struct { + FeatureId string `json:"feature_id" binding:"required"` + Availability string `json:"availability" binding:"required"` +} diff --git a/models/feature.go b/models/feature.go index e2162cd4..5881925d 100644 --- a/models/feature.go +++ b/models/feature.go @@ -5,7 +5,6 @@ import "time" type Feature struct { Id string Name string - Slug string CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time diff --git a/repositories/dbmodels/db_feature.go b/repositories/dbmodels/db_feature.go index 129f0fd1..660bfd0d 100644 --- a/repositories/dbmodels/db_feature.go +++ b/repositories/dbmodels/db_feature.go @@ -23,6 +23,5 @@ func AdaptFeature(db DBFeature) (models.Feature, error) { return models.Feature{ Id: db.Id, Name: db.Name, - Slug: db.Slug, }, nil } diff --git a/repositories/dbmodels/db_organization_entitlements.go b/repositories/dbmodels/db_organization_entitlements.go index 4ecc07f8..953e1aa6 100644 --- a/repositories/dbmodels/db_organization_entitlements.go +++ b/repositories/dbmodels/db_organization_entitlements.go @@ -13,9 +13,9 @@ var SelectOrganizationEntitlementColumn = utils.ColumnList[models.OrganizationEn type DBOrganizationEntitlement struct { Id string `db:"id"` - OrganizationId string `db:"id"` + OrganizationId string `db:"organization_id"` FeatureId string `db:"feature_id"` - Access string `db:"access"` + Availability string `db:"availability"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -25,7 +25,7 @@ func AdaptOrganizationEntitlement(db DBOrganizationEntitlement) (models.Organiza Id: db.Id, OrganizationId: db.OrganizationId, FeatureId: db.FeatureId, - Availability: models.FeatureAvailabilityFrom(db.Access), + Availability: models.FeatureAvailabilityFrom(db.Availability), }, nil } @@ -33,7 +33,7 @@ type DBOrganizationEntitlementCreateInput struct { Id string `db:"id"` OrganizationId string `db:"organization_id"` FeatureId string `db:"feature_id"` - Access string `db:"access"` + Availability string `db:"availability"` } func AdaptOrganizationEntitlementCreateInput(db DBOrganizationEntitlementCreateInput) models.CreateOrganizationEntitlementInput { @@ -41,7 +41,7 @@ func AdaptOrganizationEntitlementCreateInput(db DBOrganizationEntitlementCreateI Id: db.Id, OrganizationId: db.OrganizationId, FeatureId: db.FeatureId, - Availability: models.FeatureAvailabilityFrom(db.Access), + Availability: models.FeatureAvailabilityFrom(db.Availability), } } @@ -49,7 +49,7 @@ type DBOrganizationEntitlementUpdateInput struct { Id string `db:"id"` OrganizationId string `db:"organization_id"` FeatureId string `db:"feature_id"` - Access string `db:"access"` + Availability string `db:"availability"` } func AdaptOrganizationEntitlementUpdateInput(db DBOrganizationEntitlementUpdateInput) models.UpdateOrganizationEntitlementInput { @@ -57,6 +57,6 @@ func AdaptOrganizationEntitlementUpdateInput(db DBOrganizationEntitlementUpdateI Id: db.Id, OrganizationId: db.OrganizationId, FeatureId: db.FeatureId, - Availability: models.FeatureAvailabilityFrom(db.Access), + Availability: models.FeatureAvailabilityFrom(db.Availability), } } diff --git a/repositories/migrations/20250107094245_add_feature_table.sql b/repositories/migrations/20250107094245_add_feature_table.sql new file mode 100644 index 00000000..13bd07ed --- /dev/null +++ b/repositories/migrations/20250107094245_add_feature_table.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE features ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); +CREATE UNIQUE INDEX features_unique_name ON features (name) WHERE deleted_at IS NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX features_unique_name; +DROP TABLE features; +-- +goose StatementEnd diff --git a/repositories/migrations/20250107094526_add_organization_entitlements_table.sql b/repositories/migrations/20250107094526_add_organization_entitlements_table.sql new file mode 100644 index 00000000..845506c0 --- /dev/null +++ b/repositories/migrations/20250107094526_add_organization_entitlements_table.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE organization_entitlements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID NOT NULL, + feature_id UUID NOT NULL, + availability availability_kind NOT NULL DEFAULT 'disabled', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_org FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + CONSTRAINT fk_feature FOREIGN KEY(feature_id) REFERENCES features(id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX entitlements_unique_organization_feature ON organization_entitlements (organization_id, feature_id) WHERE deleted_at IS NULL; + +CREATE TYPE availability_kind AS ENUM ( + 'enabled', + 'disabled', + 'test' +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX entitlements_unique_organization_feature; +DROP TABLE organization_entitlements; +-- +goose StatementEnd diff --git a/repositories/organization_repository.go b/repositories/organization_repository.go index 1da25676..28406f97 100644 --- a/repositories/organization_repository.go +++ b/repositories/organization_repository.go @@ -3,6 +3,7 @@ package repositories import ( "context" + "github.com/Masterminds/squirrel" "github.com/checkmarble/marble-backend/models" "github.com/checkmarble/marble-backend/repositories/dbmodels" "github.com/checkmarble/marble-backend/utils" @@ -16,6 +17,10 @@ type OrganizationRepository interface { UpdateOrganization(ctx context.Context, exec Executor, updateOrganization models.UpdateOrganizationInput) error DeleteOrganization(ctx context.Context, exec Executor, organizationId string) error DeleteOrganizationDecisionRulesAsync(ctx context.Context, exec Executor, organizationId string) + GetOrganizationEntitlements(ctx context.Context, exec Executor, organizationId string) ( + []models.OrganizationEntitlement, error) + UpdateOrganizationEntitlements(ctx context.Context, exec Executor, organizationId string, + entitlements models.UpdateOrganizationEntitlementInput) error } type OrganizationRepositoryPostgresql struct{} @@ -124,3 +129,38 @@ func (repo *OrganizationRepositoryPostgresql) DeleteOrganizationDecisionRulesAsy } }() } + +func (repo *OrganizationRepositoryPostgresql) GetOrganizationEntitlements(ctx context.Context, exec Executor, + organizationId string, +) ([]models.OrganizationEntitlement, error) { + if err := validateMarbleDbExecutor(exec); err != nil { + return nil, err + } + + query := NewQueryBuilder(). + Select(dbmodels.SelectOrganizationEntitlementColumn...). + From(dbmodels.TABLE_ORGANIZATION_ENTITLEMENTS). + Where("organization_id = ?", organizationId). + OrderBy("created_at DESC") + + return SqlToListOfModels(ctx, exec, query, dbmodels.AdaptOrganizationEntitlement) +} + +func (repo *OrganizationRepositoryPostgresql) UpdateOrganizationEntitlements(ctx context.Context, exec Executor, + organizationId string, entitlement models.UpdateOrganizationEntitlementInput, +) error { + if err := validateMarbleDbExecutor(exec); err != nil { + return err + } + + query := NewQueryBuilder(). + Update(dbmodels.TABLE_ORGANIZATION_ENTITLEMENTS). + Where(squirrel.And{ + squirrel.Eq{"organization_id": organizationId}, + squirrel.Eq{"feature_id": entitlement.FeatureId}, + }). + Set("availability", entitlement.Availability) + + err := ExecBuilder(ctx, exec, query) + return err +} diff --git a/usecases/organization_usecase.go b/usecases/organization_usecase.go index bbc4c7b6..1fdb4641 100644 --- a/usecases/organization_usecase.go +++ b/usecases/organization_usecase.go @@ -109,3 +109,25 @@ func (usecase *OrganizationUseCase) DeleteOrganization(ctx context.Context, orga ) return nil } + +func (usecase *OrganizationUseCase) GetOrganizationEntitlements(ctx context.Context, + organizationId string, +) ([]models.OrganizationEntitlement, error) { + if err := usecase.enforceSecurity.GetOrganizationEntitlements(); err != nil { + return []models.OrganizationEntitlement{}, err + } + + return usecase.organizationRepository.GetOrganizationEntitlements(ctx, + usecase.executorFactory.NewExecutor(), organizationId) +} + +func (usecase *OrganizationUseCase) UpdateOrganizationEntitlements(ctx context.Context, + organizationId string, entitlement models.UpdateOrganizationEntitlementInput, +) error { + if err := usecase.enforceSecurity.UpdateOrganizationEntitlements(); err != nil { + return err + } + + return usecase.organizationRepository.UpdateOrganizationEntitlements(ctx, + usecase.executorFactory.NewExecutor(), organizationId, entitlement) +} diff --git a/usecases/security/enforce_security_organization.go b/usecases/security/enforce_security_organization.go index 1209fd3b..4544ae72 100644 --- a/usecases/security/enforce_security_organization.go +++ b/usecases/security/enforce_security_organization.go @@ -15,6 +15,8 @@ type EnforceSecurityOrganization interface { ReadDataModel() error WriteDataModel(organizationId string) error WriteDataModelIndexes(organizationId string) error + GetOrganizationEntitlements() error + UpdateOrganizationEntitlements() error } type EnforceSecurityOrganizationImpl struct { @@ -66,3 +68,15 @@ func (e *EnforceSecurityOrganizationImpl) WriteDataModelIndexes(organizationId s e.ReadOrganization(organizationId), ) } + +func (e *EnforceSecurityOrganizationImpl) GetOrganizationEntitlements(organizationId string) error { + return errors.Join( + e.Permission(models.ORGANIZATIONS_LIST), + ) +} + +func (e *EnforceSecurityOrganizationImpl) UpdateOrganizationEntitlements(organizationId string) error { + return errors.Join( + e.Permission(models.ORGANIZATIONS_UPDATE), + ) +}