Files
probo/pkg/server/server.go
Bryan Frimin ba53c94bdc Move trust center profile onto the page
Store website, email, and headquarters on the trust center so
public and admin surfaces read branding from one place. Drop the
trust API organization type and wire console, MCP, CLI, and apps
through the updated schema.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-21 15:44:10 +02:00

285 lines
9.0 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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 server
import (
"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/accessreview"
"go.probo.inc/probo/pkg/agentrun"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/complianceportal/management"
trust "go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/connector/provider"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/geoloc"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/iam/oauth2"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/resourcealias"
"go.probo.inc/probo/pkg/riskmanagement"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api"
"go.probo.inc/probo/pkg/server/api/complianceportal"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/server/mailactions"
trust_web "go.probo.inc/probo/pkg/server/trust"
console_web "go.probo.inc/probo/pkg/server/web"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/thirdparty"
"go.probo.inc/probo/pkg/uri"
)
type Config struct {
BaseURL *baseurl.BaseURL
AllowedOrigins []string
ExtraHeaderFields map[string]string
Probo *probo.Service
ResourceAlias *resourcealias.Service
File *filemanager.Service
IAM *iam.Service
Trust *trust.Service
ESign *esign.Service
CustomDomain *management.Service
AccessReview *accessreview.Service
AgentRun *agentrun.Service
Slack *slack.Service
Mailman *mailman.Service
CookieBanner *cookiebanner.Service
Geoloc *geoloc.Service
ThirdParty *thirdparty.Service
RiskManagement *riskmanagement.Service
Cookie securecookie.Config
TokenSecret string
ConnectorRegistry *connector.ConnectorRegistry
ProviderRegistry *provider.Registry
CustomDomainCname string
GraphQLLimits gqlutils.Limits
Logger *log.Logger
}
type Server struct {
apiServer *api.Server
mailActionsHandler http.Handler
consoleWebServer *console_web.Server
trustWebServer *trust_web.Server
router *chi.Mux
extraHeaderFields map[string]string
baseURL string
proboService *probo.Service
iamService *iam.Service
trustService *trust.Service
logger *log.Logger
}
func NewServer(cfg Config) (*Server, error) {
apiCfg := api.Config{
BaseURL: cfg.BaseURL,
AllowedOrigins: cfg.AllowedOrigins,
Probo: cfg.Probo,
ResourceAlias: cfg.ResourceAlias,
File: cfg.File,
IAM: cfg.IAM,
Trust: cfg.Trust,
ESign: cfg.ESign,
CustomDomain: cfg.CustomDomain,
AccessReview: cfg.AccessReview,
AgentRun: cfg.AgentRun,
Slack: cfg.Slack,
Mailman: cfg.Mailman,
CookieBanner: cfg.CookieBanner,
Geoloc: cfg.Geoloc,
ThirdParty: cfg.ThirdParty,
RiskManagement: cfg.RiskManagement,
Cookie: cfg.Cookie,
TokenSecret: cfg.TokenSecret,
ConnectorRegistry: cfg.ConnectorRegistry,
ProviderRegistry: cfg.ProviderRegistry,
CustomDomainCname: cfg.CustomDomainCname,
GraphQLLimits: cfg.GraphQLLimits,
Logger: cfg.Logger.Named("api"),
}
apiServer, err := api.NewServer(apiCfg)
if err != nil {
return nil, err
}
consoleWebServer, err := console_web.NewServer()
if err != nil {
return nil, err
}
trustWebServer, err := trust_web.NewServer(compliancePageHeadData(cfg.BaseURL))
if err != nil {
return nil, err
}
router := chi.NewRouter()
server := &Server{
apiServer: apiServer,
mailActionsHandler: mailactions.NewMux(cfg.Mailman, cfg.TokenSecret),
consoleWebServer: consoleWebServer,
trustWebServer: trustWebServer,
router: router,
extraHeaderFields: cfg.ExtraHeaderFields,
baseURL: cfg.BaseURL.String(),
proboService: cfg.Probo,
iamService: cfg.IAM,
trustService: cfg.Trust,
logger: cfg.Logger,
}
server.setupRoutes()
return server, nil
}
func (s *Server) setupRoutes() {
// OIDC Discovery 1.0 §4 and RFC 8414 §3 both require the metadata
// document at the issuer root under well-known paths.
s.router.Get("/.well-known/openid-configuration", s.oidcDiscoveryHandler)
s.router.Get("/.well-known/oauth-authorization-server", s.oidcDiscoveryHandler)
s.router.Get("/.well-known/oauth-protected-resource", s.protectedResourceMetadataHandler)
s.router.Mount("/api", http.StripPrefix("/api", s.apiServer))
s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler))
s.router.Mount("/", s.consoleWebServer)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.setExtraHeaders(w)
s.router.ServeHTTP(w, r)
}
func (s *Server) setExtraHeaders(w http.ResponseWriter) {
for key, value := range s.extraHeaderFields {
w.Header().Set(key, value)
}
}
func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) {
api := s.baseURL + "/api/connect/v1"
endpoints := oauth2.Endpoints{
Authorization: uri.URI(api + "/oauth2/authorize"),
Token: uri.URI(api + "/oauth2/token"),
Userinfo: uri.URI(api + "/oauth2/userinfo"),
JWKS: uri.URI(api + "/oauth2/jwks"),
Registration: uri.URI(api + "/oauth2/register"),
Introspection: uri.URI(api + "/oauth2/introspect"),
Revocation: uri.URI(api + "/oauth2/revoke"),
DeviceAuthorization: uri.URI(api + "/oauth2/device"),
}
metadata := s.iamService.OAuth2ServerMetadata(endpoints)
w.Header().Set("Cache-Control", "public, max-age=3600")
httpserver.RenderJSON(w, http.StatusOK, metadata)
}
func (s *Server) protectedResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
resource := uri.URI(s.baseURL)
metadata := s.iamService.OAuth2ProtectedResourceMetadata(resource)
w.Header().Set("Cache-Control", "public, max-age=3600")
httpserver.RenderJSON(w, http.StatusOK, metadata)
}
func (s *Server) handleCustomDomain404(w http.ResponseWriter, r *http.Request) {
httpserver.RenderError(w, http.StatusNotFound, errors.New("not found"))
}
func (s *Server) trustCenterRouter() chi.Router {
r := chi.NewRouter()
h := complianceportal.NewHandler(s.trustService)
r.Mount("/api/trust/v1", s.apiServer.CompliancePageHandler())
r.Get("/llms.txt", h.HandleLLMsTxt)
r.Get("/robots.txt", h.HandleRobotsTxt)
r.Get("/sitemap.xml", h.HandleSitemap)
r.Handle("/*", s.trustWebServer)
return r
}
func (s *Server) TrustCenterHandler() http.Handler {
r := chi.NewRouter()
r.Use(complianceportal.NewSNIMiddleware(s.trustService))
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload")
s.setExtraHeaders(w)
next.ServeHTTP(w, r)
})
})
r.NotFound(s.handleCustomDomain404)
r.Mount("/", s.trustCenterRouter())
return r
}
func compliancePageHeadData(baseURL *baseurl.BaseURL) trust_web.HeadDataFunc {
return func(r *http.Request) trust_web.HeadData {
tc := complianceportal.CompliancePageFromContext(r.Context())
if tc == nil {
return trust_web.HeadData{Title: "Compliance Page"}
}
compliancePageBaseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context())
description := tc.Title + " Compliance Page"
if tc.Description != nil && *tc.Description != "" {
description = *tc.Description
}
headData := trust_web.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
}
}