Flatten compliance portal package layout

Remove the root complianceportal package and the resolver
facade that existed only to break an IAM import cycle. Admin
policies, domain URL helpers, and actions live under
management; visitor OAuth metadata, brand URLs, and public
read paths live under visitor. Drop the duplicate trust API
magic-link mutations now that Connect handles portal auth, and
stop IAM from owning compliance page email branding.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-17 16:19:39 +02:00
parent 4cec74c1a1
commit 7e0d187dcf
57 changed files with 737 additions and 1061 deletions

View File

@@ -15,23 +15,23 @@
package complianceportal
import (
portal "go.probo.inc/probo/pkg/complianceportal"
"go.probo.inc/probo/pkg/complianceportal/visitor"
)
const (
VisitorOAuthScope = portal.VisitorOAuthScope
VisitorOAuthScope = visitor.VisitorOAuthScope
GraphQLPath = "/graphql"
CIMDMetadataPath = portal.CIMDMetadataPath
BrandLogoPath = portal.BrandLogoPath
BrandDarkLogoPath = portal.BrandDarkLogoPath
CIMDMetadataPath = visitor.CIMDMetadataPath
BrandLogoPath = visitor.BrandLogoPath
BrandDarkLogoPath = visitor.BrandDarkLogoPath
OAuthInitiatePath = "/initiate"
OAuthCallbackPath = portal.OAuthCallbackPath
OAuthCallbackPath = visitor.OAuthCallbackPath
)
func CIMDClientIDURL(portalBaseURL string) (string, error) {
return portal.CIMDClientIDURL(portalBaseURL)
return visitor.CIMDClientIDURL(portalBaseURL)
}
func OAuthCallbackURL(portalBaseURL string) (string, error) {
return portal.OAuthCallbackURL(portalBaseURL)
return visitor.OAuthCallbackURL(portalBaseURL)
}

View File

@@ -10,150 +10,14 @@ import (
"errors"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/iam"
"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/complianceportal/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
// SendMagicLink is the resolver for the sendMagicLink field.
func (r *mutationResolver) SendMagicLink(ctx context.Context, input types.SendMagicLinkInput) (*types.SendMagicLinkPayload, error) {
trustCenter := complianceportal.CompliancePageFromContext(ctx)
baseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
safeRedirect := saferedirect.New(saferedirect.StaticHosts(baseurl.MustParse(*baseURL).Host()))
if input.Continue != nil {
_, ok := safeRedirect.Validate(ctx, *input.Continue)
if !ok {
return nil, gqlutils.Invalidf(ctx, "invalid continue URL")
}
}
req := &iam.SendMagicLinkRequest{
Email: input.Email,
CompliancePageID: &trustCenter.ID,
OrganizationID: &trustCenter.OrganizationID,
URLPath: "verify-magic-link",
Continue: input.Continue,
}
if err := r.iam.AuthService.SendMagicLink(ctx, req); err != nil {
r.logger.ErrorCtx(ctx, "cannot send magic link", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return nil, nil
}
// VerifyMagicLink is the resolver for the verifyMagicLink field.
func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.VerifyMagicLinkInput) (*types.VerifyMagicLinkPayload, error) {
session := authn.SessionFromContext(ctx)
identity := authn.IdentityFromContext(ctx)
email, err := r.iam.AuthService.GetMagicLinkEmail(ctx, input.Token)
if err != nil {
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
return nil, gqlutils.TokenExpired(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok {
return nil, gqlutils.Invalid(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get magic link email", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
var continueURL *string
switch {
case session == nil:
var err error
identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token)
if err != nil {
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
return nil, gqlutils.TokenExpired(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok {
return nil, gqlutils.TokenAlreadyUsed(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok {
return nil, gqlutils.Invalid(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot open session with magic link", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
case identity.EmailAddress != email:
if err := r.iam.SessionService.CloseSession(ctx, session.ID); err != nil {
r.logger.ErrorCtx(ctx, "cannot close session", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
var err error
identity, session, continueURL, err = r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token)
if err != nil {
if _, ok := errors.AsType[*iam.ErrExpiredToken](err); ok {
return nil, gqlutils.TokenExpired(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrTokenAlreadyUsed](err); ok {
return nil, gqlutils.TokenAlreadyUsed(ctx, err)
}
if _, ok := errors.AsType[*iam.ErrInvalidToken](err); ok {
return nil, gqlutils.Invalid(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot open session with magic link", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
}
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 {
r.logger.ErrorCtx(ctx, "cannot provision member", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
w := gqlutils.HTTPResponseWriterFromContext(ctx)
r.sessionCookie.Set(w, session)
return &types.VerifyMagicLinkPayload{
Continue: continueURL,
}, nil
}
// UpdateFullName is the resolver for the updateFullName field.
func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.UpdateFullNameInput) (*types.UpdateFullNamePayload, error) {
identity := authn.IdentityFromContext(ctx)

View File

@@ -132,7 +132,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
trustCenterFile, err := trustService.GetPortalFile(ctx, scope, trustCenter.OrganizationID, id)
if err != nil {
if errors.Is(err, visitor.ErrTrustCenterFileNotFound) || errors.Is(err, visitor.ErrTrustCenterFileNotVisible) {
if errors.Is(err, visitor.ErrPortalFileNotFound) || errors.Is(err, visitor.ErrPortalFileNotVisible) {
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
}

View File

@@ -1,30 +1,9 @@
extend type Mutation {
sendMagicLink(input: SendMagicLinkInput!): SendMagicLinkPayload
@authentication(required: OPTIONAL)
verifyMagicLink(input: VerifyMagicLinkInput!): VerifyMagicLinkPayload
@authentication(required: OPTIONAL)
updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload
@authentication(required: PRESENT) @sessionOnly
signOut: SignOutPayload! @authentication(required: PRESENT) @sessionOnly
}
input SendMagicLinkInput {
email: EmailAddr!
continue: String
}
type SendMagicLinkPayload {
success: Boolean!
}
input VerifyMagicLinkInput {
token: String!
}
type VerifyMagicLinkPayload {
continue: String
}
input UpdateFullNameInput {
fullName: String!
}

View File

@@ -22,7 +22,6 @@ import (
"go.gearno.de/kit/log"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/baseurl"
page "go.probo.inc/probo/pkg/complianceportal"
visitor "go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
@@ -148,7 +147,7 @@ func compliancePageHeadData() HeadDataFunc {
}
if tc.LogoFileID != nil && compliancePageBaseURL != nil {
faviconURL, err := page.BrandLogoURL(*compliancePageBaseURL)
faviconURL, err := visitor.BrandLogoURL(*compliancePageBaseURL)
if err == nil {
headData.FaviconURL = faviconURL
}

View File

@@ -19,7 +19,7 @@ import (
"net/http"
"go.gearno.de/kit/httpserver"
portal "go.probo.inc/probo/pkg/complianceportal"
"go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/server/api/complianceportal"
)
@@ -38,7 +38,7 @@ func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
return
}
doc, err := portal.BuildClientMetadataDocument(compliancePage, *baseURL)
doc, err := visitor.BuildClientMetadataDocument(compliancePage, *baseURL)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return

View File

@@ -467,7 +467,7 @@ func (r *mutationResolver) ExportTrustCenterFile(ctx context.Context, input type
trustCenterFile, err := trustService.GetPortalFile(ctx, scope, trustCenter.OrganizationID, input.TrustCenterFileID)
if err != nil {
if errors.Is(err, visitor.ErrTrustCenterFileNotFound) || errors.Is(err, visitor.ErrTrustCenterFileNotVisible) {
if errors.Is(err, visitor.ErrPortalFileNotFound) || errors.Is(err, visitor.ErrPortalFileNotVisible) {
return nil, gqlutils.NotFoundf(ctx, "trust center file %q not found", input.TrustCenterFileID)
}
@@ -620,7 +620,7 @@ func (r *mutationResolver) RequestTrustCenterFileAccess(ctx context.Context, inp
trustCenterFile, err := trustService.GetPortalFile(ctx, scope, trustCenter.OrganizationID, input.TrustCenterFileID)
if err != nil {
if errors.Is(err, visitor.ErrTrustCenterFileNotFound) || errors.Is(err, visitor.ErrTrustCenterFileNotVisible) {
if errors.Is(err, visitor.ErrPortalFileNotFound) || errors.Is(err, visitor.ErrPortalFileNotVisible) {
return nil, gqlutils.NotFoundf(ctx, "trust center file %q not found", input.TrustCenterFileID)
}
@@ -836,7 +836,7 @@ func (r *trustCenterResolver) SubprocessorCategories(ctx context.Context, obj *t
trustCenter := complianceportal.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(obj.ID)
categories, err := r.trust.ListDistinctTrustCenterCategoriesForOrganizationID(ctx, scope, trustCenter.OrganizationID)
categories, err := r.trust.ListDistinctPortalCategoriesForOrganizationID(ctx, scope, trustCenter.OrganizationID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list subprocessor categories", log.Error(err))
return nil, gqlutils.Internal(ctx)
@@ -850,7 +850,7 @@ func (r *trustCenterResolver) SubprocessorCountries(ctx context.Context, obj *ty
trustCenter := complianceportal.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(obj.ID)
countries, err := r.trust.ListDistinctTrustCenterCountriesForOrganizationID(ctx, scope, trustCenter.OrganizationID)
countries, err := r.trust.ListDistinctPortalCountriesForOrganizationID(ctx, scope, trustCenter.OrganizationID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list subprocessor countries", log.Error(err))
return nil, gqlutils.Internal(ctx)
@@ -986,7 +986,7 @@ func (r *trustCenterFileResolver) IsUserAuthorized(ctx context.Context, obj *typ
trustCenterFile, err := trustService.GetPortalFile(ctx, scope, trustCenter.OrganizationID, obj.ID)
if err != nil {
if errors.Is(err, visitor.ErrTrustCenterFileNotFound) || errors.Is(err, visitor.ErrTrustCenterFileNotVisible) {
if errors.Is(err, visitor.ErrPortalFileNotFound) || errors.Is(err, visitor.ErrPortalFileNotVisible) {
return false, gqlutils.NotFoundf(ctx, "trust center file %q not found", obj.ID)
}