From 34fa2356e1e878b9cc4fc6d1205a530e29e74076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 30 Jul 2026 16:56:55 +0200 Subject: [PATCH] Replace @nda directive with resolver helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export and request-access already know visibility and auth; a shared requireCompletedNDA call keeps NDA enforcement there and drops GraphQL PUBLIC probing. Signed-off-by: Émile Ré --- .../v1/compliance_portal_resolvers.go | 28 ++ .../complianceportal/v1/graphql/base.graphql | 2 - .../v1/graphql/compliance_portal.graphql | 14 +- .../complianceportal/v1/graphql_handler.go | 1 - pkg/server/api/complianceportal/v1/nda.go | 75 ++++++ .../api/complianceportal/v1/nda_directive.go | 243 ------------------ 6 files changed, 110 insertions(+), 253 deletions(-) create mode 100644 pkg/server/api/complianceportal/v1/nda.go delete mode 100644 pkg/server/api/complianceportal/v1/nda_directive.go diff --git a/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go b/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go index 50bb16ce2..e0740a86e 100644 --- a/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go +++ b/pkg/server/api/complianceportal/v1/compliance_portal_resolvers.go @@ -794,6 +794,10 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex return nil, gqlutils.Unauthenticated(ctx, errors.New("unauthenticated")) } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + documentAccess, err := visitorService.GetPortalDocumentAccess( ctx, scope, compliancePortal.ID, @@ -848,6 +852,10 @@ func (r *mutationResolver) ExportReportPDF(ctx context.Context, input types.Expo return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated") } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + reportAccess, err := visitorService.GetPortalReportFileAccess( ctx, scope, compliancePortal.ID, @@ -907,6 +915,10 @@ func (r *mutationResolver) ExportCompliancePortalFile(ctx context.Context, input return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated") } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + fileAccess, err := visitorService.GetPortalFileAccess(ctx, scope, compliancePortal.ID, identity.ID, @@ -964,6 +976,10 @@ func (r *mutationResolver) RequestDocumentAccess(ctx context.Context, input type return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access") } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + if _, err := visitorService.RequestPortalAccess( ctx, scope, &visitor.PortalAccessRequest{ @@ -1013,6 +1029,10 @@ func (r *mutationResolver) RequestReportAccess(ctx context.Context, input types. return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access") } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + if _, err := visitorService.RequestPortalAccess( ctx, scope, &visitor.PortalAccessRequest{ @@ -1062,6 +1082,10 @@ func (r *mutationResolver) RequestCompliancePortalFileAccess(ctx context.Context return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access") } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + if _, err := visitorService.RequestPortalAccess( ctx, scope, &visitor.PortalAccessRequest{ @@ -1093,6 +1117,10 @@ func (r *mutationResolver) RequestAccesses(ctx context.Context, input types.Requ return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access") } + if err := r.requireCompletedNDA(ctx); err != nil { + return nil, err + } + if len(input.DocumentIds) == 0 && len(input.ReportIds) == 0 && len(input.CompliancePortalFileIds) == 0 { return nil, gqlutils.Invalidf(ctx, "at least one document, report, or file id is required") } diff --git a/pkg/server/api/complianceportal/v1/graphql/base.graphql b/pkg/server/api/complianceportal/v1/graphql/base.graphql index 921aac0b1..4887035eb 100644 --- a/pkg/server/api/complianceportal/v1/graphql/base.graphql +++ b/pkg/server/api/complianceportal/v1/graphql/base.graphql @@ -11,8 +11,6 @@ directive @goModel( directive @goEnum(value: String) on ENUM_VALUE -directive @nda on FIELD_DEFINITION | OBJECT - scalar BigInt scalar CursorKey scalar Datetime diff --git a/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql b/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql index 2beb7562e..1485f5c7b 100644 --- a/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql +++ b/pkg/server/api/complianceportal/v1/graphql/compliance_portal.graphql @@ -467,30 +467,30 @@ type DocumentAccess implements Node { extend type Mutation { exportDocumentPDF(input: ExportDocumentPDFInput!): ExportDocumentPDFPayload! - @authentication(required: OPTIONAL) @nda + @authentication(required: OPTIONAL) exportReportPDF(input: ExportReportPDFInput!): ExportReportPDFPayload! - @authentication(required: OPTIONAL) @nda + @authentication(required: OPTIONAL) exportCompliancePortalFile( input: ExportCompliancePortalFileInput! - ): ExportCompliancePortalFilePayload! @authentication(required: OPTIONAL) @nda + ): ExportCompliancePortalFilePayload! @authentication(required: OPTIONAL) requestDocumentAccess( input: RequestDocumentAccessInput! - ): RequestDocumentAccessPayload! @authentication(required: PRESENT) @nda + ): RequestDocumentAccessPayload! @authentication(required: PRESENT) requestReportAccess( input: RequestReportAccessInput! - ): RequestReportAccessPayload! @authentication(required: PRESENT) @nda + ): RequestReportAccessPayload! @authentication(required: PRESENT) requestCompliancePortalFileAccess( input: RequestCompliancePortalFileAccessInput! - ): RequestFileAccessPayload! @authentication(required: PRESENT) @nda + ): RequestFileAccessPayload! @authentication(required: PRESENT) requestAccesses( input: RequestAccessesInput! - ): RequestAccessesResultPayload! @authentication(required: PRESENT) @nda + ): RequestAccessesResultPayload! @authentication(required: PRESENT) } type RequestDocumentAccessPayload { diff --git a/pkg/server/api/complianceportal/v1/graphql_handler.go b/pkg/server/api/complianceportal/v1/graphql_handler.go index f68d1b8af..94f0888d6 100644 --- a/pkg/server/api/complianceportal/v1/graphql_handler.go +++ b/pkg/server/api/complianceportal/v1/graphql_handler.go @@ -65,7 +65,6 @@ func NewGraphQLHandler( sessionCookie: authn.NewCookie(&cookieConfig), }, Directives: schema.DirectiveRoot{ - Nda: newNDADirective(logger, visitorSvc, esignSvc), Authentication: authentication.Directive, SessionOnly: session.Directive, }, diff --git a/pkg/server/api/complianceportal/v1/nda.go b/pkg/server/api/complianceportal/v1/nda.go new file mode 100644 index 000000000..c0fb4d553 --- /dev/null +++ b/pkg/server/api/complianceportal/v1/nda.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package complianceportal_v1 + +import ( + "context" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/api/complianceportal" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +// requireCompletedNDA enforces portal NDA completion for the signed-in identity. +// No-ops when there is no viewer or the portal membership has no NDA signature. +// Callers own authentication and PUBLIC-resource skips. +func (r *Resolver) requireCompletedNDA(ctx context.Context) error { + identity := authn.IdentityFromContext(ctx) + if identity == nil { + return nil + } + + compliancePage := complianceportal.CompliancePortalFromContext(ctx) + if compliancePage == nil { + r.logger.ErrorCtx(ctx, "cannot get compliance page from context") + return gqlutils.Internal(ctx) + } + + membership, err := r.visitor.GetPortalMembership(ctx, compliancePage.ID, identity.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get compliance page membership", log.Error(err)) + return gqlutils.Internal(ctx) + } + + if membership.ElectronicSignatureID == nil { + return nil + } + + scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) + + sig, err := r.esign.GetSignatureByID(ctx, scope, *membership.ElectronicSignatureID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get NDA signature", log.Error(err)) + return gqlutils.Internal(ctx) + } + + if identity.FullName == "" { + return gqlutils.FullNameRequiredf(ctx, "full name is required") + } + + if sig.Status != coredata.ElectronicSignatureStatusCompleted { + return gqlutils.NDASignatureRequiredf(ctx, "NDA signature required") + } + + return nil +} diff --git a/pkg/server/api/complianceportal/v1/nda_directive.go b/pkg/server/api/complianceportal/v1/nda_directive.go deleted file mode 100644 index 0e95ab022..000000000 --- a/pkg/server/api/complianceportal/v1/nda_directive.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package complianceportal_v1 - -import ( - "context" - "errors" - "fmt" - - "github.com/99designs/gqlgen/graphql" - "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/complianceportal/visitor" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/esign" - "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/server/api/authn" - "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" - "go.probo.inc/probo/pkg/server/gqlutils" -) - -func newNDADirective( - logger *log.Logger, - visitorSvc *visitor.Service, - esignSvc *esign.Service, -) func(ctx context.Context, obj any, next graphql.Resolver) (any, error) { - return func(ctx context.Context, obj any, next graphql.Resolver) (any, error) { - identity := authn.IdentityFromContext(ctx) - if identity == nil { - return next(ctx) - } - - compliancePage := complianceportal.CompliancePortalFromContext(ctx) - if compliancePage == nil { - logger.ErrorCtx(ctx, "cannot get compliance page from context") - return nil, gqlutils.Internal(ctx) - } - - scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) - - skip, err := shouldSkipNDAForPublic( - ctx, - visitorSvc, - scope, - compliancePage.OrganizationID, - ) - if err != nil { - logger.ErrorCtx(ctx, "cannot check target visibility for NDA gate", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - if skip { - return next(ctx) - } - - membership, err := visitorSvc.GetPortalMembership(ctx, compliancePage.ID, identity.ID) - if err != nil { - logger.ErrorCtx(ctx, "cannot get compliance page membership", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - if membership.ElectronicSignatureID == nil { - return next(ctx) - } - - sig, err := esignSvc.GetSignatureByID(ctx, scope, *membership.ElectronicSignatureID) - if err != nil { - logger.ErrorCtx(ctx, "cannot get NDA signature", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - // We need full name before user signs NDA - if identity.FullName == "" { - return nil, gqlutils.FullNameRequiredf(ctx, "full name is required") - } - - if sig.Status != coredata.ElectronicSignatureStatusCompleted { - return nil, gqlutils.NDASignatureRequiredf(ctx, "NDA signature required") - } - - return next(ctx) - } -} - -// shouldSkipNDAForPublic reports whether the @nda field targets a single PUBLIC -// document / report / file and can therefore skip the signature gate. Bulk -// requestAccesses always returns false. Not-found / not-visible targets also -// skip so the resolver can return its usual NotFound/Invalid. -func shouldSkipNDAForPublic( - ctx context.Context, - visitorSvc *visitor.Service, - scope coredata.Scoper, - organizationID gid.GID, -) (bool, error) { - fc := graphql.GetFieldContext(ctx) - if fc == nil { - return false, nil - } - - switch fc.Field.Name { - case "requestAccesses": - return false, nil - - case "exportDocumentPDF": - input, ok := fc.Args["input"].(types.ExportDocumentPDFInput) - if !ok { - return false, nil - } - - return isPublicDocument(ctx, visitorSvc, scope, organizationID, input.DocumentID) - - case "requestDocumentAccess": - input, ok := fc.Args["input"].(types.RequestDocumentAccessInput) - if !ok { - return false, nil - } - - return isPublicDocument(ctx, visitorSvc, scope, organizationID, input.DocumentID) - - case "exportReportPDF": - input, ok := fc.Args["input"].(types.ExportReportPDFInput) - if !ok { - return false, nil - } - - return isPublicReport(ctx, visitorSvc, scope, input.ReportID) - - case "requestReportAccess": - input, ok := fc.Args["input"].(types.RequestReportAccessInput) - if !ok { - return false, nil - } - - return isPublicReport(ctx, visitorSvc, scope, input.ReportID) - - case "exportCompliancePortalFile": - input, ok := fc.Args["input"].(types.ExportCompliancePortalFileInput) - if !ok { - return false, nil - } - - return isPublicPortalFile(ctx, visitorSvc, scope, organizationID, input.CompliancePortalFileID) - - case "requestCompliancePortalFileAccess": - input, ok := fc.Args["input"].(types.RequestCompliancePortalFileAccessInput) - if !ok { - return false, nil - } - - return isPublicPortalFile(ctx, visitorSvc, scope, organizationID, input.CompliancePortalFileID) - - default: - return false, nil - } -} - -func isPublicDocument( - ctx context.Context, - visitorSvc *visitor.Service, - scope coredata.Scoper, - organizationID gid.GID, - documentID gid.GID, -) (bool, error) { - document, err := visitorSvc.GetDocument(ctx, scope, organizationID, documentID) - if err != nil { - if isMissingDocument(err) { - return true, nil - } - - return false, fmt.Errorf("cannot load document: %w", err) - } - - return document.CompliancePortalVisibility == coredata.CompliancePortalVisibilityPublic, nil -} - -func isPublicReport( - ctx context.Context, - visitorSvc *visitor.Service, - scope coredata.Scoper, - reportID gid.GID, -) (bool, error) { - audit, err := visitorSvc.GetAuditByReportFileID(ctx, scope, reportID) - if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return true, nil - } - - return false, fmt.Errorf("cannot load audit: %w", err) - } - - return audit.CompliancePortalVisibility == coredata.CompliancePortalVisibilityPublic, nil -} - -func isPublicPortalFile( - ctx context.Context, - visitorSvc *visitor.Service, - scope coredata.Scoper, - organizationID gid.GID, - fileID gid.GID, -) (bool, error) { - file, err := visitorSvc.GetPortalFile(ctx, scope, organizationID, fileID) - if err != nil { - if errors.Is(err, visitor.ErrPortalFileNotFound) || - errors.Is(err, visitor.ErrPortalFileNotVisible) || - errors.Is(err, coredata.ErrResourceNotFound) { - return true, nil - } - - return false, fmt.Errorf("cannot load portal file: %w", err) - } - - return file.CompliancePortalVisibility == coredata.CompliancePortalVisibilityPublic, nil -} - -func isMissingDocument(err error) bool { - if errors.Is(err, visitor.ErrDocumentNotFound) || - errors.Is(err, visitor.ErrDocumentNotVisible) || - errors.Is(err, coredata.ErrResourceNotFound) { - return true - } - - _, ok := errors.AsType[*visitor.ErrDocumentArchived](err) - - return ok -}