Migrate Connect org logos to File type

Replace Organization.logoUrl and horizontalLogoUrl with nested File
objects whose downloadUrl points at /api/files/v1/public/{id}, matching
the Console migration.

Org logos are FileVisibilityPublic and served without HTTP auth, so
Connect File.downloadUrl is built eagerly in NewFile with no field-level
authorize. Logo loading moves to iam.OrganizationService.LogoFile and
HorizontalLogoFile; the old URL generators are removed.

Sync IAM Relay components and n8n organization operations. Add an e2e
test for Connect multipart logo upload and ExecuteConnectWithFile.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-10 16:19:45 +02:00
parent c7e2fc209f
commit e06f3e0520
17 changed files with 315 additions and 84 deletions

View File

@@ -1372,18 +1372,17 @@ func (s *OrganizationService) GetOrganizationForMembership(ctx context.Context,
return organization, nil
}
func (s OrganizationService) GenerateLogoURL(
func (s OrganizationService) LogoFile(
ctx context.Context,
organizationID gid.GID,
expiresIn time.Duration,
) (*string, error) {
) (*coredata.File, error) {
var (
errNoLogoFile = errors.New("no logo file found")
scope = coredata.NewScopeFromObjectID(organizationID)
file = &coredata.File{}
)
err := s.pg.WithConn(
if err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
organization := &coredata.Organization{}
@@ -1401,35 +1400,28 @@ func (s OrganizationService) GenerateLogoURL(
return nil
},
)
if err == errNoLogoFile {
return nil, nil
); err != nil {
if errors.Is(err, errNoLogoFile) {
return nil, nil
}
return nil, fmt.Errorf("cannot load logo file: %w", err)
}
if err != nil {
return nil, fmt.Errorf("cannot generate logo URL: %w", err)
}
downloadURL, err := s.fm.BuildDownloadURL(file)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &downloadURL, nil
return file, nil
}
func (s OrganizationService) GenerateHorizontalLogoURL(
func (s OrganizationService) HorizontalLogoFile(
ctx context.Context,
organizationID gid.GID,
expiresIn time.Duration,
) (*string, error) {
) (*coredata.File, error) {
var (
errNoLogoFile = errors.New("no logo file found")
scope = coredata.NewScopeFromObjectID(organizationID)
file = &coredata.File{}
)
err := s.pg.WithConn(
if err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
organization := &coredata.Organization{}
@@ -1447,21 +1439,15 @@ func (s OrganizationService) GenerateHorizontalLogoURL(
return nil
},
)
if err == errNoLogoFile {
return nil, nil
); err != nil {
if errors.Is(err, errNoLogoFile) {
return nil, nil
}
return nil, fmt.Errorf("cannot load horizontal logo file: %w", err)
}
if err != nil {
return nil, err
}
downloadURL, err := s.fm.BuildDownloadURL(file)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &downloadURL, nil
return file, nil
}
func (s OrganizationService) DeleteSAMLConfiguration(

View File

@@ -38,6 +38,9 @@ models:
CursorKey:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar"
BigInt:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/bigint.BigIntScalar"
EmailAddr:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar"

View File

@@ -11,6 +11,7 @@ directive @goModel(
directive @goEnum(value: String) on ENUM_VALUE
scalar BigInt
scalar CursorKey
scalar Datetime
scalar Upload

View File

@@ -0,0 +1,12 @@
# Connect File is PUBLIC-ONLY: downloadUrl points at /api/files/v1/public/{id}
# and carries no authorization. Do not expose a private file through this type
# without adding a forceResolver + auth on downloadUrl.
type File {
id: ID!
mimeType: String!
fileName: String!
size: BigInt!
downloadUrl: String!
createdAt: Datetime!
updatedAt: Datetime!
}

View File

@@ -1,8 +1,8 @@
type Organization implements Node {
id: ID!
name: String!
logoUrl: String @goField(forceResolver: true)
horizontalLogoUrl: String @goField(forceResolver: true)
logo: File @goField(forceResolver: true)
horizontalLogo: File @goField(forceResolver: true)
email: String
description: String
websiteUrl: String

View File

@@ -9,7 +9,6 @@ import (
"context"
"errors"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
@@ -18,7 +17,6 @@ import (
"go.probo.inc/probo/pkg/iam/scim/bridge/provider/microsoft365"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/authz"
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
@@ -158,34 +156,34 @@ func (r *mutationResolver) DeleteOrganizationHorizontalLogo(ctx context.Context,
panic(fmt.Errorf("not implemented: DeleteOrganizationHorizontalLogo - deleteOrganizationHorizontalLogo"))
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionOrganizationGet, authz.WithSkipAssumptionCheck()); err != nil {
return nil, err
}
presignedURL, err := r.iam.OrganizationService.GenerateLogoURL(ctx, obj.ID, 1*time.Hour)
// Logo is the resolver for the logo field.
func (r *organizationResolver) Logo(ctx context.Context, obj *types.Organization) (*types.File, error) {
file, err := r.iam.OrganizationService.LogoFile(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot generate logo URL", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot load logo file", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return presignedURL, nil
if file == nil {
return nil, nil
}
return types.NewFile(file, r.baseURL), nil
}
// HorizontalLogoURL is the resolver for the horizontalLogoUrl field.
func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionOrganizationGet); err != nil {
return nil, err
}
presignedURL, err := r.iam.OrganizationService.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour)
// HorizontalLogo is the resolver for the horizontalLogo field.
func (r *organizationResolver) HorizontalLogo(ctx context.Context, obj *types.Organization) (*types.File, error) {
file, err := r.iam.OrganizationService.HorizontalLogoFile(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot generate horizontal logo URL", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot load horizontal logo file", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return presignedURL, nil
if file == nil {
return nil, nil
}
return types.NewFile(file, r.baseURL), nil
}
// Profiles is the resolver for the profiles field.

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
)
func NewFile(r *coredata.File, base *baseurl.BaseURL) *File {
url := base.WithPath(filemanager.DownloadAPIPath(r)).MustString()
return &File{
ID: r.ID,
MimeType: r.MimeType,
FileName: r.FileName,
Size: r.FileSize,
DownloadURL: url,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}

View File

@@ -23,7 +23,7 @@ type (
)
func NewOrganization(organization *coredata.Organization) *Organization {
return &Organization{
org := &Organization{
ID: organization.ID,
Name: organization.Name,
Email: organization.Email,
@@ -33,4 +33,14 @@ func NewOrganization(organization *coredata.Organization) *Organization {
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
}
if organization.LogoFileID != nil {
org.Logo = &File{ID: *organization.LogoFileID}
}
if organization.HorizontalLogoFileID != nil {
org.HorizontalLogo = &File{ID: *organization.HorizontalLogoFileID}
}
return org
}