diff --git a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx
index d8760c2d9..ec1e5b077 100644
--- a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx
+++ b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx
@@ -225,7 +225,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery
)}
@@ -242,7 +242,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery
diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx
index c5e0ce991..f314fb3e3 100644
--- a/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx
+++ b/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx
@@ -124,7 +124,6 @@ export function DocumentDetailsCard(props: {
documentFragmentRef: DocumentDetailsCard_documentFragment$key;
versionFragmentRef: DocumentDetailsCard_versionFragment$key;
isEditable: boolean;
- isGenerated?: boolean;
isLatestVersion?: boolean;
onDocumentUpdated: () => void;
}) {
@@ -132,7 +131,6 @@ export function DocumentDetailsCard(props: {
documentFragmentRef,
versionFragmentRef,
isEditable,
- isGenerated = false,
isLatestVersion = true,
onDocumentUpdated,
} = props;
@@ -149,7 +147,7 @@ export function DocumentDetailsCard(props: {
const version = useFragment(versionFragment, versionFragmentRef);
const canEdit = document.canUpdate && isEditable;
- const canEditVersionFields = canEdit && !isGenerated;
+ const canEditVersionFields = canEdit;
const canEditApprovers = document.canUpdate && isLatestVersion;
const { control, handleSubmit, reset } = useFormWithSchema(
diff --git a/e2e/console/statement_of_applicability_test.go b/e2e/console/statement_of_applicability_test.go
index 9afe99be8..ff9c631b0 100644
--- a/e2e/console/statement_of_applicability_test.go
+++ b/e2e/console/statement_of_applicability_test.go
@@ -440,6 +440,275 @@ func TestStatementOfApplicability_CreateDocument_RBAC(t *testing.T) {
)
}
+func TestStatementOfApplicability_UpdateDocumentMetadata(t *testing.T) {
+ t.Parallel()
+
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+
+ publishSOADocument := func(t *testing.T) (documentID, documentVersionID string) {
+ t.Helper()
+
+ frameworkID := factory.NewFramework(owner).Create()
+ controlID := factory.NewControl(owner, frameworkID).Create()
+
+ soaID := factory.NewStatementOfApplicability(owner).Create()
+ factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
+
+ const publishQuery = `
+ mutation($input: PublishStatementOfApplicabilityInput!) {
+ publishStatementOfApplicability(input: $input) {
+ documentEdge { node { id } }
+ documentVersionEdge { node { id } }
+ }
+ }
+ `
+
+ var publishResult struct {
+ PublishStatementOfApplicability struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishStatementOfApplicability"`
+ }
+
+ err := owner.Execute(
+ publishQuery,
+ map[string]any{
+ "input": map[string]any{
+ "minor": false,
+ "statementOfApplicabilityId": soaID,
+ },
+ },
+ &publishResult,
+ )
+ require.NoError(t, err)
+
+ return publishResult.PublishStatementOfApplicability.DocumentEdge.Node.ID,
+ publishResult.PublishStatementOfApplicability.DocumentVersionEdge.Node.ID
+ }
+
+ const updateQuery = `
+ mutation($input: UpdateDocumentInput!) {
+ updateDocument(input: $input) {
+ document { id writeMode }
+ documentVersion {
+ id
+ title
+ documentType
+ classification
+ status
+ }
+ }
+ }
+ `
+
+ type updateResult struct {
+ UpdateDocument struct {
+ Document struct {
+ ID string `json:"id"`
+ WriteMode string `json:"writeMode"`
+ } `json:"document"`
+ DocumentVersion *struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ DocumentType string `json:"documentType"`
+ Classification string `json:"classification"`
+ Status string `json:"status"`
+ } `json:"documentVersion"`
+ } `json:"updateDocument"`
+ }
+
+ t.Run(
+ "editing title, type, and classification on generated document creates a draft",
+ func(t *testing.T) {
+ t.Parallel()
+
+ documentID, publishedVersionID := publishSOADocument(t)
+
+ var result updateResult
+ err := owner.Execute(
+ updateQuery,
+ map[string]any{
+ "input": map[string]any{
+ "id": documentID,
+ "title": "Renamed SOA",
+ "documentType": "POLICY",
+ "classification": "INTERNAL",
+ },
+ },
+ &result,
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, "GENERATED", result.UpdateDocument.Document.WriteMode)
+ require.NotNil(t, result.UpdateDocument.DocumentVersion)
+ assert.NotEqual(t, publishedVersionID, result.UpdateDocument.DocumentVersion.ID,
+ "should create a new draft, not update the published version")
+ assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
+ assert.Equal(t, "Renamed SOA", result.UpdateDocument.DocumentVersion.Title)
+ assert.Equal(t, "POLICY", result.UpdateDocument.DocumentVersion.DocumentType)
+ assert.Equal(t, "INTERNAL", result.UpdateDocument.DocumentVersion.Classification)
+ },
+ )
+
+ t.Run(
+ "cannot edit content of a generated document",
+ func(t *testing.T) {
+ t.Parallel()
+
+ documentID, _ := publishSOADocument(t)
+
+ err := owner.ExecuteShouldFail(
+ updateQuery,
+ map[string]any{
+ "input": map[string]any{
+ "id": documentID,
+ "content": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hand-edited"}]}]}`,
+ },
+ },
+ )
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "generated")
+ },
+ )
+
+ t.Run(
+ "re-publish preserves edited title, type, and classification",
+ func(t *testing.T) {
+ t.Parallel()
+
+ frameworkID := factory.NewFramework(owner).Create()
+ controlID := factory.NewControl(owner, frameworkID).Create()
+
+ soaID := factory.NewStatementOfApplicability(owner).Create()
+ factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
+
+ const publishSOAQuery = `
+ mutation($input: PublishStatementOfApplicabilityInput!) {
+ publishStatementOfApplicability(input: $input) {
+ documentEdge { node { id } }
+ documentVersionEdge { node { id title documentType classification major minor } }
+ }
+ }
+ `
+
+ type publishSOAResult struct {
+ PublishStatementOfApplicability struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ DocumentType string `json:"documentType"`
+ Classification string `json:"classification"`
+ Major int `json:"major"`
+ Minor int `json:"minor"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishStatementOfApplicability"`
+ }
+
+ var firstPublish publishSOAResult
+ err := owner.Execute(
+ publishSOAQuery,
+ map[string]any{
+ "input": map[string]any{
+ "minor": false,
+ "statementOfApplicabilityId": soaID,
+ },
+ },
+ &firstPublish,
+ )
+ require.NoError(t, err)
+ documentID := firstPublish.PublishStatementOfApplicability.DocumentEdge.Node.ID
+ require.Equal(t, "STATEMENT_OF_APPLICABILITY", firstPublish.PublishStatementOfApplicability.DocumentVersionEdge.Node.DocumentType)
+ require.Equal(t, "CONFIDENTIAL", firstPublish.PublishStatementOfApplicability.DocumentVersionEdge.Node.Classification)
+
+ var editResult updateResult
+ err = owner.Execute(
+ updateQuery,
+ map[string]any{
+ "input": map[string]any{
+ "id": documentID,
+ "title": "Custom SOA Name",
+ "documentType": "POLICY",
+ "classification": "INTERNAL",
+ },
+ },
+ &editResult,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, editResult.UpdateDocument.DocumentVersion)
+ require.Equal(t, "DRAFT", editResult.UpdateDocument.DocumentVersion.Status)
+
+ const publishDraftQuery = `
+ mutation($input: PublishDocumentInput!) {
+ publishDocument(input: $input) {
+ documentVersion { id title documentType classification major minor status }
+ }
+ }
+ `
+ var publishDraft struct {
+ PublishDocument struct {
+ DocumentVersion struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ DocumentType string `json:"documentType"`
+ Classification string `json:"classification"`
+ Major int `json:"major"`
+ Minor int `json:"minor"`
+ Status string `json:"status"`
+ } `json:"documentVersion"`
+ } `json:"publishDocument"`
+ }
+ err = owner.Execute(
+ publishDraftQuery,
+ map[string]any{
+ "input": map[string]any{
+ "documentId": documentID,
+ "minor": true,
+ "changelog": "rename",
+ },
+ },
+ &publishDraft,
+ )
+ require.NoError(t, err)
+ require.Equal(t, "PUBLISHED", publishDraft.PublishDocument.DocumentVersion.Status)
+ require.Equal(t, "Custom SOA Name", publishDraft.PublishDocument.DocumentVersion.Title)
+ require.Equal(t, "POLICY", publishDraft.PublishDocument.DocumentVersion.DocumentType)
+ require.Equal(t, "INTERNAL", publishDraft.PublishDocument.DocumentVersion.Classification)
+
+ var rePublish publishSOAResult
+ err = owner.Execute(
+ publishSOAQuery,
+ map[string]any{
+ "input": map[string]any{
+ "minor": true,
+ "statementOfApplicabilityId": soaID,
+ },
+ },
+ &rePublish,
+ )
+ require.NoError(t, err)
+ node := rePublish.PublishStatementOfApplicability.DocumentVersionEdge.Node
+ assert.Equal(t, "Custom SOA Name", node.Title, "re-publish should preserve edited title")
+ assert.Equal(t, "POLICY", node.DocumentType, "re-publish should preserve edited type")
+ assert.Equal(t, "INTERNAL", node.Classification, "re-publish should preserve edited classification")
+ },
+ )
+}
+
func TestStatementOfApplicability_TenantIsolation(t *testing.T) {
t.Parallel()
diff --git a/pkg/cmd/document/update/update.go b/pkg/cmd/document/update/update.go
index a0d0345f8..3a1d57a17 100644
--- a/pkg/cmd/document/update/update.go
+++ b/pkg/cmd/document/update/update.go
@@ -106,7 +106,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
if err := cmdutil.ValidateEnum(
"document-type",
flagDocumentType,
- []string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"},
+ []string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE", "STATEMENT_OF_APPLICABILITY"},
); err != nil {
return err
}
@@ -174,7 +174,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagTitle, "title", "", "Document title")
cmd.Flags().StringVar(&flagContent, "content", "", "Document content")
- cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Document type: OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE")
+ cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Document type: OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE, STATEMENT_OF_APPLICABILITY")
cmd.Flags().StringVar(&flagClassification, "classification", "", "Classification: PUBLIC, INTERNAL, CONFIDENTIAL, SECRET")
cmd.Flags().StringVar(&flagTrustCenterVisibility, "trust-center-visibility", "", "Trust center visibility: NONE, PRIVATE, PUBLIC")
diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go
index cea60573d..5e11cac51 100644
--- a/pkg/probo/document_service.go
+++ b/pkg/probo/document_service.go
@@ -267,7 +267,7 @@ func (e ErrDocumentGenerated) Error() string {
}
func (e ErrDocumentVersionGenerated) Error() string {
- return "cannot edit a generated document version"
+ return "cannot edit content of a generated document version"
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
@@ -1800,7 +1800,7 @@ func (s *DocumentService) Update(
hasVersionChanges := req.Title != nil || req.Content != nil || req.Classification != nil || req.DocumentType != nil
- if hasVersionChanges && document.WriteMode == coredata.DocumentWriteModeGenerated {
+ if req.Content != nil && document.WriteMode == coredata.DocumentWriteModeGenerated {
return &ErrDocumentVersionGenerated{}
}
diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go
index febc76eca..c8115bdd7 100644
--- a/pkg/probo/generated_document_service.go
+++ b/pkg/probo/generated_document_service.go
@@ -2909,6 +2909,19 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
minor bool,
now time.Time,
) error {
+ previousVersion := &coredata.DocumentVersion{}
+ err := previousVersion.LoadLatestVersion(ctx, tx, s.svc.scope, document.ID)
+ switch {
+ case err == nil:
+ version.Title = previousVersion.Title
+ version.Classification = previousVersion.Classification
+ version.DocumentType = previousVersion.DocumentType
+ case errors.Is(err, coredata.ErrResourceNotFound):
+ // First publish: keep the caller-provided defaults.
+ default:
+ return fmt.Errorf("cannot load previous document version: %w", err)
+ }
+
if minor {
if document.CurrentPublishedMajor == nil || document.CurrentPublishedMinor == nil {
return &ErrCannotPublishMinorWithoutMajor{}
@@ -2936,7 +2949,14 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
if err := version.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
- return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
+ switch previousVersion.Status {
+ case coredata.DocumentVersionStatusDraft:
+ return fmt.Errorf("a draft version exists, publish or delete it before publishing a new one: %w", err)
+ case coredata.DocumentVersionStatusPendingApproval:
+ return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
+ default:
+ return fmt.Errorf("a version already exists at this number: %w", err)
+ }
}
return fmt.Errorf("cannot insert document version: %w", err)
}
diff --git a/pkg/server/api/console/v1/document_resolvers.go b/pkg/server/api/console/v1/document_resolvers.go
index 8b71a2cf7..467d3d3bf 100644
--- a/pkg/server/api/console/v1/document_resolvers.go
+++ b/pkg/server/api/console/v1/document_resolvers.go
@@ -864,6 +864,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
+ if errGenerated, ok := errors.AsType[*probo.ErrDocumentVersionGenerated](err); ok {
+ return nil, gqlutils.Conflict(ctx, errGenerated)
+ }
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}