Add nda signature middleware

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-20 19:17:11 +04:00
parent bb8d477495
commit 9da8cc2e3d
12 changed files with 191 additions and 41 deletions

View File

@@ -60,20 +60,6 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
);
}
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>
<IconPageCross size={26} />
{__("Access denied")}
</h1>
<p className={classNames.description}>
{__("You don't have permission to access this organization")}
</p>
</div>
);
}
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>{__("Unexpected error :(")}</h1>

View File

@@ -8,8 +8,8 @@ import { OrganizationSidebar } from "#/components/OrganizationSidebar";
import { useRequestAccessCallback } from "#/hooks/useRequestAccessCallback";
import { TrustCenterProvider } from "#/providers/TrustCenterProvider";
import { Viewer } from "#/providers/Viewer";
import type { TrustGraphCurrentQuery } from "#/queries/__generated__/TrustGraphCurrentQuery.graphql";
import { currentTrustGraphQuery } from "#/queries/TrustGraph";
import type { TrustGraphCurrentQuery } from "#/queries/__generated__/TrustGraphCurrentQuery.graphql";
type Props = {
queryRef: PreloadedQuery<TrustGraphCurrentQuery>;

View File

@@ -30,14 +30,6 @@ export class AssumptionRequiredError extends Error {
}
}
export class UnauthorizedError extends Error {
constructor(message?: string) {
super(message || "UNAUTHORIZED");
this.name = "UnauthorizedError";
Object.setPrototypeOf(this, UnauthorizedError.prototype);
}
}
export class ForbiddenError extends Error {
constructor(message?: string) {
super(message || "FORBIDDEN");

View File

@@ -2,9 +2,9 @@ import { type FetchFunction } from "relay-runtime";
import {
InternalServerError,
UnAuthenticatedError,
UnauthorizedError,
ForbiddenError,
AssumptionRequiredError,
NDASignatureRequiredError,
} from "./errors";
import { GraphQLError } from "graphql";
@@ -14,8 +14,8 @@ const hasUnauthenticatedError = (error: GraphQLError) =>
const hasAssumptionRequiredError = (error: GraphQLError) =>
error.extensions?.code == "ASSUMPTION_REQUIRED";
const hasUnauthorizedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHORIZED";
const hasNDASignatureRequiredError = (error: GraphQLError) =>
error.extensions?.code == "NDA_SIGNATURE_REQUIRED";
const hasForbiddenError = (error: GraphQLError) =>
error.extensions?.code == "FORBIDDEN";
@@ -88,9 +88,9 @@ export const makeFetchQuery = (endpoint: string): FetchFunction => {
throw new AssumptionRequiredError(assumptionRequiredError.message)
}
const unauthorizedError = errors.find(hasUnauthorizedError);
if (unauthorizedError) {
throw new UnauthorizedError(unauthorizedError.message);
const ndaSignatureRequiredError = errors.find(hasNDASignatureRequiredError);
if (ndaSignatureRequiredError) {
throw new NDASignatureRequiredError(ndaSignatureRequiredError.message);
}
const forbiddenError = errors.find(hasForbiddenError);

21
pkg/esign/errors.go Normal file
View File

@@ -0,0 +1,21 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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 esign
import "errors"
var (
ErrElectronicSignatureNotFound = errors.New("electronic signature not found")
)

View File

@@ -17,6 +17,7 @@ package esign
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"time"
@@ -338,6 +339,10 @@ func (s *Service) GetSignatureByID(ctx context.Context, id gid.GID) (*coredata.E
ctx,
func(conn pg.Conn) error {
if err := signature.LoadByID(ctx, conn, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrElectronicSignatureNotFound
}
return fmt.Errorf("cannot load electronic signature: %w", err)
}

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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 compliancepage
import (
"errors"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/trust"
)
func NewNDAMiddleware(trustSvc *trust.Service, esignSvc *esign.Service, logger *log.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
membership := ComplianceMembershipFromContext(ctx)
if membership == nil {
next.ServeHTTP(w, r)
return
}
compliancePage := CompliancePageFromContext(ctx)
if _, err := trustSvc.GetNDAFile(ctx, compliancePage.ID); err != nil {
if errors.Is(err, trust.ErrNDAFileNotFound) {
next.ServeHTTP(w, r)
return
}
logger.ErrorCtx(ctx, "cannot get NDA file", log.Error(err))
httpserver.RenderJSON(
w,
http.StatusInternalServerError,
&graphql.Response{
Errors: gqlerror.List{
gqlutils.Internal(ctx),
},
},
)
return
}
if membership.ElectronicSignatureID == nil {
next.ServeHTTP(w, r)
return
}
sig, err := esignSvc.GetSignatureByID(ctx, *membership.ElectronicSignatureID)
if err != nil {
logger.ErrorCtx(ctx, "cannot get NDA signature", log.Error(err))
httpserver.RenderJSON(
w,
http.StatusInternalServerError,
&graphql.Response{
Errors: gqlerror.List{
gqlutils.Internal(ctx),
},
},
)
return
}
if sig.Status != coredata.ElectronicSignatureStatusCompleted {
httpserver.RenderJSON(
w,
http.StatusForbidden,
&graphql.Response{
Errors: gqlerror.List{
gqlutils.NDASignatureRequiredf(ctx, "NDA signature required"),
},
},
)
return
}
next.ServeHTTP(w, r)
},
)
}
}

View File

@@ -88,6 +88,7 @@ func NewMux(
r.Use(compliancepage.NewCompliancePagePresenceMiddleware())
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
r.Use(compliancepage.NewMembershipMiddleware(trustSvc, logger))
r.Use(compliancepage.NewNDAMiddleware(trustSvc, esignSvc, logger))
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, esignSvc, logger, baseURL, cookieConfig)

View File

@@ -289,10 +289,6 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex
return nil, gqlutils.Forbiddenf(ctx, "access denied: no permission to access this document")
}
if err := r.checkNDASignature(ctx, trustCenter, identity); err != nil {
return nil, err
}
pdf, err := trustService.Documents.ExportPDF(ctx, input.DocumentID, identity.EmailAddress)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot export document PDF", log.Error(err))
@@ -347,10 +343,6 @@ func (r *mutationResolver) ExportReportPDF(ctx context.Context, input types.Expo
return nil, gqlutils.Forbiddenf(ctx, "access denied: no permission to access this report")
}
if err := r.checkNDASignature(ctx, trustCenter, identity); err != nil {
return nil, err
}
pdf, err := trustService.Reports.ExportPDF(ctx, input.ReportID, identity.EmailAddress)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot export report PDF", log.Error(err))
@@ -403,10 +395,6 @@ func (r *mutationResolver) ExportTrustCenterFile(ctx context.Context, input type
return nil, gqlutils.Forbiddenf(ctx, "access denied: no permission to access this file")
}
if err := r.checkNDASignature(ctx, trustCenter, identity); err != nil {
return nil, err
}
fileData, err := trustService.TrustCenterFiles.ExportFile(ctx, input.TrustCenterFileID, identity.EmailAddress)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot export trust center file", log.Error(err))

View File

@@ -66,6 +66,20 @@ func AssumptionRequiredf(ctx context.Context, format string, a ...any) *gqlerror
return AssumptionRequired(ctx, fmt.Errorf(format, a...))
}
func NDASignatureRequired(ctx context.Context, err error) *gqlerror.Error {
return &gqlerror.Error{
Message: err.Error(),
Path: graphql.GetPath(ctx),
Extensions: map[string]any{
"code": "NDA_SIGNATURE_REQUIRED",
},
}
}
func NDASignatureRequiredf(ctx context.Context, format string, a ...any) *gqlerror.Error {
return NDASignatureRequired(ctx, fmt.Errorf(format, a...))
}
func Forbidden(ctx context.Context, err error) *gqlerror.Error {
return &gqlerror.Error{
Message: err.Error(),

View File

@@ -22,4 +22,5 @@ var (
ErrMembershipNotFound = errors.New("membership not found")
ErrMembershipInactive = errors.New("membership inactive")
ErrDocumentAccessNotFound = errors.New("document access not found")
ErrNDAFileNotFound = errors.New("NDA file not found")
)

View File

@@ -300,3 +300,43 @@ func (s *Service) GetMembershipByCompliancePageIDAndEmail(ctx context.Context, c
return membership, nil
}
func (s *Service) GetNDAFile(
ctx context.Context,
compliancePageID gid.GID,
) (*coredata.File, error) {
var (
file *coredata.File
scope = coredata.NewScopeFromObjectID(compliancePageID)
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID == nil {
return nil
}
file = &coredata.File{}
if err := file.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrNDAFileNotFound
}
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return file, nil
}