Add document archiving

Documents can be archived and unarchived. Archived documents are
read-only, excluded from the trust center, and moved to a dedicated
Archived tab in the document list.

- Add archived_at timestamp and status (ACTIVE/ARCHIVED) PG enum column
- Rename DocumentStatus → DocumentVersionStatus, introduce DocumentStatus
- Archive/unarchive mutations in GraphQL, MCP, and CLI
- Bulk archive/unarchive mutations with Active/Archived tabs in the list
- ABAC policies: write actions denied on archived docs, unarchive denied
  on active docs
- Remove control/risk mappings and reset trust center visibility on archive
- Exclude archived documents from mapping dialogs and trust center tab

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-19 14:15:16 +01:00
parent 2e12c11c0c
commit 1db8e7133e
29 changed files with 1194 additions and 199 deletions

View File

@@ -69,15 +69,26 @@ enum EvidenceState
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
}
enum DocumentStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
DRAFT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusDraft")
enum DocumentVersionStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatus") {
DRAFT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
)
PUBLISHED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentStatusPublished"
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished"
)
}
enum DocumentStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
ACTIVE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusActive")
ARCHIVED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusArchived")
}
enum EvidenceType
@goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceType") {
FILE @goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceTypeFile")
@@ -1569,7 +1580,7 @@ input ApplicabilityStatementOrder
}
input DocumentVersionFilter {
status: DocumentStatus
status: DocumentVersionStatus
}
# Input Types for Filtering
@@ -1580,6 +1591,7 @@ input ControlFilter {
input DocumentFilter {
query: String
documentTypes: [DocumentType!]
status: [DocumentStatus!]
}
input MeasureFilter {
@@ -2402,6 +2414,9 @@ type Document implements Node {
filter: ControlFilter
): ControlConnection! @goField(forceResolver: true)
status: DocumentStatus!
archivedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
@@ -3657,6 +3672,8 @@ type Mutation {
# Document mutations
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
# Meeting mutations
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
@@ -3694,6 +3711,12 @@ type Mutation {
bulkDeleteDocuments(
input: BulkDeleteDocumentsInput!
): BulkDeleteDocumentsPayload!
bulkArchiveDocuments(
input: BulkArchiveDocumentsInput!
): BulkArchiveDocumentsPayload!
bulkUnarchiveDocuments(
input: BulkUnarchiveDocumentsInput!
): BulkUnarchiveDocumentsPayload!
bulkExportDocuments(
input: BulkExportDocumentsInput!
): BulkExportDocumentsPayload!
@@ -4367,6 +4390,14 @@ input ExportTransferImpactAssessmentsPDFInput {
filter: TransferImpactAssessmentFilter
}
input ArchiveDocumentInput {
documentId: ID!
}
input UnarchiveDocumentInput {
documentId: ID!
}
input DeleteDocumentInput {
documentId: ID!
}
@@ -5086,6 +5117,14 @@ type UpdateDocumentPayload {
document: Document!
}
type ArchiveDocumentPayload {
document: Document!
}
type UnarchiveDocumentPayload {
document: Document!
}
type DeleteDocumentPayload {
deletedDocumentId: ID!
}
@@ -5197,7 +5236,7 @@ type DeleteMeasurePayload {
type DocumentVersion implements Node {
id: ID!
document: Document! @goField(forceResolver: true)
status: DocumentStatus!
status: DocumentVersionStatus!
version: Int!
content: String!
changelog: String!
@@ -5326,6 +5365,22 @@ input BulkDeleteDocumentsInput {
documentIds: [ID!]!
}
input BulkArchiveDocumentsInput {
documentIds: [ID!]!
}
type BulkArchiveDocumentsPayload {
documents: [Document!]!
}
input BulkUnarchiveDocumentsInput {
documentIds: [ID!]!
}
type BulkUnarchiveDocumentsPayload {
documents: [Document!]!
}
input BulkExportDocumentsInput {
documentIds: [ID!]!
withWatermark: Boolean!

View File

@@ -84,6 +84,8 @@ func NewDocument(document *coredata.Document) *Document {
Classification: document.Classification,
CurrentPublishedVersion: document.CurrentPublishedVersion,
TrustCenterVisibility: document.TrustCenterVisibility,
Status: document.Status,
ArchivedAt: document.ArchivedAt,
CreatedAt: document.CreatedAt,
UpdatedAt: document.UpdatedAt,
}

View File

@@ -4286,6 +4286,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
@@ -4298,6 +4301,50 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
}, nil
}
// ArchiveDocument is the resolver for the archiveDocument field.
func (r *mutationResolver) ArchiveDocument(ctx context.Context, input types.ArchiveDocumentInput) (*types.ArchiveDocumentPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentArchive); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.DocumentID.TenantID())
document, err := prb.Documents.Archive(ctx, input.DocumentID)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
r.logger.ErrorCtx(ctx, "cannot archive document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.ArchiveDocumentPayload{
Document: types.NewDocument(document),
}, nil
}
// UnarchiveDocument is the resolver for the unarchiveDocument field.
func (r *mutationResolver) UnarchiveDocument(ctx context.Context, input types.UnarchiveDocumentInput) (*types.UnarchiveDocumentPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentUnarchive); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.DocumentID.TenantID())
document, err := prb.Documents.Unarchive(ctx, input.DocumentID)
if err != nil {
if errNotArchived, ok := errors.AsType[*probo.ErrDocumentNotArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errNotArchived)
}
r.logger.ErrorCtx(ctx, "cannot unarchive document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UnarchiveDocumentPayload{
Document: types.NewDocument(document),
}, nil
}
// DeleteDocument is the resolver for the deleteDocument field.
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {
@@ -4607,6 +4654,10 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
return nil, gqlutils.Invalid(ctx, errNoChanges)
}
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
r.logger.ErrorCtx(ctx, "cannot publish document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -4687,6 +4738,58 @@ func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.
}, nil
}
// BulkArchiveDocuments is the resolver for the bulkArchiveDocuments field.
func (r *mutationResolver) BulkArchiveDocuments(ctx context.Context, input types.BulkArchiveDocumentsInput) (*types.BulkArchiveDocumentsPayload, error) {
if len(input.DocumentIds) == 0 {
return &types.BulkArchiveDocumentsPayload{
Documents: []*types.Document{},
}, nil
}
for _, documentID := range input.DocumentIds {
if err := r.authorize(ctx, documentID, probo.ActionDocumentArchive); err != nil {
return nil, err
}
}
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
if err := prb.Documents.BulkArchive(ctx, input.DocumentIds); err != nil {
r.logger.ErrorCtx(ctx, "cannot bulk archive documents", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.BulkArchiveDocumentsPayload{
Documents: []*types.Document{},
}, nil
}
// BulkUnarchiveDocuments is the resolver for the bulkUnarchiveDocuments field.
func (r *mutationResolver) BulkUnarchiveDocuments(ctx context.Context, input types.BulkUnarchiveDocumentsInput) (*types.BulkUnarchiveDocumentsPayload, error) {
if len(input.DocumentIds) == 0 {
return &types.BulkUnarchiveDocumentsPayload{
Documents: []*types.Document{},
}, nil
}
for _, documentID := range input.DocumentIds {
if err := r.authorize(ctx, documentID, probo.ActionDocumentUnarchive); err != nil {
return nil, err
}
}
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
if err := prb.Documents.BulkUnarchive(ctx, input.DocumentIds); err != nil {
r.logger.ErrorCtx(ctx, "cannot bulk unarchive documents", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.BulkUnarchiveDocumentsPayload{
Documents: []*types.Document{},
}, nil
}
// BulkExportDocuments is the resolver for the bulkExportDocuments field.
func (r *mutationResolver) BulkExportDocuments(ctx context.Context, input types.BulkExportDocumentsInput) (*types.BulkExportDocumentsPayload, error) {
if len(input.DocumentIds) == 0 {
@@ -4803,6 +4906,9 @@ func (r *mutationResolver) UpdateDocumentVersion(ctx context.Context, input type
return nil, gqlutils.Conflict(ctx, errNotDraft)
}
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
@@ -6479,7 +6585,8 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query).
WithDocumentTypes(filter.DocumentTypes)
WithDocumentTypes(filter.DocumentTypes).
WithStatus(filter.Status)
}
page, err := prb.Documents.ListByOrganizationID(ctx, obj.ID, cursor, documentFilter)

View File

@@ -799,7 +799,7 @@ func (r *Resolver) AddFindingTool(ctx context.Context, req *mcp.CallToolRequest,
Kind: input.Kind,
Description: input.Description,
Source: input.Source,
IdentifiedOn: input.IdentifiedOn,
IdentifiedOn: input.IdentifiedOn,
RootCause: input.RootCause,
CorrectiveAction: input.CorrectiveAction,
OwnerID: input.OwnerID,
@@ -830,7 +830,7 @@ func (r *Resolver) UpdateFindingTool(ctx context.Context, req *mcp.CallToolReque
ID: input.ID,
Description: UnwrapOmittable(input.Description),
Source: UnwrapOmittable(input.Source),
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
RootCause: UnwrapOmittable(input.RootCause),
CorrectiveAction: UnwrapOmittable(input.CorrectiveAction),
OwnerID: input.OwnerID,
@@ -3201,3 +3201,43 @@ func (r *Resolver) ListFindingAuditsTool(ctx context.Context, req *mcp.CallToolR
return nil, types.NewListFindingAuditsOutput(auditPage), nil
}
func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ArchiveDocumentInput) (*mcp.CallToolResult, types.ArchiveDocumentOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionDocumentArchive)
svc := r.ProboService(ctx, input.ID)
document, err := svc.Documents.Archive(ctx, input.ID)
if err != nil {
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
}
return nil, types.ArchiveDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
}, nil
}
func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnarchiveDocumentInput) (*mcp.CallToolResult, types.UnarchiveDocumentOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionDocumentUnarchive)
svc := r.ProboService(ctx, input.ID)
document, err := svc.Documents.Unarchive(ctx, input.ID)
if err != nil {
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
}
return nil, types.UnarchiveDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
}, nil
}

View File

@@ -5057,11 +5057,18 @@ components:
- SECRET
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentClassification
DocumentStatus:
DocumentVersionStatus:
type: string
enum:
- DRAFT
- PUBLISHED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus
DocumentStatus:
type: string
enum:
- ACTIVE
- ARCHIVED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentStatus
DocumentVersionSignatureState:
@@ -5142,6 +5149,7 @@ components:
- document_type
- classification
- trust_center_visibility
- status
- created_at
- updated_at
properties:
@@ -5173,6 +5181,15 @@ components:
trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility
status:
$ref: "#/components/schemas/DocumentStatus"
description: Document status
archived_at:
type:
- string
- "null"
format: date-time
description: Archive timestamp
created_at:
type: string
format: date-time
@@ -5228,8 +5245,8 @@ components:
type: string
description: Changelog
status:
$ref: "#/components/schemas/DocumentStatus"
description: Document status
$ref: "#/components/schemas/DocumentVersionStatus"
description: Document version status
published_at:
type:
- string
@@ -5434,6 +5451,40 @@ components:
document:
$ref: "#/components/schemas/Document"
ArchiveDocumentInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Document ID
ArchiveDocumentOutput:
type: object
required:
- document
properties:
document:
$ref: "#/components/schemas/Document"
UnarchiveDocumentInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Document ID
UnarchiveDocumentOutput:
type: object
required:
- document
properties:
document:
$ref: "#/components/schemas/Document"
ListDocumentVersionsInput:
type: object
required:
@@ -7133,6 +7184,22 @@ tools:
$ref: "#/components/schemas/UpdateDocumentInput"
outputSchema:
$ref: "#/components/schemas/UpdateDocumentOutput"
- name: archiveDocument
description: Archive a document to prevent further modifications
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/ArchiveDocumentInput"
outputSchema:
$ref: "#/components/schemas/ArchiveDocumentOutput"
- name: unarchiveDocument
description: Unarchive a document to allow modifications again
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/UnarchiveDocumentInput"
outputSchema:
$ref: "#/components/schemas/UnarchiveDocumentOutput"
- name: listDocumentVersions
description: List all versions for a document
hints:

View File

@@ -30,6 +30,8 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
Classification: d.Classification,
CurrentPublishedVersion: d.CurrentPublishedVersion,
TrustCenterVisibility: d.TrustCenterVisibility,
Status: d.Status,
ArchivedAt: d.ArchivedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}

View File

@@ -95,6 +95,12 @@ func (r *documentResolver) IsUserAuthorized(ctx context.Context, obj *types.Docu
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, obj.ID)
if err != nil {
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
return false, gqlutils.NotFoundf(ctx, "document %q not found", obj.ID)
}
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
return false, gqlutils.NotFoundf(ctx, "document %q not found", obj.ID)
}
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
return false, gqlutils.Internal(ctx)
}
@@ -363,6 +369,12 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
if err != nil {
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
}
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
}
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -525,6 +537,12 @@ func (r *mutationResolver) RequestDocumentAccess(ctx context.Context, input type
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
if err != nil {
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
}
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
}
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -871,6 +889,9 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
}
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
}
r.logger.ErrorCtx(ctx, "cannot get document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}