diff --git a/apps/console/src/components/form/ControlMaturityLevelOptions.tsx b/apps/console/src/components/form/ControlMaturityLevelOptions.tsx new file mode 100644 index 000000000..cda11fae7 --- /dev/null +++ b/apps/console/src/components/form/ControlMaturityLevelOptions.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { + controlMaturityLevels, + getControlMaturityLevelLabel, +} from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { Option } from "@probo/ui"; + +export const MATURITY_LEVEL_UNSET = "__unset__" as const; + +export function ControlMaturityLevelOptions() { + const { __ } = useTranslate(); + + return ( + <> + + {controlMaturityLevels.map(level => ( + + ))} + + ); +} diff --git a/apps/console/src/hooks/graph/FrameworkGraph.ts b/apps/console/src/hooks/graph/FrameworkGraph.ts index fe671a774..e41ebea66 100644 --- a/apps/console/src/hooks/graph/FrameworkGraph.ts +++ b/apps/console/src/hooks/graph/FrameworkGraph.ts @@ -120,6 +120,7 @@ export const frameworkControlNodeQuery = graphql` bestPractice implemented notImplementedJustification + maturityLevel canUpdate: permission(action: "core:control:update") canDelete: permission(action: "core:control:delete") canCreateMeasureMapping: permission( diff --git a/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx b/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx index d08c6eaa2..5a4c74edb 100644 --- a/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx +++ b/apps/console/src/pages/organizations/frameworks/FrameworkControlPage.tsx @@ -12,7 +12,11 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { formatError, type GraphQLError } from "@probo/helpers"; +import { + formatError, + getControlMaturityLevelLabel, + type GraphQLError, +} from "@probo/helpers"; import { promisifyMutation } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { @@ -390,6 +394,14 @@ export default function FrameworkControlPage({ queryRef }: Props) {
{control.notImplementedJustification}
)} +
+ {__("Maturity level")} + + {control.maturityLevel + ? getControlMaturityLevelLabel(__, control.maturityLevel) + : __("Not set")} + +
diff --git a/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx b/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx index c5fab251b..5ade90c7d 100644 --- a/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx +++ b/apps/console/src/pages/organizations/frameworks/dialogs/FrameworkControlDialog.tsx @@ -21,6 +21,7 @@ import { DialogContent, DialogFooter, Input, + Select, Textarea, useDialogRef, } from "@probo/ui"; @@ -31,6 +32,10 @@ import { graphql } from "relay-runtime"; import { z } from "zod"; import type { FrameworkControlDialogFragment$key } from "#/__generated__/core/FrameworkControlDialogFragment.graphql"; +import { + ControlMaturityLevelOptions, + MATURITY_LEVEL_UNSET, +} from "#/components/form/ControlMaturityLevelOptions"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; @@ -50,6 +55,7 @@ const controlFragment = graphql` bestPractice implemented notImplementedJustification + maturityLevel } `; @@ -85,6 +91,15 @@ const schema = z.object({ bestPractice: z.boolean(), implemented: z.enum(["IMPLEMENTED", "NOT_IMPLEMENTED"]), notImplementedJustification: z.string().optional().nullable(), + maturityLevel: z.enum([ + MATURITY_LEVEL_UNSET, + "NONE", + "INITIAL", + "MANAGED", + "DEFINED", + "QUANTITATIVELY_MANAGED", + "OPTIMIZING", + ]), }); export function FrameworkControlDialog(props: Props) { @@ -103,7 +118,7 @@ export function FrameworkControlDialog(props: Props) { }, ); - const defaultValues = useMemo( + const defaultValues = useMemo>( () => ({ name: frameworkControl?.name ?? "", description: frameworkControl?.description ?? "", @@ -111,6 +126,7 @@ export function FrameworkControlDialog(props: Props) { bestPractice: frameworkControl?.bestPractice ?? true, implemented: frameworkControl?.implemented ?? "IMPLEMENTED", notImplementedJustification: frameworkControl?.notImplementedJustification ?? "", + maturityLevel: frameworkControl?.maturityLevel ?? MATURITY_LEVEL_UNSET, }), [frameworkControl], ); @@ -126,8 +142,13 @@ export function FrameworkControlDialog(props: Props) { const bestPracticeValue = watch("bestPractice"); const implementedValue = watch("implemented"); + const maturityLevelValue = watch("maturityLevel"); const onSubmit = async (data: z.infer) => { + const maturityLevel = data.maturityLevel === MATURITY_LEVEL_UNSET + ? null + : data.maturityLevel; + if (frameworkControl) { await mutate({ variables: { @@ -139,6 +160,7 @@ export function FrameworkControlDialog(props: Props) { bestPractice: data.bestPractice, implemented: data.implemented, notImplementedJustification: data.implemented === "IMPLEMENTED" ? null : (data.notImplementedJustification || null), + maturityLevel, }, }, }); @@ -153,6 +175,7 @@ export function FrameworkControlDialog(props: Props) { bestPractice: data.bestPractice ?? true, implemented: data.implemented ?? "IMPLEMENTED", notImplementedJustification: data.implemented === "IMPLEMENTED" ? null : (data.notImplementedJustification || null), + maturityLevel, }, connections: [props.connectionId!], }, @@ -226,6 +249,17 @@ export function FrameworkControlDialog(props: Props) { {...register("notImplementedJustification")} /> )} +
+ {__("Maturity level")} + +
diff --git a/apps/console/src/pages/organizations/statements-of-applicability/tabs/StatementOfApplicabilityControlsTab.tsx b/apps/console/src/pages/organizations/statements-of-applicability/tabs/StatementOfApplicabilityControlsTab.tsx index b421e54e4..848a30038 100644 --- a/apps/console/src/pages/organizations/statements-of-applicability/tabs/StatementOfApplicabilityControlsTab.tsx +++ b/apps/console/src/pages/organizations/statements-of-applicability/tabs/StatementOfApplicabilityControlsTab.tsx @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { getControlMaturityLevelLabel } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { ActionDropdown, @@ -87,6 +88,7 @@ export const controlsFragment = graphql` bestPractice implemented notImplementedJustification + maturityLevel regulatory contractual riskAssessment @@ -151,6 +153,7 @@ export default function StatementOfApplicabilityControlsTab({ bestPractice: edge.node.control.bestPractice, implemented: edge.node.control.implemented, notImplementedJustification: edge.node.control.notImplementedJustification, + maturityLevel: edge.node.control.maturityLevel, regulatory: edge.node.control.regulatory, contractual: edge.node.control.contractual, riskAssessment: edge.node.control.riskAssessment, @@ -220,14 +223,15 @@ export default function StatementOfApplicabilityControlsTab({ - - - - - - - - + + + + + + + + + {(canUpdate || canDelete) && ( )} @@ -237,7 +241,7 @@ export default function StatementOfApplicabilityControlsTab({ {linkedControls.length === 0 && ( +
{__("Framework")}{__("Control")}{__("Applicability")}{__("Implemented")}{__("Regulatory")}{__("Contractual")}{__("Best Practice")}{__("Risk Assessment")}{__("Framework")}{__("Control")}{__("Applicability")}{__("Implemented")}{__("Maturity")}{__("Regulatory")}{__("Contractual")}{__("Best Practice")}{__("Risk Assessment")}
{__("No controls linked")} @@ -302,6 +306,17 @@ export default function StatementOfApplicabilityControlsTab({ )} + {control.applicability === false + ? - + : ( + + {control.maturityLevel + ? getControlMaturityLevelLabel(__, control.maturityLevel) + : __("Not set")} + + )} + {control.applicability === false ? - diff --git a/e2e/console/control_test.go b/e2e/console/control_test.go index e8bfb330f..f4fbb4531 100644 --- a/e2e/console/control_test.go +++ b/e2e/console/control_test.go @@ -522,6 +522,178 @@ func TestControl_OmittableDescription(t *testing.T) { }) } +func TestControl_MaturityLevel(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + frameworkID := factory.CreateFramework(owner, factory.Attrs{"name": "Framework for Maturity Tests"}) + + createControlQuery := ` + mutation CreateControl($input: CreateControlInput!) { + createControl(input: $input) { + controlEdge { + node { + id + maturityLevel + } + } + } + } + ` + + updateControlQuery := ` + mutation UpdateControl($input: UpdateControlInput!) { + updateControl(input: $input) { + control { + id + maturityLevel + } + } + } + ` + + type createResult struct { + CreateControl struct { + ControlEdge struct { + Node struct { + ID string `json:"id"` + MaturityLevel *string `json:"maturityLevel"` + } `json:"node"` + } `json:"controlEdge"` + } `json:"createControl"` + } + + type updateResult struct { + UpdateControl struct { + Control struct { + ID string `json:"id"` + MaturityLevel *string `json:"maturityLevel"` + } `json:"control"` + } `json:"updateControl"` + } + + t.Run("create without maturityLevel returns null", func(t *testing.T) { + var res createResult + err := owner.Execute(createControlQuery, map[string]any{ + "input": map[string]any{ + "frameworkId": frameworkID, + "sectionTitle": "M.1", + "name": "Control without maturity", + "description": "control without maturity description", + "bestPractice": true, + "implemented": "IMPLEMENTED", + }, + }, &res) + require.NoError(t, err) + assert.Nil(t, res.CreateControl.ControlEdge.Node.MaturityLevel) + }) + + t.Run("create with maturityLevel persists value", func(t *testing.T) { + var res createResult + err := owner.Execute(createControlQuery, map[string]any{ + "input": map[string]any{ + "frameworkId": frameworkID, + "sectionTitle": "M.2", + "name": "Control with maturity", + "description": "control with maturity description", + "bestPractice": true, + "implemented": "IMPLEMENTED", + "maturityLevel": "DEFINED", + }, + }, &res) + require.NoError(t, err) + require.NotNil(t, res.CreateControl.ControlEdge.Node.MaturityLevel) + assert.Equal(t, "DEFINED", *res.CreateControl.ControlEdge.Node.MaturityLevel) + }) + + t.Run("update lifecycle: set, change, clear, omit", func(t *testing.T) { + var created createResult + err := owner.Execute(createControlQuery, map[string]any{ + "input": map[string]any{ + "frameworkId": frameworkID, + "sectionTitle": "M.3", + "name": "Lifecycle control", + "description": "lifecycle control description", + "bestPractice": true, + "implemented": "IMPLEMENTED", + }, + }, &created) + require.NoError(t, err) + controlID := created.CreateControl.ControlEdge.Node.ID + + // set + var setRes updateResult + err = owner.Execute(updateControlQuery, map[string]any{ + "input": map[string]any{ + "id": controlID, + "maturityLevel": "INITIAL", + }, + }, &setRes) + require.NoError(t, err) + require.NotNil(t, setRes.UpdateControl.Control.MaturityLevel) + assert.Equal(t, "INITIAL", *setRes.UpdateControl.Control.MaturityLevel) + + // change + var changeRes updateResult + err = owner.Execute(updateControlQuery, map[string]any{ + "input": map[string]any{ + "id": controlID, + "maturityLevel": "OPTIMIZING", + }, + }, &changeRes) + require.NoError(t, err) + require.NotNil(t, changeRes.UpdateControl.Control.MaturityLevel) + assert.Equal(t, "OPTIMIZING", *changeRes.UpdateControl.Control.MaturityLevel) + + // clear (explicit null) + var clearRes updateResult + err = owner.Execute(updateControlQuery, map[string]any{ + "input": map[string]any{ + "id": controlID, + "maturityLevel": nil, + }, + }, &clearRes) + require.NoError(t, err) + assert.Nil(t, clearRes.UpdateControl.Control.MaturityLevel) + + // set again, then omit field on next update -> stays unchanged + var setAgain updateResult + err = owner.Execute(updateControlQuery, map[string]any{ + "input": map[string]any{ + "id": controlID, + "maturityLevel": "MANAGED", + }, + }, &setAgain) + require.NoError(t, err) + + var omitRes updateResult + err = owner.Execute(updateControlQuery, map[string]any{ + "input": map[string]any{ + "id": controlID, + "name": "Renamed without touching maturity", + }, + }, &omitRes) + require.NoError(t, err) + require.NotNil(t, omitRes.UpdateControl.Control.MaturityLevel) + assert.Equal(t, "MANAGED", *omitRes.UpdateControl.Control.MaturityLevel) + }) + + t.Run("invalid maturityLevel is rejected", func(t *testing.T) { + var res createResult + err := owner.Execute(createControlQuery, map[string]any{ + "input": map[string]any{ + "frameworkId": frameworkID, + "sectionTitle": "M.4", + "name": "Bad maturity", + "description": "bad maturity description", + "bestPractice": true, + "implemented": "IMPLEMENTED", + "maturityLevel": "BOGUS", + }, + }, &res) + assert.Error(t, err) + }) +} + func TestControl_SubResolvers(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) diff --git a/packages/helpers/src/controls.ts b/packages/helpers/src/controls.ts new file mode 100644 index 000000000..ccb8ffcab --- /dev/null +++ b/packages/helpers/src/controls.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +type Translator = (s: string) => string; + +export const controlMaturityLevels = [ + "NONE", + "INITIAL", + "MANAGED", + "DEFINED", + "QUANTITATIVELY_MANAGED", + "OPTIMIZING", +] as const; + +export type ControlMaturityLevel = (typeof controlMaturityLevels)[number]; + +export function getControlMaturityLevelLabel(__: Translator, level: string) { + switch (level) { + case "NONE": + return __("0 - None"); + case "INITIAL": + return __("1 - Initial"); + case "MANAGED": + return __("2 - Managed"); + case "DEFINED": + return __("3 - Defined"); + case "QUANTITATIVELY_MANAGED": + return __("4 - Quantitatively Managed"); + case "OPTIMIZING": + return __("5 - Optimizing"); + } +} diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index c3818637d..27d284177 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -51,6 +51,11 @@ export { documentWriteModes, getDocumentWriteModeLabel, } from "./documents"; +export { + controlMaturityLevels, + getControlMaturityLevelLabel, + type ControlMaturityLevel, +} from "./controls"; export { getAssetTypeVariant } from "./assets"; export { getSnapshotTypeLabel, diff --git a/packages/n8n-node/nodes/Probo/actions/control/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/control/create.operation.ts index 91eb15f29..e559bc12f 100644 --- a/packages/n8n-node/nodes/Probo/actions/control/create.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/control/create.operation.ts @@ -71,6 +71,28 @@ export const description: INodeProperties[] = [ default: '', description: 'The description of the control', }, + { + displayName: 'Maturity Level', + name: 'maturityLevel', + type: 'options', + displayOptions: { + show: { + resource: ['control'], + operation: ['create'], + }, + }, + options: [ + { name: '0 - None', value: 'NONE' }, + { name: '1 - Initial', value: 'INITIAL' }, + { name: '2 - Managed', value: 'MANAGED' }, + { name: '3 - Defined', value: 'DEFINED' }, + { name: '4 - Quantitatively Managed', value: 'QUANTITATIVELY_MANAGED' }, + { name: '5 - Optimizing', value: 'OPTIMIZING' }, + { name: 'Not Set', value: '' }, + ], + default: '', + description: 'CMMI 0-5 maturity level (optional)', + }, ]; export async function execute( @@ -81,6 +103,7 @@ export async function execute( const sectionTitle = this.getNodeParameter('sectionTitle', itemIndex) as string; const name = this.getNodeParameter('name', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex, '') as string; + const maturityLevel = this.getNodeParameter('maturityLevel', itemIndex, '') as string; const query = ` mutation CreateControl($input: CreateControlInput!) { @@ -91,6 +114,7 @@ export async function execute( sectionTitle name description + maturityLevel createdAt updatedAt } @@ -106,6 +130,7 @@ export async function execute( name, bestPractice: true, ...(description && { description }), + ...(maturityLevel && { maturityLevel }), }, }; diff --git a/packages/n8n-node/nodes/Probo/actions/control/get.operation.ts b/packages/n8n-node/nodes/Probo/actions/control/get.operation.ts index b80c8c141..1be2bf8b3 100644 --- a/packages/n8n-node/nodes/Probo/actions/control/get.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/control/get.operation.ts @@ -46,6 +46,9 @@ export async function execute( sectionTitle name description + implemented + notImplementedJustification + maturityLevel createdAt updatedAt } diff --git a/packages/n8n-node/nodes/Probo/actions/control/getAll.operation.ts b/packages/n8n-node/nodes/Probo/actions/control/getAll.operation.ts index 644572e99..fcd95f2cc 100644 --- a/packages/n8n-node/nodes/Probo/actions/control/getAll.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/control/getAll.operation.ts @@ -81,6 +81,8 @@ export async function execute( sectionTitle name description + implemented + maturityLevel createdAt updatedAt } diff --git a/packages/n8n-node/nodes/Probo/actions/control/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/control/update.operation.ts index d12b09dce..597344ed9 100644 --- a/packages/n8n-node/nodes/Probo/actions/control/update.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/control/update.operation.ts @@ -69,6 +69,29 @@ export const description: INodeProperties[] = [ default: '', description: 'The description of the control', }, + { + displayName: 'Maturity Level', + name: 'maturityLevel', + type: 'options', + displayOptions: { + show: { + resource: ['control'], + operation: ['update'], + }, + }, + options: [ + { name: '(Unchanged)', value: '' }, + { name: '0 - None', value: 'NONE' }, + { name: '1 - Initial', value: 'INITIAL' }, + { name: '2 - Managed', value: 'MANAGED' }, + { name: '3 - Defined', value: 'DEFINED' }, + { name: '4 - Quantitatively Managed', value: 'QUANTITATIVELY_MANAGED' }, + { name: '5 - Optimizing', value: 'OPTIMIZING' }, + { name: 'Not Set', value: '__unset__' }, + ], + default: '', + description: 'CMMI 0-5 maturity level. Choose "Not set" to clear the value.', + }, ]; export async function execute( @@ -79,6 +102,7 @@ export async function execute( const sectionTitle = this.getNodeParameter('sectionTitle', itemIndex, '') as string; const name = this.getNodeParameter('name', itemIndex, '') as string; const description = this.getNodeParameter('description', itemIndex, '') as string; + const maturityLevel = this.getNodeParameter('maturityLevel', itemIndex, '') as string; const query = ` mutation UpdateControl($input: UpdateControlInput!) { @@ -88,6 +112,7 @@ export async function execute( sectionTitle name description + maturityLevel createdAt updatedAt } @@ -95,10 +120,15 @@ export async function execute( } `; - const input: Record = { id }; + const input: Record = { id }; if (sectionTitle) input.sectionTitle = sectionTitle; if (name) input.name = name; if (description) input.description = description; + if (maturityLevel === '__unset__') { + input.maturityLevel = null; + } else if (maturityLevel) { + input.maturityLevel = maturityLevel; + } const responseData = await proboApiRequest.call(this, query, { input }); diff --git a/pkg/cmd/control/create/create.go b/pkg/cmd/control/create/create.go index 0c7ff258c..cf8b3021e 100644 --- a/pkg/cmd/control/create/create.go +++ b/pkg/cmd/control/create/create.go @@ -35,12 +35,22 @@ mutation($input: CreateControlInput!) { bestPractice implemented notImplementedJustification + maturityLevel } } } } ` +var maturityLevelValues = []string{ + "NONE", + "INITIAL", + "MANAGED", + "DEFINED", + "QUANTITATIVELY_MANAGED", + "OPTIMIZING", +} + type createResponse struct { CreateControl struct { ControlEdge struct { @@ -52,6 +62,7 @@ type createResponse struct { BestPractice bool `json:"bestPractice"` Implemented string `json:"implemented"` NotImplementedJustification *string `json:"notImplementedJustification"` + MaturityLevel *string `json:"maturityLevel"` } `json:"node"` } `json:"controlEdge"` } `json:"createControl"` @@ -66,6 +77,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { flagBestPractice bool flagNotImplemented bool flagNotImplementedJustification string + flagMaturityLevel string ) cmd := &cobra.Command{ @@ -114,6 +126,13 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { input["notImplementedJustification"] = flagNotImplementedJustification } + if flagMaturityLevel != "" { + if err := cmdutil.ValidateEnum("maturity-level", flagMaturityLevel, maturityLevelValues); err != nil { + return err + } + input["maturityLevel"] = flagMaturityLevel + } + data, err := client.Do( createMutation, map[string]any{"input": input}, @@ -146,6 +165,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVar(&flagBestPractice, "best-practice", false, "Mark as best practice") cmd.Flags().BoolVar(&flagNotImplemented, "not-implemented", false, "Mark as not implemented") cmd.Flags().StringVar(&flagNotImplementedJustification, "not-implemented-justification", "", "Justification for non-implementation") + cmd.Flags().StringVar(&flagMaturityLevel, "maturity-level", "", "CMMI maturity level (NONE, INITIAL, MANAGED, DEFINED, QUANTITATIVELY_MANAGED, OPTIMIZING)") _ = cmd.MarkFlagRequired("framework") _ = cmd.MarkFlagRequired("section-title") diff --git a/pkg/cmd/control/list/list.go b/pkg/cmd/control/list/list.go index 4e49d25f3..844563157 100644 --- a/pkg/cmd/control/list/list.go +++ b/pkg/cmd/control/list/list.go @@ -37,6 +37,8 @@ query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ControlOrder, $filter: name description bestPractice + implemented + maturityLevel } } pageInfo { @@ -50,11 +52,13 @@ query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ControlOrder, $filter: ` type control struct { - ID string `json:"id"` - SectionTitle string `json:"sectionTitle"` - Name string `json:"name"` - Description *string `json:"description"` - BestPractice bool `json:"bestPractice"` + ID string `json:"id"` + SectionTitle string `json:"sectionTitle"` + Name string `json:"name"` + Description *string `json:"description"` + BestPractice bool `json:"bestPractice"` + Implemented string `json:"implemented"` + MaturityLevel *string `json:"maturityLevel"` } func NewCmdList(f *cmdutil.Factory) *cobra.Command { @@ -163,15 +167,20 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { if c.BestPractice { bp = "Yes" } + maturity := "-" + if c.MaturityLevel != nil { + maturity = *c.MaturityLevel + } rows = append(rows, []string{ c.ID, c.SectionTitle, c.Name, bp, + maturity, }) } - t := cmdutil.NewTable("ID", "SECTION", "NAME", "BEST PRACTICE").Rows(rows...) + t := cmdutil.NewTable("ID", "SECTION", "NAME", "BEST PRACTICE", "MATURITY").Rows(rows...) _, _ = fmt.Fprintln(f.IOStreams.Out, t) diff --git a/pkg/cmd/control/update/update.go b/pkg/cmd/control/update/update.go index 14fd575b7..0ca6b8011 100644 --- a/pkg/cmd/control/update/update.go +++ b/pkg/cmd/control/update/update.go @@ -34,6 +34,7 @@ mutation($input: UpdateControlInput!) { bestPractice implemented notImplementedJustification + maturityLevel } } } @@ -49,10 +50,20 @@ type updateResponse struct { BestPractice bool `json:"bestPractice"` Implemented string `json:"implemented"` NotImplementedJustification *string `json:"notImplementedJustification"` + MaturityLevel *string `json:"maturityLevel"` } `json:"control"` } `json:"updateControl"` } +var maturityLevelValues = []string{ + "NONE", + "INITIAL", + "MANAGED", + "DEFINED", + "QUANTITATIVELY_MANAGED", + "OPTIMIZING", +} + func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { var ( flagSectionTitle string @@ -61,6 +72,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { flagBestPractice bool flagNotImplemented bool flagNotImplementedJustification string + flagMaturityLevel string ) cmd := &cobra.Command{ @@ -120,6 +132,16 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { input["notImplementedJustification"] = flagNotImplementedJustification } } + if cmd.Flags().Changed("maturity-level") { + if flagMaturityLevel == "" { + input["maturityLevel"] = nil + } else { + if err := cmdutil.ValidateEnum("maturity-level", flagMaturityLevel, maturityLevelValues); err != nil { + return err + } + input["maturityLevel"] = flagMaturityLevel + } + } if len(input) == 1 { return fmt.Errorf("at least one field must be specified for update") @@ -156,6 +178,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVar(&flagBestPractice, "best-practice", false, "Mark as best practice") cmd.Flags().BoolVar(&flagNotImplemented, "not-implemented", false, "Mark as not implemented") cmd.Flags().StringVar(&flagNotImplementedJustification, "not-implemented-justification", "", "Justification for non-implementation") + cmd.Flags().StringVar(&flagMaturityLevel, "maturity-level", "", "CMMI maturity level (NONE, INITIAL, MANAGED, DEFINED, QUANTITATIVELY_MANAGED, OPTIMIZING). Empty string clears the value.") return cmd } diff --git a/pkg/cmd/control/view/view.go b/pkg/cmd/control/view/view.go index 5118f1661..e1c4099c3 100644 --- a/pkg/cmd/control/view/view.go +++ b/pkg/cmd/control/view/view.go @@ -34,6 +34,9 @@ query($id: ID!) { name description bestPractice + implemented + notImplementedJustification + maturityLevel framework { id name @@ -47,13 +50,16 @@ query($id: ID!) { type viewResponse struct { Node *struct { - Typename string `json:"__typename"` - ID string `json:"id"` - SectionTitle string `json:"sectionTitle"` - Name string `json:"name"` - Description *string `json:"description"` - BestPractice bool `json:"bestPractice"` - Framework struct { + Typename string `json:"__typename"` + ID string `json:"id"` + SectionTitle string `json:"sectionTitle"` + Name string `json:"name"` + Description *string `json:"description"` + BestPractice bool `json:"bestPractice"` + Implemented string `json:"implemented"` + NotImplementedJustification *string `json:"notImplementedJustification"` + MaturityLevel *string `json:"maturityLevel"` + Framework struct { ID string `json:"id"` Name string `json:"name"` } `json:"framework"` @@ -138,6 +144,16 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { bp = "Yes" } _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Best Practice:"), bp) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Implemented:"), c.Implemented) + if c.Implemented == "NOT_IMPLEMENTED" && c.NotImplementedJustification != nil && *c.NotImplementedJustification != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Justification:"), *c.NotImplementedJustification) + } + + maturity := "Not set" + if c.MaturityLevel != nil { + maturity = *c.MaturityLevel + } + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Maturity:"), maturity) _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(c.CreatedAt)) diff --git a/pkg/coredata/control.go b/pkg/coredata/control.go index 4bad2b6f5..258fa5930 100644 --- a/pkg/coredata/control.go +++ b/pkg/coredata/control.go @@ -39,6 +39,7 @@ type ( BestPractice bool `db:"best_practice"` Implemented ControlImplementationState `db:"implemented"` NotImplementedJustification *string `db:"not_implemented_justification"` + MaturityLevel *ControlMaturityLevel `db:"maturity_level"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -135,6 +136,7 @@ WITH ctrl AS ( c.best_practice, c.implemented, c.not_implemented_justification, + c.maturity_level, c.created_at, c.updated_at, c.search_vector @@ -155,6 +157,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -248,6 +251,7 @@ WITH ctrl AS ( c.best_practice, c.implemented, c.not_implemented_justification, + c.maturity_level, c.created_at, c.updated_at, c.search_vector @@ -268,6 +272,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -367,6 +372,7 @@ WITH ctrl AS ( c.best_practice, c.implemented, c.not_implemented_justification, + c.maturity_level, c.created_at, c.updated_at, c.search_vector @@ -393,6 +399,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -474,6 +481,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -570,6 +578,7 @@ WITH ctrl AS ( c.best_practice, c.implemented, c.not_implemented_justification, + c.maturity_level, c.created_at, c.updated_at, c.search_vector @@ -590,6 +599,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -638,6 +648,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -688,6 +699,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -737,6 +749,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -783,6 +796,7 @@ INSERT INTO best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at ) @@ -797,6 +811,7 @@ VALUES ( @best_practice, @implemented, @not_implemented_justification, + @maturity_level, @created_at, @updated_at ); @@ -813,6 +828,7 @@ VALUES ( "best_practice": c.BestPractice, "implemented": c.Implemented, "not_implemented_justification": c.NotImplementedJustification, + "maturity_level": c.MaturityLevel, "created_at": c.CreatedAt, "updated_at": c.UpdatedAt, } @@ -866,6 +882,7 @@ UPDATE controls SET best_practice = @best_practice, implemented = @implemented, not_implemented_justification = @not_implemented_justification, + maturity_level = @maturity_level, updated_at = @updated_at WHERE %s AND id = @control_id @@ -880,6 +897,7 @@ WHERE %s "best_practice": c.BestPractice, "implemented": c.Implemented, "not_implemented_justification": c.NotImplementedJustification, + "maturity_level": c.MaturityLevel, "updated_at": c.UpdatedAt, } @@ -920,6 +938,7 @@ WITH ctrl AS ( c.best_practice, c.implemented, c.not_implemented_justification, + c.maturity_level, c.created_at, c.updated_at, c.search_vector @@ -940,6 +959,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM @@ -991,6 +1011,7 @@ WITH ctrl AS ( c.best_practice, c.implemented, c.not_implemented_justification, + c.maturity_level, c.created_at, c.updated_at, c.search_vector @@ -1011,6 +1032,7 @@ SELECT best_practice, implemented, not_implemented_justification, + maturity_level, created_at, updated_at FROM diff --git a/pkg/coredata/control_maturity_level.go b/pkg/coredata/control_maturity_level.go new file mode 100644 index 000000000..6f99c67d4 --- /dev/null +++ b/pkg/coredata/control_maturity_level.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type ( + ControlMaturityLevel string +) + +const ( + ControlMaturityLevelNone ControlMaturityLevel = "NONE" + ControlMaturityLevelInitial ControlMaturityLevel = "INITIAL" + ControlMaturityLevelManaged ControlMaturityLevel = "MANAGED" + ControlMaturityLevelDefined ControlMaturityLevel = "DEFINED" + ControlMaturityLevelQuantitativelyManaged ControlMaturityLevel = "QUANTITATIVELY_MANAGED" + ControlMaturityLevelOptimizing ControlMaturityLevel = "OPTIMIZING" +) + +func (l ControlMaturityLevel) IsValid() bool { + switch l { + case ControlMaturityLevelNone, + ControlMaturityLevelInitial, + ControlMaturityLevelManaged, + ControlMaturityLevelDefined, + ControlMaturityLevelQuantitativelyManaged, + ControlMaturityLevelOptimizing: + return true + } + return false +} + +func (l ControlMaturityLevel) String() string { + return string(l) +} + +func (l ControlMaturityLevel) MarshalText() ([]byte, error) { + return []byte(l.String()), nil +} + +func (l *ControlMaturityLevel) UnmarshalText(data []byte) error { + val := ControlMaturityLevel(data) + if !val.IsValid() { + return fmt.Errorf("invalid ControlMaturityLevel value: %q", string(data)) + } + *l = val + return nil +} + +func (l *ControlMaturityLevel) Scan(value any) error { + val, ok := value.(string) + if !ok { + return fmt.Errorf("invalid scan source for ControlMaturityLevel, expected string got %T", value) + } + return l.UnmarshalText([]byte(val)) +} + +func (l ControlMaturityLevel) Value() (driver.Value, error) { + return l.String(), nil +} diff --git a/pkg/coredata/control_maturity_level_test.go b/pkg/coredata/control_maturity_level_test.go new file mode 100644 index 000000000..2344f051d --- /dev/null +++ b/pkg/coredata/control_maturity_level_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import "testing" + +func TestControlMaturityLevelIsValid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level ControlMaturityLevel + want bool + }{ + {name: "none", level: ControlMaturityLevelNone, want: true}, + {name: "initial", level: ControlMaturityLevelInitial, want: true}, + {name: "managed", level: ControlMaturityLevelManaged, want: true}, + {name: "defined", level: ControlMaturityLevelDefined, want: true}, + {name: "quantitatively managed", level: ControlMaturityLevelQuantitativelyManaged, want: true}, + {name: "optimizing", level: ControlMaturityLevelOptimizing, want: true}, + {name: "empty string", level: "", want: false}, + {name: "unknown value", level: "BOGUS", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.level.IsValid(); got != tt.want { + t.Fatalf("IsValid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestControlMaturityLevelScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want ControlMaturityLevel + wantErr bool + }{ + {name: "none string", input: "NONE", want: ControlMaturityLevelNone}, + {name: "initial string", input: "INITIAL", want: ControlMaturityLevelInitial}, + {name: "managed string", input: "MANAGED", want: ControlMaturityLevelManaged}, + {name: "defined string", input: "DEFINED", want: ControlMaturityLevelDefined}, + {name: "quantitatively managed string", input: "QUANTITATIVELY_MANAGED", want: ControlMaturityLevelQuantitativelyManaged}, + {name: "optimizing string", input: "OPTIMIZING", want: ControlMaturityLevelOptimizing}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got ControlMaturityLevel + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestControlMaturityLevelValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level ControlMaturityLevel + want string + }{ + {name: "none", level: ControlMaturityLevelNone, want: "NONE"}, + {name: "initial", level: ControlMaturityLevelInitial, want: "INITIAL"}, + {name: "managed", level: ControlMaturityLevelManaged, want: "MANAGED"}, + {name: "defined", level: ControlMaturityLevelDefined, want: "DEFINED"}, + {name: "quantitatively managed", level: ControlMaturityLevelQuantitativelyManaged, want: "QUANTITATIVELY_MANAGED"}, + {name: "optimizing", level: ControlMaturityLevelOptimizing, want: "OPTIMIZING"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.level.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != tt.want { + t.Fatalf("Value() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestControlMaturityLevelMarshalUnmarshalText(t *testing.T) { + t.Parallel() + + for _, level := range []ControlMaturityLevel{ + ControlMaturityLevelNone, + ControlMaturityLevelInitial, + ControlMaturityLevelManaged, + ControlMaturityLevelDefined, + ControlMaturityLevelQuantitativelyManaged, + ControlMaturityLevelOptimizing, + } { + t.Run(string(level), func(t *testing.T) { + t.Parallel() + + data, err := level.MarshalText() + if err != nil { + t.Fatalf("MarshalText() returned error: %v", err) + } + + var roundtrip ControlMaturityLevel + if err := roundtrip.UnmarshalText(data); err != nil { + t.Fatalf("UnmarshalText(%q) returned error: %v", string(data), err) + } + if roundtrip != level { + t.Fatalf("roundtrip = %q, want %q", roundtrip, level) + } + }) + } + + t.Run("invalid", func(t *testing.T) { + t.Parallel() + + var l ControlMaturityLevel + if err := l.UnmarshalText([]byte("BOGUS")); err == nil { + t.Fatal("UnmarshalText(BOGUS) expected error") + } + }) +} diff --git a/pkg/coredata/migrations/20260419T172808Z.sql b/pkg/coredata/migrations/20260419T172808Z.sql new file mode 100644 index 000000000..bf7078ad4 --- /dev/null +++ b/pkg/coredata/migrations/20260419T172808Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TABLE controls ADD COLUMN maturity_level TEXT; diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go index bf6236a15..df5b85531 100644 --- a/pkg/docgen/generator.go +++ b/pkg/docgen/generator.go @@ -291,6 +291,7 @@ type ( Justification string Implemented string NotImplJustification string + MaturityLevel string Regulatory string Contractual string BestPractice string @@ -320,6 +321,27 @@ func BoolLabel(v bool) string { return "No" } +func MaturityLabel(l *coredata.ControlMaturityLevel) string { + if l == nil { + return "Not set" + } + switch *l { + case coredata.ControlMaturityLevelNone: + return "0 - None" + case coredata.ControlMaturityLevelInitial: + return "1 - Initial" + case coredata.ControlMaturityLevelManaged: + return "2 - Managed" + case coredata.ControlMaturityLevelDefined: + return "3 - Defined" + case coredata.ControlMaturityLevelQuantitativelyManaged: + return "4 - Quantitatively Managed" + case coredata.ControlMaturityLevelOptimizing: + return "5 - Optimizing" + } + return "Not set" +} + const ( ClassificationPublic Classification = "PUBLIC" ClassificationInternal Classification = "INTERNAL" diff --git a/pkg/probo/control_service.go b/pkg/probo/control_service.go index 73a6e3911..a6feb9981 100644 --- a/pkg/probo/control_service.go +++ b/pkg/probo/control_service.go @@ -40,6 +40,7 @@ type ( BestPractice bool Implemented coredata.ControlImplementationState NotImplementedJustification *string + MaturityLevel *coredata.ControlMaturityLevel } UpdateControlRequest struct { @@ -50,6 +51,7 @@ type ( BestPractice *bool Implemented *coredata.ControlImplementationState NotImplementedJustification **string + MaturityLevel **coredata.ControlMaturityLevel } ) @@ -72,6 +74,21 @@ func (ccr *CreateControlRequest) Validate() error { }), ) + if ccr.MaturityLevel != nil { + v.Check( + *ccr.MaturityLevel, + "maturity_level", + validator.OneOfSlice([]string{ + string(coredata.ControlMaturityLevelNone), + string(coredata.ControlMaturityLevelInitial), + string(coredata.ControlMaturityLevelManaged), + string(coredata.ControlMaturityLevelDefined), + string(coredata.ControlMaturityLevelQuantitativelyManaged), + string(coredata.ControlMaturityLevelOptimizing), + }), + ) + } + return v.Error() } @@ -92,6 +109,21 @@ func (ucr *UpdateControlRequest) Validate() error { }), ) + if ucr.MaturityLevel != nil && *ucr.MaturityLevel != nil { + v.Check( + **ucr.MaturityLevel, + "maturity_level", + validator.OneOfSlice([]string{ + string(coredata.ControlMaturityLevelNone), + string(coredata.ControlMaturityLevelInitial), + string(coredata.ControlMaturityLevelManaged), + string(coredata.ControlMaturityLevelDefined), + string(coredata.ControlMaturityLevelQuantitativelyManaged), + string(coredata.ControlMaturityLevelOptimizing), + }), + ) + } + return v.Error() } @@ -868,6 +900,7 @@ func (s ControlService) Create( BestPractice: req.BestPractice, Implemented: req.Implemented, NotImplementedJustification: notImplementedJustification, + MaturityLevel: req.MaturityLevel, CreatedAt: now, UpdatedAt: now, } @@ -985,6 +1018,10 @@ func (s ControlService) Update( control.NotImplementedJustification = *req.NotImplementedJustification } + if req.MaturityLevel != nil { + control.MaturityLevel = *req.MaturityLevel + } + control.UpdatedAt = time.Now() return control.Update(ctx, conn, s.svc.scope) diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 49cabd97e..dcfcb33a0 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -74,6 +74,7 @@ type ( BestPractice *bool `json:"best_practice,omitempty"` Implemented string `json:"implemented,omitempty"` NotImplementedJustification *string `json:"not_implemented_justification,omitempty"` + MaturityLevel *string `json:"maturity_level,omitempty"` } `json:"controls"` } } @@ -612,6 +613,13 @@ func (s FrameworkService) Import( if implemented == coredata.ControlImplementationStateNotImplemented { notImplementedJustification = control.NotImplementedJustification } + var maturityLevel *coredata.ControlMaturityLevel + if control.MaturityLevel != nil { + ml := coredata.ControlMaturityLevel(*control.MaturityLevel) + if ml.IsValid() { + maturityLevel = &ml + } + } control := &coredata.Control{ ID: controlID, FrameworkID: frameworkID, @@ -622,6 +630,7 @@ func (s FrameworkService) Import( BestPractice: bestPractice, Implemented: implemented, NotImplementedJustification: notImplementedJustification, + MaturityLevel: maturityLevel, CreatedAt: now, UpdatedAt: now, } diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go index 61146b23b..80693b5f2 100644 --- a/pkg/probo/generated_document_service.go +++ b/pkg/probo/generated_document_service.go @@ -317,6 +317,11 @@ func (s *GeneratedDocumentService) buildStatementOfApplicabilityDocumentData( riskAssessment = docgen.BoolLabel(hasRisk) } + maturityLevel := "-" + if applicable { + maturityLevel = docgen.MaturityLabel(control.MaturityLevel) + } + rows = append(rows, docgen.SOARow{ FrameworkName: framework.Name, ControlSection: control.SectionTitle, @@ -325,6 +330,7 @@ func (s *GeneratedDocumentService) buildStatementOfApplicabilityDocumentData( Justification: justification, Implemented: implemented, NotImplJustification: notImplJustification, + MaturityLevel: maturityLevel, Regulatory: regulatory, Contractual: contractual, BestPractice: bestPractice, diff --git a/pkg/probo/templates/statement_of_applicability.json.tmpl b/pkg/probo/templates/statement_of_applicability.json.tmpl index b3c0c9bbd..88779211a 100644 --- a/pkg/probo/templates/statement_of_applicability.json.tmpl +++ b/pkg/probo/templates/statement_of_applicability.json.tmpl @@ -28,6 +28,7 @@ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for non-applicability", "marks": [{ "type": "bold" }] }] }] }, { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Implemented", "marks": [{ "type": "bold" }] }] }] }, { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for non-implementation", "marks": [{ "type": "bold" }] }] }] }, + { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Maturity", "marks": [{ "type": "bold" }] }] }] }, { "type": "tableHeader", "attrs": { "colspan": 4, "rowspan": 1 }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for inclusion", "marks": [{ "type": "bold" }] }] }] } ] }, @@ -49,6 +50,7 @@ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Justification}} }] }] }, { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Implemented}} }] }] }, { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .NotImplJustification}} }] }] }, + { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .MaturityLevel}} }] }] }, { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Regulatory}} }] }] }, { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Contractual}} }] }] }, { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .BestPractice}} }] }] }, @@ -124,6 +126,28 @@ "type": "paragraph", "content": [{ "type": "text", "text": "Provides the rationale when a control is not implemented. This field is empty for implemented controls or when the control is not applicable." }] }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "Maturity" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Maturity level of the control, expressed on a CMMI 0-5 scale (Capability Maturity Model Integration). This is a best practice for ISO 27001 clause 9.1 (effectiveness measurement) and is required by frameworks such as HITRUST CSF." }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "0 - None: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "the control is not implemented or does not exist." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "1 - Initial: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "ad-hoc and unpredictable, depending on individual effort." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "2 - Managed: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "the process is planned and tracked." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "3 - Defined: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "documented, standardized and integrated into the organization." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "4 - Quantitatively Managed: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "measured, controlled with metrics and statistical objectives." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "5 - Optimizing: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "continuous improvement based on quantitative analysis." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Not set: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "the maturity level has not yet been assessed." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] } + ] + }, { "type": "heading", "attrs": { "level": 3 }, diff --git a/pkg/server/api/console/v1/control_resolvers.go b/pkg/server/api/console/v1/control_resolvers.go index da0bc320f..f06265b32 100644 --- a/pkg/server/api/console/v1/control_resolvers.go +++ b/pkg/server/api/console/v1/control_resolvers.go @@ -417,6 +417,7 @@ func (r *mutationResolver) CreateControl(ctx context.Context, input types.Create BestPractice: input.BestPractice, Implemented: input.Implemented, NotImplementedJustification: input.NotImplementedJustification, + MaturityLevel: input.MaturityLevel, }, ) if err != nil { @@ -454,6 +455,7 @@ func (r *mutationResolver) UpdateControl(ctx context.Context, input types.Update BestPractice: input.BestPractice, Implemented: input.Implemented, NotImplementedJustification: gqlutils.UnwrapOmittable(input.NotImplementedJustification), + MaturityLevel: gqlutils.UnwrapOmittable(input.MaturityLevel), }, ) diff --git a/pkg/server/api/console/v1/graphql/control.graphql b/pkg/server/api/console/v1/graphql/control.graphql index 0179f2ab7..9936d162c 100644 --- a/pkg/server/api/console/v1/graphql/control.graphql +++ b/pkg/server/api/console/v1/graphql/control.graphql @@ -12,6 +12,34 @@ enum ControlImplementationState ) } +enum ControlMaturityLevel + @goModel(model: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevel") { + NONE + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevelNone" + ) + INITIAL + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevelInitial" + ) + MANAGED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevelManaged" + ) + DEFINED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevelDefined" + ) + QUANTITATIVELY_MANAGED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevelQuantitativelyManaged" + ) + OPTIMIZING + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ControlMaturityLevelOptimizing" + ) +} + enum ControlOrderField @goModel(model: "go.probo.inc/probo/pkg/coredata.ControlOrderField") { CREATED_AT @@ -87,6 +115,7 @@ type Control implements Node { bestPractice: Boolean! implemented: ControlImplementationState! notImplementedJustification: String + maturityLevel: ControlMaturityLevel regulatory: Boolean! @goField(forceResolver: true) contractual: Boolean! @goField(forceResolver: true) riskAssessment: Boolean! @goField(forceResolver: true) @@ -280,6 +309,7 @@ input CreateControlInput { bestPractice: Boolean! implemented: ControlImplementationState! notImplementedJustification: String + maturityLevel: ControlMaturityLevel } input UpdateControlInput { @@ -290,6 +320,7 @@ input UpdateControlInput { bestPractice: Boolean implemented: ControlImplementationState notImplementedJustification: String @goField(omittable: true) + maturityLevel: ControlMaturityLevel @goField(omittable: true) } input DeleteControlInput { diff --git a/pkg/server/api/console/v1/types/control.go b/pkg/server/api/console/v1/types/control.go index 7f2ca8530..4049e28a5 100644 --- a/pkg/server/api/console/v1/types/control.go +++ b/pkg/server/api/console/v1/types/control.go @@ -77,6 +77,7 @@ func NewControl(control *coredata.Control) *Control { BestPractice: control.BestPractice, Implemented: control.Implemented, NotImplementedJustification: control.NotImplementedJustification, + MaturityLevel: control.MaturityLevel, CreatedAt: control.CreatedAt, UpdatedAt: control.UpdatedAt, } diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 223f81322..1bcbe91e9 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -1473,6 +1473,12 @@ func (r *Resolver) AddControlTool(ctx context.Context, req *mcp.CallToolRequest, svc := r.ProboService(ctx, input.FrameworkID) + var maturityLevel *coredata.ControlMaturityLevel + if input.MaturityLevel != nil { + v := coredata.ControlMaturityLevel(*input.MaturityLevel) + maturityLevel = &v + } + control, err := svc.Controls.Create( ctx, probo.CreateControlRequest{ @@ -1483,6 +1489,7 @@ func (r *Resolver) AddControlTool(ctx context.Context, req *mcp.CallToolRequest, BestPractice: input.BestPractice, Implemented: coredata.ControlImplementationState(input.Implemented), NotImplementedJustification: input.NotImplementedJustification, + MaturityLevel: maturityLevel, }, ) if err != nil { @@ -1505,6 +1512,16 @@ func (r *Resolver) UpdateControlTool(ctx context.Context, req *mcp.CallToolReque implemented = &v } + var maturityLevel **coredata.ControlMaturityLevel + if rawMaturity := UnwrapOmittable(input.MaturityLevel); rawMaturity != nil { + var inner *coredata.ControlMaturityLevel + if *rawMaturity != nil { + v := coredata.ControlMaturityLevel(**rawMaturity) + inner = &v + } + maturityLevel = &inner + } + control, err := svc.Controls.Update( ctx, probo.UpdateControlRequest{ @@ -1515,6 +1532,7 @@ func (r *Resolver) UpdateControlTool(ctx context.Context, req *mcp.CallToolReque BestPractice: input.BestPractice, Implemented: implemented, NotImplementedJustification: UnwrapOmittable(input.NotImplementedJustification), + MaturityLevel: maturityLevel, }, ) if err != nil { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index f09386a2b..607768d21 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -4249,6 +4249,13 @@ components: - string - "null" description: Justification for non-implementation + maturity_level: + type: + - string + - "null" + enum: [NONE, INITIAL, MANAGED, DEFINED, QUANTITATIVELY_MANAGED, OPTIMIZING, null] + description: CMMI 0-5 maturity level of the control + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.ControlMaturityLevel created_at: type: string format: date-time @@ -4353,6 +4360,13 @@ components: - string - "null" description: Justification for non-implementation + maturity_level: + type: + - string + - "null" + enum: [NONE, INITIAL, MANAGED, DEFINED, QUANTITATIVELY_MANAGED, OPTIMIZING, null] + description: CMMI 0-5 maturity level of the control + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.ControlMaturityLevel AddControlOutput: type: object @@ -4392,6 +4406,12 @@ components: type: ["string", "null"] description: Justification for non-implementation go.probo.inc/mcpgen/omittable: true + maturity_level: + type: ["string", "null"] + enum: [NONE, INITIAL, MANAGED, DEFINED, QUANTITATIVELY_MANAGED, OPTIMIZING, null] + description: CMMI 0-5 maturity level of the control + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.ControlMaturityLevel + go.probo.inc/mcpgen/omittable: true UpdateControlOutput: type: object diff --git a/pkg/server/api/mcp/v1/types/control.go b/pkg/server/api/mcp/v1/types/control.go index 599440a90..dabcd85b7 100644 --- a/pkg/server/api/mcp/v1/types/control.go +++ b/pkg/server/api/mcp/v1/types/control.go @@ -19,6 +19,12 @@ import ( ) func NewControl(c *coredata.Control) *Control { + var maturityLevel *string + if c.MaturityLevel != nil { + s := string(*c.MaturityLevel) + maturityLevel = &s + } + return &Control{ ID: c.ID, OrganizationID: c.OrganizationID, @@ -29,6 +35,7 @@ func NewControl(c *coredata.Control) *Control { BestPractice: c.BestPractice, Implemented: ControlImplemented(c.Implemented), NotImplementedJustification: c.NotImplementedJustification, + MaturityLevel: maturityLevel, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, }