Add electronic signature

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-02-16 23:12:09 +01:00
parent e6f9d7aae2
commit c191d25e9a
58 changed files with 7557 additions and 292 deletions

View File

@@ -19,6 +19,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn"
@@ -28,11 +29,12 @@ import (
"go.probo.inc/probo/pkg/trust"
)
func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, esignSvc *esign.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
iam: iamSvc,
trust: trustSvc,
esign: esignSvc,
logger: logger,
baseURL: baseURL,
sessionCookie: authn.NewCookie(&cookieConfig),

View File

@@ -23,6 +23,7 @@ import (
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
@@ -46,6 +47,7 @@ type (
Resolver struct {
trust *trust.Service
esign *esign.Service
logger *log.Logger
iam *iam.Service
sessionCookie *authn.Cookie
@@ -75,6 +77,7 @@ func NewMux(
logger *log.Logger,
iamSvc *iam.Service,
trustSvc *trust.Service,
esignSvc *esign.Service,
cookieConfig securecookie.Config,
baseURL *baseurl.BaseURL,
) *chi.Mux {
@@ -84,7 +87,7 @@ func NewMux(
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
r.Use(compliancepage.NewMembershipMiddleware(trustSvc, logger))
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, logger, baseURL, cookieConfig)
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, esignSvc, logger, baseURL, cookieConfig)
r.Handle("/graphql", graphqlHandler)

View File

@@ -496,6 +496,7 @@ type TrustCenter implements Node {
darkLogoFileUrl: String @goField(forceResolver: true)
ndaFileName: String
ndaFileUrl: String @goField(forceResolver: true)
ndaSignature: ElectronicSignature @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
isViewerMember: Boolean! @goField(forceResolver: true)
hasAcceptedNonDisclosureAgreement: Boolean! @goField(forceResolver: true)
@@ -608,6 +609,143 @@ type AcceptNonDisclosureAgreementPayload {
success: Boolean!
}
# Electronic Signature
enum ElectronicSignatureStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatus"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusPending"
)
ACCEPTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusAccepted"
)
PROCESSING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusProcessing"
)
COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusCompleted"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusFailed"
)
}
enum ElectronicSignatureDocumentType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentType"
) {
NDA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeNDA"
)
DPA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeDPA"
)
MSA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeMSA"
)
SOW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSOW"
)
SLA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSLA"
)
TOS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeTOS"
)
PRIVACY_POLICY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypePrivacyPolicy"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
)
}
enum ElectronicSignatureEventType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventType"
) {
DOCUMENT_VIEWED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeDocumentViewed"
)
CONSENT_GIVEN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeConsentGiven"
)
FULL_NAME_TYPED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeFullNameTyped"
)
SIGNATURE_ACCEPTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureAccepted"
)
SIGNATURE_COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureCompleted"
)
SEAL_COMPUTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSealComputed"
)
TIMESTAMP_REQUESTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeTimestampRequested"
)
CERTIFICATE_GENERATED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeCertificateGenerated"
)
PROCESSING_ERROR
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeProcessingError"
)
}
type ElectronicSignature implements Node {
id: ID!
status: ElectronicSignatureStatus!
documentType: ElectronicSignatureDocumentType!
consentText: String!
lastError: String
signedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
}
input AcceptElectronicSignatureInput {
signatureId: ID!
fullName: String!
}
type AcceptElectronicSignaturePayload {
signature: ElectronicSignature!
}
input RecordSigningEventInput {
signatureId: ID!
eventType: ElectronicSignatureEventType!
}
type RecordSigningEventPayload {
success: Boolean!
}
type Query {
viewer: Identity
node(id: ID!): Node!
@@ -647,4 +785,12 @@ type Mutation {
requestTrustCenterFileAccess(
input: RequestTrustCenterFileAccessInput!
): RequestAccessesPayload! @session(required: PRESENT)
acceptElectronicSignature(
input: AcceptElectronicSignatureInput!
): AcceptElectronicSignaturePayload @session(required: PRESENT)
recordSigningEvent(
input: RecordSigningEventInput!
): RecordSigningEventPayload @session(required: PRESENT)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
// 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 types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewElectronicSignature(es *coredata.ElectronicSignature) *ElectronicSignature {
return &ElectronicSignature{
ID: es.ID,
Status: es.Status,
DocumentType: es.DocumentType,
ConsentText: es.ConsentText,
LastError: es.LastError,
SignedAt: es.SignedAt,
CreatedAt: es.CreatedAt,
UpdatedAt: es.UpdatedAt,
}
}

View File

@@ -16,6 +16,15 @@ type Node interface {
GetID() gid.GID
}
type AcceptElectronicSignatureInput struct {
SignatureID gid.GID `json:"signatureId"`
FullName string `json:"fullName"`
}
type AcceptElectronicSignaturePayload struct {
Signature *ElectronicSignature `json:"signature"`
}
type AcceptNonDisclosureAgreementInput struct {
FullName string `json:"fullName"`
}
@@ -64,6 +73,20 @@ type DocumentEdge struct {
Node *Document `json:"node"`
}
type ElectronicSignature struct {
ID gid.GID `json:"id"`
Status coredata.ElectronicSignatureStatus `json:"status"`
DocumentType coredata.ElectronicSignatureDocumentType `json:"documentType"`
ConsentText string `json:"consentText"`
LastError *string `json:"lastError,omitempty"`
SignedAt *time.Time `json:"signedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ElectronicSignature) IsNode() {}
func (this ElectronicSignature) GetID() gid.GID { return this.ID }
type ExportDocumentPDFInput struct {
DocumentID gid.GID `json:"documentId"`
}
@@ -136,6 +159,15 @@ type PageInfo struct {
type Query struct {
}
type RecordSigningEventInput struct {
SignatureID gid.GID `json:"signatureId"`
EventType coredata.ElectronicSignatureEventType `json:"eventType"`
}
type RecordSigningEventPayload struct {
Success bool `json:"success"`
}
type Report struct {
ID gid.GID `json:"id"`
Filename string `json:"filename"`
@@ -178,6 +210,7 @@ type TrustCenter struct {
DarkLogoFileURL *string `json:"darkLogoFileUrl,omitempty"`
NdaFileName *string `json:"ndaFileName,omitempty"`
NdaFileURL *string `json:"ndaFileUrl,omitempty"`
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
Organization *Organization `json:"organization"`
IsViewerMember bool `json:"isViewerMember"`
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`

View File

@@ -14,6 +14,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
@@ -648,6 +649,71 @@ func (r *mutationResolver) RequestTrustCenterFileAccess(ctx context.Context, inp
}, nil
}
// AcceptElectronicSignature is the resolver for the acceptElectronicSignature field.
func (r *mutationResolver) AcceptElectronicSignature(ctx context.Context, input types.AcceptElectronicSignatureInput) (*types.AcceptElectronicSignaturePayload, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated")
}
httpReq := gqlutils.HTTPRequestFromContext(ctx)
if _, err := r.iam.AuthService.UpdateIdentity(ctx, identity.ID, input.FullName); err != nil {
var errNotFound *iam.ErrIdentityNotFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if err := r.esign.AcceptSignature(ctx, &esign.AcceptSignatureRequest{
SignatureID: input.SignatureID,
SignerFullName: input.FullName,
SignerEmail: identity.EmailAddress,
SignerIPAddr: httpReq.RemoteAddr,
SignerUA: httpReq.UserAgent(),
}); err != nil {
r.logger.ErrorCtx(ctx, "cannot accept electronic signature", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
sig, err := r.esign.LoadSignatureByID(ctx, input.SignatureID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load electronic signature", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AcceptElectronicSignaturePayload{
Signature: types.NewElectronicSignature(sig),
}, nil
}
// RecordSigningEvent is the resolver for the recordSigningEvent field.
func (r *mutationResolver) RecordSigningEvent(ctx context.Context, input types.RecordSigningEventInput) (*types.RecordSigningEventPayload, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated")
}
httpReq := gqlutils.HTTPRequestFromContext(ctx)
if err := r.esign.RecordEvent(ctx, &esign.RecordEventRequest{
SignatureID: input.SignatureID,
EventType: input.EventType,
EventSource: coredata.ElectronicSignatureEventSourceClient,
ActorEmail: identity.EmailAddress,
ActorIPAddr: httpReq.RemoteAddr,
ActorUA: httpReq.UserAgent(),
}); err != nil {
r.logger.ErrorCtx(ctx, "cannot record signing event", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RecordSigningEventPayload{Success: true}, nil
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
trustService := r.TrustService(ctx, obj.ID.TenantID())
@@ -868,6 +934,32 @@ func (r *trustCenterResolver) NdaFileURL(ctx context.Context, obj *types.TrustCe
return &fileURL, nil
}
// NdaSignature is the resolver for the ndaSignature field.
func (r *trustCenterResolver) NdaSignature(ctx context.Context, obj *types.TrustCenter) (*types.ElectronicSignature, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, nil
}
trustCenter := compliancepage.CompliancePageFromContext(ctx)
if trustCenter == nil || trustCenter.NonDisclosureAgreementFileID == nil {
return nil, nil
}
sig, err := r.esign.LoadSignatureByOrgEmailAndDocType(
ctx,
trustCenter.OrganizationID,
identity.EmailAddress.String(),
coredata.ElectronicSignatureDocumentTypeNDA,
*trustCenter.NonDisclosureAgreementFileID,
)
if err != nil {
return nil, nil
}
return types.NewElectronicSignature(sig), nil
}
// Organization is the resolver for the organization field.
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
return obj.Organization, nil