diff --git a/pkg/server/api/complianceportal/oauth.go b/pkg/server/api/complianceportal/oauth.go new file mode 100644 index 000000000..241060dfe --- /dev/null +++ b/pkg/server/api/complianceportal/oauth.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal + +import ( + portal "go.probo.inc/probo/pkg/complianceportal" +) + +const ( + VisitorOAuthScope = portal.VisitorOAuthScope + GraphQLPath = "/graphql" + CIMDMetadataPath = portal.CIMDMetadataPath + OAuthInitiatePath = "/initiate" + OAuthCallbackPath = portal.OAuthCallbackPath +) + +func CIMDClientIDURL(portalBaseURL string) (string, error) { + return portal.CIMDClientIDURL(portalBaseURL) +} + +func OAuthCallbackURL(portalBaseURL string) (string, error) { + return portal.OAuthCallbackURL(portalBaseURL) +} diff --git a/pkg/server/api/complianceportal/session.go b/pkg/server/api/complianceportal/session.go new file mode 100644 index 000000000..ca044a01f --- /dev/null +++ b/pkg/server/api/complianceportal/session.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal + +import ( + "net/http" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" +) + +func TrustedRequestHost(r *http.Request) (string, bool) { + if r.TLS != nil && r.TLS.ServerName != "" { + return coredata.NormalizeBoundHost(r.TLS.ServerName), true + } + + if r.Host == "" { + return "", false + } + + return coredata.NormalizeBoundHost(r.Host), true +} + +func NewSessionHostMiddleware(cookieConfig securecookie.Config) 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() + session := authn.SessionFromContext(ctx) + if session == nil { + next.ServeHTTP(w, r) + return + } + + host, ok := TrustedRequestHost(r) + if ok && session.Data.MatchesBoundHost(host) { + next.ServeHTTP(w, r) + return + } + + securecookie.Clear(w, cookieConfig) + ctx = authn.ContextWithSession(ctx, nil) + ctx = authn.ContextWithIdentity(ctx, nil) + next.ServeHTTP(w, r.WithContext(ctx)) + }, + ) + } +} diff --git a/pkg/server/api/complianceportal/session_test.go b/pkg/server/api/complianceportal/session_test.go new file mode 100644 index 000000000..57569f3c0 --- /dev/null +++ b/pkg/server/api/complianceportal/session_test.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" +) + +func TestTrustedRequestHost_PrefersTLSServerName(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "https://evil.example.com/graphql", nil) + req.Host = "evil.example.com" + req.TLS = &tls.ConnectionState{ServerName: "portal.example.com"} + + host, ok := TrustedRequestHost(req) + + require.True(t, ok) + assert.Equal(t, "portal.example.com", host) +} + +func TestSessionHostMiddleware_RejectsMismatchedHost(t *testing.T) { + t.Parallel() + + var authenticated bool + + handler := NewSessionHostMiddleware(securecookie.Config{Name: "ssid"})( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticated = authn.IdentityFromContext(r.Context()) != nil + w.WriteHeader(http.StatusOK) + }), + ) + + identity := &coredata.Identity{ID: gid.New(gid.NilTenant, coredata.IdentityEntityType)} + session := &coredata.Session{ + ID: identity.ID, + Data: coredata.SessionDataForHost("portal-a.example.com"), + } + + req := httptest.NewRequest(http.MethodGet, "/graphql", nil) + req.Host = "portal-b.example.com" + req.TLS = &tls.ConnectionState{ServerName: "portal-b.example.com"} + req = req.WithContext(authn.ContextWithIdentity(req.Context(), identity)) + req = req.WithContext(authn.ContextWithSession(req.Context(), session)) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.False(t, authenticated) +} + +func TestSessionHostMiddleware_AllowsMatchingTLSHost(t *testing.T) { + t.Parallel() + + var authenticated bool + + handler := NewSessionHostMiddleware(securecookie.Config{Name: "ssid"})( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticated = authn.IdentityFromContext(r.Context()) != nil + w.WriteHeader(http.StatusOK) + }), + ) + + identity := &coredata.Identity{ID: gid.New(gid.NilTenant, coredata.IdentityEntityType)} + session := &coredata.Session{ + ID: identity.ID, + Data: coredata.SessionDataForHost("portal.example.com"), + } + + req := httptest.NewRequest(http.MethodGet, "/graphql", nil) + req.Host = "evil.example.com" + req.TLS = &tls.ConnectionState{ServerName: "portal.example.com"} + req = req.WithContext(authn.ContextWithIdentity(req.Context(), identity)) + req = req.WithContext(authn.ContextWithSession(req.Context(), session)) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.True(t, authenticated) +} + +func TestSessionHostMiddleware_RejectsSpoofedHostHeader(t *testing.T) { + t.Parallel() + + var authenticated bool + + handler := NewSessionHostMiddleware(securecookie.Config{Name: "ssid"})( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticated = authn.IdentityFromContext(r.Context()) != nil + w.WriteHeader(http.StatusOK) + }), + ) + + identity := &coredata.Identity{ID: gid.New(gid.NilTenant, coredata.IdentityEntityType)} + session := &coredata.Session{ + ID: identity.ID, + Data: coredata.SessionDataForHost("portal.example.com"), + } + + req := httptest.NewRequest(http.MethodGet, "/graphql", nil) + req.Host = "portal.example.com" + req.TLS = &tls.ConnectionState{ServerName: "other.example.com"} + req = req.WithContext(authn.ContextWithIdentity(req.Context(), identity)) + req = req.WithContext(authn.ContextWithSession(req.Context(), session)) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.False(t, authenticated) +} diff --git a/pkg/server/api/trust/v1/auth_resolvers.go b/pkg/server/api/complianceportal/v1/auth_resolvers.go similarity index 92% rename from pkg/server/api/trust/v1/auth_resolvers.go rename to pkg/server/api/complianceportal/v1/auth_resolvers.go index 581f9c0d0..f34a7723d 100644 --- a/pkg/server/api/trust/v1/auth_resolvers.go +++ b/pkg/server/api/complianceportal/v1/auth_resolvers.go @@ -1,4 +1,4 @@ -package trust_v1 +package complianceportal_v1 // This file will be automatically regenerated based on the schema, any resolver // implementations @@ -16,7 +16,7 @@ import ( "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" ) @@ -123,6 +123,22 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri } } + req := gqlutils.HTTPRequestFromContext(ctx) + if req == nil { + return nil, gqlutils.Internal(ctx) + } + + host, ok := complianceportal.TrustedRequestHost(req) + if !ok { + return nil, gqlutils.Internal(ctx) + } + + session.Data = coredata.SessionDataForHost(host) + if err := r.iam.SessionService.UpdateSessionData(ctx, session.ID, session.Data); err != nil { + r.logger.ErrorCtx(ctx, "cannot bind session to host", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + trustCenter := complianceportal.CompliancePageFromContext(ctx) if _, err := r.trust.ProvisionPortalMember(ctx, trustCenter.ID, identity.ID); err != nil { diff --git a/pkg/server/api/trust/v1/base_resolvers.go b/pkg/server/api/complianceportal/v1/base_resolvers.go similarity index 98% rename from pkg/server/api/trust/v1/base_resolvers.go rename to pkg/server/api/complianceportal/v1/base_resolvers.go index 59e1fa08d..67025ee4c 100644 --- a/pkg/server/api/trust/v1/base_resolvers.go +++ b/pkg/server/api/complianceportal/v1/base_resolvers.go @@ -1,4 +1,4 @@ -package trust_v1 +package complianceportal_v1 // This file will be automatically regenerated based on the schema, any resolver // implementations @@ -17,8 +17,8 @@ import ( "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/api/trust/v1/schema" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/schema" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" ) diff --git a/pkg/server/api/trust/v1/file_loader.go b/pkg/server/api/complianceportal/v1/file_loader.go similarity index 94% rename from pkg/server/api/trust/v1/file_loader.go rename to pkg/server/api/complianceportal/v1/file_loader.go index dd6fc561c..5e51711d3 100644 --- a/pkg/server/api/trust/v1/file_loader.go +++ b/pkg/server/api/complianceportal/v1/file_loader.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package trust_v1 +package complianceportal_v1 import ( "context" @@ -27,7 +27,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" ) diff --git a/pkg/server/api/trust/v1/gqlgen.yaml b/pkg/server/api/complianceportal/v1/gqlgen.yaml similarity index 96% rename from pkg/server/api/trust/v1/gqlgen.yaml rename to pkg/server/api/complianceportal/v1/gqlgen.yaml index ee5cbe567..51747273f 100644 --- a/pkg/server/api/trust/v1/gqlgen.yaml +++ b/pkg/server/api/complianceportal/v1/gqlgen.yaml @@ -14,7 +14,7 @@ model: resolver: layout: "follow-schema" dir: "." - package: "trust_v1" + package: "complianceportal_v1" filename_template: "{name}_resolvers.go" autobind: [] diff --git a/pkg/server/api/trust/v1/graphql/auth.graphql b/pkg/server/api/complianceportal/v1/graphql/auth.graphql similarity index 100% rename from pkg/server/api/trust/v1/graphql/auth.graphql rename to pkg/server/api/complianceportal/v1/graphql/auth.graphql diff --git a/pkg/server/api/trust/v1/graphql/base.graphql b/pkg/server/api/complianceportal/v1/graphql/base.graphql similarity index 100% rename from pkg/server/api/trust/v1/graphql/base.graphql rename to pkg/server/api/complianceportal/v1/graphql/base.graphql diff --git a/pkg/server/api/trust/v1/graphql/file.graphql b/pkg/server/api/complianceportal/v1/graphql/file.graphql similarity index 100% rename from pkg/server/api/trust/v1/graphql/file.graphql rename to pkg/server/api/complianceportal/v1/graphql/file.graphql diff --git a/pkg/server/api/trust/v1/graphql/mailing_list.graphql b/pkg/server/api/complianceportal/v1/graphql/mailing_list.graphql similarity index 89% rename from pkg/server/api/trust/v1/graphql/mailing_list.graphql rename to pkg/server/api/complianceportal/v1/graphql/mailing_list.graphql index 061291f86..87e08a704 100644 --- a/pkg/server/api/trust/v1/graphql/mailing_list.graphql +++ b/pkg/server/api/complianceportal/v1/graphql/mailing_list.graphql @@ -29,10 +29,7 @@ enum MailingListSubscriberStatus ) } -type MailingListSubscriber implements Node - @goModel( - model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber" - ) { +type MailingListSubscriber implements Node { id: ID! fullName: String! email: EmailAddr! diff --git a/pkg/server/api/trust/v1/graphql/nda.graphql b/pkg/server/api/complianceportal/v1/graphql/nda.graphql similarity index 100% rename from pkg/server/api/trust/v1/graphql/nda.graphql rename to pkg/server/api/complianceportal/v1/graphql/nda.graphql diff --git a/pkg/server/api/trust/v1/graphql/rights_request.graphql b/pkg/server/api/complianceportal/v1/graphql/rights_request.graphql similarity index 100% rename from pkg/server/api/trust/v1/graphql/rights_request.graphql rename to pkg/server/api/complianceportal/v1/graphql/rights_request.graphql diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/complianceportal/v1/graphql/trust_center.graphql similarity index 96% rename from pkg/server/api/trust/v1/graphql/trust_center.graphql rename to pkg/server/api/complianceportal/v1/graphql/trust_center.graphql index 018d52c9f..4eaf6d98d 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/complianceportal/v1/graphql/trust_center.graphql @@ -172,26 +172,20 @@ type AuditEdge @nda { node: Audit! } -type ComplianceFramework implements Node - @goModel( - model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.ComplianceFramework" - ) { +type ComplianceFramework implements Node { id: ID! framework: Framework! @goField(forceResolver: true) } type ComplianceFrameworkConnection @goModel( - model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.ComplianceFrameworkConnection" + model: "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types.ComplianceFrameworkConnection" ) { edges: [ComplianceFrameworkEdge!]! pageInfo: PageInfo! } -type ComplianceFrameworkEdge - @goModel( - model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.ComplianceFrameworkEdge" - ) { +type ComplianceFrameworkEdge { cursor: CursorKey! node: ComplianceFramework! } @@ -285,7 +279,7 @@ input SubprocessorFilter { type SubprocessorConnection @goModel( - model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.SubprocessorConnection" + model: "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types.SubprocessorConnection" ) @nda { edges: [SubprocessorEdge!]! pageInfo: PageInfo! diff --git a/pkg/server/api/trust/v1/graphql_handler.go b/pkg/server/api/complianceportal/v1/graphql_handler.go similarity index 96% rename from pkg/server/api/trust/v1/graphql_handler.go rename to pkg/server/api/complianceportal/v1/graphql_handler.go index 697fb5abb..f326a0c76 100644 --- a/pkg/server/api/trust/v1/graphql_handler.go +++ b/pkg/server/api/complianceportal/v1/graphql_handler.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package trust_v1 +package complianceportal_v1 import ( "net/http" @@ -33,7 +33,7 @@ import ( "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" - "go.probo.inc/probo/pkg/server/api/trust/v1/schema" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/schema" "go.probo.inc/probo/pkg/server/gqlutils" "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication" "go.probo.inc/probo/pkg/server/gqlutils/directives/session" diff --git a/pkg/server/api/trust/v1/mailing_list_resolvers.go b/pkg/server/api/complianceportal/v1/mailing_list_resolvers.go similarity index 96% rename from pkg/server/api/trust/v1/mailing_list_resolvers.go rename to pkg/server/api/complianceportal/v1/mailing_list_resolvers.go index 5990848bf..ab7922278 100644 --- a/pkg/server/api/trust/v1/mailing_list_resolvers.go +++ b/pkg/server/api/complianceportal/v1/mailing_list_resolvers.go @@ -1,4 +1,4 @@ -package trust_v1 +package complianceportal_v1 // This file will be automatically regenerated based on the schema, any resolver // implementations @@ -13,7 +13,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" "go.probo.inc/probo/pkg/validator" ) diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go new file mode 100644 index 000000000..ff0afb21b --- /dev/null +++ b/pkg/server/api/complianceportal/v1/mux.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal_v1 + +import ( + "context" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.gearno.de/x/ref" + "go.probo.inc/probo/pkg/baseurl" + visitor "go.probo.inc/probo/pkg/complianceportal/visitor" + "go.probo.inc/probo/pkg/esign" + "go.probo.inc/probo/pkg/filemanager" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/mailman" + "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/api/complianceportal" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +type MuxConfig struct { + BaseURL *baseurl.BaseURL + ExtraHeaderFields map[string]string + Logger *log.Logger + IAM *iam.Service + Visitor *visitor.Service + ResourceAlias *resourcealias.Service + File *filemanager.Service + ESign *esign.Service + Mailman *mailman.Service + Cookie securecookie.Config + TokenSecret string + GraphQLLimits gqlutils.Limits +} + +func NewMux(cfg MuxConfig) (http.Handler, error) { + webServer, err := NewServer(compliancePageHeadData(cfg.BaseURL)) + if err != nil { + return nil, err + } + + r := chi.NewRouter() + + r.Use(complianceportal.NewSNIMiddleware(cfg.Visitor)) + r.Use(server.NewSecurityHeadersMiddleware(cfg.ExtraHeaderFields)) + + markdownHandler := complianceportal.NewHandler(cfg.Visitor) + + r.Get("/llms.txt", markdownHandler.HandleLLMsTxt) + r.Get("/robots.txt", markdownHandler.HandleRobotsTxt) + r.Get("/sitemap.xml", markdownHandler.HandleSitemap) + + allowedHost := func(ctx context.Context, host string) bool { + _, err := cfg.Visitor.GetPortalByDomainName(ctx, host) + return err == nil + } + + oauthInitiateHandler := NewOAuthInitiateHandler( + cfg.BaseURL, + cfg.Visitor, + allowedHost, + cfg.Logger, + ) + oauthCallbackHandler := NewOAuthCallbackHandler( + cfg.IAM, + cfg.Visitor, + cfg.Cookie, + allowedHost, + cfg.Logger, + ) + + graphqlHandler := NewGraphQLHandler( + cfg.IAM, + cfg.Visitor, + cfg.ResourceAlias, + cfg.File, + cfg.ESign, + cfg.Mailman, + cfg.Logger, + cfg.BaseURL, + cfg.Cookie, + cfg.TokenSecret, + cfg.GraphQLLimits, + ) + + r.Group( + func(r chi.Router) { + r.Use(complianceportal.NewCompliancePagePresenceMiddleware()) + + r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler()) + r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler) + r.Method(http.MethodGet, complianceportal.OAuthCallbackPath, oauthCallbackHandler) + + r.Group( + func(r chi.Router) { + r.Use(authn.NewSessionMiddleware(cfg.IAM, cfg.Cookie)) + r.Use(complianceportal.NewSessionHostMiddleware(cfg.Cookie)) + r.Use(complianceportal.NewMemberProvisioningMiddleware(cfg.Visitor, cfg.Logger)) + r.Handle(complianceportal.GraphQLPath, graphqlHandler) + }, + ) + + r.Handle("/*", webServer) + r.NotFound(handleCustomDomain404) + }, + ) + + return r, nil +} + +func handleCustomDomain404(w http.ResponseWriter, r *http.Request) { + httpserver.RenderError(w, http.StatusNotFound, errors.New("not found")) +} + +func compliancePageHeadData(baseURL *baseurl.BaseURL) HeadDataFunc { + return func(r *http.Request) HeadData { + tc := complianceportal.CompliancePageFromContext(r.Context()) + if tc == nil { + return HeadData{Title: "Compliance Page"} + } + + compliancePageBaseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context()) + + description := tc.Title + " Compliance Page" + if tc.Description != nil && *tc.Description != "" { + description = *tc.Description + } + + headData := HeadData{ + Title: tc.Title, + Description: description, + OGURL: ref.UnrefOrZero(compliancePageBaseURL), + } + + if tc.LogoFileID != nil { + faviconURL, err := baseURL.WithPath("/api/files/v1/public/" + tc.LogoFileID.String()).String() + if err == nil { + headData.FaviconURL = faviconURL + } + } + + return headData + } +} diff --git a/pkg/server/api/trust/v1/nda_directive.go b/pkg/server/api/complianceportal/v1/nda_directive.go similarity index 99% rename from pkg/server/api/trust/v1/nda_directive.go rename to pkg/server/api/complianceportal/v1/nda_directive.go index 377bb533f..4315eda8d 100644 --- a/pkg/server/api/trust/v1/nda_directive.go +++ b/pkg/server/api/complianceportal/v1/nda_directive.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package trust_v1 +package complianceportal_v1 import ( "context" diff --git a/pkg/server/api/trust/v1/nda_resolvers.go b/pkg/server/api/complianceportal/v1/nda_resolvers.go similarity index 86% rename from pkg/server/api/trust/v1/nda_resolvers.go rename to pkg/server/api/complianceportal/v1/nda_resolvers.go index a40698f9d..f2f97888e 100644 --- a/pkg/server/api/trust/v1/nda_resolvers.go +++ b/pkg/server/api/complianceportal/v1/nda_resolvers.go @@ -1,4 +1,4 @@ -package trust_v1 +package complianceportal_v1 // This file will be automatically regenerated based on the schema, any resolver // implementations @@ -15,16 +15,17 @@ import ( "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/api/trust/v1/schema" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/schema" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" ) // AcceptElectronicSignature is the resolver for the acceptElectronicSignature field. func (r *mutationResolver) AcceptElectronicSignature(ctx context.Context, input types.AcceptElectronicSignatureInput) (*types.AcceptElectronicSignaturePayload, error) { var ( - identity = authn.IdentityFromContext(ctx) - httpReq = gqlutils.HTTPRequestFromContext(ctx) + identity = authn.IdentityFromContext(ctx) + httpReq = gqlutils.HTTPRequestFromContext(ctx) + trustCenter = complianceportal.CompliancePageFromContext(ctx) ) signerIP, _, _ := net.SplitHostPort(httpReq.RemoteAddr) @@ -32,8 +33,11 @@ func (r *mutationResolver) AcceptElectronicSignature(ctx context.Context, input signerIP = httpReq.RemoteAddr } + scope := coredata.NewScopeFromObjectID(trustCenter.ID) + signature, err := r.esign.AcceptSignature( ctx, + scope, &esign.AcceptSignatureRequest{ SignatureID: input.SignatureID, SignerFullName: identity.FullName, @@ -55,8 +59,9 @@ func (r *mutationResolver) AcceptElectronicSignature(ctx context.Context, input // RecordSigningEvent is the resolver for the recordSigningEvent field. func (r *mutationResolver) RecordSigningEvent(ctx context.Context, input types.RecordSigningEventInput) (*types.RecordSigningEventPayload, error) { var ( - identity = authn.IdentityFromContext(ctx) - httpReq = gqlutils.HTTPRequestFromContext(ctx) + identity = authn.IdentityFromContext(ctx) + httpReq = gqlutils.HTTPRequestFromContext(ctx) + trustCenter = complianceportal.CompliancePageFromContext(ctx) ) actorIP, _, _ := net.SplitHostPort(httpReq.RemoteAddr) @@ -64,8 +69,11 @@ func (r *mutationResolver) RecordSigningEvent(ctx context.Context, input types.R actorIP = httpReq.RemoteAddr } + scope := coredata.NewScopeFromObjectID(trustCenter.ID) + if err := r.esign.RecordEvent( ctx, + scope, &esign.RecordEventRequest{ SignatureID: input.SignatureID, EventType: input.EventType, @@ -132,7 +140,7 @@ func (r *nonDisclosureAgreementResolver) ViewerSignature(ctx context.Context, ob return nil, nil } - sig, err := r.esign.GetSignatureByID(ctx, *access.ElectronicSignatureID) + sig, err := r.esign.GetSignatureByID(ctx, scope, *access.ElectronicSignatureID) if err != nil { return nil, nil } diff --git a/pkg/server/api/complianceportal/v1/oauth_callback_handler.go b/pkg/server/api/complianceportal/v1/oauth_callback_handler.go new file mode 100644 index 000000000..994eecf59 --- /dev/null +++ b/pkg/server/api/complianceportal/v1/oauth_callback_handler.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal_v1 + +import ( + "errors" + "net/http" + + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/complianceportal/visitor" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/saferedirect" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/api/complianceportal" +) + +var ( + errNotFound = errors.New("not found") + errInvalidContinueURL = errors.New("invalid continue URL") + errInternal = errors.New("internal server error") + errInvalidOAuthRequest = errors.New("invalid oauth request") +) + +type OAuthCallbackHandler struct { + iam *iam.Service + visitor *visitor.Service + sessionCookie *authn.Cookie + safeRedirect *saferedirect.SafeRedirect + logger *log.Logger +} + +func NewOAuthCallbackHandler( + iamSvc *iam.Service, + visitorSvc *visitor.Service, + cookieConfig securecookie.Config, + allowedHost saferedirect.AllowedHostFunc, + logger *log.Logger, +) *OAuthCallbackHandler { + return &OAuthCallbackHandler{ + iam: iamSvc, + visitor: visitorSvc, + sessionCookie: authn.NewCookie(&cookieConfig), + safeRedirect: saferedirect.New(allowedHost), + logger: logger, + } +} + +func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if oauthErr := r.URL.Query().Get("error"); oauthErr != "" { + h.logger.WarnCtx( + ctx, + "oauth callback returned error", + log.String("error", oauthErr), + log.String("error_description", r.URL.Query().Get("error_description")), + ) + httpserver.RenderError(w, http.StatusBadRequest, errInvalidOAuthRequest) + + return + } + + code := r.URL.Query().Get("code") + stateToken := r.URL.Query().Get("state") + if code == "" || stateToken == "" { + httpserver.RenderError(w, http.StatusBadRequest, errInvalidOAuthRequest) + return + } + + state, err := h.visitor.ConsumeOAuthState(ctx, stateToken) + if err != nil { + h.logger.WarnCtx(ctx, "invalid oauth state", log.Error(err)) + httpserver.RenderError(w, http.StatusBadRequest, errInvalidOAuthRequest) + + return + } + + portal := complianceportal.CompliancePageFromContext(ctx) + portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx) + if portal == nil || portalBaseURL == nil { + httpserver.RenderError(w, http.StatusNotFound, errNotFound) + return + } + + clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + tokenResult, err := h.iam.OAuth2ServerService.ExchangeAuthorizationCode( + ctx, + clientID, + code, + redirectURI, + state.CodeVerifier, + ) + if err != nil { + h.logger.WarnCtx(ctx, "cannot exchange authorization code", log.Error(err)) + httpserver.RenderError(w, http.StatusBadRequest, errInvalidOAuthRequest) + + return + } + + identityID, err := oauth2.ParseIDTokenIdentity(tokenResult.IDToken, state.Nonce) + if err != nil { + h.logger.WarnCtx(ctx, "cannot validate id token", log.Error(err)) + httpserver.RenderError(w, http.StatusBadRequest, errInvalidOAuthRequest) + + return + } + + host, ok := complianceportal.TrustedRequestHost(r) + if !ok { + httpserver.RenderError(w, http.StatusBadRequest, errInvalidOAuthRequest) + return + } + + session, err := h.iam.AuthService.OpenRootSession( + ctx, + identityID, + coredata.AuthMethodOIDC, + coredata.SessionDataForHost(host), + ) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot open session", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + if _, err := h.visitor.ProvisionPortalMember(ctx, portal.ID, identityID); err != nil { + h.logger.ErrorCtx(ctx, "cannot provision portal member", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + h.sessionCookie.Set(w, session) + + continueURL := state.ContinueURL + if continueURL == "" { + continueURL = "/" + } + + h.safeRedirect.Redirect(w, r, continueURL, "/", http.StatusFound) +} diff --git a/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go b/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go new file mode 100644 index 000000000..a97953880 --- /dev/null +++ b/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal_v1 + +import ( + "encoding/json" + "net/http" + + "go.gearno.de/kit/httpserver" + portal "go.probo.inc/probo/pkg/complianceportal" + "go.probo.inc/probo/pkg/server/api/complianceportal" +) + +type oauthClientMetadataHandler struct{} + +func NewOAuthClientMetadataHandler() http.Handler { + return &oauthClientMetadataHandler{} +} + +func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + compliancePage := complianceportal.CompliancePageFromContext(r.Context()) + baseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context()) + + if compliancePage == nil || baseURL == nil { + httpserver.RenderError(w, http.StatusNotFound, errNotFound) + return + } + + doc, err := portal.BuildClientMetadataDocument(compliancePage, *baseURL) + if err != nil { + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + _ = json.NewEncoder(w).Encode(doc) +} diff --git a/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go b/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go new file mode 100644 index 000000000..7bd37e3ec --- /dev/null +++ b/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 complianceportal_v1 + +import ( + "net/http" + + "go.gearno.de/kit/httpclient" + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/complianceportal/visitor" + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/saferedirect" + "go.probo.inc/probo/pkg/server/api/complianceportal" +) + +type OAuthInitiateHandler struct { + proboBaseURL *baseurl.BaseURL + visitor *visitor.Service + safeRedirect *saferedirect.SafeRedirect + httpClient *http.Client + logger *log.Logger +} + +func NewOAuthInitiateHandler( + proboBaseURL *baseurl.BaseURL, + visitorSvc *visitor.Service, + allowedHost saferedirect.AllowedHostFunc, + logger *log.Logger, +) *OAuthInitiateHandler { + return &OAuthInitiateHandler{ + proboBaseURL: proboBaseURL, + visitor: visitorSvc, + safeRedirect: saferedirect.New(allowedHost), + httpClient: httpclient.DefaultClient( + httpclient.WithLogger(logger), + httpclient.WithSSRFProtection(), + ), + logger: logger, + } +} + +func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx) + if portalBaseURL == nil { + httpserver.RenderError(w, http.StatusNotFound, errNotFound) + return + } + + continueURL := r.URL.Query().Get("continue") + if continueURL == "" { + continueURL = "/overview" + } + + safeContinue, ok := h.safeRedirect.Validate(ctx, continueURL) + if !ok { + httpserver.RenderError(w, http.StatusBadRequest, errInvalidContinueURL) + return + } + + clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + metadata, err := oauth2.FetchServerMetadata(ctx, h.httpClient, h.proboBaseURL.String()) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot fetch discovery metadata", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + authorizeURL, err := h.visitor.InitiateOAuthAuthorizeURL( + ctx, + metadata.AuthorizationEndpoint.String(), + clientID, + redirectURI, + []string{complianceportal.VisitorOAuthScope}, + safeContinue, + ) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot initiate oauth authorize", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errInternal) + + return + } + + http.Redirect(w, r, authorizeURL, http.StatusFound) +} diff --git a/pkg/server/api/complianceportal/v1/resolver.go b/pkg/server/api/complianceportal/v1/resolver.go new file mode 100644 index 000000000..01270a6b3 --- /dev/null +++ b/pkg/server/api/complianceportal/v1/resolver.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +//go:generate go run github.com/99designs/gqlgen generate + +package complianceportal_v1 + +import ( + "time" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + trust "go.probo.inc/probo/pkg/complianceportal/visitor" + "go.probo.inc/probo/pkg/esign" + "go.probo.inc/probo/pkg/filemanager" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/mailman" + "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/server/api/authn" +) + +type ( + TrustAuthConfig struct { + CookieName string + CookieDomain string + CookieDuration time.Duration + TokenDuration time.Duration + ReportURLDuration time.Duration + Scope string + TokenType string + CookieSecure bool + } + + Resolver struct { + trust *trust.Service + resourceAlias *resourcealias.Service + fileManager *filemanager.Service + esign *esign.Service + mailman *mailman.Service + logger *log.Logger + iam *iam.Service + sessionCookie *authn.Cookie + baseURL *baseurl.BaseURL + } +) diff --git a/pkg/server/api/trust/v1/resource_alias_resolvers.go b/pkg/server/api/complianceportal/v1/resource_alias_resolvers.go similarity index 98% rename from pkg/server/api/trust/v1/resource_alias_resolvers.go rename to pkg/server/api/complianceportal/v1/resource_alias_resolvers.go index a873b6e38..4a4f79f7f 100644 --- a/pkg/server/api/trust/v1/resource_alias_resolvers.go +++ b/pkg/server/api/complianceportal/v1/resource_alias_resolvers.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package trust_v1 +package complianceportal_v1 import ( "context" diff --git a/pkg/server/api/trust/v1/rights_request_resolvers.go b/pkg/server/api/complianceportal/v1/rights_request_resolvers.go similarity index 83% rename from pkg/server/api/trust/v1/rights_request_resolvers.go rename to pkg/server/api/complianceportal/v1/rights_request_resolvers.go index 201edb003..d0712d2c0 100644 --- a/pkg/server/api/trust/v1/rights_request_resolvers.go +++ b/pkg/server/api/complianceportal/v1/rights_request_resolvers.go @@ -1,4 +1,4 @@ -package trust_v1 +package complianceportal_v1 // This file will be automatically regenerated based on the schema, any resolver // implementations @@ -9,12 +9,12 @@ import ( "context" "go.gearno.de/kit/log" + trust "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/server/api/authn" - "go.probo.inc/probo/pkg/server/api/compliancepage" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "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" - "go.probo.inc/probo/pkg/trust" ) // CreateRightsRequest is the resolver for the createRightsRequest field. @@ -26,10 +26,10 @@ func (r *mutationResolver) CreateRightsRequest(ctx context.Context, input types. return nil, gqlutils.Unauthenticatedf(ctx, "a verified email is required to submit a request") } - compliancePage := compliancepage.CompliancePageFromContext(ctx) + compliancePage := complianceportal.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) - rightsRequest, err := r.trust.RightsRequests.Create( + rightsRequest, err := r.trust.CreateRightsRequest( ctx, scope, &trust.CreateRightsRequest{ diff --git a/pkg/server/api/trust/v1/schema/.gitignore b/pkg/server/api/complianceportal/v1/schema/.gitignore similarity index 100% rename from pkg/server/api/trust/v1/schema/.gitignore rename to pkg/server/api/complianceportal/v1/schema/.gitignore diff --git a/pkg/server/api/trust/v1/schema/doc.go b/pkg/server/api/complianceportal/v1/schema/doc.go similarity index 100% rename from pkg/server/api/trust/v1/schema/doc.go rename to pkg/server/api/complianceportal/v1/schema/doc.go diff --git a/pkg/server/trust/trust.go b/pkg/server/api/complianceportal/v1/spa.go similarity index 96% rename from pkg/server/trust/trust.go rename to pkg/server/api/complianceportal/v1/spa.go index d6d8e73f9..e3a90f17d 100644 --- a/pkg/server/trust/trust.go +++ b/pkg/server/api/complianceportal/v1/spa.go @@ -18,8 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -// Package trust provides functionality for serving the trust center SPA frontend. -package trust +package complianceportal_v1 import ( "fmt" diff --git a/pkg/server/api/trust/v1/trust_center_resolvers.go b/pkg/server/api/complianceportal/v1/trust_center_resolvers.go similarity index 98% rename from pkg/server/api/trust/v1/trust_center_resolvers.go rename to pkg/server/api/complianceportal/v1/trust_center_resolvers.go index 8e5ad5b5b..a98c82438 100644 --- a/pkg/server/api/trust/v1/trust_center_resolvers.go +++ b/pkg/server/api/complianceportal/v1/trust_center_resolvers.go @@ -1,4 +1,4 @@ -package trust_v1 +package complianceportal_v1 // This file will be automatically regenerated based on the schema, any resolver // implementations @@ -18,8 +18,8 @@ import ( "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/api/trust/v1/schema" - "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/schema" + "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" ) @@ -165,7 +165,13 @@ func (r *complianceFrameworkResolver) Framework(ctx context.Context, obj *types. scope := coredata.NewScopeFromObjectID(obj.ID) trustService := r.trust - framework, err := trustService.GetFramework(ctx, scope, obj.FrameworkID) + complianceFramework, err := trustService.GetComplianceFramework(ctx, scope, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot load compliance framework", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + framework, err := trustService.GetFramework(ctx, scope, complianceFramework.FrameworkID) if err != nil { r.logger.ErrorCtx(ctx, "cannot load framework", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -827,9 +833,10 @@ func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.Trus // SubprocessorCategories is the resolver for the subprocessorCategories field. func (r *trustCenterResolver) SubprocessorCategories(ctx context.Context, obj *types.TrustCenter) ([]coredata.ThirdPartyCategory, error) { + trustCenter := complianceportal.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(obj.ID) - categories, err := r.trust.ListDistinctTrustCenterCategoriesForOrganizationID(ctx, scope, obj.Organization.ID) + categories, err := r.trust.ListDistinctTrustCenterCategoriesForOrganizationID(ctx, scope, trustCenter.OrganizationID) if err != nil { r.logger.ErrorCtx(ctx, "cannot list subprocessor categories", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -840,9 +847,10 @@ func (r *trustCenterResolver) SubprocessorCategories(ctx context.Context, obj *t // SubprocessorCountries is the resolver for the subprocessorCountries field. func (r *trustCenterResolver) SubprocessorCountries(ctx context.Context, obj *types.TrustCenter) ([]coredata.CountryCode, error) { + trustCenter := complianceportal.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(obj.ID) - countries, err := r.trust.ListDistinctTrustCenterCountriesForOrganizationID(ctx, scope, obj.Organization.ID) + countries, err := r.trust.ListDistinctTrustCenterCountriesForOrganizationID(ctx, scope, trustCenter.OrganizationID) if err != nil { r.logger.ErrorCtx(ctx, "cannot list subprocessor countries", log.Error(err)) return nil, gqlutils.Internal(ctx) diff --git a/pkg/server/api/trust/v1/types/audit.go b/pkg/server/api/complianceportal/v1/types/audit.go similarity index 100% rename from pkg/server/api/trust/v1/types/audit.go rename to pkg/server/api/complianceportal/v1/types/audit.go diff --git a/pkg/server/api/trust/v1/types/audit_report.go b/pkg/server/api/complianceportal/v1/types/audit_report.go similarity index 100% rename from pkg/server/api/trust/v1/types/audit_report.go rename to pkg/server/api/complianceportal/v1/types/audit_report.go diff --git a/pkg/server/api/trust/v1/types/compliance_custom_link.go b/pkg/server/api/complianceportal/v1/types/compliance_custom_link.go similarity index 100% rename from pkg/server/api/trust/v1/types/compliance_custom_link.go rename to pkg/server/api/complianceportal/v1/types/compliance_custom_link.go diff --git a/pkg/server/api/trust/v1/types/compliance_framework.go b/pkg/server/api/complianceportal/v1/types/compliance_framework.go similarity index 81% rename from pkg/server/api/trust/v1/types/compliance_framework.go rename to pkg/server/api/complianceportal/v1/types/compliance_framework.go index 96d121fbf..be6e1a990 100644 --- a/pkg/server/api/trust/v1/types/compliance_framework.go +++ b/pkg/server/api/complianceportal/v1/types/compliance_framework.go @@ -22,33 +22,17 @@ package types import ( "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" ) -type ComplianceFramework struct { - ID gid.GID `json:"id"` - Framework *Framework `json:"framework"` - FrameworkID gid.GID `json:"-"` -} - -func (ComplianceFramework) IsNode() {} -func (cf ComplianceFramework) GetID() gid.GID { return cf.ID } - type ComplianceFrameworkConnection struct { Edges []*ComplianceFrameworkEdge `json:"edges"` PageInfo *PageInfo `json:"pageInfo"` } -type ComplianceFrameworkEdge struct { - Cursor page.CursorKey `json:"cursor"` - Node *ComplianceFramework `json:"node"` -} - func NewComplianceFramework(cf *coredata.ComplianceFramework) *ComplianceFramework { return &ComplianceFramework{ - ID: cf.ID, - FrameworkID: cf.FrameworkID, + ID: cf.ID, } } diff --git a/pkg/server/api/trust/v1/types/compliance_portal_commitment.go b/pkg/server/api/complianceportal/v1/types/compliance_portal_commitment.go similarity index 100% rename from pkg/server/api/trust/v1/types/compliance_portal_commitment.go rename to pkg/server/api/complianceportal/v1/types/compliance_portal_commitment.go diff --git a/pkg/server/api/trust/v1/types/cursorkey.go b/pkg/server/api/complianceportal/v1/types/cursorkey.go similarity index 100% rename from pkg/server/api/trust/v1/types/cursorkey.go rename to pkg/server/api/complianceportal/v1/types/cursorkey.go diff --git a/pkg/server/api/trust/v1/types/document.go b/pkg/server/api/complianceportal/v1/types/document.go similarity index 100% rename from pkg/server/api/trust/v1/types/document.go rename to pkg/server/api/complianceportal/v1/types/document.go diff --git a/pkg/server/api/trust/v1/types/electronic_signature.go b/pkg/server/api/complianceportal/v1/types/electronic_signature.go similarity index 100% rename from pkg/server/api/trust/v1/types/electronic_signature.go rename to pkg/server/api/complianceportal/v1/types/electronic_signature.go diff --git a/pkg/server/api/trust/v1/types/file.go b/pkg/server/api/complianceportal/v1/types/file.go similarity index 100% rename from pkg/server/api/trust/v1/types/file.go rename to pkg/server/api/complianceportal/v1/types/file.go diff --git a/pkg/server/api/trust/v1/types/framework.go b/pkg/server/api/complianceportal/v1/types/framework.go similarity index 100% rename from pkg/server/api/trust/v1/types/framework.go rename to pkg/server/api/complianceportal/v1/types/framework.go diff --git a/pkg/server/api/trust/v1/types/mailing_list_subscriber.go b/pkg/server/api/complianceportal/v1/types/mailing_list_subscriber.go similarity index 70% rename from pkg/server/api/trust/v1/types/mailing_list_subscriber.go rename to pkg/server/api/complianceportal/v1/types/mailing_list_subscriber.go index 9e6929806..ee00d4474 100644 --- a/pkg/server/api/trust/v1/types/mailing_list_subscriber.go +++ b/pkg/server/api/complianceportal/v1/types/mailing_list_subscriber.go @@ -21,25 +21,9 @@ package types import ( - "time" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/mail" ) -type MailingListSubscriber struct { - ID gid.GID `json:"id"` - FullName string `json:"fullName"` - Email mail.Addr `json:"email"` - Status coredata.MailingListSubscriberStatus `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (MailingListSubscriber) IsNode() {} -func (m MailingListSubscriber) GetID() gid.GID { return m.ID } - func NewMailingListSubscriber(s *coredata.MailingListSubscriber) *MailingListSubscriber { return &MailingListSubscriber{ ID: s.ID, diff --git a/pkg/server/api/trust/v1/types/mailing_list_update.go b/pkg/server/api/complianceportal/v1/types/mailing_list_update.go similarity index 100% rename from pkg/server/api/trust/v1/types/mailing_list_update.go rename to pkg/server/api/complianceportal/v1/types/mailing_list_update.go diff --git a/pkg/server/api/trust/v1/types/pageinfo.go b/pkg/server/api/complianceportal/v1/types/pageinfo.go similarity index 100% rename from pkg/server/api/trust/v1/types/pageinfo.go rename to pkg/server/api/complianceportal/v1/types/pageinfo.go diff --git a/pkg/server/api/trust/v1/types/rights_request.go b/pkg/server/api/complianceportal/v1/types/rights_request.go similarity index 100% rename from pkg/server/api/trust/v1/types/rights_request.go rename to pkg/server/api/complianceportal/v1/types/rights_request.go diff --git a/pkg/server/api/trust/v1/types/third_party.go b/pkg/server/api/complianceportal/v1/types/third_party.go similarity index 100% rename from pkg/server/api/trust/v1/types/third_party.go rename to pkg/server/api/complianceportal/v1/types/third_party.go diff --git a/pkg/server/api/trust/v1/types/trust_center.go b/pkg/server/api/complianceportal/v1/types/trust_center.go similarity index 100% rename from pkg/server/api/trust/v1/types/trust_center.go rename to pkg/server/api/complianceportal/v1/types/trust_center.go diff --git a/pkg/server/api/trust/v1/types/trust_center_file.go b/pkg/server/api/complianceportal/v1/types/trust_center_file.go similarity index 100% rename from pkg/server/api/trust/v1/types/trust_center_file.go rename to pkg/server/api/complianceportal/v1/types/trust_center_file.go diff --git a/pkg/server/api/trust/v1/types/trust_center_reference.go b/pkg/server/api/complianceportal/v1/types/trust_center_reference.go similarity index 100% rename from pkg/server/api/trust/v1/types/trust_center_reference.go rename to pkg/server/api/complianceportal/v1/types/trust_center_reference.go diff --git a/pkg/server/api/trust/v1/resolver.go b/pkg/server/api/trust/v1/resolver.go deleted file mode 100644 index 5548c5355..000000000 --- a/pkg/server/api/trust/v1/resolver.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) 2025-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. - -//go:generate go tool github.com/99designs/gqlgen generate - -// Copyright (c) 2025 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 trust_v1 - -import ( - "context" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/baseurl" - trust "go.probo.inc/probo/pkg/complianceportal/visitor" - "go.probo.inc/probo/pkg/esign" - "go.probo.inc/probo/pkg/filemanager" - "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/mailman" - "go.probo.inc/probo/pkg/resourcealias" - "go.probo.inc/probo/pkg/securecookie" - "go.probo.inc/probo/pkg/server/api/authn" - "go.probo.inc/probo/pkg/server/api/complianceportal" - "go.probo.inc/probo/pkg/server/gqlutils" -) - -type ( - TrustAuthConfig struct { - CookieName string - CookieDomain string - CookieDuration time.Duration - TokenDuration time.Duration - ReportURLDuration time.Duration - Scope string - TokenType string - CookieSecure bool - } - - Resolver struct { - trust *trust.Service - resourceAlias *resourcealias.Service - fileManager *filemanager.Service - esign *esign.Service - mailman *mailman.Service - logger *log.Logger - iam *iam.Service - sessionCookie *authn.Cookie - baseURL *baseurl.BaseURL - } -) - -func NewMux( - logger *log.Logger, - iamSvc *iam.Service, - trustSvc *trust.Service, - resourceAliasSvc *resourcealias.Service, - fileManagerSvc *filemanager.Service, - esignSvc *esign.Service, - mailmanSvc *mailman.Service, - cookieConfig securecookie.Config, - tokenSecret string, - baseURL *baseurl.BaseURL, - graphqlLimits gqlutils.Limits, -) *chi.Mux { - r := chi.NewMux() - - r.Use(complianceportal.NewCompliancePagePresenceMiddleware()) - - sessionTransferHandler := NewSessionTransferHandler( - iamSvc, - cookieConfig, - func(ctx context.Context, host string) bool { - _, err := trustSvc.GetPortalByDomainName(ctx, host) - return err == nil - }, - logger, - ) - r.Method(http.MethodGet, "/session-transfer", sessionTransferHandler) - - graphqlHandler := NewGraphQLHandler( - iamSvc, - trustSvc, - resourceAliasSvc, - fileManagerSvc, - esignSvc, - mailmanSvc, - logger, - baseURL, - cookieConfig, - tokenSecret, - graphqlLimits, - ) - - r.Group( - func(r chi.Router) { - r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) - r.Use(complianceportal.NewMemberProvisioningMiddleware(trustSvc, logger)) - r.Handle("/graphql", graphqlHandler) - }, - ) - - return r -}