From 1c29643ecbfeeaa238ee4c6f5332a8d54faf09e8 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 4 Oct 2026 02:52:22 -0700 Subject: [PATCH] feat: re-apply the app and form write rules to workspace imports so imported OAuth apps get displayname-checked names, http(s) websites, valid redirect URIs and webhooks, only their workspace's own logos, and stay suspended when suspended at the source or imported under a developer block, imported forms keep only http(s) redirect URLs, valid designs and embed domains, and the hosted form page navigates only to http(s) redirect targets --- .../docs/guides/workspace-export-import.mdx | 3 +- internal/api/handler/oauth_logo.go | 15 +- internal/app/oauth/imported.go | 82 ++++++++++ internal/app/orgtransfer/import.go | 21 +++ internal/app/orgtransfer/import_rules.go | 145 ++++++++++++++++++ internal/app/orgtransfer/import_rules_test.go | 108 +++++++++++++ .../orgtransfer/import_tenant_live_test.go | 25 +++ internal/app/orgtransfer/spec.go | 2 +- internal/repository/pg_orgtransfer.go | 13 ++ 9 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 internal/app/oauth/imported.go create mode 100644 internal/app/orgtransfer/import_rules.go create mode 100644 internal/app/orgtransfer/import_rules_test.go diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index a3ae6fda9..a0f5ab391 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -108,6 +108,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap | Failure and delivery counters | A webhook endpoint's failure streak and auto-disable state, and whether a notification's email already went out, describe what happened on the source. They start fresh, so an endpoint is not pre-disabled on the new instance and a notification is not re-sent | | Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history | | An invalid workspace name | An archive's workspace name is applied only when it passes the same [naming rules](/api/error-codes/#name-refusals) as a rename. Otherwise the destination keeps its own | +| Invalid form settings | Each form passes the same checks as an edit. A redirect address that is not an `http` or `https` URL is cleared, a design the editor would refuse resets to the default, and an embed domain that is not a domain is dropped. A published form that lost an embed domain arrives as a draft, so review its domains and publish it again | | Personal list layouts | Which columns each member shows on the contacts list and how they sort it, and how they arrange the unibox scope rail (Favorites, row order, hidden rows), belongs to the person, not the workspace. Everyone starts from the default view on the new instance and sets it up again | | Salesforce links and activity | Which Salesforce record each contact is, the activity waiting to be logged and its recent results, and how far the pull loop read. The destination links contacts again by address on its first sync, and activity already logged is in Salesforce. Saved list view and Campaign imports do travel, with the records each one already brought in | | Send plan snapshots | A campaign's precomputed "today's sending plan" is worked out from the campaign, its leads, its mailboxes and the instance's own limits, which all travel. The destination works it out again in the background, so a plan for today is current where the campaign now lives rather than a copy of what the source instance expected | @@ -143,7 +144,7 @@ A few things belong to the instance rather than the workspace and are left out o [Contact imports](/guides/contacts-crm/#importing) do not travel, finished or running, for the same reason: an import is work the source instance is doing. The contacts it created are ordinary contacts and move with the Contacts group, and the saved column mappings come with them. Let a running import finish before exporting, or the rows it has not reached are not in the archive. -A [community directory](/guides/integrations/#community-apps) listing does not travel. The OAuth app itself moves with the archive, but its listing is a publication on the source instance, featured or hidden by its team. Publish it again on the destination, where it starts as link only and is featured or not by that instance's team. An operator's suspension of an app, and a block on building apps, belong to the source instance too: the app arrives unsuspended and the destination's operators decide for themselves. +A [community directory](/guides/integrations/#community-apps) listing does not travel. The OAuth app itself moves with the archive, but its listing is a publication on the source instance, featured or hidden by its team. Publish it again on the destination, where it starts as link only and is featured or not by that instance's team. An app suspended on the source arrives suspended, and so does every app imported into a workspace whose members may not build apps on the destination; the destination's operators decide whether to lift it. An import never clears a suspension the destination already applied. Each app passes the same checks as registering it: a name that breaks the [naming rules](/api/error-codes/#name-refusals) becomes "Imported app", and a website, redirect URI or webhook address the destination would refuse is dropped. Its logo arrives empty, so upload it again. [Slack](/guides/slack/) account links, the record of which Slack thread belongs to which assistant conversation, and which Slack thread mirrors which inbox conversation in the [inbox channel](/guides/slack/#inbox-in-slack) do not travel. They name the Slack install on the source instance, so members link again after Slack is reconnected on the destination, and inbox conversations start new Slack threads there. The assistant conversations themselves move with the Assistant group. diff --git a/internal/api/handler/oauth_logo.go b/internal/api/handler/oauth_logo.go index 954a17e74..6fb788780 100644 --- a/internal/api/handler/oauth_logo.go +++ b/internal/api/handler/oauth_logo.go @@ -27,7 +27,7 @@ import ( // for the app's own workspace. const ( - appLogoPrefix = "oauth-app-logos/" + appLogoPrefix = oauth.AppLogoPrefix appLogoMinSide = 32 // ErrCodeInvalidLogo is the response code for a logo URL this instance did not issue. ErrCodeInvalidLogo = "invalid_logo" @@ -115,17 +115,8 @@ func checkAppLogo(ctx context.Context, store storage.Store, orgID uuid.UUID, log if !ok || store == nil { return refuse } - base := pu.PublicURL("") - if !strings.HasPrefix(logoURL, base) { - return refuse - } - key := strings.TrimPrefix(logoURL, base) - wantPrefix := appLogoPrefix + orgID.String() + "/" - if !strings.HasPrefix(key, wantPrefix) || strings.Contains(key, "..") || pu.PublicURL(key) != logoURL { - return refuse - } - name := strings.TrimPrefix(key, wantPrefix) - if strings.Contains(name, "/") || !(strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg")) { + key, ok := oauth.IssuedLogoKey(pu, orgID, logoURL) + if !ok { return refuse } r, err := store.Get(ctx, key) diff --git a/internal/app/oauth/imported.go b/internal/app/oauth/imported.go new file mode 100644 index 000000000..cc2190fb0 --- /dev/null +++ b/internal/app/oauth/imported.go @@ -0,0 +1,82 @@ +package oauth + +import ( + "strings" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/webhook" + "github.com/warmbly/warmbly/internal/infrastructure/storage" + "github.com/warmbly/warmbly/internal/pkg/displayname" + "github.com/warmbly/warmbly/internal/pkg/whdomain" +) + +// AppLogoPrefix is where a workspace's uploaded app logos live in blob storage. +const AppLogoPrefix = "oauth-app-logos/" + +// IssuedLogoKey returns the storage key of logoURL when it is a logo this +// instance stored for orgID's apps. +func IssuedLogoKey(pu storage.PublicURLer, orgID uuid.UUID, logoURL string) (string, bool) { + if pu == nil || logoURL == "" { + return "", false + } + base := pu.PublicURL("") + if !strings.HasPrefix(logoURL, base) { + return "", false + } + key := strings.TrimPrefix(logoURL, base) + wantPrefix := AppLogoPrefix + orgID.String() + "/" + if !strings.HasPrefix(key, wantPrefix) || strings.Contains(key, "..") || pu.PublicURL(key) != logoURL { + return "", false + } + name := strings.TrimPrefix(key, wantPrefix) + if strings.Contains(name, "/") || !(strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".jpg")) { + return "", false + } + return key, true +} + +// The Imported* helpers apply the app write rules to an app arriving in a +// workspace archive, dropping what fails since nobody can be asked to fix it. + +// ImportedName is the archive's app name under the naming rules, or a neutral one. +func ImportedName(raw string) string { + if name := displayname.Clean(raw, displayname.Workspace); name != "" { + return name + } + return "Imported app" +} + +// ImportedWebsite is the archive's website when it is an http(s) address. +func ImportedWebsite(raw string) string { + website, err := appWebsite(raw) + if err != nil { + return "" + } + return website +} + +// ImportedRedirectURIs keeps the redirect URIs registration would accept. +func ImportedRedirectURIs(uris []string) []string { + out := make([]string, 0, len(uris)) + for _, u := range uris { + if valid, err := validateRedirectURIs([]string{u}); err == nil { + out = append(out, valid...) + } + } + return out +} + +// ImportedWebhook returns the app's normalized webhook domains and whether its +// webhook URL passes the outbound and domain checks. +func ImportedWebhook(webhookURL string, domains []string) ([]string, bool) { + normalized, err := whdomain.NormalizeList(domains) + if err != nil { + normalized = []string{} + } + webhookURL = strings.TrimSpace(webhookURL) + if webhookURL == "" || webhook.ValidateOutboundURL(webhookURL) != nil { + return normalized, false + } + return normalized, whdomain.HostAllowed(hostOf(webhookURL), normalized) +} diff --git a/internal/app/orgtransfer/import.go b/internal/app/orgtransfer/import.go index d1688f934..a6ed5077a 100644 --- a/internal/app/orgtransfer/import.go +++ b/internal/app/orgtransfer/import.go @@ -15,6 +15,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/app/cipher" + "github.com/warmbly/warmbly/internal/infrastructure/storage" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/pkg/displayname" "github.com/warmbly/warmbly/internal/repository" @@ -126,6 +127,14 @@ func (s *service) ImportFrom( return nil, err } + rules := &ruleEnv{orgID: orgID, heldApps: map[uuid.UUID]string{}} + if pu, ok := s.blobs.(storage.PublicURLer); ok { + rules.logos = pu + } + if rules.developerBlocked, err = s.repo.DeveloperBlocked(ctx, tx, orgID, opts.ActorUserID); err != nil { + return nil, err + } + byName := manifestTables(manifest) applied := 0 for i := range Tables { @@ -152,10 +161,16 @@ func (s *service) ImportFrom( actor: opts.ActorUserID, conflict: opts.Conflict, selected: selected, + rules: rules, }) if err != nil { return nil, err } + if t.Name == "oauth_applications" { + if err := holdImportedApps(ctx, tx, orgID, rules.heldApps); err != nil { + return nil, fmt.Errorf("hold imported apps: %w", err) + } + } if n > 0 { result.RowCounts[t.Name] = n } @@ -195,6 +210,8 @@ type importContext struct { // selected is the set of groups this run applies, used to decide which // references the import can actually satisfy. selected map[models.OrgDataGroup]bool + // rules re-apply write rules to the tables in importRules. + rules *ruleEnv } // importTable streams one table's rows out of the archive and into the @@ -413,6 +430,10 @@ func (s *service) importRow( } } + if rule := importRules[t.Name]; rule != nil && ic.rules != nil { + rule(ic.rules, obj) + } + for _, sc := range t.Secrets { raw, ok := obj[sc.Column] if !ok { diff --git a/internal/app/orgtransfer/import_rules.go b/internal/app/orgtransfer/import_rules.go new file mode 100644 index 000000000..c04a31df2 --- /dev/null +++ b/internal/app/orgtransfer/import_rules.go @@ -0,0 +1,145 @@ +package orgtransfer + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/warmbly/warmbly/internal/app/oauth" + "github.com/warmbly/warmbly/internal/infrastructure/storage" + "github.com/warmbly/warmbly/internal/models" +) + +// importRules re-apply a table's write rules to its archive rows, for values +// shown to other people or followed by a browser. A value that fails is +// dropped, since nobody can be asked to correct an archive. +var importRules = map[string]func(env *ruleEnv, row map[string]json.RawMessage){ + "oauth_applications": cleanImportedApp, + "forms": cleanImportedForm, +} + +// ruleEnv is what the rules need to know about the destination. +type ruleEnv struct { + orgID uuid.UUID + // logos resolves this instance's public object URLs; nil when it has none. + logos storage.PublicURLer + // developerBlocked is an operator's block on building apps here. + developerBlocked bool + // heldApps are imported apps that land suspended, with the reason. + heldApps map[uuid.UUID]string +} + +const ( + heldAppSourceSuspended = "Suspended on the instance this workspace was exported from." + heldAppDeveloperBlock = "Imported while building apps is blocked for this workspace." +) + +func cleanImportedApp(env *ruleEnv, row map[string]json.RawMessage) { + if raw, ok := row["name"]; ok { + setJSON(row, "name", oauth.ImportedName(jsonString(raw))) + } + if raw, ok := row["website_url"]; ok { + setJSON(row, "website_url", oauth.ImportedWebsite(jsonString(raw))) + } + if raw, ok := row["logo_url"]; ok { + if _, issued := oauth.IssuedLogoKey(env.logos, env.orgID, jsonString(raw)); !issued { + setJSON(row, "logo_url", "") + } + } + if raw, ok := row["redirect_uris"]; ok { + setJSON(row, "redirect_uris", oauth.ImportedRedirectURIs(jsonStrings(raw))) + } + if _, ok := row["allowed_webhook_domains"]; ok { + domains, webhookOK := oauth.ImportedWebhook(jsonString(row["webhook_url"]), jsonStrings(row["allowed_webhook_domains"])) + if domains == nil { + domains = []string{} + } + setJSON(row, "allowed_webhook_domains", domains) + if !webhookOK { + setJSON(row, "webhook_url", "") + setJSON(row, "webhook_events", []string{}) + } + } + + id, err := uuid.Parse(jsonString(row["id"])) + if err != nil { + return + } + switch { + case jsonString(row["suspended_at"]) != "": + env.heldApps[id] = heldAppSourceSuspended + case env.developerBlocked: + env.heldApps[id] = heldAppDeveloperBlock + } +} + +func cleanImportedForm(_ *ruleEnv, row map[string]json.RawMessage) { + if raw, ok := row["name"]; ok { + name, xerr := models.ValidateFormName(jsonString(raw)) + if xerr != nil { + name = "Imported form" + } + setJSON(row, "name", name) + } + if raw, ok := row["redirect_url"]; ok { + u, xerr := models.ValidateFormRedirectURL(jsonString(raw)) + if xerr != nil { + u = "" + } + setJSON(row, "redirect_url", u) + } + if raw, ok := row["design"]; ok { + var d models.FormDesign + if json.Unmarshal(raw, &d) != nil || models.ValidateFormDesign(&d) != nil { + row["design"] = json.RawMessage(`{}`) + } else { + models.NormalizeFormDesign(&d) + setJSON(row, "design", d) + } + } + if raw, ok := row["allowed_domains"]; ok { + in := jsonStrings(raw) + kept := make([]string, 0, len(in)) + for _, d := range in { + if v, xerr := models.ValidateFormDomains([]string{d}); xerr == nil && len(kept) < models.FormMaxDomains { + kept = append(kept, v...) + } + } + setJSON(row, "allowed_domains", kept) + // A dropped entry can only widen the allowlist, so the form waits as a draft for review. + if len(kept) < len(in) && jsonString(row["status"]) == string(models.FormStatusPublished) { + setJSON(row, "status", string(models.FormStatusDraft)) + } + } +} + +// holdImportedApps suspends the apps the rules held, without replacing a +// suspension the destination already has. +func holdImportedApps(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, held map[uuid.UUID]string) error { + for id, reason := range held { + if _, err := tx.Exec(ctx, ` + UPDATE oauth_applications + SET suspended_at = NOW(), suspended_reason = $3, updated_at = NOW() + WHERE organization_id = $1 AND id = $2 AND suspended_at IS NULL + `, orgID, id, reason); err != nil { + return err + } + } + return nil +} + +func setJSON(row map[string]json.RawMessage, col string, v any) { + if enc, err := json.Marshal(v); err == nil { + row[col] = enc + } +} + +func jsonStrings(raw json.RawMessage) []string { + var out []string + if len(raw) == 0 || json.Unmarshal(raw, &out) != nil { + return nil + } + return out +} diff --git a/internal/app/orgtransfer/import_rules_test.go b/internal/app/orgtransfer/import_rules_test.go new file mode 100644 index 000000000..a09cda7d1 --- /dev/null +++ b/internal/app/orgtransfer/import_rules_test.go @@ -0,0 +1,108 @@ +package orgtransfer + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" +) + +type fakePublicURLs struct{} + +func (fakePublicURLs) PublicURL(key string) string { return "https://cdn.example.com/" + key } + +func rowOf(t *testing.T, v map[string]any) map[string]json.RawMessage { + t.Helper() + out := map[string]json.RawMessage{} + for k, val := range v { + enc, err := json.Marshal(val) + if err != nil { + t.Fatal(err) + } + out[k] = enc + } + return out +} + +func TestImportedAppPassesTheAppWriteRules(t *testing.T) { + org := uuid.New() + id := uuid.New() + ownLogo := "https://cdn.example.com/oauth-app-logos/" + org.String() + "/abc.png" + env := &ruleEnv{orgID: org, logos: fakePublicURLs{}, heldApps: map[uuid.UUID]string{}} + row := rowOf(t, map[string]any{ + "id": id, + "name": "Visit evil.example.com now", + "website_url": "javascript:alert(1)", + "logo_url": "https://cdn.example.com/oauth-app-logos/" + uuid.NewString() + "/abc.png", + "redirect_uris": []string{"https://app.example.com/cb", "javascript:alert(1)", "http://evil.example.com/cb"}, + "allowed_webhook_domains": []string{"hooks.example.com"}, + "webhook_url": "https://elsewhere.example.net/hook", + "webhook_events": []string{"email.sent"}, + "suspended_at": "2026-01-01T00:00:00Z", + }) + cleanImportedApp(env, row) + + if got := jsonString(row["name"]); got != "Imported app" { + t.Errorf("name = %q", got) + } + if got := jsonString(row["website_url"]); got != "" { + t.Errorf("website_url = %q", got) + } + if got := jsonString(row["logo_url"]); got != "" { + t.Errorf("another workspace's logo kept: %q", got) + } + if got := jsonStrings(row["redirect_uris"]); len(got) != 1 || got[0] != "https://app.example.com/cb" { + t.Errorf("redirect_uris = %v", got) + } + if got := jsonString(row["webhook_url"]); got != "" { + t.Errorf("webhook outside its allowed domains kept: %q", got) + } + if env.heldApps[id] != heldAppSourceSuspended { + t.Errorf("a suspended app was not held") + } + + own := rowOf(t, map[string]any{"id": uuid.New(), "name": "Acme Sync", "logo_url": ownLogo}) + env.developerBlocked = true + cleanImportedApp(env, own) + if got := jsonString(own["logo_url"]); got != ownLogo { + t.Errorf("own logo dropped: %q", got) + } + if got := jsonString(own["name"]); got != "Acme Sync" { + t.Errorf("valid name changed: %q", got) + } + if len(env.heldApps) != 2 { + t.Errorf("an app imported under a developer block was not held") + } +} + +func TestImportedFormPassesTheFormWriteRules(t *testing.T) { + row := rowOf(t, map[string]any{ + "name": "Newsletter", + "redirect_url": "javascript:alert(1)", + "design": map[string]any{"accent_color": "red;}body{display:none"}, + "allowed_domains": []string{"example.com", "bad host"}, + "status": "published", + }) + cleanImportedForm(nil, row) + if got := jsonString(row["redirect_url"]); got != "" { + t.Errorf("redirect_url = %q", got) + } + if got := string(row["design"]); got != "{}" { + t.Errorf("design = %s", got) + } + if got := jsonStrings(row["allowed_domains"]); len(got) != 1 || got[0] != "example.com" { + t.Errorf("allowed_domains = %v", got) + } + if got := jsonString(row["status"]); got != "draft" { + t.Errorf("a form whose allowlist lost an entry stayed %q", got) + } + + ok := rowOf(t, map[string]any{"redirect_url": "https://example.com/thanks", "status": "published", "allowed_domains": []string{"example.com"}}) + cleanImportedForm(nil, ok) + if got := jsonString(ok["redirect_url"]); got != "https://example.com/thanks" { + t.Errorf("valid redirect dropped: %q", got) + } + if got := jsonString(ok["status"]); got != "published" { + t.Errorf("valid form unpublished: %q", got) + } +} diff --git a/internal/app/orgtransfer/import_tenant_live_test.go b/internal/app/orgtransfer/import_tenant_live_test.go index 2df3e2626..0835e9882 100644 --- a/internal/app/orgtransfer/import_tenant_live_test.go +++ b/internal/app/orgtransfer/import_tenant_live_test.go @@ -196,6 +196,31 @@ func TestLiveImportRefusesReferencesIntoAnotherWorkspace(t *testing.T) { } } +func TestLiveImportKeepsASuspendedAppSuspended(t *testing.T) { + svc, pool := liveImportService(t) + ctx := context.Background() + dest := newTenantFixture(t, pool) + + app := uuid.New() + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM oauth_applications WHERE id = $1`, app) }) + archive := tenantArchive(t, map[string][]map[string]any{ + "oauth_applications": {{ + "id": app, "organization_id": uuid.New(), "name": "Acme Sync", "client_id": "wbc_" + app.String(), + "suspended_at": "2026-01-01T00:00:00Z", "suspended_reason": "abuse", + }}, + }) + if _, err := svc.ImportFrom(ctx, dest.org, archive, ImportOptions{Conflict: models.OrgImportConflictOverwrite, ActorUserID: dest.owner}, nil); err != nil { + t.Fatal(err) + } + var suspended bool + if err := pool.QueryRow(ctx, `SELECT suspended_at IS NOT NULL FROM oauth_applications WHERE id = $1 AND organization_id = $2`, app, dest.org).Scan(&suspended); err != nil { + t.Fatal(err) + } + if !suspended { + t.Fatal("an app suspended at the source arrived active") + } +} + func TestLiveImportOverwritesItsOwnRows(t *testing.T) { svc, pool := liveImportService(t) ctx := context.Background() diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index e06b11cf6..956eddad2 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -265,7 +265,7 @@ var Tables = []Table{ { Name: "oauth_applications", Group: models.OrgDataGroupCore, Scope: scopeOrg, - // A suspension is the source operator's decision; the destination's operators make their own. + // Never written, so an overwrite keeps a destination suspension; importRules re-applies a source one. ResetOnImport: []string{"suspended_at", "suspended_reason", "suspended_by"}, // The app's webhook signing secret is sealed under the instance key, like each endpoint's copy. Secrets: []SecretColumn{ diff --git a/internal/repository/pg_orgtransfer.go b/internal/repository/pg_orgtransfer.go index 279c50387..f4f8d24ae 100644 --- a/internal/repository/pg_orgtransfer.go +++ b/internal/repository/pg_orgtransfer.go @@ -67,6 +67,9 @@ type OrgTransferRepository interface { // InsertBatch writes rows into table, honouring the conflict strategy. An // overwrite only updates an existing row that owner selects for orgID. InsertBatch(ctx context.Context, tx pgx.Tx, table string, cols []string, rows []json.RawMessage, conflict models.OrgImportConflict, pk []string, owner string, orgID uuid.UUID) (int64, error) + // DeveloperBlocked reports an operator's block on building apps for this + // workspace or person. + DeveloperBlocked(ctx context.Context, tx pgx.Tx, orgID, userID uuid.UUID) (bool, error) // MergeOrganization applies the archive's organization row onto an // existing workspace, restricted to cols. MergeOrganization(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, cols []string, row json.RawMessage) error @@ -526,6 +529,16 @@ func (r *orgTransferRepository) InsertBatch( return tag.RowsAffected(), nil } +func (r *orgTransferRepository) DeveloperBlocked(ctx context.Context, tx pgx.Tx, orgID, userID uuid.UUID) (bool, error) { + var blocked bool + err := tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM oauth_developer_blocks + WHERE organization_id = $1 OR (user_id = $2 AND $2 <> '00000000-0000-0000-0000-000000000000'::uuid) + )`, orgID, userID).Scan(&blocked) + return blocked, err +} + func (r *orgTransferRepository) MergeOrganization(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, cols []string, row json.RawMessage) error { if len(cols) == 0 { return nil