diff --git a/.task/checksum/generate-ent-smart b/.task/checksum/generate-ent-smart index 339e8697ab..9b687ae85d 100644 --- a/.task/checksum/generate-ent-smart +++ b/.task/checksum/generate-ent-smart @@ -1 +1 @@ -c89c7e7c9a937583a194a2c8d524ccfb +6d449fe0a221fc086960d7c64d3c5254 diff --git a/common/go.mod b/common/go.mod index 7df0e2ee7c..4a4fb3c1c5 100644 --- a/common/go.mod +++ b/common/go.mod @@ -33,7 +33,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.5 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v1.0.0 // indirect diff --git a/common/go.sum b/common/go.sum index 3c53e248c3..26bb8318a7 100644 --- a/common/go.sum +++ b/common/go.sum @@ -38,8 +38,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA= +github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/controls/trustcenter.go b/internal/controls/trustcenter.go index b00ccecddc..7a0fdbc123 100644 --- a/internal/controls/trustcenter.go +++ b/internal/controls/trustcenter.go @@ -4,12 +4,13 @@ import ( "context" "entgo.io/ent/dialect/sql" + "github.com/theopenlane/iam/auth" + "github.com/theopenlane/utils/rout" + "github.com/theopenlane/core/v2/internal/ent/generated" "github.com/theopenlane/core/v2/internal/ent/generated/privacy" "github.com/theopenlane/core/v2/internal/ent/generated/standard" "github.com/theopenlane/core/v2/pkg/logx" - "github.com/theopenlane/iam/auth" - "github.com/theopenlane/utils/rout" ) var ( @@ -17,13 +18,16 @@ var ( // used to identify controls that should be flagged as trust center controls during clone trustCenterStandardShortName = "OTS" - // trustCenterFrameworkName is the name of the framework the trust center standard belongs to, used in conjunction with trustCenterStandardShortName to identify controls that should be flagged as trust center controls during clone - trustCenterFrameworkName = "openlane-trust-center" + // TrustCenterFrameworkName is the name of the framework the trust center standard belongs to, used in conjunction with trustCenterStandardShortName to identify controls that should be flagged as trust center controls during clone + TrustCenterFrameworkName = "openlane-trust-center" + + // StandardTrustCenterFrameworkName is the name of the fremework the default standards go to + StandardTrustCenterFrameworkName = "openlane-standard" // trustCenterStandardFilter is the filter used to identify controls that should be flagged as trust center controls during clone trustCenterStandardFilter = CloneFilterOptions{ StandardShortName: &trustCenterStandardShortName, - StandardFrameworkName: &trustCenterFrameworkName, + StandardFrameworkName: &TrustCenterFrameworkName, } ) diff --git a/internal/ent/entityops/entity_registry.go b/internal/ent/entityops/entity_registry.go index 6520ef2a14..242ea0bcd2 100644 --- a/internal/ent/entityops/entity_registry.go +++ b/internal/ent/entityops/entity_registry.go @@ -6542,7 +6542,7 @@ func init() { SchemaOnboarding.Fields = []FieldDescriptor{ {Name: "company_details", Label: "CompanyDetails", Type: "map[string]interface {}", Clearable: true}, {Name: "company_name", Label: "CompanyName", Type: "string", MatchKey: true}, - {Name: "compliance", Label: "Compliance", Type: "map[string]interface {}", Clearable: true, TaskRules: []TaskRuleDescriptor{{RuleID: "framework", EachElement: "value.frameworks", Trigger: "createOnly"}, {RuleID: "framework-generic", Expression: "!(has(value.frameworks) && size(value.frameworks) > 0)", Trigger: "createOnly"}, {RuleID: "import-existing-controls", Expression: "value.existing_controls == true", Trigger: "createOnly"}, {RuleID: "import-template-controls", Expression: "!(has(value.existing_controls) && value.existing_controls == true)", Trigger: "createOnly"}, {RuleID: "import-existing-policies", Expression: "value.existing_policies_procedures == true", Trigger: "createOnly"}, {RuleID: "import-policy-templates", Expression: "!(has(value.existing_policies_procedures) && value.existing_policies_procedures == true)", Trigger: "createOnly"}, {RuleID: "has-auditor-at-onboarding", Expression: "value.has_auditor == true", Trigger: "createOnly"}, {RuleID: "wants-auditor-recommendation", Expression: "value.recommend_auditors == true", Trigger: "createOnly"}, {RuleID: "wants-partner-recommendation", Expression: "value.recommend_vciso_partner == true", Trigger: "createOnly"}}}, + {Name: "compliance", Label: "Compliance", Type: "map[string]interface {}", Clearable: true, TaskRules: []TaskRuleDescriptor{{RuleID: "framework-generic", Expression: "!(has(value.frameworks) && size(value.frameworks) > 0)", Trigger: "createOnly"}, {RuleID: "import-existing-controls", Expression: "value.existing_controls == true", Trigger: "createOnly"}, {RuleID: "import-template-controls", Expression: "!(has(value.existing_controls) && value.existing_controls == true)", Trigger: "createOnly"}, {RuleID: "import-existing-policies", Expression: "value.existing_policies_procedures == true", Trigger: "createOnly"}, {RuleID: "import-policy-templates", Expression: "!(has(value.existing_policies_procedures) && value.existing_policies_procedures == true)", Trigger: "createOnly"}, {RuleID: "has-auditor-at-onboarding", Expression: "value.has_auditor == true", Trigger: "createOnly"}, {RuleID: "wants-auditor-recommendation", Expression: "value.recommend_auditors == true", Trigger: "createOnly"}, {RuleID: "wants-partner-recommendation", Expression: "value.recommend_vciso_partner == true", Trigger: "createOnly"}}}, {Name: "deleted_at", Label: "DeletedAt", Type: "time.Time", Clearable: true}, {Name: "deleted_by", Label: "DeletedBy", Type: "string", MatchKey: true, Clearable: true}, {Name: "demo_requested", Label: "DemoRequested", Type: "bool", Clearable: true, TaskRules: []TaskRuleDescriptor{{RuleID: "demo-requested", Expression: "value == true", Trigger: "createOnly"}}}, diff --git a/internal/ent/hooks/listeners_onboarding_program.go b/internal/ent/hooks/listeners_onboarding_program.go new file mode 100644 index 0000000000..95d41c91e6 --- /dev/null +++ b/internal/ent/hooks/listeners_onboarding_program.go @@ -0,0 +1,196 @@ +package hooks + +import ( + "context" + "fmt" + "strings" + "time" + + "entgo.io/ent/dialect/sql" + "github.com/theopenlane/iam/auth" + + "github.com/theopenlane/core/common/enums" + + "github.com/theopenlane/core/v2/internal/controls" + "github.com/theopenlane/core/v2/internal/ent/entityops" + "github.com/theopenlane/core/v2/internal/ent/generated" + "github.com/theopenlane/core/v2/internal/ent/generated/standard" + "github.com/theopenlane/core/v2/internal/workflows" + "github.com/theopenlane/core/v2/pkg/gala" + "github.com/theopenlane/core/v2/pkg/jsonx" +) + +func init() { registerListeners(OnboardingProgramListeners) } + +// OnboardingProgramListeners sets up gala to process onboarding requests such as creating programs +// and cloning them in the background +func OnboardingProgramListeners() []gala.Registration { + return []gala.Registration{ + entityops.MutationListener{ + Schema: entityops.SchemaOnboarding, + Operations: []string{entityops.OpCreate}, + Caller: func(restored *auth.Caller, _ entityops.MutationPayload) *auth.Caller { + return restored.WithCapabilities(auth.CapInternalOperation) + }, + Handle: handleOnboardingProgram, + }, + } +} + +func handleOnboardingProgram(inv entityops.Invocation, _ entityops.MutationPayload) error { + // cannot use MutationPayload because the organization_id needed is actually stored by the hook + // so it will not be available here + record, ok, err := entityops.LoadEntity(inv.Context, inv.EntityID, inv.Client.Onboarding.Get) + if err != nil || !ok { + return err + } + + if len(record.Compliance) == 0 { + return nil + } + + _, err = workflows.WithTx(inv.Context, inv.Client, nil, func(tx *generated.Tx) (struct{}, error) { + return struct{}{}, createProgram(inv.Context, tx.Client(), record.OrganizationID, record.Compliance) + }) + + return err +} + +func generateProgramName(standards []*generated.Standard, year int) string { + if len(standards) == 1 { + name := standards[0].ShortName + if name == "" { + name = standards[0].Name + } + + return fmt.Sprintf("%s Program %d", name, year) + } + + return fmt.Sprintf("Compliance Program %d", year) +} + +func createProgram(ctx context.Context, client *generated.Client, orgID string, complianceData map[string]interface{}) error { + standards, labels, err := resolveOnboardingStandards(ctx, client, complianceData) + if err != nil || len(labels) == 0 { + return err + } + + currentYear := time.Now().Year() + + frameworks := strings.Join(labels, ", ") + + description := fmt.Sprintf("Track %s compliance activities, evidence, and audit readiness for %d.", frameworks, currentYear) + + builder := client.Program.Create(). + SetName(generateProgramName(standards, currentYear)). + SetDescription(description). + SetFrameworkName(frameworks) + + if auditor, ok := complianceData["auditor_name"].(string); ok && auditor != "" { + builder.SetAuditor(auditor) + } + + if email, ok := complianceData["auditor_email"].(string); ok && email != "" { + builder.SetAuditorEmail(email) + } + + program, err := builder.Save(ctx) + if err != nil { + return err + } + + for _, std := range standards { + filters := controls.CloneFilterOptions{StandardID: &std.ID} + if std.Framework == "soc2" { + filters.Categories = []string{"Security"} + } + + where, err := controls.ControlFilterByStandard(ctx, filters, std) + if err != nil { + return err + } + + sources, err := client.Control.Query().Where(where...).WithStandard().WithSubcontrols().All(ctx) + if err != nil { + return err + } + + // can't use worker pool here since we are in a tx + for _, source := range sources { + input, _ := controls.CreateCloneControlInput(source, &program.ID, orgID) + cloned, err := client.Control.Create().SetInput(input).Save(ctx) + if err != nil { + return err + } + + for _, subcontrol := range source.Edges.Subcontrols { + input := controls.CreateCloneSubcontrolInput(subcontrol, orgID, controls.SubcontrolToCreate{RefControl: source}) + input.ControlID = cloned.ID + if err := client.Subcontrol.Create().SetInput(*input).Exec(ctx); err != nil { + return err + } + } + } + } + + return nil +} + +func getOnboardingFrameworks(complianceData map[string]interface{}) ([]string, error) { + var frameworks []string + if err := jsonx.RoundTrip(complianceData["frameworks"], &frameworks); err != nil { + return nil, fmt.Errorf("invalid onboarding frameworks: %w", err) + } + + return frameworks, nil +} + +func resolveOnboardingStandards(ctx context.Context, client *generated.Client, complianceData map[string]interface{}) ([]*generated.Standard, []string, error) { + frameworks, err := getOnboardingFrameworks(complianceData) + if err != nil || len(frameworks) == 0 { + return nil, nil, err + } + + standards := make([]*generated.Standard, 0, len(frameworks)) + + labels := make([]string, 0, len(frameworks)) + seen := make(map[string]bool, len(frameworks)) + + for _, framework := range frameworks { + if seen[framework] { + continue + } + + seen[framework] = true + + if framework == "other" { + labels = append(labels, "Other") + continue + } + + std, err := client.Standard.Query(). + Where( + standard.FrameworkEQ(framework), + standard.StatusEQ(enums.StandardActive), + standard.SystemOwned(true), + ). + Order( + standard.ByVersion(sql.OrderDesc()), + standard.ByID(), + ). + First(ctx) + if err != nil { + return nil, nil, fmt.Errorf("resolve onboarding framework %q: %w", framework, err) + } + + standards = append(standards, std) + label := std.ShortName + if label == "" { + label = std.Name + } + + labels = append(labels, label) + } + + return standards, labels, nil +} diff --git a/internal/ent/hooks/listeners_registry.go b/internal/ent/hooks/listeners_registry.go index 41693bb72a..aaaa80f89d 100644 --- a/internal/ent/hooks/listeners_registry.go +++ b/internal/ent/hooks/listeners_registry.go @@ -22,10 +22,9 @@ import ( // functions in this package var listenerProviders []func() []gala.Registration -// registerListeners adds a listener family constructor to the registry; call it from an -// init function in the file that declares the constructor -func registerListeners(provider func() []gala.Registration) { - listenerProviders = append(listenerProviders, provider) +// registerListeners takes in a variadic amount of listeners and sets them up +func registerListeners(providers ...func() []gala.Registration) { + listenerProviders = append(listenerProviders, providers...) } // AllListeners builds the registrations for every listener family in this package; the diff --git a/internal/ent/hooks/listeners_task_rules.go b/internal/ent/hooks/listeners_task_rules.go index d5ae2b36fb..d99b7a9bd0 100644 --- a/internal/ent/hooks/listeners_task_rules.go +++ b/internal/ent/hooks/listeners_task_rules.go @@ -18,7 +18,6 @@ import ( "github.com/theopenlane/core/v2/internal/ent/generated" "github.com/theopenlane/core/v2/internal/ent/generated/notification" "github.com/theopenlane/core/v2/internal/ent/generated/organization" - "github.com/theopenlane/core/v2/internal/ent/generated/standard" "github.com/theopenlane/core/v2/internal/ent/generated/task" "github.com/theopenlane/core/v2/internal/ent/taskrules" "github.com/theopenlane/core/v2/pkg/celx" @@ -97,7 +96,7 @@ func handleTaskRuleMutation(inv entityops.Invocation, payload entityops.Mutation continue } - rendered, err := evaluateRule(inv.Context, inv.Client, fieldRule.Rule, value, placeholders) + rendered, err := evaluateRule(inv.Context, fieldRule.Rule, value, placeholders) if err != nil { logx.FromContext(inv.Context).Error().Err(err).Str("rule", fieldRule.Rule.RuleID).Msg("entityops: task rule evaluation failed") @@ -234,10 +233,10 @@ type renderedTask struct { } // evaluateRule evaluates one rule against value: for EachElement rules it expands into one -// renderedTask per list element, resolving {label} via any registered resolver; for Expression +// renderedTask per list element; for Expression // rules it fires at most one renderedTask when the condition is true. placeholders carries the // firing entity's own fields (e.g. {id}, {body}), available regardless of which case fires -func evaluateRule(ctx context.Context, client *generated.Client, rule entityops.TaskRuleDescriptor, value any, placeholders map[string]string) ([]renderedTask, error) { +func evaluateRule(ctx context.Context, rule entityops.TaskRuleDescriptor, value any, placeholders map[string]string) ([]renderedTask, error) { tmpl, ok := taskrules.Lookup(rule.RuleID) if !ok { return nil, fmt.Errorf("%w: %s", ErrMissingTaskTemplate, rule.RuleID) @@ -264,9 +263,8 @@ func evaluateRule(ctx context.Context, client *generated.Client, rule entityops. for _, element := range elements { elementValue := fmt.Sprint(element) - label := resolveLabel(ctx, client, rule.RuleID, elementValue) - rendered, err := renderTask(tmpl, rule.RuleID, elementValue, label, placeholders) + rendered, err := renderTask(tmpl, rule.RuleID, elementValue, elementValue, placeholders) if err != nil { return nil, err } @@ -513,49 +511,3 @@ func evaluateCELList(ctx context.Context, expression string, value any) ([]any, return list, nil } - -// TaskLabelResolver resolves a human-readable label for one EachElement value filling the {label} placeholder in a task template -type TaskLabelResolver func(ctx context.Context, client *generated.Client, value string) string - -var taskLabelResolvers = map[string]TaskLabelResolver{ - taskrules.RuleFramework: resolveFrameworkLabel, -} - -// resolveLabel looks up value's label via any resolver registered for ruleID, falling back to -// value itself when none is registered or the resolver comes up empty -func resolveLabel(ctx context.Context, client *generated.Client, ruleID, value string) string { - resolver, ok := taskLabelResolvers[ruleID] - if !ok { - return value - } - - if label := resolver(ctx, client, value); label != "" { - return label - } - - return value -} - -// resolveFrameworkLabel resolves a framework code (the value submitted for the "frameworks" -// onboarding question, see internal/onboarding/catalog.go's getFrameworkOptions) to its display name -func resolveFrameworkLabel(ctx context.Context, client *generated.Client, value string) string { - if client == nil { - return value - } - - std, err := client.Standard.Query(). - Where( - standard.FrameworkEQ(value), - standard.StatusEQ(enums.StandardActive), - ). - First(ctx) - if err != nil { - return value - } - - if std.ShortName != "" { - return std.ShortName - } - - return std.Name -} diff --git a/internal/ent/hooks/listeners_task_rules_test.go b/internal/ent/hooks/listeners_task_rules_test.go index b1b7c7db7a..f56ebef651 100644 --- a/internal/ent/hooks/listeners_task_rules_test.go +++ b/internal/ent/hooks/listeners_task_rules_test.go @@ -4,17 +4,21 @@ package hooks_test import ( "context" + "fmt" + "time" "github.com/brianvoe/gofakeit/v7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/theopenlane/core/common/enums" + "github.com/theopenlane/iam/auth" + "github.com/theopenlane/core/v2/internal/ent/generated" "github.com/theopenlane/core/v2/internal/ent/generated/privacy" + "github.com/theopenlane/core/v2/internal/ent/generated/program" "github.com/theopenlane/core/v2/internal/ent/generated/task" "github.com/theopenlane/core/v2/internal/ent/taskrules" - "github.com/theopenlane/iam/auth" ) func (suite *HookTestSuite) TestTaskRuleListenersCreateSuggestedTasks() { @@ -49,6 +53,7 @@ func (suite *HookTestSuite) TestTaskRuleListenersCreateSuggestedTasks() { // onboarding compliance answers were left blank, so the unanswered-fallback rule fires assert.Contains(t, sourceKeys, "onboarding-"+taskrules.RuleImportTemplateControls) + assert.Contains(t, sourceKeys, "onboarding-"+taskrules.RuleFrameworkGeneric) } // TestTaskRuleListenersNotificationTaskOwnerAttribution guards against a suggested task @@ -103,37 +108,55 @@ func (suite *HookTestSuite) TestTaskRuleListenersNotificationTaskOwnerAttributio assert.Empty(t, tasksB) } -func (suite *HookTestSuite) TestTaskRuleListenersFrameworkLinkIncludesAuditorParams() { +func (suite *HookTestSuite) TestOnboardingCreatesProgramWithSelectedFrameworks() { t := suite.T() - user := suite.seedUser() + ctx := generated.NewContext(auth.NewTestContextWithOrgID(user.ID, user.Edges.OrgMemberships[0].OrganizationID), suite.client) + ctx = privacy.DecisionContext(ctx, privacy.Allow) - userCtx := auth.NewTestContextWithOrgID(user.ID, user.Edges.OrgMemberships[0].ID) - userCtx = generated.NewContext(userCtx, suite.client) - ctx := privacy.DecisionContext(userCtx, privacy.Allow) - ctx = generated.NewContext(ctx, suite.client) - - // the standard must exist before the onboarding mutation fires the listener; the - // production catalog is seeded system-wide ahead of any onboarding admin := suite.seedSystemAdmin() - - sysCtx := auth.NewTestContextForSystemAdmin(admin.ID, admin.Edges.OrgMemberships[0].ID) - sysCtx = generated.NewContext(sysCtx, suite.client) - - _, err := suite.client.Standard.Create(). - SetSystemOwned(true). - SetIsPublic(true). - SetFramework("iso27001"). - SetShortName("ISO 27001"). - SetName("ISO/IEC 27001"). - SetStatus(enums.StandardActive). - Save(sysCtx) - require.NoError(t, err) + sysCtx := generated.NewContext(auth.NewTestContextForSystemAdmin(admin.ID, admin.Edges.OrgMemberships[0].OrganizationID), suite.client) + + for _, framework := range []struct { + code string + label string + }{ + {code: "soc2", label: "SOC 2"}, + {code: "iso27001", label: "ISO 27001"}, + } { + std, err := suite.client.Standard.Create(). + SetSystemOwned(true). + SetIsPublic(true). + SetFramework(framework.code). + SetShortName(framework.label). + SetName(framework.label). + SetStatus(enums.StandardActive). + Save(sysCtx) + require.NoError(t, err) + + for _, category := range []string{"Security", "Availability", "Confidentiality", "Processing Integrity", "Privacy"} { + control, err := suite.client.Control.Create(). + SetSystemOwned(true). + SetStandardID(std.ID). + SetRefCode(framework.code + "-" + category). + SetCategory(category). + Save(sysCtx) + require.NoError(t, err) + + _, err = suite.client.Subcontrol.Create(). + SetSystemOwned(true). + SetControlID(control.ID). + SetRefCode(control.RefCode + "-1"). + SetCategory(category). + Save(sysCtx) + require.NoError(t, err) + } + } onboarding, err := suite.client.Onboarding.Create().SetInput(generated.CreateOnboardingInput{ - CompanyName: "Framework Link Co", + CompanyName: "Framework Program Co", Compliance: map[string]interface{}{ - "frameworks": []interface{}{"soc2", "iso27001"}, + "frameworks": []interface{}{"soc2", "iso27001", "soc2"}, "auditor_name": "Jane Doe", "auditor_email": "jane@example.com", }, @@ -142,16 +165,40 @@ func (suite *HookTestSuite) TestTaskRuleListenersFrameworkLinkIncludesAuditorPar suite.waitForEvents() + created, err := suite.client.Program.Query().Where(program.OwnerIDEQ(onboarding.OrganizationID)). + WithControls(func(q *generated.ControlQuery) { q.WithSubcontrols() }). + WithMembers().Only(ctx) + + require.NoError(t, err) + assert.Equal(t, "SOC 2, ISO 27001", created.FrameworkName) + assert.Equal(t, fmt.Sprintf("Compliance Program %d", time.Now().Year()), created.Name) + assert.Equal(t, "Jane Doe", created.Auditor) + assert.Equal(t, "jane@example.com", created.AuditorEmail) + require.Len(t, created.Edges.Members, 1) + + refs := make([]string, 0, len(created.Edges.Controls)) + + for _, control := range created.Edges.Controls { + refs = append(refs, control.RefCode) + assert.Equal(t, onboarding.OrganizationID, control.OwnerID) + assert.False(t, control.SystemOwned) + require.Len(t, control.Edges.Subcontrols, 1) + assert.Equal(t, control.RefCode+"-1", control.Edges.Subcontrols[0].RefCode) + assert.Equal(t, onboarding.OrganizationID, control.Edges.Subcontrols[0].OwnerID) + assert.Equal(t, control.ID, control.Edges.Subcontrols[0].ControlID) + } + assert.ElementsMatch(t, []string{ + "soc2-Security", "iso27001-Security", "iso27001-Availability", + "iso27001-Confidentiality", "iso27001-Processing Integrity", "iso27001-Privacy", + }, refs) + tasks, err := suite.client.Task.Query().Where(task.OwnerIDEQ(onboarding.OrganizationID)).All(ctx) require.NoError(t, err) + require.NotEmpty(t, tasks) - links := make(map[string]string, len(tasks)) for _, tk := range tasks { - if link, ok := tk.Metadata["link"].(string); ok { - links[tk.SourceKey] = link - } + assert.NotEqual(t, "onboarding-framework-soc2", tk.SourceKey) + assert.NotEqual(t, "onboarding-framework-iso27001", tk.SourceKey) + assert.NotEqual(t, "onboarding-"+taskrules.RuleFrameworkGeneric, tk.SourceKey) } - - assert.Equal(t, "/programs/create/soc2?onboarding=true&auditorName=Jane Doe&auditorEmail=jane@example.com", links["onboarding-framework-soc2"]) - assert.Equal(t, "/programs/create/framework-based?onboarding=true&framework=ISO 27001&auditorName=Jane Doe&auditorEmail=jane@example.com", links["onboarding-framework-iso27001"]) } diff --git a/internal/ent/hooks/onboarding.go b/internal/ent/hooks/onboarding.go index 5f3aae79e5..6f09e2ab80 100644 --- a/internal/ent/hooks/onboarding.go +++ b/internal/ent/hooks/onboarding.go @@ -56,6 +56,8 @@ func HookOnboarding() ent.Hook { } } + compliance, _ := m.Compliance() + v, err := next.Mutate(ctx, m) if err != nil { return nil, err @@ -68,7 +70,6 @@ func HookOnboarding() ent.Hook { companyDetails, _ := m.CompanyDetails() userDetails, _ := m.UserDetails() - compliance, _ := m.Compliance() demoRequested, _ := m.DemoRequested() if err := sendSystemSlack(ctx, slackdef.DemoRequestOp.Name(), slackdef.DemoRequestMessage{ diff --git a/internal/ent/hooks/onboarding_test.go b/internal/ent/hooks/onboarding_test.go index aead8be66b..f3824a2ab2 100644 --- a/internal/ent/hooks/onboarding_test.go +++ b/internal/ent/hooks/onboarding_test.go @@ -1,15 +1,22 @@ package hooks_test import ( + "fmt" "testing" + "time" "github.com/brianvoe/gofakeit/v7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/theopenlane/core/common/enums" + "github.com/theopenlane/iam/auth" + "github.com/theopenlane/core/v2/internal/ent/generated" "github.com/theopenlane/core/v2/internal/ent/generated/organization" "github.com/theopenlane/core/v2/internal/ent/generated/privacy" - "github.com/theopenlane/iam/auth" + "github.com/theopenlane/core/v2/internal/ent/generated/program" + "github.com/theopenlane/core/v2/internal/ent/generated/task" + "github.com/theopenlane/core/v2/internal/ent/taskrules" ) func (suite *HookTestSuite) TestHookOnboarding() { @@ -114,3 +121,85 @@ func (suite *HookTestSuite) TestHookOnboarding() { }) } } + +func (suite *HookTestSuite) TestOnboardingProgramFrameworkSelections() { + t := suite.T() + + admin := suite.seedSystemAdmin() + sysCtx := generated.NewContext(auth.NewTestContextForSystemAdmin(admin.ID, admin.Edges.OrgMemberships[0].OrganizationID), suite.client) + framework := gofakeit.UUID() + + std, err := suite.client.Standard.Create().SetName(framework).SetShortName(framework). + SetFramework(framework).SetIsPublic(true).SetSystemOwned(true). + SetStatus(enums.StandardActive).Save(sysCtx) + require.NoError(t, err) + + _, err = suite.client.Control.Create().SetStandardID(std.ID). + SetSystemOwned(true).SetRefCode("TEST-1").Save(sysCtx) + require.NoError(t, err) + + for _, tc := range []struct { + name string + compliance map[string]interface{} + programCount int + controlCount int + }{ + {name: "no compliance"}, + {name: "no frameworks", compliance: map[string]interface{}{"existing_controls": true}}, + {name: "empty frameworks", compliance: map[string]interface{}{"frameworks": []string{}}}, + { + name: "single framework", + compliance: map[string]interface{}{"frameworks": []string{framework}}, + programCount: 1, + controlCount: 1, + }, + { + name: "other framework", + compliance: map[string]interface{}{"frameworks": []string{"other"}}, + programCount: 1, + }, + } { + + t.Run(tc.name, func(t *testing.T) { + user := suite.seedUser() + ctx := generated.NewContext(auth.NewTestContextWithOrgID(user.ID, user.Edges.OrgMemberships[0].OrganizationID), suite.client) + ctx = privacy.DecisionContext(ctx, privacy.Allow) + + onboarding, err := suite.client.Onboarding.Create().SetInput(generated.CreateOnboardingInput{ + CompanyName: gofakeit.Company(), + Compliance: tc.compliance, + }).Save(ctx) + + require.NoError(t, err) + + suite.waitForEvents() + + programs, err := suite.client.Program.Query().Where(program.OwnerIDEQ(onboarding.OrganizationID)).WithControls().All(ctx) + require.NoError(t, err) + require.Len(t, programs, tc.programCount) + + if tc.programCount > 0 { + assert.Len(t, programs[0].Edges.Controls, tc.controlCount) + year := time.Now().Year() + expectedName := fmt.Sprintf("Compliance Program %d", year) + if tc.name == "single framework" { + expectedName = fmt.Sprintf("%s Program %d", framework, year) + } + assert.Equal(t, expectedName, programs[0].Name) + assert.Equal(t, fmt.Sprintf("Track %s compliance activities, evidence, and audit readiness for %d.", programs[0].FrameworkName, year), programs[0].Description) + } + + taskCount, err := suite.client.Task.Query().Where( + task.OwnerIDEQ(onboarding.OrganizationID), + task.SourceKeyEQ("onboarding-"+taskrules.RuleFrameworkGeneric), + ).Count(ctx) + require.NoError(t, err) + + if tc.programCount == 0 { + assert.Equal(t, 1, taskCount) + } else { + assert.Zero(t, taskCount) + } + }) + } +} diff --git a/internal/ent/hooks/tools_test.go b/internal/ent/hooks/tools_test.go index e956e28c17..4baec07084 100644 --- a/internal/ent/hooks/tools_test.go +++ b/internal/ent/hooks/tools_test.go @@ -162,6 +162,9 @@ func (suite *HookTestSuite) setupClient() *generated.Client { _, err = gala.Register(galaRuntime, hooks.TaskRuleListeners()...) require.NoError(t, err) + _, err = gala.Register(galaRuntime, hooks.OnboardingProgramListeners()...) + require.NoError(t, err) + _, err = gala.Register(galaRuntime, hooks.CampaignRecurringListeners()...) require.NoError(t, err) diff --git a/internal/ent/taskrules/onboarding.go b/internal/ent/taskrules/onboarding.go index 07fa457926..233b28104a 100644 --- a/internal/ent/taskrules/onboarding.go +++ b/internal/ent/taskrules/onboarding.go @@ -4,7 +4,6 @@ import "github.com/theopenlane/entx" // Onboarding compliance rule IDs const ( - RuleFramework = "framework" RuleFrameworkGeneric = "framework-generic" RuleImportExistingControls = "import-existing-controls" RuleImportTemplateControls = "import-template-controls" @@ -18,11 +17,6 @@ const ( // OnboardingComplianceRules generate suggested tasks from the onboarding compliance answers var OnboardingComplianceRules = []entx.TaskRuleSpec{ - { - RuleID: RuleFramework, - EachElement: "value.frameworks", - Trigger: entx.TaskRuleOnCreateOnly, - }, { RuleID: RuleFrameworkGeneric, Expression: "!(has(value.frameworks) && size(value.frameworks) > 0)", diff --git a/internal/ent/taskrules/templates/onboarding.yaml b/internal/ent/taskrules/templates/onboarding.yaml index b3b511a21e..a5042f48d3 100644 --- a/internal/ent/taskrules/templates/onboarding.yaml +++ b/internal/ent/taskrules/templates/onboarding.yaml @@ -1,14 +1,4 @@ rules: - framework: - title: Set up your {{.label}} program - taskKindName: Operational - details: | - Kick your program off by creating a program with the framework controls - priority: 10 - metadata: - docsLink: https://docs.theopenlane.io/docs/platform/compliance-management/programs/overview - link: '{{if eq .value "soc2"}}/programs/create/soc2?onboarding=true{{else}}/programs/create/framework-based?onboarding=true&framework={{.label}}{{end}}{{if .auditor_name}}&auditorName={{.auditor_name}}{{end}}{{if .auditor_email}}&auditorEmail={{.auditor_email}}{{end}}' - framework-generic: title: Set up your compliance program taskKindName: Operational diff --git a/internal/graphapi/control_test.go b/internal/graphapi/control_test.go index 0ab38a241c..724ea493e4 100644 --- a/internal/graphapi/control_test.go +++ b/internal/graphapi/control_test.go @@ -6,6 +6,7 @@ import ( "slices" "testing" + "github.com/theopenlane/core/v2/internal/controls" th "github.com/theopenlane/core/v2/internal/graphapi/testharness" "gotest.tools/v3/assert" @@ -17,6 +18,7 @@ import ( "github.com/theopenlane/core/common/enums" "github.com/theopenlane/core/common/models" + "github.com/theopenlane/core/v2/internal/ent/generated" controlgen "github.com/theopenlane/core/v2/internal/ent/generated/control" "github.com/theopenlane/core/v2/internal/graphapi/gqlerrors" @@ -1050,7 +1052,7 @@ func TestMutationCreateControlsByCloneOpenlaneControls(t *testing.T) { standard := (&th.StandardBuilder{ Client: suite.Client, Name: "Openlane Baseline", - Framework: "openlane-standard", + Framework: controls.StandardTrustCenterFrameworkName, IsPublic: true, }).MustNew(th.SharedSystemAdminUser.UserCtx, t) diff --git a/internal/graphapi/eventstest/listeners_onboarding_program_test.go b/internal/graphapi/eventstest/listeners_onboarding_program_test.go new file mode 100644 index 0000000000..39a88d04da --- /dev/null +++ b/internal/graphapi/eventstest/listeners_onboarding_program_test.go @@ -0,0 +1,61 @@ +//go:build test + +package eventstest_test + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + + "github.com/theopenlane/core/v2/internal/ent/generated" + "github.com/theopenlane/core/v2/internal/ent/generated/privacy" + "github.com/theopenlane/core/v2/internal/ent/generated/program" + "github.com/theopenlane/core/v2/internal/ent/hooks" + "github.com/theopenlane/core/v2/internal/graphapi" + th "github.com/theopenlane/core/v2/internal/graphapi/testharness" +) + +func TestOnboardingProgramListener(t *testing.T) { + + setup, err := graphapi.SetupListenerRuntime(suite.GalaRuntime, hooks.OnboardingProgramListeners()) + assert.NilError(t, err) + defer setup.Teardown() + + user := suite.UserBuilder(context.Background(), t) + ctx := th.SetContext(user.UserCtx, suite.Client.DB) + allowCtx := privacy.DecisionContext(ctx, privacy.Allow) + + tx, err := suite.Client.DB.Tx(ctx) + assert.NilError(t, err) + defer tx.Rollback() + txCtx := generated.NewContext(generated.NewTxContext(ctx, tx), tx.Client()) + + onboarding, err := tx.Client().Onboarding.Create(). + SetInput(generated.CreateOnboardingInput{ + CompanyName: "Program Co one", + Compliance: map[string]interface{}{ + "frameworks": []string{"other", "other"}, + "auditor_name": "New Auditor", + "auditor_email": "auditor@example.com", + }, + }).Save(txCtx) + assert.NilError(t, err) + + ok, err := tx.Client().Program.Query(). + Where(program.OwnerID(onboarding.OrganizationID)). + Exist(privacy.DecisionContext(txCtx, privacy.Allow)) + assert.NilError(t, err) + assert.Assert(t, !ok) + assert.NilError(t, tx.Commit()) + waitForGala(t, setup.Runtime) + + created, err := suite.Client.DB.Program.Query(). + Where(program.OwnerID(onboarding.OrganizationID)). + Only(allowCtx) + assert.NilError(t, err) + assert.Equal(t, created.FrameworkName, "Other") + assert.Equal(t, created.Auditor, "New Auditor") + assert.Equal(t, created.AuditorEmail, "auditor@example.com") + +} diff --git a/internal/graphapi/trustcenter_test.go b/internal/graphapi/trustcenter_test.go index 16305b6a2e..2bd7b02d24 100644 --- a/internal/graphapi/trustcenter_test.go +++ b/internal/graphapi/trustcenter_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/theopenlane/core/v2/internal/controls" th "github.com/theopenlane/core/v2/internal/graphapi/testharness" "github.com/99designs/gqlgen/graphql" @@ -21,6 +22,7 @@ import ( "github.com/theopenlane/core/common/enums" "github.com/theopenlane/core/common/jobspec" + "github.com/theopenlane/core/v2/internal/ent/generated" "github.com/theopenlane/core/v2/internal/ent/generated/customdomain" "github.com/theopenlane/core/v2/internal/graphapi/testclient" @@ -234,7 +236,12 @@ func TestMutationCreateTrustCenter(t *testing.T) { customDomain := (&th.CustomDomainBuilder{Client: suite.Client}).MustNew(localTestUser.Owner.UserCtx, t) // create trust center standard - trustCenterControlStd := (&th.StandardBuilder{Client: suite.Client, Name: "OTS", Framework: "openlane-trust-center", IsPublic: true}).MustNew(th.SharedSystemAdminUser.UserCtx, t) + trustCenterControlStd := (&th.StandardBuilder{ + Client: suite.Client, + Name: "OTS", + IsPublic: true, + Framework: controls.TrustCenterFrameworkName, + }).MustNew(th.SharedSystemAdminUser.UserCtx, t) trustCenterControlIDs := []string{} numTrustCenterControls := 5 diff --git a/internal/onboarding/catalog.go b/internal/onboarding/catalog.go index 2bf5b547b6..a0df505d70 100644 --- a/internal/onboarding/catalog.go +++ b/internal/onboarding/catalog.go @@ -6,6 +6,8 @@ import ( "github.com/theopenlane/core/common/enums" "github.com/theopenlane/core/common/models" + + "github.com/theopenlane/core/v2/internal/controls" "github.com/theopenlane/core/v2/internal/ent/generated" "github.com/theopenlane/core/v2/internal/ent/generated/privacy" "github.com/theopenlane/core/v2/internal/ent/generated/standard" @@ -93,7 +95,7 @@ func getFrameworkOptions(ctx context.Context, client *generated.Client) ([]model standards, err := client.Standard.Query(). Where( standard.StatusEQ(enums.StandardActive), - standard.FrameworkNotIn("openlane-standard", "openlane-trust-center"), + standard.FrameworkNotIn(controls.StandardTrustCenterFrameworkName, controls.TrustCenterFrameworkName), standard.SystemOwned(true), standard.IsPublic(true), ).