From 03f621ec8dc06b453ab29b18c71ce032bc7af00d Mon Sep 17 00:00:00 2001
From: gearnode
Date: Thu, 13 Mar 2025 16:32:30 +0100
Subject: [PATCH] Add framework import
Signed-off-by: gearnode
---
apps/console/src/pages/FrameworkListPage.tsx | 144 ++++++++++++-
...ListPageImportFrameworkMutation.graphql.ts | 202 ++++++++++++++++++
pkg/coredata/control.go | 9 +-
pkg/coredata/migrations/20250313T160700Z.sql | 2 +
pkg/probo/framework_service.go | 92 ++++++++
pkg/server/api/console/v1/v1_resolver.go | 17 +-
6 files changed, 453 insertions(+), 13 deletions(-)
create mode 100644 apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts
create mode 100644 pkg/coredata/migrations/20250313T160700Z.sql
diff --git a/apps/console/src/pages/FrameworkListPage.tsx b/apps/console/src/pages/FrameworkListPage.tsx
index 342e96a8c..455ccb3e0 100644
--- a/apps/console/src/pages/FrameworkListPage.tsx
+++ b/apps/console/src/pages/FrameworkListPage.tsx
@@ -1,16 +1,28 @@
-import { Suspense, useEffect } from "react";
+import { Suspense, useEffect, useRef, useState } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
+ useMutation,
} from "react-relay";
import { Card, CardContent } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import type { FrameworkListPageQuery as FrameworkListPageQueryType } from "./__generated__/FrameworkListPageQuery.graphql";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
-import { Plus } from "lucide-react";
+import { Plus, Upload } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { FrameworkListPageImportFrameworkMutation as FrameworkListPageImportFrameworkMutationType } from "./__generated__/FrameworkListPageImportFrameworkMutation.graphql";
const FrameworkListPageQuery = graphql`
query FrameworkListPageQuery($organizationId: ID!) {
@@ -40,6 +52,32 @@ const FrameworkListPageQuery = graphql`
}
`;
+const FrameworkListPageImportFrameworkMutation = graphql`
+ mutation FrameworkListPageImportFrameworkMutation(
+ $input: ImportFrameworkInput!
+ ) {
+ importFramework(input: $input) {
+ frameworkEdge {
+ node {
+ id
+ name
+ description
+ controls {
+ edges {
+ node {
+ id
+ state
+ }
+ }
+ }
+ createdAt
+ updatedAt
+ }
+ }
+ }
+ }
+`;
+
function FrameworkCard({
title,
description,
@@ -88,12 +126,60 @@ function FrameworkListPageContent({
}) {
const data = usePreloadedQuery(
FrameworkListPageQuery,
- queryRef,
+ queryRef
);
const { organizationId } = useParams();
const frameworks =
data.organization.frameworks?.edges.map((edge) => edge?.node) ?? [];
+ const [importFramework] =
+ useMutation(
+ FrameworkListPageImportFrameworkMutation
+ );
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const fileInputRef = useRef(null);
+ const { toast } = useToast();
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setIsUploading(true);
+
+ importFramework({
+ variables: {
+ input: {
+ organizationId: organizationId!,
+ file: null,
+ },
+ },
+ uploadables: {
+ "input.file": file,
+ },
+ onCompleted: () => {
+ setIsUploading(false);
+ setIsImportDialogOpen(false);
+ toast({
+ title: "Framework imported",
+ description: "Framework has been imported successfully.",
+ variant: "default",
+ });
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ },
+ onError: (error) => {
+ setIsUploading(false);
+ toast({
+ title: "Error importing framework",
+ description: error.message,
+ variant: "destructive",
+ });
+ },
+ });
+ };
+
return (
<>
@@ -107,18 +193,54 @@ function FrameworkListPageContent({
Manage your compliance frameworks
-
{frameworks.map((framework) => {
const validatedControls = framework.controls.edges.filter(
- (edge) => edge?.node?.state === "IMPLEMENTED",
+ (edge) => edge?.node?.state === "IMPLEMENTED"
).length;
const totalControls = framework.controls.edges.length;
@@ -185,7 +307,7 @@ function FrameworkListPageFallback() {
export default function FrameworkListPage() {
const [queryRef, loadQuery] = useQueryLoader(
- FrameworkListPageQuery,
+ FrameworkListPageQuery
);
const { organizationId } = useParams();
diff --git a/apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts b/apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts
new file mode 100644
index 000000000..be869040e
--- /dev/null
+++ b/apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts
@@ -0,0 +1,202 @@
+/**
+ * @generated SignedSource<<886db9744d1d937174d0138601e644a5>>
+ * @lightSyntaxTransform
+ * @nogrep
+ */
+
+/* tslint:disable */
+/* eslint-disable */
+// @ts-nocheck
+
+import { ConcreteRequest } from 'relay-runtime';
+export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
+export type ImportFrameworkInput = {
+ file: any;
+ organizationId: string;
+};
+export type FrameworkListPageImportFrameworkMutation$variables = {
+ input: ImportFrameworkInput;
+};
+export type FrameworkListPageImportFrameworkMutation$data = {
+ readonly importFramework: {
+ readonly frameworkEdge: {
+ readonly node: {
+ readonly controls: {
+ readonly edges: ReadonlyArray<{
+ readonly node: {
+ readonly id: string;
+ readonly state: ControlState;
+ };
+ }>;
+ };
+ readonly createdAt: string;
+ readonly description: string;
+ readonly id: string;
+ readonly name: string;
+ readonly updatedAt: string;
+ };
+ };
+ };
+};
+export type FrameworkListPageImportFrameworkMutation = {
+ response: FrameworkListPageImportFrameworkMutation$data;
+ variables: FrameworkListPageImportFrameworkMutation$variables;
+};
+
+const node: ConcreteRequest = (function(){
+var v0 = [
+ {
+ "defaultValue": null,
+ "kind": "LocalArgument",
+ "name": "input"
+ }
+],
+v1 = {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "id",
+ "storageKey": null
+},
+v2 = [
+ {
+ "alias": null,
+ "args": [
+ {
+ "kind": "Variable",
+ "name": "input",
+ "variableName": "input"
+ }
+ ],
+ "concreteType": "ImportFrameworkPayload",
+ "kind": "LinkedField",
+ "name": "importFramework",
+ "plural": false,
+ "selections": [
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "FrameworkEdge",
+ "kind": "LinkedField",
+ "name": "frameworkEdge",
+ "plural": false,
+ "selections": [
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "Framework",
+ "kind": "LinkedField",
+ "name": "node",
+ "plural": false,
+ "selections": [
+ (v1/*: any*/),
+ {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "name",
+ "storageKey": null
+ },
+ {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "description",
+ "storageKey": null
+ },
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "ControlConnection",
+ "kind": "LinkedField",
+ "name": "controls",
+ "plural": false,
+ "selections": [
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "ControlEdge",
+ "kind": "LinkedField",
+ "name": "edges",
+ "plural": true,
+ "selections": [
+ {
+ "alias": null,
+ "args": null,
+ "concreteType": "Control",
+ "kind": "LinkedField",
+ "name": "node",
+ "plural": false,
+ "selections": [
+ (v1/*: any*/),
+ {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "state",
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ },
+ {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "createdAt",
+ "storageKey": null
+ },
+ {
+ "alias": null,
+ "args": null,
+ "kind": "ScalarField",
+ "name": "updatedAt",
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+ ],
+ "storageKey": null
+ }
+];
+return {
+ "fragment": {
+ "argumentDefinitions": (v0/*: any*/),
+ "kind": "Fragment",
+ "metadata": null,
+ "name": "FrameworkListPageImportFrameworkMutation",
+ "selections": (v2/*: any*/),
+ "type": "Mutation",
+ "abstractKey": null
+ },
+ "kind": "Request",
+ "operation": {
+ "argumentDefinitions": (v0/*: any*/),
+ "kind": "Operation",
+ "name": "FrameworkListPageImportFrameworkMutation",
+ "selections": (v2/*: any*/)
+ },
+ "params": {
+ "cacheID": "2ea6f840625bd25b0469103c8f98d3f2",
+ "id": null,
+ "metadata": {},
+ "name": "FrameworkListPageImportFrameworkMutation",
+ "operationKind": "mutation",
+ "text": "mutation FrameworkListPageImportFrameworkMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n description\n controls {\n edges {\n node {\n id\n state\n }\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n}\n"
+ }
+};
+})();
+
+(node as any).hash = "cb68b53c21e128e59354c51048f347cc";
+
+export default node;
diff --git a/pkg/coredata/control.go b/pkg/coredata/control.go
index 2a8727ff3..eebb27151 100644
--- a/pkg/coredata/control.go
+++ b/pkg/coredata/control.go
@@ -40,6 +40,7 @@ type (
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Version int `db:"version"`
+ Standards []string `db:"standards"`
}
Controls []*Control
@@ -76,6 +77,7 @@ SELECT
content_ref,
created_at,
updated_at,
+ standards,
version
FROM
controls
@@ -124,6 +126,7 @@ INSERT INTO
content_ref,
created_at,
updated_at,
+ standards,
version
)
VALUES (
@@ -138,6 +141,7 @@ VALUES (
@content_ref,
@created_at,
@updated_at,
+ @standards,
@version
);
`
@@ -155,6 +159,7 @@ VALUES (
"updated_at": c.UpdatedAt,
"state": c.State,
"importance": c.Importance,
+ "standards": c.Standards,
}
_, err := conn.Exec(ctx, q, args)
return err
@@ -179,6 +184,7 @@ SELECT
content_ref,
created_at,
updated_at,
+ standards,
version
FROM
controls
@@ -237,7 +243,8 @@ RETURNING
content_ref,
created_at,
updated_at,
- version
+ version,
+ standards
`
q = fmt.Sprintf(q, scope.SQLFragment())
diff --git a/pkg/coredata/migrations/20250313T160700Z.sql b/pkg/coredata/migrations/20250313T160700Z.sql
new file mode 100644
index 000000000..6737a3f54
--- /dev/null
+++ b/pkg/coredata/migrations/20250313T160700Z.sql
@@ -0,0 +1,2 @@
+ALTER TABLE controls ADD COLUMN standards TEXT[] DEFAULT '{}' NOT NULL;
+ALTER TABLE controls ALTER COLUMN standards DROP DEFAULT;
\ No newline at end of file
diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go
index d5c52d70d..8b88f4b70 100644
--- a/pkg/probo/framework_service.go
+++ b/pkg/probo/framework_service.go
@@ -43,6 +43,25 @@ type (
Name *string
Description *string
}
+
+ ImportFrameworkRequest struct {
+ Data struct {
+ Framework struct {
+ Name string `json:"name"`
+ ContentRef string `json:"content-ref"`
+ Description string `json:"description"`
+ Version string `json:"version"`
+ Controls []struct {
+ ContentRef string `json:"content-ref"`
+ Category string `json:"category"`
+ Importance coredata.ControlImportance `json:"importance"`
+ Standards []string `json:"standards"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ } `json:"controls"`
+ } `json:"framework"`
+ }
+ }
)
func (s FrameworkService) Create(
@@ -163,3 +182,76 @@ func (s FrameworkService) Delete(
},
)
}
+
+func (s FrameworkService) Import(
+ ctx context.Context,
+ organizationID gid.GID,
+ req ImportFrameworkRequest,
+) (*coredata.Framework, error) {
+
+ now := time.Now()
+
+ frameworkID, err := gid.NewGID(organizationID.TenantID(), coredata.FrameworkEntityType)
+ if err != nil {
+ return nil, fmt.Errorf("cannot create global id: %w", err)
+ }
+
+ framework := &coredata.Framework{
+ ID: frameworkID,
+ OrganizationID: organizationID,
+ Name: req.Data.Framework.Name,
+ Description: req.Data.Framework.Description,
+ ContentRef: req.Data.Framework.ContentRef,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ importedControls := coredata.Controls{}
+ for _, control := range req.Data.Framework.Controls {
+ controlID, err := gid.NewGID(organizationID.TenantID(), coredata.ControlEntityType)
+ if err != nil {
+ return nil, fmt.Errorf("cannot create global id: %w", err)
+ }
+
+ importedControl := &coredata.Control{
+ ID: controlID,
+ FrameworkID: frameworkID,
+ Category: control.Category,
+ Importance: coredata.ControlImportance(control.Importance),
+ Name: control.Name,
+ Description: control.Description,
+ State: coredata.ControlStateNotStarted,
+ ContentRef: control.ContentRef,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Standards: control.Standards,
+ }
+
+ importedControls = append(importedControls, importedControl)
+ }
+
+ err = s.svc.pg.WithTx(
+ ctx,
+ func(tx pg.Conn) error {
+
+ err := framework.Insert(ctx, tx, s.svc.scope)
+ if err != nil {
+ return fmt.Errorf("cannot insert framework: %w", err)
+ }
+
+ for _, importedControl := range importedControls {
+ if err := importedControl.Insert(ctx, tx, s.svc.scope); err != nil {
+ return fmt.Errorf("cannot insert control: %w", err)
+ }
+ }
+
+ return nil
+ },
+ )
+
+ if err != nil {
+ return nil, err
+ }
+
+ return framework, nil
+}
diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go
index 721765a72..16a8bde15 100644
--- a/pkg/server/api/console/v1/v1_resolver.go
+++ b/pkg/server/api/console/v1/v1_resolver.go
@@ -6,6 +6,7 @@ package console_v1
import (
"context"
+ "encoding/json"
"fmt"
"time"
@@ -348,7 +349,21 @@ func (r *mutationResolver) UpdateFramework(ctx context.Context, input types.Upda
// ImportFramework is the resolver for the importFramework field.
func (r *mutationResolver) ImportFramework(ctx context.Context, input types.ImportFrameworkInput) (*types.ImportFrameworkPayload, error) {
- panic(fmt.Errorf("not implemented: ImportFramework - importFramework"))
+ svc := r.GetTenantServiceIfAuthorized(ctx, input.OrganizationID.TenantID())
+
+ req := probo.ImportFrameworkRequest{}
+ if err := json.NewDecoder(input.File.File).Decode(&req.Data); err != nil {
+ return nil, fmt.Errorf("cannot decode framework: %w", err)
+ }
+
+ framework, err := svc.Frameworks.Import(ctx, input.OrganizationID, req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot import framework: %w", err)
+ }
+
+ return &types.ImportFrameworkPayload{
+ FrameworkEdge: types.NewFrameworkEdge(framework),
+ }, nil
}
// CreateControl is the resolver for the createControl field.