From 2926f888c27b23fc57c7d74d72eec0df26f849f5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 7 Oct 2026 13:49:42 +0000 Subject: [PATCH 1/3] feat: allow unlimited self-hosted workspaces and bind Cloud billing access to individual workspace links while preserving legacy enrollments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/content/docs/api/endpoints.mdx | 2 +- docs/content/docs/api/error-codes.mdx | 10 + docs/content/docs/guides/warmbly-cloud.mdx | 24 +- .../docs/guides/workspace-export-import.mdx | 2 + internal/api/handler/cloudlink.go | 35 ++- internal/api/routes.go | 5 +- .../app/cloudlink/disconnect_managed_test.go | 6 +- internal/app/cloudlink/managed.go | 38 +-- internal/app/cloudlink/placement.go | 17 +- internal/app/cloudlink/redirects.go | 95 +++++--- .../app/cloudlink/revoke_for_delete_test.go | 6 +- internal/app/cloudlink/service.go | 225 ++++++++++++------ internal/app/cloudlink/standing.go | 35 ++- internal/app/cloudlink/standing_test.go | 4 +- internal/app/cloudlink/warmup_reports.go | 82 ++++--- internal/app/cloudlink/warmup_reports_test.go | 4 +- .../app/cloudlink/workspace_links_test.go | 193 +++++++++++++++ internal/app/dangerzone/service.go | 6 + .../app/organization/create_timezone_test.go | 48 +++- internal/app/organization/service.go | 21 +- internal/app/orgtransfer/spec.go | 4 +- internal/app/placement/batch.go | 2 +- internal/app/placement/remote.go | 15 +- internal/app/placement/service.go | 13 +- internal/app/placement/service_test.go | 7 + internal/app/placement/view.go | 2 +- internal/app/poollink/adopt_warmup_test.go | 40 +++- internal/app/poollink/oauth.go | 12 + internal/app/poollink/service.go | 33 ++- internal/app/sendingdomain/service.go | 6 +- internal/app/sendingdomain/service_test.go | 12 +- internal/app/sendingdomain/serving.go | 35 +-- internal/app/sendingdomain/serving_test.go | 73 ++++-- .../000266_workspace_cloud_links.down.sql | 21 ++ .../000266_workspace_cloud_links.up.sql | 31 +++ .../db/workspace_cloud_link_migration_test.go | 107 +++++++++ internal/models/mailbox_import.go | 21 +- internal/models/placement.go | 7 +- internal/models/poollink.go | 60 +++-- .../cloud_link_standing_live_test.go | 16 +- .../repository/mailbox_sources_live_test.go | 6 +- internal/repository/pg_cloudlink.go | 107 ++++++--- internal/repository/pg_danger_zone.go | 17 ++ internal/repository/pg_domain_redirect.go | 28 ++- internal/repository/pg_organization.go | 10 + internal/repository/pg_placement.go | 5 +- internal/repository/pg_poollink.go | 54 +++-- .../workspace_cloud_links_live_test.go | 127 ++++++++++ scripts/check-pages-build.sh | 4 +- web/src/app/app/emails/page.tsx | 4 +- .../settings/warmbly-cloud/ConnectFlow.tsx | 2 +- .../settings/warmbly-cloud/MailboxTable.tsx | 5 +- .../app/app/settings/warmbly-cloud/page.tsx | 42 +++- .../components/app/emails/CloudPoolBanner.tsx | 3 + .../components/app/emails/CloudWarmupCard.tsx | 2 +- .../app/emails/domains/cloudServing.ts | 4 +- web/src/hooks/useCloudPool.ts | 1 + web/src/hooks/useMailboxOAuth.ts | 2 +- .../lib/api/client/app/cloudlink/cloudLink.ts | 4 +- .../api/hooks/app/cloudlink/useCloudLink.ts | 15 +- .../lib/api/models/app/cloudlink/CloudLink.ts | 4 + 61 files changed, 1421 insertions(+), 400 deletions(-) create mode 100644 internal/app/cloudlink/workspace_links_test.go create mode 100644 internal/infrastructure/db/migrations/000266_workspace_cloud_links.down.sql create mode 100644 internal/infrastructure/db/migrations/000266_workspace_cloud_links.up.sql create mode 100644 internal/infrastructure/db/workspace_cloud_link_migration_test.go create mode 100644 internal/repository/workspace_cloud_links_live_test.go diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 519014464..f9fd4af34 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -604,7 +604,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example - `GET /website-tracking/settings`, `PATCH /website-tracking/settings`, `POST /website-tracking/settings/rotate-key` (the [website tracking](/guides/website-tracking/) snippet's consent mode, location precision, allowed hosts and retention; JWT permission `MANAGE_SETTINGS`. The rotate is bodyless and safe to repeat, each call issues a new key) - All of `/subscription/*` (checkout, portal, cancel, change-plan, preview-change, enterprise-inquiry, discounts, referrals, etc.) - All of `/auth/cli/*` except the two handshake routes below (`GET /auth/cli/codes/:code`, `POST /auth/cli/codes/:code/approve`, `POST /auth/cli/codes/:code/deny`: the browser half of `warmbly auth login`, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the `MANAGE_API_KEYS` organization permission and a fresh authentication, is session-only (an API key must not be able to mint another one this way), and caps the key to the scopes the approver's role allows. `GET /auth/cli/codes/:code?organization_id=` adds `granted_scopes` and `granted_scope_names`: what approving into that workspace would grant) -- All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `/cloud-link/*` exists only on a self-hosted instance. The link belongs to the whole instance, so linking and unlinking it (`/connect`, `/connect/poll`, `DELETE /cloud-link`) and the linked cloud workspace's mailboxes (`/workspace-mailboxes`, `.../adopt`) take a platform admin holding `manage_settings` whose session presented a second factor (`admin_mfa_required` otherwise). Reading the link and putting a workspace's own mailboxes on it (`GET /cloud-link`, `/mailboxes`, enroll, pause, resume and the Google and Microsoft sign-in under `/oauth/*`) take the workspace's `MANAGE_EMAILS`. `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`. `/pool-link/instance/redirects` (list), and `GET`, `PUT`, `DELETE` and `POST .../verify` on `/pool-link/instance/redirects/:domain`, are the root redirects Warmbly Cloud serves for the calling instance; the instance drives them through `PUT /emails/domains/:domain/redirect` with `served_by: "cloud"`, and `GET /pool-link/instance` carries `redirects` (`available`, `host`, `limit`, `used`) +- All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `/cloud-link/*` exists only on a self-hosted instance. Connections belong to the selected local workspace, but linking and unlinking them (`/connect`, `/connect/poll`, `DELETE /cloud-link`) and the linked cloud workspace's mailboxes (`/workspace-mailboxes`, `.../adopt`) take a platform admin holding `manage_settings` whose session presented a second factor (`admin_mfa_required` otherwise). Reading the link and putting a workspace's own mailboxes on it (`GET /cloud-link`, `/mailboxes`, enroll, pause, resume and the Google and Microsoft sign-in under `/oauth/*`) take the workspace's `MANAGE_EMAILS`. `GET /cloud-link` includes `legacy_connected`; mailbox rows include `legacy` when they still use the old connection. A normal `DELETE /cloud-link` only disconnects the selected workspace's scoped link. `DELETE /cloud-link?legacy=true` explicitly disconnects the legacy link across all workspaces and uses the same platform-admin permission. New connections and enrollments require workspace scope, and a Cloud workspace accepts only one active link (legacy links count). `POST /pool-link/codes` requires `remote_organization_id` and returns `workspace_scoped: true` for supported clients. `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`. `/pool-link/instance/redirects` (list), and `GET`, `PUT`, `DELETE` and `POST .../verify` on `/pool-link/instance/redirects/:domain`, are the root redirects Warmbly Cloud serves for the calling instance; the instance drives them through `PUT /emails/domains/:domain/redirect` with `served_by: "cloud"`, and `GET /pool-link/instance` carries `redirects` (`available`, `host`, `limit`, `used`) - All of `/admin/*`. `DELETE /admin/instance/invitations/expired` requires platform admin `manage_organizations` and an MFA-verified session. It removes only expired invitation records across all workspaces, leaves active invitations and membership unchanged, records an admin audit entry, and returns `{ "cleaned": true }`. The admin panel confirms before calling it. - `POST /admin/organizations/:id/sample-data`: `manage_testers` and an MFA-verified admin session. Adds the [reviewer sample dataset](/development/accounts-and-access/#adding-sample-data) only to an active dedicated Test workspace. No request body. Returns `200` with `organization_id`, `created` (false when already seeded) and `seeded_at`; retries preserve edits and deletions. An ordinary workspace, revoked or expired tester, or missing/expired managed Test grant returns `400`. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs. - `GET /admin/testers`: `view_users` and an MFA-verified admin session. Each tester in `data` may include `test_workspace_id` for its owned dedicated Test workspace and `sample_data_seeded_at` when populated. These fields are absent for a tester merely attached to an ordinary workspace. diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 0127dac33..da1bd0498 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -141,6 +141,16 @@ A password must be 8 to 128 characters. There is no composition rule, but it is } ``` +#### Cloud connection refusals + +| `code` | Status | Meaning | +|--------|--------|---------| +| `cloud_link_workspace_required` | 409 | The selected workspace only has a legacy instance-wide connection. Existing enrollments keep working; connect the workspace separately before adding mailboxes or placement tests | +| `pool_link_workspace_required` | 409 | Cloud refused a new connection or enrollment without local workspace scope. Upgrade the self-hosted instance and connect per workspace; existing legacy enrollments keep working | +| `pool_link_workspace_connected` | 409 | The Cloud workspace already has an active self-hosted connection, including a legacy link. Use another Cloud workspace and plan, or disconnect the existing link first | +| `cloud_link_upgrade_required` | 409 | This Cloud deployment does not support workspace-scoped connections. Upgrade Cloud first | +| `organization_cloud_connected` | 409 | Disconnect the workspace's Cloud connection before deleting it or scheduling deletion of it or its owner account | + #### Name refusals | `code` | Status | Meaning | diff --git a/docs/content/docs/guides/warmbly-cloud.mdx b/docs/content/docs/guides/warmbly-cloud.mdx index bc4016e51..fcd5dae62 100644 --- a/docs/content/docs/guides/warmbly-cloud.mdx +++ b/docs/content/docs/guides/warmbly-cloud.mdx @@ -13,7 +13,7 @@ Only warmup moves to the cloud. Campaigns, contacts, the unibox, tracking and ev ## How it works -Your instance keeps a single link to a Warmbly Cloud workspace. When you enroll a mailbox, the instance sends that mailbox's SMTP/IMAP credential to the cloud, sealed in transit and at rest with the same envelope encryption the hosted product uses for every customer mailbox. From then on: +Each local workspace connects separately to a Warmbly Cloud workspace. A Cloud workspace can have only one active self-hosted connection, including any older instance-wide link. Different local workspaces need different Cloud workspaces, each with its own plan. Self-hosted workspace creation itself is unlimited; hosted creation limits and request-rate protection are unchanged. When you enroll a mailbox, the instance sends that mailbox's SMTP/IMAP credential to the cloud, sealed in transit and at rest with the same envelope encryption the hosted product uses for every customer mailbox. From then on: - Warmbly Cloud sends the mailbox's warmup mail, replies to partners, and rescues, opens and stars the warmup mail it receives, on Warmbly's own workers - the cloud reads only verified warmup mail from the mailbox. Everything else that arrives is dropped unread; no history is imported and nothing is stored @@ -37,17 +37,27 @@ Existing enrollments work without reconnecting or re-enrolling. Deploy the repor Once a mailbox is unenrolled, the instance no longer requests its Cloud history. SMTP/IMAP unenrollment deletes the Cloud mailbox and its warmup history; a Cloud-managed mailbox's history stays in its Cloud workspace. The instance's earlier local history is unchanged. -## Linking your instance +## Linking a workspace The link is offered on a self-hosted instance as the last step of onboarding when you first sign in, as a banner on **Mailboxes** until you connect or dismiss it, under **Settings > Warmbly Cloud** at any time, and on **Sending domains** when a [root redirect](#root-redirects-served-by-warmbly-cloud) could be served from Cloud. Whichever you use, the steps are the same: -1. Press **Connect to Warmbly Cloud**. The instance shows an eight character code. +1. Select the local workspace you want to connect, then press **Connect to Warmbly Cloud**. The instance shows an eight character code. 2. Press **Open Warmbly Cloud**, or go to `app.warmbly.com/connect`, sign in with your Warmbly account (create one if you do not have it, it is free), enter the code and pick the workspace to link to. The workspace needs the manage settings permission. 3. Your instance notices the approval within a few seconds and moves on to mailbox selection. Switch on the mailboxes you want warmed. The whole flow takes about a minute. A code expires after 15 minutes if nobody approves it; start again from the instance. -The link belongs to the whole instance, not to one workspace, so connecting and disconnecting it, and bringing in mailboxes from the cloud workspace, is an instance administrator's job: it needs a platform admin account with the manage settings admin permission (the [first owner](/development/first-run/) has it) signed in with two-factor authentication. Once the instance is linked, any member with the manage mailboxes permission sees the link and puts their own workspace's mailboxes on it: enrolling, pausing and resuming, and connecting Google and Microsoft mailboxes through Warmbly Cloud. The onboarding step that links the instance is shown only to an administrator who can complete it. +The link belongs to the selected local workspace. Connecting and disconnecting it, and bringing in mailboxes from its Cloud workspace, still requires an instance administrator because credentials leave the server: it needs a platform admin account with the manage settings admin permission (the [first owner](/development/first-run/) has it) signed in with two-factor authentication. Once a workspace is linked, its members with the manage mailboxes permission can use that workspace's connection: enrolling, pausing and resuming, and connecting Google and Microsoft mailboxes through Warmbly Cloud. The onboarding step that links the instance is shown only to an administrator who can complete it. + +### Existing instance-wide connections + +Upgrading does not revoke an existing connection or move its mailboxes. The migration records which connection enrolled each mailbox, and existing warmup, brokered access tokens, warmup verification, analytics, standing synchronization, redirects and placement results keep using that original connection. Old placement results and Cloud-served redirects are backfilled to that connection too. + +A legacy connection cannot enroll more mailboxes, adopt Cloud mailboxes, start new Google or Microsoft sign-ins, or start new placement tests. New Cloud-served redirects also require a workspace-scoped connection; existing redirects stay manageable. **Settings > Warmbly Cloud** labels the old connection and offers **Connect this workspace**. Existing mailbox rows say **legacy connection**, even after the workspace establishes a new link. + +You can leave the old connection in place and connect each workspace to a separate, unused Cloud workspace, moving mailboxes at your own pace. No mailbox changes connection automatically. To reuse the old Cloud workspace, first disconnect its legacy link; that stops its remaining enrollments across **all** local workspaces. Workspace-scoped connections are not affected. Removing an SMTP/IMAP enrollment deletes its old Cloud mailbox and history; enrolling it again starts a new Cloud enrollment. Cloud-managed mailboxes remain on their original Cloud workspace, but disconnecting removes their local mirrors. Sign in again through the new connection to create new mirrors. + +Deploy the Cloud backend and migration **before** publishing the self-hosted upgrade. Old self-hosted clients keep using existing enrollments, but cannot establish new instance-wide connections or add enrollments to them. New self-hosted clients refuse to connect to older Cloud versions that do not advertise workspace-scoped support. No new environment variable is required. ## Google and Microsoft mailboxes: sign in through Warmbly Cloud @@ -144,20 +154,20 @@ A [root redirect](/guides/sending-domains/#root-redirects) sends visitors to a s - Cloud proves the domain on its own terms: it lists its own ownership value for the `_warmbly` `TXT` record and its own addresses for the root, and serves nothing until it sees both. - Only the domain, the website and the `www` setting go to Cloud. Visitors to the domain reach Cloud, not your server; nothing about your mailboxes, contacts or mail is involved. - Cloud serves up to 200 redirects for one linked instance. -- Unlinking stops every redirect Cloud served for the instance. Each one says so on your instance and can be moved back to it. +- Unlinking stops redirects using that connection, not redirects using another workspace's connection or a retained legacy link. Each one says so on your instance and can be moved back to it. See [serve it from Warmbly Cloud](/guides/sending-domains/#serve-it-from-warmbly-cloud) for how it looks on the sending domains page. ## Unlinking -On your instance, **Settings > Warmbly Cloud > Disconnect** removes every enrolled mailbox from the pool, deletes their credentials on the cloud and lets local warmup take over. Root redirects Cloud served for the instance stop at the same time. On Warmbly Cloud, **Settings > Linked instances** lists the instances linked to a workspace and can unlink any of them from the other side. +On your instance, **Settings > Warmbly Cloud > Disconnect** releases only mailboxes and redirects using the selected workspace's connection. SMTP/IMAP credentials are deleted on Cloud and local warmup takes over; Cloud-managed mirrors using that connection are removed. Mailboxes still using a legacy connection and other workspaces' links are unaffected. **Disconnect legacy link** is a separate, explicit action affecting all mailboxes and redirects still using the old link across all local workspaces. Disconnect a workspace's Cloud link before scheduling its deletion or deleting its owner account. On Warmbly Cloud, **Settings > Linked instances** lists the instances linked to a workspace and can unlink any of them from the other side. ## Self-hosting notes - Traffic is outbound only: the instance calls `WARMBLY_CLOUD_URL` (default `https://api.warmbly.com`) over HTTPS, on port 443 or 8443, and only to a public address. Plain HTTP to `localhost` is accepted only when `APP_ENV` is `dev`. Nothing needs to reach the instance from the internet - The instance token is stored sealed with `CREDENTIALS_ENCRYPTION_KEY`. Rotating that key invalidates the stored token; reconnect afterwards - Brokered access tokens reach the worker through the backend's internal API (`/api/v1/internal/cloud-link/token/:id`), so workers keep needing only `ENCRYPTED_KEYS_BACKEND_URL` and `ENCRYPTED_KEYS_WORKER_TOKEN`. A worker that cannot reach the backend keeps sending from such mailboxes until its cached token expires, about an hour -- The link and the enrollment list are properties of the instance, not of a workspace, so they are not part of a [workspace export](/guides/workspace-export-import/). Reconnect after moving a workspace to a new instance +- Connections and enrollments are bound to this installation's encryption key, even when workspace-scoped, so they are not part of a [workspace export](/guides/workspace-export-import/). Reconnect after moving a workspace to a new instance - `INSTANCE_NAME` sets the name shown on the approval page; the hostname is used when unset. The name follows the workspace [naming rules](/api/error-codes/#name-refusals), so one that reads as a web address (a hostname, for example) shows as "Self-hosted instance" ## Related guides diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index f385a6adb..336079551 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -91,6 +91,8 @@ Anyone in the archive without an account on the destination has their rows reass ### What deliberately does not import +Cloud connections and mailbox enrollments never travel, including workspace-scoped connections and retained legacy links. Connect the destination workspace separately to its own Cloud workspace. Cloud-served redirects arrive served locally with their source connection association cleared. + Some things belong to an instance rather than to a workspace, so they are not applied even when the archive contains them: | Not imported | Why | diff --git a/internal/api/handler/cloudlink.go b/internal/api/handler/cloudlink.go index 296facf22..317622ae8 100644 --- a/internal/api/handler/cloudlink.go +++ b/internal/api/handler/cloudlink.go @@ -24,7 +24,12 @@ func (h *Handler) CloudLinkStatus(c *gin.Context) { if !h.cloudLinkReady(c) { return } - st, xerr := h.CloudLinkService.Status(c.Request.Context()) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.ErrNoOrganization) + return + } + st, xerr := h.CloudLinkService.Status(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return @@ -36,6 +41,11 @@ func (h *Handler) CloudLinkConnectStart(c *gin.Context) { if !h.cloudLinkReady(c) { return } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.ErrNoOrganization) + return + } userID, err := middleware.GetUserUUID(c) if err != nil { errx.JSON(c, errx.ErrUnauthorized) @@ -45,7 +55,7 @@ func (h *Handler) CloudLinkConnectStart(c *gin.Context) { CloudURL string `json:"cloud_url"` } _ = c.ShouldBindJSON(&req) - p, xerr := h.CloudLinkService.StartConnect(c.Request.Context(), userID, req.CloudURL) + p, xerr := h.CloudLinkService.StartConnect(c.Request.Context(), *orgID, userID, req.CloudURL) if xerr != nil { errx.JSON(c, xerr) return @@ -57,12 +67,17 @@ func (h *Handler) CloudLinkConnectPoll(c *gin.Context) { if !h.cloudLinkReady(c) { return } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.ErrNoOrganization) + return + } userID, err := middleware.GetUserUUID(c) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } - res, xerr := h.CloudLinkService.PollConnect(c.Request.Context(), userID) + res, xerr := h.CloudLinkService.PollConnect(c.Request.Context(), *orgID, userID) if xerr != nil { errx.JSON(c, xerr) return @@ -77,7 +92,12 @@ func (h *Handler) CloudLinkDisconnect(c *gin.Context) { if !h.cloudLinkReady(c) { return } - if xerr := h.CloudLinkService.Disconnect(c.Request.Context()); xerr != nil { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.ErrNoOrganization) + return + } + if xerr := h.CloudLinkService.Disconnect(c.Request.Context(), *orgID, c.Query("legacy") == "true"); xerr != nil { errx.JSON(c, xerr) return } @@ -230,7 +250,12 @@ func (h *Handler) CloudLinkWorkspaceMailboxes(c *gin.Context) { if !h.cloudLinkReady(c) { return } - list, xerr := h.CloudLinkService.ListWorkspaceMailboxes(c.Request.Context()) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.ErrNoOrganization) + return + } + list, xerr := h.CloudLinkService.ListWorkspaceMailboxes(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/api/routes.go b/internal/api/routes.go index 6f051233a..858f3e819 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -1680,10 +1680,7 @@ func Run( } // Self-hosted side: Settings > Warmbly Cloud, registered on a self-host only. - // The link is one per instance, so linking, unlinking and the linked cloud - // workspace's mailboxes take the instance administrator (admin - // manage_settings, second factor). A workspace's own mailboxes ride the - // link under its manage_emails. + // Cloud connections are workspace-scoped; approving their credential sharing still requires an instance administrator. if config.SelfHosted() { cloudLink := jwtOnly.Group("/cloud-link") cloudLink.Use(m.RateLimitMiddleware(models.RateLimitWrite), m.RequireOrganization()) diff --git a/internal/app/cloudlink/disconnect_managed_test.go b/internal/app/cloudlink/disconnect_managed_test.go index 78fff637d..dda671403 100644 --- a/internal/app/cloudlink/disconnect_managed_test.go +++ b/internal/app/cloudlink/disconnect_managed_test.go @@ -25,12 +25,12 @@ func (r *disconnectRepo) List(context.Context) ([]models.CloudLinkMailbox, error return r.rows, nil } -func (r *disconnectRepo) UnenrollAll(context.Context) error { +func (r *disconnectRepo) UnenrollAll(context.Context, uuid.UUID) error { r.unenrollAll++ return nil } -func (r *disconnectRepo) Delete(context.Context) error { +func (r *disconnectRepo) Delete(context.Context, uuid.UUID) error { r.deleted++ return nil } @@ -77,7 +77,7 @@ func TestDisconnectDeletesManagedMirrorsAfterTheInstanceIsRevoked(t *testing.T) calls, refused := &[]string{}, &[]*errx.Error{} f.svc.emailSvc = revokingEmails{stubEmailDeletes: stubEmailDeletes{deletes: calls}, svc: f.svc, org: f.org, refused: refused} - if xerr := f.svc.Disconnect(context.Background()); xerr != nil { + if xerr := f.svc.Disconnect(context.Background(), f.org, true); xerr != nil { t.Fatalf("Disconnect: %v", xerr) } if len(*refused) != 0 { diff --git a/internal/app/cloudlink/managed.go b/internal/app/cloudlink/managed.go index 0e5b5264c..de43aa133 100644 --- a/internal/app/cloudlink/managed.go +++ b/internal/app/cloudlink/managed.go @@ -28,10 +28,11 @@ const OAuthReturnPath = "/cloud-oauth/done" const tokenCacheMax = 10 * time.Minute type oauthSession struct { - OrgID uuid.UUID - UserID uuid.UUID - Provider models.InboxProvider - ExpiresAt time.Time + InstanceID uuid.UUID + OrgID uuid.UUID + UserID uuid.UUID + Provider models.InboxProvider + ExpiresAt time.Time } type cachedToken struct { @@ -40,7 +41,7 @@ type cachedToken struct { } func (s *service) StartOAuth(ctx context.Context, orgID, userID uuid.UUID, provider models.InboxProvider) (*models.CloudLinkOAuthStart, *errx.Error) { - l, xerr := s.link(ctx) + l, xerr := s.newLink(ctx, orgID) if xerr != nil { return nil, xerr } @@ -53,7 +54,7 @@ func (s *service) StartOAuth(ctx context.Context, orgID, userID uuid.UUID, provi return nil, xerr } s.mu.Lock() - s.sessions[res.Session] = oauthSession{OrgID: orgID, UserID: userID, Provider: provider, ExpiresAt: time.Now().Add(15 * time.Minute)} + s.sessions[res.Session] = oauthSession{InstanceID: l.InstanceID, OrgID: orgID, UserID: userID, Provider: provider, ExpiresAt: time.Now().Add(15 * time.Minute)} for k, v := range s.sessions { if time.Now().After(v.ExpiresAt) { delete(s.sessions, k) @@ -67,13 +68,16 @@ func (s *service) FinishOAuth(ctx context.Context, orgID, userID uuid.UUID, sess s.mu.Lock() sess, ok := s.sessions[session] s.mu.Unlock() - if !ok || sess.OrgID != orgID || time.Now().After(sess.ExpiresAt) { + if !ok || sess.UserID != userID || sess.OrgID != orgID || time.Now().After(sess.ExpiresAt) { return nil, ErrOAuthSession } - l, xerr := s.link(ctx) + l, xerr := s.newLink(ctx, orgID) if xerr != nil { return nil, xerr } + if l.InstanceID != sess.InstanceID { + return nil, ErrOAuthSession + } var state models.PoolLinkMailboxState if xerr := s.clientFor(l).do(ctx, http.MethodPost, "/instance/oauth/finish", models.PoolLinkOAuthFinishRequest{Session: session}, &state); xerr != nil { return nil, xerr @@ -103,7 +107,7 @@ func (s *service) mirror(ctx context.Context, l *models.CloudLink, orgID, userID } return nil, xerr } - if _, err := s.repo.Enroll(ctx, acc.ID, state.RemoteID, true); err != nil { + if _, err := s.repo.Enroll(ctx, acc.ID, state.RemoteID, l.InstanceID, true); err != nil { if s.emailSvc != nil && acc.OrganizationID != nil { _ = s.emailSvc.Delete(ctx, acc.OrganizationID.String(), acc.ID.String()) } @@ -124,8 +128,8 @@ func (s *service) mirror(ctx context.Context, l *models.CloudLink, orgID, userID return acc, nil } -func (s *service) ListWorkspaceMailboxes(ctx context.Context) ([]models.PoolLinkWorkspaceMailbox, *errx.Error) { - l, xerr := s.link(ctx) +func (s *service) ListWorkspaceMailboxes(ctx context.Context, orgID uuid.UUID) ([]models.PoolLinkWorkspaceMailbox, *errx.Error) { + l, xerr := s.newLink(ctx, orgID) if xerr != nil { return nil, xerr } @@ -140,7 +144,7 @@ func (s *service) ListWorkspaceMailboxes(ctx context.Context) ([]models.PoolLink } func (s *service) Adopt(ctx context.Context, orgID, userID, cloudAccountID uuid.UUID) (*models.Email, *errx.Error) { - l, xerr := s.link(ctx) + l, xerr := s.newLink(ctx, orgID) if xerr != nil { return nil, xerr } @@ -168,7 +172,7 @@ func (s *service) AccessToken(ctx context.Context, accountID uuid.UUID) (*models if m == nil || !m.Managed { return nil, ErrNotManaged } - l, xerr := s.link(ctx) + l, xerr := s.mailboxLink(ctx, m) if xerr != nil { return nil, xerr } @@ -195,7 +199,7 @@ func (s *service) forgetToken(accountID uuid.UUID) { // removeManaged deletes the local mirror; the cloud keeps the mailbox in the workspace. func (s *service) removeManaged(ctx context.Context, orgID string, m *models.CloudLinkMailbox) *errx.Error { - if l, err := s.repo.Get(ctx); err == nil && l != nil { + if l, err := s.repo.GetByInstance(ctx, m.InstanceID); err == nil && l != nil { if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance/mailboxes/"+m.RemoteID.String(), nil, nil); xerr != nil && xerr.Identifier != "pool_link_mailbox_not_found" { return xerr } @@ -215,7 +219,7 @@ func (s *service) VerifyWarmupToken(ctx context.Context, accountID uuid.UUID, to if err != nil || m == nil { return false, err } - l, xerr := s.link(ctx) + l, xerr := s.mailboxLink(ctx, m) if xerr != nil { return false, xerr } @@ -240,7 +244,7 @@ func (s *service) IsCloudWarmupDelivery(ctx context.Context, accountID uuid.UUID if err != nil || m == nil { return false, err } - l, xerr := s.link(ctx) + l, xerr := s.mailboxLink(ctx, m) if xerr != nil { return false, xerr } @@ -265,7 +269,7 @@ func (s *service) IsCloudWarmupThreadReply(ctx context.Context, accountID uuid.U if err != nil || m == nil { return false, err } - l, xerr := s.link(ctx) + l, xerr := s.mailboxLink(ctx, m) if xerr != nil { return false, xerr } diff --git a/internal/app/cloudlink/placement.go b/internal/app/cloudlink/placement.go index f7d47e011..3cf746301 100644 --- a/internal/app/cloudlink/placement.go +++ b/internal/app/cloudlink/placement.go @@ -10,8 +10,8 @@ import ( "github.com/warmbly/warmbly/internal/models" ) -func (s *service) PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) { - l, xerr := s.link(ctx) +func (s *service) PlacementPanel(ctx context.Context, orgID uuid.UUID) (*models.PlacementCloudPanel, *errx.Error) { + l, xerr := s.newLink(ctx, orgID) if xerr != nil { return nil, xerr } @@ -22,8 +22,8 @@ func (s *service) PlacementPanel(ctx context.Context) (*models.PlacementCloudPan return &out, nil } -func (s *service) StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) { - l, xerr := s.link(ctx) +func (s *service) StartPlacement(ctx context.Context, orgID uuid.UUID, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) { + l, xerr := s.newLink(ctx, orgID) if xerr != nil { return nil, xerr } @@ -31,11 +31,12 @@ func (s *service) StartPlacement(ctx context.Context, req models.PlacementCloudS if xerr := s.clientFor(l).do(ctx, http.MethodPost, "/instance/placement/tests", req, &out); xerr != nil { return nil, xerr } + out.InstanceID = l.InstanceID return &out, nil } -func (s *service) ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error { - l, xerr := s.link(ctx) +func (s *service) ReportPlacementSends(ctx context.Context, instanceID, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error { + l, xerr := s.mailboxLink(ctx, &models.CloudLinkMailbox{InstanceID: instanceID}) if xerr != nil { return xerr } @@ -43,8 +44,8 @@ func (s *service) ReportPlacementSends(ctx context.Context, testID uuid.UUID, se models.PlacementCloudSends{Sends: sends}, nil) } -func (s *service) PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) { - l, xerr := s.link(ctx) +func (s *service) PlacementVerdicts(ctx context.Context, instanceID, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) { + l, xerr := s.mailboxLink(ctx, &models.CloudLinkMailbox{InstanceID: instanceID}) if xerr != nil { return nil, xerr } diff --git a/internal/app/cloudlink/redirects.go b/internal/app/cloudlink/redirects.go index 34f31676d..8cb8bfc6b 100644 --- a/internal/app/cloudlink/redirects.go +++ b/internal/app/cloudlink/redirects.go @@ -6,6 +6,8 @@ import ( "net/url" "time" + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) @@ -19,28 +21,31 @@ type cachedOffer struct { } // rememberOffer keeps Cloud's answer; a Cloud that sends none does not serve redirects. -func (s *service) rememberOffer(o *models.PoolLinkRedirectOffer) { +func (s *service) rememberOffer(instanceID uuid.UUID, o *models.PoolLinkRedirectOffer) { if o == nil { o = &models.PoolLinkRedirectOffer{} } s.mu.Lock() - s.offer = cachedOffer{offer: o, at: time.Now()} + if s.offers == nil { + s.offers = map[uuid.UUID]cachedOffer{} + } + s.offers[instanceID] = cachedOffer{offer: o, at: time.Now()} s.mu.Unlock() } -func (s *service) forgetOffer() { +func (s *service) forgetOffer(instanceID uuid.UUID) { s.mu.Lock() - s.offer = cachedOffer{} + delete(s.offers, instanceID) s.mu.Unlock() } -func (s *service) OnDisconnect(fn func(context.Context)) { +func (s *service) OnDisconnect(fn func(context.Context, uuid.UUID)) { s.disconnected = append(s.disconnected, fn) } // RedirectOffer is Cloud's offer and whether the instance is linked; an unreachable Cloud keeps the last answer, nil before any. -func (s *service) RedirectOffer(ctx context.Context) (*models.PoolLinkRedirectOffer, bool) { - l, err := s.repo.Get(ctx) +func (s *service) RedirectOffer(ctx context.Context, orgID uuid.UUID) (*models.PoolLinkRedirectOffer, bool) { + l, err := s.repo.Get(ctx, &orgID) if err != nil { return nil, true // unknown reads as unreachable, never as "not linked" } @@ -50,7 +55,8 @@ func (s *service) RedirectOffer(ctx context.Context) (*models.PoolLinkRedirectOf fresh := func() (*models.PoolLinkRedirectOffer, bool) { s.mu.Lock() defer s.mu.Unlock() - return s.offer.offer, !s.offer.at.IsZero() && time.Since(s.offer.at) < offerTTL + offer := s.offers[l.InstanceID] + return offer.offer, !offer.at.IsZero() && time.Since(offer.at) < offerTTL } if o, ok := fresh(); ok { return o, true @@ -65,30 +71,48 @@ func (s *service) RedirectOffer(ctx context.Context) (*models.PoolLinkRedirectOf if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance", nil, &info); xerr != nil { return cached, true } - s.rememberOffer(info.Redirects) + s.rememberOffer(l.InstanceID, info.Redirects) s.mu.Lock() defer s.mu.Unlock() - return s.offer.offer, true + return s.offers[l.InstanceID].offer, true } func (s *service) ListRedirects(ctx context.Context) ([]models.DomainRedirect, *errx.Error) { - l, xerr := s.link(ctx) - if xerr != nil { - return nil, xerr + links, err := s.repo.ListLinks(ctx) + if err != nil { + return nil, errx.InternalError() } - var out struct { - Data []models.DomainRedirect `json:"data"` + var rows []models.DomainRedirect + for _, l := range links { + var out struct { + Data []models.DomainRedirect `json:"data"` + } + if xerr := s.clientFor(&l).do(ctx, http.MethodGet, "/instance/redirects", nil, &out); xerr != nil { + return nil, xerr + } + for i := range out.Data { + out.Data[i].CloudLinkInstanceID = &l.InstanceID + } + rows = append(rows, out.Data...) } - if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/redirects", nil, &out); xerr != nil { - return nil, xerr + return rows, nil +} + +func (s *service) redirectLink(ctx context.Context, orgID uuid.UUID, domain string) (*models.CloudLink, *errx.Error) { + l, err := s.repo.GetForRedirect(ctx, orgID, domain) + if err != nil { + return nil, errx.InternalError() } - return out.Data, nil + if l == nil { + return nil, ErrNotConnected + } + return l, nil } func redirectPath(domain string) string { return "/instance/redirects/" + url.PathEscape(domain) } -func (s *service) redirectCall(ctx context.Context, method, path string, body any) (*models.DomainRedirect, *errx.Error) { - l, xerr := s.link(ctx) +func (s *service) redirectCall(ctx context.Context, orgID uuid.UUID, domain, method, path string, body any) (*models.DomainRedirect, *errx.Error) { + l, xerr := s.redirectLink(ctx, orgID, domain) if xerr != nil { return nil, xerr } @@ -100,22 +124,37 @@ func (s *service) redirectCall(ctx context.Context, method, path string, body an } // PutRedirect leaves the offer cached: Cloud enforces its own limit, and a bulk move must not re-read the offer per domain. -func (s *service) PutRedirect(ctx context.Context, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) { +func (s *service) PutRedirect(ctx context.Context, orgID uuid.UUID, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) { // Cloud serves it itself; the instance's own choice of server means nothing there. + l, xerr := s.redirectLink(ctx, orgID, domain) + if xerr != nil { + return nil, xerr + } + if err := s.repo.BindRedirect(ctx, orgID, domain, l.InstanceID); err != nil { + return nil, errx.InternalError() + } in.ServedBy = "" - return s.redirectCall(ctx, http.MethodPut, redirectPath(domain), in) + return s.redirectCall(ctx, orgID, domain, http.MethodPut, redirectPath(domain), in) } -func (s *service) GetRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) { - return s.redirectCall(ctx, http.MethodGet, redirectPath(domain), nil) +func (s *service) GetRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) { + return s.redirectCall(ctx, orgID, domain, http.MethodGet, redirectPath(domain), nil) } -func (s *service) VerifyRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) { - return s.redirectCall(ctx, http.MethodPost, redirectPath(domain)+"/verify", nil) +func (s *service) VerifyRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) { + return s.redirectCall(ctx, orgID, domain, http.MethodPost, redirectPath(domain)+"/verify", nil) } -func (s *service) DeleteRedirect(ctx context.Context, domain string) *errx.Error { - l, xerr := s.link(ctx) +func (s *service) DeleteRedirect(ctx context.Context, orgID uuid.UUID, domain string) *errx.Error { + l, xerr := s.redirectLink(ctx, orgID, domain) + if xerr != nil { + return xerr + } + return s.clientFor(l).do(ctx, http.MethodDelete, redirectPath(domain), nil, nil) +} + +func (s *service) ReleaseRedirect(ctx context.Context, instanceID uuid.UUID, domain string) *errx.Error { + l, xerr := s.mailboxLink(ctx, &models.CloudLinkMailbox{InstanceID: instanceID}) if xerr != nil { return xerr } diff --git a/internal/app/cloudlink/revoke_for_delete_test.go b/internal/app/cloudlink/revoke_for_delete_test.go index f38d07126..333c2ec44 100644 --- a/internal/app/cloudlink/revoke_for_delete_test.go +++ b/internal/app/cloudlink/revoke_for_delete_test.go @@ -29,7 +29,11 @@ type stubLinkRepo struct { unenrollErr error } -func (r *stubLinkRepo) Get(context.Context) (*models.CloudLink, error) { +func (r *stubLinkRepo) Get(context.Context, *uuid.UUID) (*models.CloudLink, error) { + return r.link, r.linkErr +} + +func (r *stubLinkRepo) GetByInstance(context.Context, uuid.UUID) (*models.CloudLink, error) { return r.link, r.linkErr } diff --git a/internal/app/cloudlink/service.go b/internal/app/cloudlink/service.go index 9c133ca22..fb2d5959c 100644 --- a/internal/app/cloudlink/service.go +++ b/internal/app/cloudlink/service.go @@ -22,8 +22,9 @@ import ( const DefaultCloudURL = "https://api.warmbly.com" var ( - ErrNotConnected = errx.NewWithIdentifier(errx.Conflict, "cloud_link_not_connected", "This instance is not connected to Warmbly Cloud.") - ErrAlreadyLinked = errx.NewWithIdentifier(errx.Conflict, "cloud_link_connected", "This instance is already connected. Disconnect first to link a different workspace.") + ErrLegacyLink = errx.NewWithIdentifier(errx.Conflict, "cloud_link_workspace_required", "Connect this workspace separately to add Cloud mailboxes. Existing legacy enrollments continue working.") + ErrNotConnected = errx.NewWithIdentifier(errx.Conflict, "cloud_link_not_connected", "This workspace is not connected to Warmbly Cloud.") + ErrAlreadyLinked = errx.NewWithIdentifier(errx.Conflict, "cloud_link_connected", "This workspace is already connected. Disconnect first to link a different Cloud workspace.") ErrNoPendingCode = errx.NewWithIdentifier(errx.NotFound, "cloud_link_no_pending", "No connection in progress. Start again.") ErrCodeExpired = errx.NewWithIdentifier(errx.NotFound, "cloud_link_code_expired", "The code expired before it was approved. Start again.") ErrOAuthMailbox = errx.NewWithIdentifier(errx.Unprocessable, "cloud_link_oauth_mailbox", "Google and Microsoft sign-in mailboxes cannot be warmed by Warmbly Cloud yet, because their refresh grant is bound to this instance's own OAuth app. Connect the mailbox with SMTP/IMAP (an app password) to enroll it.") @@ -81,6 +82,7 @@ func instanceName() string { // PendingConnect is an in-flight device-code handshake, held in memory. type PendingConnect struct { + OrganizationID uuid.UUID `json:"organization_id"` DeviceCode string `json:"-"` UserCode string `json:"user_code"` VerificationURL string `json:"verification_url"` @@ -98,10 +100,10 @@ type ConnectPollResult struct { } type Service interface { - Status(ctx context.Context) (*models.CloudLinkStatus, *errx.Error) - StartConnect(ctx context.Context, userID uuid.UUID, cloudURL string) (*PendingConnect, *errx.Error) - PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPollResult, *errx.Error) - Disconnect(ctx context.Context) *errx.Error + Status(ctx context.Context, orgID uuid.UUID) (*models.CloudLinkStatus, *errx.Error) + StartConnect(ctx context.Context, orgID, userID uuid.UUID, cloudURL string) (*PendingConnect, *errx.Error) + PollConnect(ctx context.Context, orgID, userID uuid.UUID) (*ConnectPollResult, *errx.Error) + Disconnect(ctx context.Context, orgID uuid.UUID, legacy bool) *errx.Error ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models.CloudLinkMailboxRow, *errx.Error) WarmupStats(ctx context.Context, orgID uuid.UUID, id *uuid.UUID, from, to time.Time) ([]models.WarmupDailyStats, *errx.Error) @@ -116,19 +118,20 @@ type Service interface { SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID, action string) (*models.CloudLinkMailboxRow, *errx.Error) // Root redirects Warmbly Cloud serves for this instance (redirects.go). - RedirectOffer(ctx context.Context) (*models.PoolLinkRedirectOffer, bool) + RedirectOffer(ctx context.Context, orgID uuid.UUID) (*models.PoolLinkRedirectOffer, bool) + ReleaseRedirect(ctx context.Context, instanceID uuid.UUID, domain string) *errx.Error ListRedirects(ctx context.Context) ([]models.DomainRedirect, *errx.Error) - PutRedirect(ctx context.Context, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) - GetRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) - VerifyRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) - DeleteRedirect(ctx context.Context, domain string) *errx.Error + PutRedirect(ctx context.Context, orgID uuid.UUID, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) + GetRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) + VerifyRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) + DeleteRedirect(ctx context.Context, orgID uuid.UUID, domain string) *errx.Error // OnDisconnect runs after the link ends, for state that only held while linked. - OnDisconnect(fn func(context.Context)) + OnDisconnect(fn func(context.Context, uuid.UUID)) // Cloud-managed mailboxes: consent through the cloud, tokens brokered from it (managed.go). StartOAuth(ctx context.Context, orgID, userID uuid.UUID, provider models.InboxProvider) (*models.CloudLinkOAuthStart, *errx.Error) FinishOAuth(ctx context.Context, orgID, userID uuid.UUID, session string) (*models.Email, *errx.Error) - ListWorkspaceMailboxes(ctx context.Context) ([]models.PoolLinkWorkspaceMailbox, *errx.Error) + ListWorkspaceMailboxes(ctx context.Context, orgID uuid.UUID) ([]models.PoolLinkWorkspaceMailbox, *errx.Error) Adopt(ctx context.Context, orgID, userID, cloudAccountID uuid.UUID) (*models.Email, *errx.Error) // AccessToken is the worker's credential for a managed mailbox, via the internal API. AccessToken(ctx context.Context, accountID uuid.UUID) (*models.PoolLinkAccessToken, *errx.Error) @@ -151,10 +154,10 @@ type Service interface { // The placement seed panel the cloud lends a linked instance. The // instance renders and sends every copy; the cloud only hands out seed // addresses and reports where each copy landed. - PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) - StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) - ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error - PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) + PlacementPanel(ctx context.Context, orgID uuid.UUID) (*models.PlacementCloudPanel, *errx.Error) + StartPlacement(ctx context.Context, orgID uuid.UUID, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) + ReportPlacementSends(ctx context.Context, instanceID, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error + PlacementVerdicts(ctx context.Context, instanceID, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) } type service struct { @@ -162,23 +165,46 @@ type service struct { emails repository.EmailRepository emailSvc email.EmailService - mu sync.Mutex - pending *PendingConnect - sessions map[string]oauthSession - tokens map[uuid.UUID]cachedToken - offer cachedOffer + mu sync.Mutex + pending map[uuid.UUID]*PendingConnect + sessions map[string]oauthSession + tokens map[uuid.UUID]cachedToken + offers map[uuid.UUID]cachedOffer + connectMu sync.Mutex // offerFetch lets one caller ask Cloud for the offer while the others wait for its answer. offerFetch sync.Mutex - disconnected []func(context.Context) + disconnected []func(context.Context, uuid.UUID) } func NewService(repo repository.CloudLinkRepository, emails repository.EmailRepository, emailSvc email.EmailService) Service { - return &service{repo: repo, emails: emails, emailSvc: emailSvc, sessions: map[string]oauthSession{}, tokens: map[uuid.UUID]cachedToken{}} + return &service{pending: map[uuid.UUID]*PendingConnect{}, repo: repo, emails: emails, emailSvc: emailSvc, sessions: map[string]oauthSession{}, tokens: map[uuid.UUID]cachedToken{}} } -func (s *service) link(ctx context.Context) (*models.CloudLink, *errx.Error) { - l, err := s.repo.Get(ctx) +func (s *service) link(ctx context.Context, orgID uuid.UUID) (*models.CloudLink, *errx.Error) { + l, err := s.repo.Get(ctx, &orgID) + if err != nil { + return nil, errx.InternalError() + } + if l == nil { + return nil, ErrNotConnected + } + return l, nil +} + +func (s *service) newLink(ctx context.Context, orgID uuid.UUID) (*models.CloudLink, *errx.Error) { + l, xerr := s.link(ctx, orgID) + if xerr != nil { + return nil, xerr + } + if l.OrganizationID == nil { + return nil, ErrLegacyLink + } + return l, nil +} + +func (s *service) mailboxLink(ctx context.Context, m *models.CloudLinkMailbox) (*models.CloudLink, *errx.Error) { + l, err := s.repo.GetByInstance(ctx, m.InstanceID) if err != nil { return nil, errx.InternalError() } @@ -192,9 +218,9 @@ func (s *service) clientFor(l *models.CloudLink) *client { return newClient(l.CloudURL, l.Token, instanceVersion()) } -func (s *service) Status(ctx context.Context) (*models.CloudLinkStatus, *errx.Error) { +func (s *service) Status(ctx context.Context, orgID uuid.UUID) (*models.CloudLinkStatus, *errx.Error) { st := &models.CloudLinkStatus{DefaultCloudURL: CloudURL()} - l, err := s.repo.Get(ctx) + l, err := s.repo.Get(ctx, &orgID) if err != nil { return nil, errx.InternalError() } @@ -203,21 +229,30 @@ func (s *service) Status(ctx context.Context) (*models.CloudLinkStatus, *errx.Er } st.Connected = true st.Link = l + legacy, err := s.repo.Get(ctx, nil) + if err != nil { + return nil, errx.InternalError() + } + st.LegacyConnected = legacy != nil var info models.PoolLinkInstanceInfo if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance", nil, &info); xerr != nil { st.Error = xerr.Message - _ = s.repo.SetSyncResult(ctx, time.Now(), xerr.Message) + _ = s.repo.SetSyncResult(ctx, l.InstanceID, time.Now(), xerr.Message) return st, nil } st.Reachable = true st.Info = &info - s.rememberOffer(info.Redirects) - _ = s.repo.SetSyncResult(ctx, time.Now(), "") + s.rememberOffer(l.InstanceID, info.Redirects) + _ = s.repo.SetSyncResult(ctx, l.InstanceID, time.Now(), "") return st, nil } -func (s *service) StartConnect(ctx context.Context, userID uuid.UUID, cloudURL string) (*PendingConnect, *errx.Error) { - if l, err := s.repo.Get(ctx); err == nil && l != nil { +func (s *service) StartConnect(ctx context.Context, orgID, userID uuid.UUID, cloudURL string) (*PendingConnect, *errx.Error) { + s.connectMu.Lock() + defer s.connectMu.Unlock() + if l, err := s.repo.Get(ctx, &orgID); err != nil { + return nil, errx.InternalError() + } else if l != nil && l.OrganizationID != nil { return nil, ErrAlreadyLinked } // The handshake is one-time: refuse it now rather than lose the token @@ -235,13 +270,18 @@ func (s *service) StartConnect(ctx context.Context, userID uuid.UUID, cloudURL s c := newClient(cloudURL, "", instanceVersion()) var res models.PoolLinkStartResponse if xerr := c.do(ctx, http.MethodPost, "/codes", models.PoolLinkStartRequest{ - InstanceName: instanceName(), - InstanceURL: config.AppBaseURL(), - InstanceVersion: instanceVersion(), + RemoteOrganizationID: orgID, + InstanceName: instanceName(), + InstanceURL: config.AppBaseURL(), + InstanceVersion: instanceVersion(), }, &res); xerr != nil { return nil, xerr } + if !res.WorkspaceScoped { + return nil, errx.NewWithIdentifier(errx.Conflict, "cloud_link_upgrade_required", "Upgrade Warmbly Cloud before connecting per workspace.") + } p := &PendingConnect{ + OrganizationID: orgID, DeviceCode: res.DeviceCode, UserCode: res.UserCode, VerificationURL: res.VerificationURL, @@ -251,22 +291,30 @@ func (s *service) StartConnect(ctx context.Context, userID uuid.UUID, cloudURL s StartedBy: userID, } s.mu.Lock() - s.pending = p + if s.pending == nil { + s.pending = map[uuid.UUID]*PendingConnect{} + } + s.pending[orgID] = p s.mu.Unlock() return p, nil } -func (s *service) PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPollResult, *errx.Error) { +func (s *service) PollConnect(ctx context.Context, orgID, userID uuid.UUID) (*ConnectPollResult, *errx.Error) { + s.connectMu.Lock() + defer s.connectMu.Unlock() s.mu.Lock() - p := s.pending + p := s.pending[orgID] s.mu.Unlock() if p == nil { // Another tab may have finished the handshake already. - if l, err := s.repo.Get(ctx); err == nil && l != nil { + if l, err := s.repo.Get(ctx, &orgID); err == nil && l != nil && l.OrganizationID != nil { return &ConnectPollResult{Status: models.PoolLinkCodeApproved, Link: l}, nil } return nil, ErrNoPendingCode } + if p.StartedBy != userID { + return nil, ErrNoPendingCode + } if time.Now().After(p.ExpiresAt) { s.clearPending(p) return nil, ErrCodeExpired @@ -286,10 +334,11 @@ func (s *service) PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPo return nil, errx.InternalError() } l := &models.CloudLink{ - CloudURL: p.CloudURL, - InstanceID: *res.InstanceID, - Token: res.InstanceToken, - ConnectedBy: &userID, + OrganizationID: &orgID, + CloudURL: p.CloudURL, + InstanceID: *res.InstanceID, + Token: res.InstanceToken, + ConnectedBy: &userID, } if res.Organization != nil { l.OrganizationName = res.Organization.Name @@ -298,7 +347,7 @@ func (s *service) PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPo return nil, errx.InternalError() } s.clearPending(p) - s.forgetOffer() + s.forgetOffer(l.InstanceID) var info models.PoolLinkInstanceInfo out := &ConnectPollResult{Status: models.PoolLinkCodeApproved, Link: l} if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance", nil, &info); xerr == nil { @@ -309,8 +358,8 @@ func (s *service) PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPo func (s *service) clearPending(p *PendingConnect) { s.mu.Lock() - if s.pending == p { - s.pending = nil + if s.pending[p.OrganizationID] == p { + delete(s.pending, p.OrganizationID) } s.mu.Unlock() } @@ -324,10 +373,24 @@ func linkAlreadyGone(xerr *errx.Error) bool { return false } -func (s *service) Disconnect(ctx context.Context) *errx.Error { - l, xerr := s.link(ctx) - if xerr != nil { - return xerr +func (s *service) Disconnect(ctx context.Context, orgID uuid.UUID, legacy bool) *errx.Error { + s.connectMu.Lock() + defer s.connectMu.Unlock() + var l *models.CloudLink + var err error + if legacy { + l, err = s.repo.Get(ctx, nil) + } else { + l, err = s.repo.Get(ctx, &orgID) + } + if err != nil { + return errx.InternalError() + } + if l == nil { + return ErrNotConnected + } + if !legacy && l.OrganizationID == nil { + return ErrLegacyLink } // The cloud must confirm (or already have dropped) the link before local // state goes, or managed mailboxes stay owned there with no way to retry. @@ -344,6 +407,9 @@ func (s *service) Disconnect(ctx context.Context) *errx.Error { } var released []models.CloudLinkMailbox for _, m := range rows { + if m.InstanceID != l.InstanceID { + continue + } if !m.Managed { released = append(released, m) continue @@ -356,10 +422,10 @@ func (s *service) Disconnect(ctx context.Context) *errx.Error { _ = s.emailSvc.Delete(ctx, acc.OrganizationID.String(), acc.ID.String()) } } - if err := s.repo.UnenrollAll(ctx); err != nil { + if err := s.repo.UnenrollAll(ctx, l.InstanceID); err != nil { return errx.InternalError() } - if err := s.repo.Delete(ctx); err != nil { + if err := s.repo.Delete(ctx, l.InstanceID); err != nil { return errx.InternalError() } // The mailboxes the cloud was warming rejoin this instance's pool. @@ -367,9 +433,21 @@ func (s *service) Disconnect(ctx context.Context) *errx.Error { s.syncLocalPool(ctx, m.EmailAccountID) s.carryStanding(ctx, m) } - s.forgetOffer() + s.mu.Lock() + for key, sess := range s.sessions { + if sess.InstanceID == l.InstanceID { + delete(s.sessions, key) + } + } + for _, m := range rows { + if m.InstanceID == l.InstanceID { + delete(s.tokens, m.EmailAccountID) + } + } + s.mu.Unlock() + s.forgetOffer(l.InstanceID) for _, fn := range s.disconnected { - fn(ctx) + fn(ctx, l.InstanceID) } return nil } @@ -388,7 +466,7 @@ func (s *service) ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models. if xerr != nil { return nil, xerr } - enrolled, err := s.repo.List(ctx) + enrolled, err := s.repo.ListForOrg(ctx, orgID, nil) if err != nil { return nil, errx.InternalError() } @@ -399,8 +477,10 @@ func (s *service) ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models. // One round trip for every enrolled mailbox's cloud state. cloudByRemote := map[uuid.UUID]*models.PoolLinkMailboxState{} - if len(enrolled) > 0 { - if l, err := s.repo.Get(ctx); err == nil && l != nil { + legacyInstances := map[uuid.UUID]bool{} + for instanceID := range mailboxGroups(enrolled) { + if l, err := s.repo.GetByInstance(ctx, instanceID); err == nil && l != nil { + legacyInstances[instanceID] = l.OrganizationID == nil var states []models.PoolLinkMailboxState if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/mailboxes", nil, &states); xerr == nil { for i := range states { @@ -418,6 +498,7 @@ func (s *service) ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models. if e, ok := byAccount[a.ID]; ok { at := e.EnrolledAt row.Enrolled = true + row.Legacy = legacyInstances[e.InstanceID] row.EnrolledAt = &at row.Managed = e.Managed row.Cloud = cloudByRemote[e.RemoteID] @@ -482,7 +563,17 @@ func (s *service) syncLocalPool(ctx context.Context, accountID uuid.UUID) { } func (s *service) Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*models.CloudLinkMailboxRow, *errx.Error) { - l, xerr := s.link(ctx) + m, err := s.repo.GetByAccount(ctx, accountID) + if err != nil { + return nil, errx.InternalError() + } + var l *models.CloudLink + var xerr *errx.Error + if m != nil { + l, xerr = s.mailboxLink(ctx, m) + } else { + l, xerr = s.newLink(ctx, orgID) + } if xerr != nil { return nil, xerr } @@ -521,7 +612,7 @@ func (s *service) Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*mode if xerr := s.clientFor(l).do(ctx, http.MethodPost, "/instance/mailboxes", req, &state); xerr != nil { return nil, xerr } - if _, err := s.repo.Enroll(ctx, acc.ID, acc.ID, false); err != nil { + if _, err := s.repo.Enroll(ctx, acc.ID, acc.ID, l.InstanceID, false); err != nil { // Without the local row the mailbox would warm in both places; undo the cloud side. if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance/mailboxes/"+acc.ID.String(), nil, nil); xerr != nil { log.Error().Str("account_id", acc.ID.String()).Str("code", xerr.Identifier).Msg("cloud link: local enrollment failed and the cloud copy could not be removed; unenroll it from Settings") @@ -568,9 +659,9 @@ func (s *service) Unenroll(ctx context.Context, orgID, accountID uuid.UUID) *err if err := s.repo.Unenroll(ctx, accountID); err != nil { return errx.InternalError() } - if l, err := s.repo.Get(ctx); err == nil && l != nil { + if l, err := s.repo.GetByInstance(ctx, m.InstanceID); err == nil && l != nil { if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance/mailboxes/"+m.RemoteID.String(), nil, nil); xerr != nil && xerr.Identifier != "pool_link_mailbox_not_found" { - if _, rerr := s.repo.Enroll(ctx, accountID, m.RemoteID, false); rerr != nil { + if _, rerr := s.repo.Enroll(ctx, accountID, m.RemoteID, m.InstanceID, false); rerr != nil { log.Error().Str("account_id", accountID.String()).Msg("cloud link: cloud unenroll failed and the local row could not be restored") } else if m.Standing != nil { // The restored row keeps the hold it had. @@ -598,7 +689,7 @@ func (s *service) RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUI if m == nil { return nil } - l, err := s.repo.Get(ctx) + l, err := s.repo.GetByInstance(ctx, m.InstanceID) if err != nil { log.Error().Err(err).Str("account_id", accountID.String()).Msg("cloud link: link unreadable, so the mailbox's enrollment cannot be revoked") return errx.InternalError() @@ -623,10 +714,6 @@ func (s *service) SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID, if action != "pause" && action != "resume" { return nil, errx.NewWithIdentifier(errx.BadRequest, "cloud_link_lifecycle", "Action must be pause or resume.") } - l, xerr := s.link(ctx) - if xerr != nil { - return nil, xerr - } if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil { return nil, xerr } @@ -637,6 +724,10 @@ func (s *service) SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID, if m == nil { return nil, errx.ErrNotFound } + l, xerr := s.mailboxLink(ctx, m) + if xerr != nil { + return nil, xerr + } var state models.PoolLinkMailboxState if xerr := s.clientFor(l).do(ctx, http.MethodPatch, "/instance/mailboxes/"+m.RemoteID.String(), models.PoolLinkMailboxPatch{Lifecycle: action}, &state); xerr != nil { return nil, xerr diff --git a/internal/app/cloudlink/standing.go b/internal/app/cloudlink/standing.go index d62a8e829..eef8c3af2 100644 --- a/internal/app/cloudlink/standing.go +++ b/internal/app/cloudlink/standing.go @@ -62,21 +62,38 @@ func (s *service) carryStanding(ctx context.Context, m models.CloudLinkMailbox) } } +func mailboxGroups(rows []models.CloudLinkMailbox) map[uuid.UUID][]models.CloudLinkMailbox { + groups := map[uuid.UUID][]models.CloudLinkMailbox{} + for _, m := range rows { + groups[m.InstanceID] = append(groups[m.InstanceID], m) + } + return groups +} + func (s *service) SyncStanding(ctx context.Context) ([]models.CloudLinkStandingChange, *errx.Error) { - l, err := s.repo.Get(ctx) - if err != nil { - return nil, errx.InternalError() - } - if l == nil { - return nil, nil - } enrolled, err := s.repo.List(ctx) if err != nil { return nil, errx.InternalError() } - if len(enrolled) == 0 { - return nil, nil + var changes []models.CloudLinkStandingChange + var lastError *errx.Error + for _, rows := range mailboxGroups(enrolled) { + l, xerr := s.mailboxLink(ctx, &rows[0]) + if xerr != nil { + lastError = xerr + continue + } + part, xerr := s.syncStanding(ctx, l, rows) + if xerr != nil { + lastError = xerr + continue + } + changes = append(changes, part...) } + return changes, lastError +} + +func (s *service) syncStanding(ctx context.Context, l *models.CloudLink, enrolled []models.CloudLinkMailbox) ([]models.CloudLinkStandingChange, *errx.Error) { byRemote, xerr := s.fetchStanding(ctx, l) if xerr != nil { return nil, xerr diff --git a/internal/app/cloudlink/standing_test.go b/internal/app/cloudlink/standing_test.go index 2303cdbd8..11cdb94eb 100644 --- a/internal/app/cloudlink/standing_test.go +++ b/internal/app/cloudlink/standing_test.go @@ -22,7 +22,9 @@ type standingRepo struct { current map[uuid.UUID]models.WarmupHealthState } -func (r *standingRepo) Get(context.Context) (*models.CloudLink, error) { return r.link, nil } +func (r *standingRepo) GetByInstance(context.Context, uuid.UUID) (*models.CloudLink, error) { + return r.link, nil +} func (r *standingRepo) List(context.Context) ([]models.CloudLinkMailbox, error) { return r.enrolled, nil diff --git a/internal/app/cloudlink/warmup_reports.go b/internal/app/cloudlink/warmup_reports.go index ce197b75f..5f83afc97 100644 --- a/internal/app/cloudlink/warmup_reports.go +++ b/internal/app/cloudlink/warmup_reports.go @@ -31,29 +31,31 @@ func (s *service) WarmupStats(ctx context.Context, orgID uuid.UUID, id *uuid.UUI if xerr != nil || len(mailboxes) == 0 { return nil, xerr } - l, xerr := s.link(ctx) - if xerr != nil { - return nil, xerr - } var out []models.WarmupDailyStats - c := s.clientFor(l) - for start := from; !start.After(to); { - end := start.AddDate(0, 0, models.WarmupReportMaxDays-1) - if end.After(to) { - end = to + for _, mailboxes := range mailboxGroups(mailboxes) { + l, xerr := s.mailboxLink(ctx, &mailboxes[0]) + if xerr != nil { + return nil, xerr } - for i := 0; i < len(mailboxes); i += models.WarmupReportBatchSize { - batch := mailboxes[i:min(i+models.WarmupReportBatchSize, len(mailboxes))] - var rows []models.WarmupDailyStats - if xerr := c.do(ctx, http.MethodPost, "/instance/analytics/warmup", reportRequest(batch, start, end), &rows); xerr != nil { - return nil, xerr + c := s.clientFor(l) + for start := from; !start.After(to); { + end := start.AddDate(0, 0, models.WarmupReportMaxDays-1) + if end.After(to) { + end = to } - if rows == nil { - return nil, errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable report.") + for i := 0; i < len(mailboxes); i += models.WarmupReportBatchSize { + batch := mailboxes[i:min(i+models.WarmupReportBatchSize, len(mailboxes))] + var rows []models.WarmupDailyStats + if xerr := c.do(ctx, http.MethodPost, "/instance/analytics/warmup", reportRequest(batch, start, end), &rows); xerr != nil { + return nil, xerr + } + if rows == nil { + return nil, errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable report.") + } + out = models.MergeWarmupStats(out, rows) } - out = models.MergeWarmupStats(out, rows) + start = end.AddDate(0, 0, 1) } - start = end.AddDate(0, 0, 1) } return out, nil } @@ -63,31 +65,33 @@ func (s *service) WarmupPlacementData(ctx context.Context, orgID uuid.UUID, id * if xerr != nil || len(mailboxes) == 0 { return nil, xerr } - l, xerr := s.link(ctx) - if xerr != nil { - return nil, xerr - } out := &models.WarmupPlacementData{} - c := s.clientFor(l) - // Placement includes per-sender days; keep a year of rows within the client's response limit. - const batchSize = 10 - for i := 0; i < len(mailboxes); i += batchSize { - batch := mailboxes[i:min(i+batchSize, len(mailboxes))] - var part *models.WarmupPlacementData - if xerr := c.do(ctx, http.MethodPost, "/instance/analytics/warmup/placement", reportRequest(batch, from, to), &part); xerr != nil { + for _, mailboxes := range mailboxGroups(mailboxes) { + l, xerr := s.mailboxLink(ctx, &mailboxes[0]) + if xerr != nil { return nil, xerr } - if part == nil || part.Daily == nil || part.Hosts == nil || part.Sent == nil || part.Unconfirmed == nil || part.Windows == nil { - return nil, errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable report.") + c := s.clientFor(l) + // Placement includes per-sender days; keep a year of rows within the client's response limit. + const batchSize = 10 + for i := 0; i < len(mailboxes); i += batchSize { + batch := mailboxes[i:min(i+batchSize, len(mailboxes))] + var part *models.WarmupPlacementData + if xerr := c.do(ctx, http.MethodPost, "/instance/analytics/warmup/placement", reportRequest(batch, from, to), &part); xerr != nil { + return nil, xerr + } + if part == nil || part.Daily == nil || part.Hosts == nil || part.Sent == nil || part.Unconfirmed == nil || part.Windows == nil { + return nil, errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable report.") + } + localIDs := make(map[uuid.UUID]uuid.UUID, len(batch)) + for _, mailbox := range batch { + localIDs[mailbox.RemoteID] = mailbox.EmailAccountID + } + if err := part.RemapSenders(localIDs); err != nil { + return nil, errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable report.") + } + out.Add(part) } - localIDs := make(map[uuid.UUID]uuid.UUID, len(batch)) - for _, mailbox := range batch { - localIDs[mailbox.RemoteID] = mailbox.EmailAccountID - } - if err := part.RemapSenders(localIDs); err != nil { - return nil, errx.NewWithIdentifier(errx.ServiceUnavailable, "cloud_link_bad_response", "Warmbly Cloud returned an unreadable report.") - } - out.Add(part) } return out, nil } diff --git a/internal/app/cloudlink/warmup_reports_test.go b/internal/app/cloudlink/warmup_reports_test.go index 16fbf6814..ac9d13665 100644 --- a/internal/app/cloudlink/warmup_reports_test.go +++ b/internal/app/cloudlink/warmup_reports_test.go @@ -20,7 +20,9 @@ type warmupReportRepo struct { mailboxes []models.CloudLinkMailbox } -func (r warmupReportRepo) Get(context.Context) (*models.CloudLink, error) { return r.link, nil } +func (r warmupReportRepo) GetByInstance(context.Context, uuid.UUID) (*models.CloudLink, error) { + return r.link, nil +} func (r warmupReportRepo) ListForOrg(_ context.Context, org uuid.UUID, id *uuid.UUID) ([]models.CloudLinkMailbox, error) { out := make([]models.CloudLinkMailbox, 0) if org == r.org { diff --git a/internal/app/cloudlink/workspace_links_test.go b/internal/app/cloudlink/workspace_links_test.go new file mode 100644 index 000000000..20aa0a601 --- /dev/null +++ b/internal/app/cloudlink/workspace_links_test.go @@ -0,0 +1,193 @@ +package cloudlink + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +type workspaceLinkRepo struct { + repository.CloudLinkRepository + links map[uuid.UUID]*models.CloudLink + mailboxes map[uuid.UUID]*models.CloudLinkMailbox +} + +func (r *workspaceLinkRepo) Get(_ context.Context, org *uuid.UUID) (*models.CloudLink, error) { + var legacy *models.CloudLink + for _, l := range r.links { + if l.OrganizationID == nil { + legacy = l + } + if org != nil && l.OrganizationID != nil && *l.OrganizationID == *org { + return l, nil + } + } + return legacy, nil +} +func (r *workspaceLinkRepo) GetByInstance(_ context.Context, id uuid.UUID) (*models.CloudLink, error) { + return r.links[id], nil +} +func (r *workspaceLinkRepo) GetByAccount(_ context.Context, id uuid.UUID) (*models.CloudLinkMailbox, error) { + return r.mailboxes[id], nil +} +func (r *workspaceLinkRepo) List(context.Context) ([]models.CloudLinkMailbox, error) { + var rows []models.CloudLinkMailbox + for _, m := range r.mailboxes { + rows = append(rows, *m) + } + return rows, nil +} +func (r *workspaceLinkRepo) Delete(_ context.Context, id uuid.UUID) error { + delete(r.links, id) + return nil +} +func (r *workspaceLinkRepo) UnenrollAll(_ context.Context, id uuid.UUID) error { + for key, m := range r.mailboxes { + if m.InstanceID == id { + delete(r.mailboxes, key) + } + } + return nil +} +func (r *workspaceLinkRepo) CanStore() error { return nil } + +func (r *workspaceLinkRepo) ListForOrg(context.Context, uuid.UUID, *uuid.UUID) ([]models.CloudLinkMailbox, error) { + return r.List(context.Background()) +} + +func TestWarmupReportsUseEachMailboxesOriginalLink(t *testing.T) { + t.Setenv("APP_ENV", "dev") + org := uuid.New() + legacyRemote, scopedRemote := uuid.New(), uuid.New() + got := map[string]int{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req models.PoolLinkWarmupReportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + } + auth := r.Header.Get("Authorization") + got[auth]++ + want := scopedRemote + if auth == "Bearer legacy" { + want = legacyRemote + } + if len(req.RemoteIDs) != 1 || req.RemoteIDs[0] != want { + t.Errorf("wrong mailbox on %s", auth) + } + _ = json.NewEncoder(w).Encode([]models.WarmupDailyStats{{Date: req.From, EmailsSent: 1, Active: true}}) + })) + defer srv.Close() + legacy := &models.CloudLink{InstanceID: uuid.New(), CloudURL: srv.URL, Token: "legacy"} + scoped := &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &org, CloudURL: srv.URL, Token: "scoped"} + a, b := uuid.New(), uuid.New() + repo := &workspaceLinkRepo{links: map[uuid.UUID]*models.CloudLink{legacy.InstanceID: legacy, scoped.InstanceID: scoped}, + mailboxes: map[uuid.UUID]*models.CloudLinkMailbox{a: {EmailAccountID: a, RemoteID: legacyRemote, InstanceID: legacy.InstanceID}, b: {EmailAccountID: b, RemoteID: scopedRemote, InstanceID: scoped.InstanceID}}} + svc := &service{repo: repo} + day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + rows, xerr := svc.WarmupStats(context.Background(), org, nil, day, day) + if xerr != nil || len(rows) != 1 || rows[0].EmailsSent != 2 || got["Bearer legacy"] != 1 || got["Bearer scoped"] != 1 { + t.Fatalf("per-link reports = %+v, %v; calls %v", rows, xerr, got) + } +} + +func TestWorkspaceResolutionDoesNotGrantNewAccessOnLegacyLinks(t *testing.T) { + org, other := uuid.New(), uuid.New() + legacy := &models.CloudLink{InstanceID: uuid.New()} + scoped := &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &org} + repo := &workspaceLinkRepo{links: map[uuid.UUID]*models.CloudLink{legacy.InstanceID: legacy, scoped.InstanceID: scoped}} + svc := &service{repo: repo} + if got, xerr := svc.newLink(context.Background(), org); xerr != nil || got != scoped { + t.Fatalf("workspace link = %v, %v", got, xerr) + } + if _, xerr := svc.newLink(context.Background(), other); xerr != ErrLegacyLink { + t.Fatalf("legacy granted new access: %v", xerr) + } + if _, xerr := svc.StartOAuth(context.Background(), other, uuid.New(), models.InboxProviderGoogle); xerr != ErrLegacyLink { + t.Fatalf("legacy OAuth = %v", xerr) + } + svc.emails = stubEmails{account: &models.Email{ID: uuid.New(), OrganizationID: &other}} + if _, xerr := svc.Enroll(context.Background(), other, uuid.New()); xerr != ErrLegacyLink { + t.Fatalf("legacy enrollment = %v", xerr) + } +} + +func TestLegacyMailboxKeepsItsTokenAfterWorkspaceReconnects(t *testing.T) { + t.Setenv("APP_ENV", "dev") + org, account := uuid.New(), uuid.New() + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Header.Get("Authorization") != "Bearer legacy-token" { + t.Errorf("used wrong workspace token") + } + _ = json.NewEncoder(w).Encode(models.PoolLinkAccessToken{AccessToken: "provider-fixture", ExpiresAt: time.Now().Add(time.Hour)}) + })) + defer srv.Close() + legacy := &models.CloudLink{InstanceID: uuid.New(), CloudURL: srv.URL, Token: "legacy-token"} + scoped := &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &org, CloudURL: srv.URL, Token: "new-workspace-token"} + repo := &workspaceLinkRepo{links: map[uuid.UUID]*models.CloudLink{legacy.InstanceID: legacy, scoped.InstanceID: scoped}, + mailboxes: map[uuid.UUID]*models.CloudLinkMailbox{account: {EmailAccountID: account, RemoteID: uuid.New(), InstanceID: legacy.InstanceID, Managed: true}}} + svc := &service{repo: repo, tokens: map[uuid.UUID]cachedToken{}} + tok, xerr := svc.AccessToken(context.Background(), account) + if xerr != nil || tok.AccessToken != "provider-fixture" || requests != 1 { + t.Fatalf("legacy token = %v, %v; calls %d", tok, xerr, requests) + } +} + +func TestDisconnectAffectsOnlyTheSelectedLink(t *testing.T) { + t.Setenv("APP_ENV", "dev") + org, other := uuid.New(), uuid.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.Header.Get("Authorization") != "Bearer scoped" { + t.Errorf("revoked wrong link: %s", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + legacy := &models.CloudLink{InstanceID: uuid.New(), CloudURL: srv.URL, Token: "legacy"} + scoped := &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &org, CloudURL: srv.URL, Token: "scoped"} + otherLink := &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &other, CloudURL: srv.URL, Token: "other"} + a, b, c := uuid.New(), uuid.New(), uuid.New() + repo := &workspaceLinkRepo{links: map[uuid.UUID]*models.CloudLink{legacy.InstanceID: legacy, scoped.InstanceID: scoped, otherLink.InstanceID: otherLink}, + mailboxes: map[uuid.UUID]*models.CloudLinkMailbox{a: {EmailAccountID: a, InstanceID: legacy.InstanceID}, b: {EmailAccountID: b, InstanceID: scoped.InstanceID}, c: {EmailAccountID: c, InstanceID: otherLink.InstanceID}}} + svc := &service{repo: repo, sessions: map[string]oauthSession{"other": {InstanceID: otherLink.InstanceID}, "scoped": {InstanceID: scoped.InstanceID}}, + tokens: map[uuid.UUID]cachedToken{a: {}, b: {}, c: {}}} + var disconnected uuid.UUID + svc.OnDisconnect(func(_ context.Context, id uuid.UUID) { disconnected = id }) + if xerr := svc.Disconnect(context.Background(), org, false); xerr != nil { + t.Fatal(xerr) + } + if len(repo.links) != 2 || repo.links[legacy.InstanceID] == nil || repo.links[otherLink.InstanceID] == nil || len(repo.mailboxes) != 2 || repo.mailboxes[b] != nil { + t.Fatalf("another connection was removed: %+v", repo) + } + if disconnected != scoped.InstanceID || len(svc.tokens) != 2 || len(svc.sessions) != 1 || svc.sessions["other"].InstanceID != otherLink.InstanceID { + t.Fatal("disconnect invalidated another workspace's state") + } + if xerr := svc.Disconnect(context.Background(), org, false); xerr != ErrLegacyLink { + t.Fatalf("implicit legacy disconnect = %v", xerr) + } +} + +func TestConnectRefusesCloudWithoutWorkspaceSupport(t *testing.T) { + t.Setenv("APP_ENV", "dev") + org := uuid.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req models.PoolLinkStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RemoteOrganizationID != org { + t.Errorf("handshake omitted workspace: %v", err) + } + _ = json.NewEncoder(w).Encode(models.PoolLinkStartResponse{UserCode: "ABCD-EFGH"}) + })) + defer srv.Close() + svc := &service{repo: &workspaceLinkRepo{}, pending: map[uuid.UUID]*PendingConnect{}} + if _, xerr := svc.StartConnect(context.Background(), org, uuid.New(), srv.URL); xerr == nil || xerr.Identifier != "cloud_link_upgrade_required" { + t.Fatalf("old Cloud was accepted: %v", xerr) + } +} diff --git a/internal/app/dangerzone/service.go b/internal/app/dangerzone/service.go index 0703d9226..4387c8520 100644 --- a/internal/app/dangerzone/service.go +++ b/internal/app/dangerzone/service.go @@ -135,6 +135,9 @@ func (s *service) ScheduleOrganizationDeletion(ctx context.Context, orgID, reque } if err := s.repo.CreatePending(ctx, d); err != nil { + if errors.Is(err, repository.ErrOrganizationCloudLinked) { + return nil, errx.NewWithIdentifier(errx.Conflict, "organization_cloud_connected", "Disconnect owned workspaces from Warmbly Cloud before scheduling deletion.") + } if errors.Is(err, repository.ErrPendingDeletionExists) { return nil, errx.New(errx.Conflict, "organization is already scheduled for deletion") } @@ -249,6 +252,9 @@ func (s *service) ScheduleUserDeletion(ctx context.Context, userID uuid.UUID, re } if err := s.repo.CreatePending(ctx, d); err != nil { + if errors.Is(err, repository.ErrOrganizationCloudLinked) { + return nil, errx.NewWithIdentifier(errx.Conflict, "organization_cloud_connected", "Disconnect owned workspaces from Warmbly Cloud before scheduling deletion.") + } if errors.Is(err, repository.ErrPendingDeletionExists) { return nil, errx.New(errx.Conflict, "account is already scheduled for deletion") } diff --git a/internal/app/organization/create_timezone_test.go b/internal/app/organization/create_timezone_test.go index e1a8aac9c..5197367ab 100644 --- a/internal/app/organization/create_timezone_test.go +++ b/internal/app/organization/create_timezone_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/app/dailythrottle" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" @@ -14,10 +15,11 @@ import ( type createRepo struct { repository.OrganizationRepository created *models.Organization + owned int } func (r *createRepo) GetUserOwnedOrganizationCount(context.Context, uuid.UUID) (int, error) { - return 0, nil + return r.owned, nil } func (r *createRepo) Create(_ context.Context, org *models.Organization) error { @@ -56,3 +58,47 @@ func TestCreateStoresTheWorkspaceTimezone(t *testing.T) { t.Fatalf("an unknown zone was accepted: %v", xerr) } } + +func TestWorkspaceLimitsApplyOnlyOnCloud(t *testing.T) { + for _, mode := range []string{"self_hosted", "cloud"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("DEPLOYMENT_MODE", mode) + repo := &createRepo{owned: 100} + svc := &organizationService{orgRepo: repo, userRepo: createUsers{}} + _, xerr := svc.Create(context.Background(), uuid.New(), "Acme", "UTC") + if mode == "self_hosted" && (xerr != nil || repo.created == nil) { + t.Fatalf("self-hosted cap was enforced: %v", xerr) + } + if mode == "cloud" && (xerr == nil || repo.created != nil) { + t.Fatalf("hosted cap was not enforced: %v", xerr) + } + }) + } +} + +type denyingWorkspaceThrottle struct{ calls int } + +func (d *denyingWorkspaceThrottle) CheckAndIncrement(_ context.Context, _ uuid.UUID, res dailythrottle.Resource, _ int) *errx.Error { + d.calls++ + if res != dailythrottle.ResourceOrg { + panic("unexpected throttle resource") + } + return errx.New(errx.TooManyRequests, "daily workspace limit") +} + +func TestWorkspaceDailyThrottleIsPreservedOnlyOnCloud(t *testing.T) { + for _, mode := range []string{"self_hosted", "cloud"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("DEPLOYMENT_MODE", mode) + repo, throttle := &createRepo{}, &denyingWorkspaceThrottle{} + svc := &organizationService{orgRepo: repo, userRepo: createUsers{}, throttle: throttle} + _, xerr := svc.Create(context.Background(), uuid.New(), "Acme", "UTC") + if mode == "self_hosted" && (xerr != nil || throttle.calls != 0 || repo.created == nil) { + t.Fatalf("self-host throttle ran: %v; calls %d", xerr, throttle.calls) + } + if mode == "cloud" && (xerr == nil || throttle.calls != 1 || repo.created != nil) { + t.Fatal("hosted daily limit was bypassed") + } + }) + } +} diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 79e13c75f..97f3f2fbf 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -301,7 +301,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name // Daily creation throttle — caps "new workspaces per owner per // day" so a script can't spawn 100 orgs from one user account. // Scope is the owner uuid (not the org, which doesn't exist yet). - if s.throttle != nil { + if !config.SelfHosted() && s.throttle != nil { if xerr := s.throttle.CheckAndIncrement(ctx, userID, dailythrottle.ResourceOrg, config.DailyThrottleNewOrgs); xerr != nil { return nil, xerr } @@ -317,13 +317,15 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name return nil, errx.New(errx.NotFound, "user not found") } - ownedCount, countErr := s.orgRepo.GetUserOwnedOrganizationCount(ctx, userID) - if countErr != nil { - errs.CaptureException(countErr) - return nil, errx.New(errx.Internal, "failed to get organization count") - } - if ownedCount >= user.MaxOrganizations { - return nil, errx.New(errx.Forbidden, "maximum organization limit reached") + if !config.SelfHosted() { + ownedCount, countErr := s.orgRepo.GetUserOwnedOrganizationCount(ctx, userID) + if countErr != nil { + errs.CaptureException(countErr) + return nil, errx.New(errx.Internal, "failed to get organization count") + } + if ownedCount >= user.MaxOrganizations { + return nil, errx.New(errx.Forbidden, "maximum organization limit reached") + } } org := &models.Organization{ @@ -526,6 +528,9 @@ func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req * // Delete deletes an organization func (s *organizationService) Delete(ctx context.Context, orgID uuid.UUID) *errx.Error { if err := s.orgRepo.Delete(ctx, orgID); err != nil { + if errors.Is(err, repository.ErrOrganizationCloudLinked) { + return errx.NewWithIdentifier(errx.Conflict, "organization_cloud_connected", "Disconnect this workspace from Warmbly Cloud before deleting it.") + } errs.CaptureException(err) return errx.New(errx.Internal, "failed to delete organization") } diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index adb1ae659..1c7deb9ef 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -191,7 +191,7 @@ var Tables = []Table{ Owner: scopeOrg, // DNS points at the source (or at Cloud for it) until moved, so the destination serves it itself once its own check passes. ResetOnImport: []string{"verified", "verified_at", "last_checked_at", "last_error", "served_by", "remote_host", "remote_records", - "linked_instance_id", "reach_status", "reach_hint", "reach_detail", "reach_proxy", "reach_checked_at"}, + "linked_instance_id", "cloud_link_instance_id", "reach_status", "reach_hint", "reach_detail", "reach_proxy", "reach_checked_at"}, Note: "Sending domains whose root redirects to the workspace's website. The destination lists its own TXT value, derived from its secret and the new workspace, and verifies once it is published. A redirect Warmbly Cloud served for the source arrives served by the destination.", }, { @@ -995,7 +995,7 @@ var ExcludedTables = map[string]string{ "cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.", "pool_link_instances": "Self-hosted instances linked to this workspace's pool allowance. The token hash only authenticates against this instance, and the enrolled mailboxes are mirrors of mailboxes that live elsewhere.", "pool_link_mailboxes": "Which mailbox rows are warmup-only mirrors for a linked instance. They follow pool_link_instances, which does not travel.", - "cloud_link": "This instance's own link to Warmbly Cloud: an instance property, not workspace data, and its token would be wrong on any other instance.", + "cloud_link": "Workspace connections to Warmbly Cloud and legacy instance links: their encrypted tokens only work on this installation and do not travel.", "cloud_link_mailboxes": "Which local mailboxes Warmbly Cloud warms for this instance. The enrollment belongs to the link, which does not travel.", "warmup_conversations": "The instance's shared warmup content library, not workspace data.", "copy_judgments": "A cache of copy judgments keyed by the hash of the words judged. The destination re-reads a step the first time its Advisor runs.", diff --git a/internal/app/placement/batch.go b/internal/app/placement/batch.go index 1f535a242..80f45100d 100644 --- a/internal/app/placement/batch.go +++ b/internal/app/placement/batch.go @@ -569,7 +569,7 @@ func (s *service) batchCost(ctx context.Context, orgID uuid.UUID, panel string, c.usage = usage case models.PlacementPanelCloud: if s.Cloud != nil { - if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr == nil && panel != nil { + if panel, xerr := s.Cloud.PlacementPanel(ctx, orgID); xerr == nil && panel != nil { c.usage = panel.Usage } } diff --git a/internal/app/placement/remote.go b/internal/app/placement/remote.go index 856ef75cb..dca20a7fc 100644 --- a/internal/app/placement/remote.go +++ b/internal/app/placement/remote.go @@ -39,6 +39,9 @@ func (s *service) RemotePanel(ctx context.Context, inst *models.PoolLinkInstance // RemoteStart opens one test per requested variant against the same seeds, // charged to the linked workspace's allowance. func (s *service) RemoteStart(ctx context.Context, inst *models.PoolLinkInstance, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) { + if inst.RemoteOrganizationID == nil { + return nil, placementErr(errx.Conflict, "pool_link_workspace_required", "Connect per workspace to start placement tests. Existing legacy results keep working.") + } if req.Tests < 1 || req.Tests > 2 { return nil, errx.New(errx.BadRequest, "tests must be 1 or 2") } @@ -171,9 +174,11 @@ func (s *service) syncCloud(ctx context.Context) map[uuid.UUID]bool { errs.CaptureException(err) return touched } - byTest := map[uuid.UUID][]int{} + type remoteKey struct{ instanceID, testID uuid.UUID } + byTest := map[remoteKey][]int{} for i, r := range reports { - byTest[r.RemoteTestID] = append(byTest[r.RemoteTestID], i) + key := remoteKey{instanceID: r.InstanceID, testID: r.RemoteTestID} + byTest[key] = append(byTest[key], i) } for remoteTest, idx := range byTest { sends := make([]models.PlacementCloudSend, 0, len(idx)) @@ -187,7 +192,7 @@ func (s *service) syncCloud(ctx context.Context) map[uuid.UUID]bool { sends = append(sends, snd) ids = append(ids, r.ResultID) } - if xerr := s.Cloud.ReportPlacementSends(ctx, remoteTest, sends); xerr != nil { + if xerr := s.Cloud.ReportPlacementSends(ctx, remoteTest.instanceID, remoteTest.testID, sends); xerr != nil { continue } if err := s.Repo.MarkRemoteReported(ctx, ids); err != nil { @@ -201,10 +206,10 @@ func (s *service) syncCloud(ctx context.Context) map[uuid.UUID]bool { return touched } for _, t := range open { - if t.RemoteTestID == nil { + if t.RemoteTestID == nil || t.RemoteInstanceID == nil { continue } - answer, xerr := s.Cloud.PlacementVerdicts(ctx, *t.RemoteTestID) + answer, xerr := s.Cloud.PlacementVerdicts(ctx, *t.RemoteInstanceID, *t.RemoteTestID) if xerr != nil || answer == nil { continue } diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index 7f869a55e..a02640f6e 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -75,10 +75,10 @@ type CampaignPauser interface { // CloudPanel is the instance side of Warmbly Cloud's seed panel. The // cloudlink service satisfies it; nil when the instance is not linked. type CloudPanel interface { - PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) - StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) - ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error - PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) + PlacementPanel(ctx context.Context, orgID uuid.UUID) (*models.PlacementCloudPanel, *errx.Error) + StartPlacement(ctx context.Context, orgID uuid.UUID, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) + ReportPlacementSends(ctx context.Context, instanceID, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error + PlacementVerdicts(ctx context.Context, instanceID, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) } // Deps are the service's collaborators. Repo, Emails, Campaigns, Tasks and @@ -384,13 +384,13 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, if perVariant < config.PlacementSeedsPerTestMin { return nil, budgetShort(config.PlacementSeedsPerTestMin) } - if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr == nil && panel != nil { + if panel, xerr := s.Cloud.PlacementPanel(ctx, in.OrgID); xerr == nil && panel != nil { if lim := panel.Usage.Limit; lim != nil && panel.Usage.Used+len(variants) > *lim { return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded", "The linked Warmbly Cloud workspace has used its placement tests for the month.") } } - start, xerr := s.Cloud.StartPlacement(ctx, models.PlacementCloudStartRequest{ + start, xerr := s.Cloud.StartPlacement(ctx, in.OrgID, models.PlacementCloudStartRequest{ SenderDomain: senderDomain, Tests: len(variants), MaxSeeds: perVariant, Families: families, }) if xerr != nil { @@ -470,6 +470,7 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, if cloudStart != nil { remote := cloudStart.TestIDs[i] out[i].test.RemoteTestID = &remote + out[i].test.RemoteInstanceID = &cloudStart.InstanceID } } // One probe every spacing, jittered, alternating variants per seed in a diff --git a/internal/app/placement/service_test.go b/internal/app/placement/service_test.go index 1a9e55500..927ac0cc8 100644 --- a/internal/app/placement/service_test.go +++ b/internal/app/placement/service_test.go @@ -20,6 +20,13 @@ import ( "github.com/warmbly/warmbly/internal/tasks/proto" ) +func TestLegacyCloudLinkCannotStartNewPlacementTests(t *testing.T) { + svc := &service{} + if _, xerr := svc.RemoteStart(context.Background(), &models.PoolLinkInstance{ID: uuid.New()}, models.PlacementCloudStartRequest{Tests: 1}); xerr == nil || xerr.Identifier != "pool_link_workspace_required" { + t.Fatalf("legacy placement = %v", xerr) + } +} + // Each fake embeds the interface it stands in for, so a call the test does // not expect panics instead of passing silently. diff --git a/internal/app/placement/view.go b/internal/app/placement/view.go index accf7d8d5..bfcd2d54e 100644 --- a/internal/app/placement/view.go +++ b/internal/app/placement/view.go @@ -240,7 +240,7 @@ func (s *service) Overview(ctx context.Context, orgID uuid.UUID) (*Overview, *er case s.Cloud == nil: cloud.Reason = "Link this instance to Warmbly Cloud to test on its seed panel." default: - if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr != nil { + if panel, xerr := s.Cloud.PlacementPanel(ctx, orgID); xerr != nil { cloud.Reason = xerr.Message } else if panel != nil { cloud = panel.Panel diff --git a/internal/app/poollink/adopt_warmup_test.go b/internal/app/poollink/adopt_warmup_test.go index 32b1ca3a5..178af1f24 100644 --- a/internal/app/poollink/adopt_warmup_test.go +++ b/internal/app/poollink/adopt_warmup_test.go @@ -55,13 +55,51 @@ func (e *adoptEmails) SetWarmupLifecycle(_ context.Context, orgID, _, action str func (e *adoptEmails) LoadAccountOntoWorker(context.Context, uuid.UUID) error { return nil } +type conflictApprovalRepo struct{ repository.PoolLinkRepository } + +func (conflictApprovalRepo) GetMailboxByRemote(context.Context, uuid.UUID, uuid.UUID) (*models.PoolLinkMailbox, error) { + return nil, nil +} + +func (conflictApprovalRepo) GetCodeByUserCode(context.Context, string) (*models.PoolLinkCode, error) { + org := uuid.New() + return &models.PoolLinkCode{UserCode: "ABCD-EFGH", Status: models.PoolLinkCodePending, RemoteOrganizationID: &org}, nil +} +func (conflictApprovalRepo) CreateInstance(context.Context, *models.PoolLinkInstance, string) error { + return repository.ErrCloudWorkspaceLinked +} + +func TestApprovalExplainsThatCloudWorkspaceIsAlreadyConnected(t *testing.T) { + s := &service{repo: conflictApprovalRepo{}} + if _, xerr := s.ApproveCode(context.Background(), "ABCD-EFGH", uuid.New(), uuid.New()); xerr != ErrWorkspaceLinked { + t.Fatalf("duplicate approval = %v", xerr) + } +} + +func TestLegacyInstancesCannotAddCloudMailboxes(t *testing.T) { + s := &service{repo: conflictApprovalRepo{}} + inst := &models.PoolLinkInstance{ID: uuid.New()} + if _, xerr := s.StartCode(context.Background(), models.PoolLinkStartRequest{InstanceName: "Legacy"}); xerr != ErrLegacyLink { + t.Fatalf("legacy device handshake = %v", xerr) + } + if _, xerr := s.Adopt(context.Background(), inst, models.PoolLinkAdoptRequest{}); xerr != ErrLegacyLink { + t.Fatalf("legacy adoption = %v", xerr) + } + if _, xerr := s.Enroll(context.Background(), inst, models.PoolLinkEnrollRequest{RemoteID: uuid.New(), Email: "user@example.com"}); xerr != ErrLegacyLink { + t.Fatalf("legacy enrollment = %v", xerr) + } + if _, xerr := s.StartOAuth(context.Background(), inst, models.PoolLinkOAuthStartRequest{}); xerr != ErrLegacyLink { + t.Fatalf("legacy OAuth = %v", xerr) + } +} + // Adopting a workspace mailbox into a linked instance must start its warmup on the cloud. func TestAdoptStartsWarmupInTheInstanceWorkspace(t *testing.T) { org, owner := uuid.New(), uuid.New() acc := &models.Email{ID: uuid.New(), OrganizationID: &org, Status: "active", Provider: string(models.InboxProviderGoogle)} emails := &adoptEmails{org: org} s := &service{repo: &adoptLinkRepo{}, emails: adoptAccounts{acc: acc}, emailSvc: emails} - inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: org, CreatedBy: &owner} + inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: org, RemoteOrganizationID: &org, CreatedBy: &owner} if _, xerr := s.Adopt(context.Background(), inst, models.PoolLinkAdoptRequest{RemoteID: uuid.New(), EmailAccountID: acc.ID}); xerr != nil { t.Fatalf("Adopt: %v", xerr) diff --git a/internal/app/poollink/oauth.go b/internal/app/poollink/oauth.go index 81dc10f53..8d023a842 100644 --- a/internal/app/poollink/oauth.go +++ b/internal/app/poollink/oauth.go @@ -96,6 +96,9 @@ func returnURLAllowed(raw, instanceURL string) bool { } func (s *service) StartOAuth(ctx context.Context, inst *models.PoolLinkInstance, req models.PoolLinkOAuthStartRequest) (*models.PoolLinkOAuthStartResponse, *errx.Error) { + if inst.RemoteOrganizationID == nil { + return nil, ErrLegacyLink + } if s.cache == nil { return nil, errx.InternalError() } @@ -277,6 +280,9 @@ func (s *service) connectBrokered(ctx context.Context, st brokerState, code stri if inst == nil || inst.RevokedAt != nil { return uuid.Nil, ErrInstanceRevoked } + if inst.RemoteOrganizationID == nil { + return uuid.Nil, ErrLegacyLink + } userID, xerr := s.ownerUserID(ctx, inst) if xerr != nil { return uuid.Nil, xerr @@ -365,6 +371,9 @@ func (s *service) AccessToken(ctx context.Context, inst *models.PoolLinkInstance } func (s *service) ListWorkspaceMailboxes(ctx context.Context, inst *models.PoolLinkInstance) ([]models.PoolLinkWorkspaceMailbox, *errx.Error) { + if inst.RemoteOrganizationID == nil { + return nil, ErrLegacyLink + } list, err := s.repo.ListAdoptableMailboxes(ctx, inst.OrganizationID) if err != nil { return nil, errx.InternalError() @@ -374,6 +383,9 @@ func (s *service) ListWorkspaceMailboxes(ctx context.Context, inst *models.PoolL // Adopt links a mailbox that was connected directly on the workspace. func (s *service) Adopt(ctx context.Context, inst *models.PoolLinkInstance, req models.PoolLinkAdoptRequest) (*models.PoolLinkMailboxState, *errx.Error) { + if inst.RemoteOrganizationID == nil { + return nil, ErrLegacyLink + } if req.RemoteID == uuid.Nil || req.EmailAccountID == uuid.Nil { return nil, ErrBadRequest } diff --git a/internal/app/poollink/service.go b/internal/app/poollink/service.go index 54f6f7221..82c672f5c 100644 --- a/internal/app/poollink/service.go +++ b/internal/app/poollink/service.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "net/url" "strings" "time" @@ -27,6 +28,8 @@ import ( ) var ( + ErrLegacyLink = errx.NewWithIdentifier(errx.Conflict, "pool_link_workspace_required", "Reconnect per workspace to add mailboxes. Existing legacy enrollments continue working.") + ErrWorkspaceLinked = errx.NewWithIdentifier(errx.Conflict, "pool_link_workspace_connected", "This Cloud workspace already has an active connection. Use a separate Cloud workspace and subscription, or disconnect the existing link first.") ErrCodeNotFound = errx.NewWithIdentifier(errx.NotFound, "pool_link_code_not_found", "That code is unknown or has expired. Start the connection again from your instance.") ErrCodeNotPending = errx.NewWithIdentifier(errx.Conflict, "pool_link_code_used", "That code has already been used.") ErrCodeDenied = errx.NewWithIdentifier(errx.Forbidden, "pool_link_denied", "The connection was declined.") @@ -169,7 +172,10 @@ func NormalizeUserCode(raw string) string { } func (s *service) StartCode(ctx context.Context, req models.PoolLinkStartRequest) (*models.PoolLinkStartResponse, *errx.Error) { - if strings.TrimSpace(req.InstanceName) == "" { + if strings.TrimSpace(req.InstanceName) == "" || req.RemoteOrganizationID == uuid.Nil { + if req.RemoteOrganizationID == uuid.Nil { + return nil, ErrLegacyLink + } return nil, ErrBadRequest } // Shown to the approving workspace, so it follows the workspace naming rules. @@ -203,6 +209,7 @@ func (s *service) StartCode(ctx context.Context, req models.PoolLinkStartRequest return nil, errx.InternalError() } return &models.PoolLinkStartResponse{ + WorkspaceScoped: true, DeviceCode: deviceCode, UserCode: code.UserCode, VerificationURL: config.AppBaseURL() + "/connect?code=" + url.QueryEscape(code.UserCode), @@ -267,18 +274,26 @@ func (s *service) ApproveCode(ctx context.Context, userCode string, orgID, userI return nil, errx.InternalError() } inst := &models.PoolLinkInstance{ - ID: uuid.New(), - OrganizationID: orgID, - Name: code.InstanceName, - URL: code.InstanceURL, - Version: code.InstanceVersion, - CreatedBy: &userID, + ID: uuid.New(), + RemoteOrganizationID: code.RemoteOrganizationID, + OrganizationID: orgID, + Name: code.InstanceName, + URL: code.InstanceURL, + Version: code.InstanceVersion, + CreatedBy: &userID, + } + if code.RemoteOrganizationID == nil || *code.RemoteOrganizationID == uuid.Nil { + return nil, ErrLegacyLink } if err := s.repo.CreateInstance(ctx, inst, hashToken(token)); err != nil { + if errors.Is(err, repository.ErrCloudWorkspaceLinked) { + return nil, ErrWorkspaceLinked + } return nil, errx.InternalError() } ok, err := s.repo.ApproveCode(ctx, code.UserCode, orgID, userID, inst.ID, token) if err != nil { + _ = s.repo.RevokeInstance(ctx, inst.ID) return nil, errx.InternalError() } if !ok { @@ -425,6 +440,10 @@ func (s *service) Enroll(ctx context.Context, inst *models.PoolLinkInstance, req return s.PatchMailbox(ctx, inst, req.RemoteID, models.PoolLinkMailboxPatch{Warmup: &req.Warmup, OAuth: req.OAuth, SMTPIMAP: req.SMTPIMAP}) } + if inst.RemoteOrganizationID == nil { + return nil, ErrLegacyLink + } + plan, xerr := s.Plan(ctx, inst.OrganizationID) if xerr != nil { return nil, xerr diff --git a/internal/app/sendingdomain/service.go b/internal/app/sendingdomain/service.go index 3d1c2ead0..9a214d8a5 100644 --- a/internal/app/sendingdomain/service.go +++ b/internal/app/sendingdomain/service.go @@ -231,7 +231,7 @@ func (s *Service) SetRedirect(ctx context.Context, orgID, userID uuid.UUID, doma } if server == models.RedirectServedByInstance && existing != nil && existing.ServedBy == models.RedirectServedByCloud { - if xerr := s.releaseCloud(ctx, domain); xerr != nil { + if xerr := s.releaseCloud(ctx, orgID, domain); xerr != nil { return nil, xerr } } @@ -251,7 +251,7 @@ func (s *Service) SetRedirect(ctx context.Context, orgID, userID uuid.UUID, doma if server != models.RedirectServedByCloud { return s.check(ctx, r, true) } - remote, xerr := s.cloud.PutRedirect(ctx, domain, models.DomainRedirectRequest{TargetURL: target, IncludeWWW: &www}) + remote, xerr := s.cloud.PutRedirect(ctx, orgID, domain, models.DomainRedirectRequest{TargetURL: target, IncludeWWW: &www}) if xerr == nil { return s.mirror(ctx, r, remote) } @@ -338,7 +338,7 @@ func (s *Service) DeleteRedirect(ctx context.Context, orgID uuid.UUID, domain st return errx.ErrNotFound } if r.ServedBy == models.RedirectServedByCloud { - if xerr := s.releaseCloud(ctx, domain); xerr != nil { + if xerr := s.releaseCloud(ctx, orgID, domain); xerr != nil { return xerr } } diff --git a/internal/app/sendingdomain/service_test.go b/internal/app/sendingdomain/service_test.go index 884e1dc87..0e471f111 100644 --- a/internal/app/sendingdomain/service_test.go +++ b/internal/app/sendingdomain/service_test.go @@ -154,7 +154,7 @@ func (m *memRedirects) SetRemote(_ context.Context, id uuid.UUID, host string, r m.rows[id].RemoteHost, m.rows[id].RemoteRecords = host, records return nil } -func (m *memRedirects) UnverifyCloudServed(_ context.Context, last string) error { +func (m *memRedirects) UnverifyCloudServed(_ context.Context, _ uuid.UUID, last string) error { for _, e := range m.rows { if e.ServedBy == models.RedirectServedByCloud { e.Verified, e.LastError, e.Reach = false, last, nil @@ -162,11 +162,15 @@ func (m *memRedirects) UnverifyCloudServed(_ context.Context, last string) error } return nil } -func (m *memRedirects) CloudServedDomains(context.Context) (map[string]bool, error) { - out := map[string]bool{} +func (m *memRedirects) CloudServedDomains(context.Context) (map[string]uuid.UUID, error) { + out := map[string]uuid.UUID{} for _, e := range m.rows { if e.ServedBy == models.RedirectServedByCloud { - out[e.Domain] = true + if e.CloudLinkInstanceID != nil { + out[e.Domain] = *e.CloudLinkInstanceID + } else { + out[e.Domain] = uuid.Nil + } } } return out, nil diff --git a/internal/app/sendingdomain/serving.go b/internal/app/sendingdomain/serving.go index 046610501..0e53303b5 100644 --- a/internal/app/sendingdomain/serving.go +++ b/internal/app/sendingdomain/serving.go @@ -19,12 +19,13 @@ import ( // CloudRedirects is Warmbly Cloud serving root redirects for this linked instance. type CloudRedirects interface { // RedirectOffer is Cloud's offer and whether the instance is linked; a linked instance gets nil while Cloud is unreachable. - RedirectOffer(ctx context.Context) (*models.PoolLinkRedirectOffer, bool) + RedirectOffer(ctx context.Context, orgID uuid.UUID) (*models.PoolLinkRedirectOffer, bool) + ReleaseRedirect(ctx context.Context, instanceID uuid.UUID, domain string) *errx.Error ListRedirects(ctx context.Context) ([]models.DomainRedirect, *errx.Error) - PutRedirect(ctx context.Context, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) - GetRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) - VerifyRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) - DeleteRedirect(ctx context.Context, domain string) *errx.Error + PutRedirect(ctx context.Context, orgID uuid.UUID, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) + GetRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) + VerifyRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) + DeleteRedirect(ctx context.Context, orgID uuid.UUID, domain string) *errx.Error } // WireCloud attaches the instance's link to Warmbly Cloud; optional. @@ -57,7 +58,7 @@ func (s *Service) cloudServable(ctx context.Context, orgID uuid.UUID, domain str return errx.NewWithIdentifier(errx.Conflict, ErrIDCloudUnavailable, "Connect this instance to Warmbly Cloud to serve redirects from there.") } if !alreadyCloud { - offer, linked := s.cloud.RedirectOffer(ctx) + offer, linked := s.cloud.RedirectOffer(ctx, orgID) switch { case !linked: return errx.NewWithIdentifier(errx.Conflict, ErrIDCloudUnavailable, "Connect this instance to Warmbly Cloud to serve redirects from there.") @@ -102,11 +103,11 @@ func cloudGone(xerr *errx.Error) bool { } // releaseCloud stops Cloud serving a domain before this instance forgets it did. -func (s *Service) releaseCloud(ctx context.Context, domain string) *errx.Error { +func (s *Service) releaseCloud(ctx context.Context, orgID uuid.UUID, domain string) *errx.Error { if s.cloud == nil { return nil } - if xerr := s.cloud.DeleteRedirect(ctx, domain); xerr != nil && !cloudGone(xerr) { + if xerr := s.cloud.DeleteRedirect(ctx, orgID, domain); xerr != nil && !cloudGone(xerr) { return errx.NewWithIdentifier(errx.ServiceUnavailable, ErrIDCloudUnreachable, "Warmbly Cloud could not be reached, so the redirect is still served there. Try again in a moment.") } @@ -138,14 +139,14 @@ func (s *Service) checkCloud(ctx context.Context, r *models.DomainRedirect, forc var remote *models.DomainRedirect var xerr *errx.Error if force { - remote, xerr = s.cloud.VerifyRedirect(ctx, r.Domain) + remote, xerr = s.cloud.VerifyRedirect(ctx, r.OrganizationID, r.Domain) } else { - remote, xerr = s.cloud.GetRedirect(ctx, r.Domain) + remote, xerr = s.cloud.GetRedirect(ctx, r.OrganizationID, r.Domain) } // A row Cloud lost, or holds with an older target (a save whose answer was lost), is sent again. if (xerr != nil && xerr.Identifier == ErrIDRemoteNotFound) || (xerr == nil && (remote.TargetURL != r.TargetURL || remote.IncludeWWW != r.IncludeWWW)) { www := r.IncludeWWW - remote, xerr = s.cloud.PutRedirect(ctx, r.Domain, models.DomainRedirectRequest{TargetURL: r.TargetURL, IncludeWWW: &www}) + remote, xerr = s.cloud.PutRedirect(ctx, r.OrganizationID, r.Domain, models.DomainRedirectRequest{TargetURL: r.TargetURL, IncludeWWW: &www}) } if xerr != nil { if cloudGone(xerr) { @@ -193,8 +194,9 @@ func (s *Service) reconcileCloud(ctx context.Context) { return } for _, r := range remote { - if !local[r.Domain] && time.Since(r.CreatedAt) > cloudOrphanGrace { - if xerr := s.cloud.DeleteRedirect(ctx, r.Domain); xerr != nil && !cloudGone(xerr) { + owner, exists := local[r.Domain] + if r.CloudLinkInstanceID != nil && (!exists || owner != *r.CloudLinkInstanceID) && time.Since(r.CreatedAt) > cloudOrphanGrace { + if xerr := s.cloud.ReleaseRedirect(ctx, *r.CloudLinkInstanceID, r.Domain); xerr != nil && !cloudGone(xerr) { log.Warn().Str("domain", r.Domain).Str("code", xerr.ResponseCode()).Msg("domain redirect sweep: could not release a Cloud redirect") } } @@ -202,8 +204,8 @@ func (s *Service) reconcileCloud(ctx context.Context) { } // MarkCloudUnlinked stops every cloud-served redirect claiming to be live, once the link to Cloud ends. -func (s *Service) MarkCloudUnlinked(ctx context.Context) { - _ = s.redirects.UnverifyCloudServed(ctx, unlinkedMessage) +func (s *Service) MarkCloudUnlinked(ctx context.Context, instanceID uuid.UUID) { + _ = s.redirects.UnverifyCloudServed(ctx, instanceID, unlinkedMessage) } // The Cloud side: rows served for a linked instance, proven against this deployment's own TXT value and tracking host. @@ -281,6 +283,9 @@ func (s *Service) LinkedSet(ctx context.Context, inst *models.PoolLinkInstance, return nil, errx.NewWithIdentifier(errx.Conflict, ErrIDTaken, "Your Warmbly Cloud workspace already has a redirect for this domain. Remove it there first.") } } + if existing == nil && inst.RemoteOrganizationID == nil { + return nil, errx.NewWithIdentifier(errx.Conflict, "pool_link_workspace_required", "Connect per workspace to add redirects. Existing legacy redirects keep working.") + } instanceID := inst.ID r := &models.DomainRedirect{ID: uuid.New(), OrganizationID: inst.OrganizationID, Domain: domain, TargetURL: target, IncludeWWW: www, VerifyToken: s.proof.Value(inst.OrganizationID, domain), ServedBy: models.RedirectServedByInstance, LinkedInstanceID: &instanceID} diff --git a/internal/app/sendingdomain/serving_test.go b/internal/app/sendingdomain/serving_test.go index 56de2986a..06c48cb6a 100644 --- a/internal/app/sendingdomain/serving_test.go +++ b/internal/app/sendingdomain/serving_test.go @@ -14,12 +14,13 @@ import ( // fakeCloud is Warmbly Cloud as the linked instance sees it. type fakeCloud struct { - offer *models.PoolLinkRedirectOffer - rows map[string]*models.DomainRedirect - down bool - unlinked bool - deletes []string - putErr *errx.Error + releasedInstances []uuid.UUID + offer *models.PoolLinkRedirectOffer + rows map[string]*models.DomainRedirect + down bool + unlinked bool + deletes []string + putErr *errx.Error } func newFakeCloud() *fakeCloud { @@ -36,7 +37,7 @@ func (f *fakeCloud) fail() *errx.Error { return nil } -func (f *fakeCloud) RedirectOffer(context.Context) (*models.PoolLinkRedirectOffer, bool) { +func (f *fakeCloud) RedirectOffer(context.Context, uuid.UUID) (*models.PoolLinkRedirectOffer, bool) { if f.unlinked { return nil, false } @@ -45,7 +46,7 @@ func (f *fakeCloud) RedirectOffer(context.Context) (*models.PoolLinkRedirectOffe } return f.offer, true } -func (f *fakeCloud) PutRedirect(_ context.Context, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) { +func (f *fakeCloud) PutRedirect(_ context.Context, _ uuid.UUID, domain string, in models.DomainRedirectRequest) (*models.DomainRedirect, *errx.Error) { if xerr := f.fail(); xerr != nil { return nil, xerr } @@ -56,7 +57,8 @@ func (f *fakeCloud) PutRedirect(_ context.Context, domain string, in models.Doma old.TargetURL, old.IncludeWWW = in.TargetURL, *in.IncludeWWW return old, nil } - r := &models.DomainRedirect{Domain: domain, TargetURL: in.TargetURL, IncludeWWW: *in.IncludeWWW, ServeHost: "t.warmbly.cloud", CreatedAt: time.Now(), + id := uuid.Nil + r := &models.DomainRedirect{CloudLinkInstanceID: &id, Domain: domain, TargetURL: in.TargetURL, IncludeWWW: *in.IncludeWWW, ServeHost: "t.warmbly.cloud", CreatedAt: time.Now(), Records: []models.DNSRecord{{Purpose: "root", Type: "A", Name: domain, Value: "198.51.100.7"}}, LastError: "The TXT record is not there yet."} f.rows[domain] = r return r, nil @@ -71,7 +73,7 @@ func (f *fakeCloud) ListRedirects(context.Context) ([]models.DomainRedirect, *er } return out, nil } -func (f *fakeCloud) GetRedirect(_ context.Context, domain string) (*models.DomainRedirect, *errx.Error) { +func (f *fakeCloud) GetRedirect(_ context.Context, _ uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) { if xerr := f.fail(); xerr != nil { return nil, xerr } @@ -80,10 +82,10 @@ func (f *fakeCloud) GetRedirect(_ context.Context, domain string) (*models.Domai } return nil, errx.NewWithIdentifier(errx.NotFound, ErrIDRemoteNotFound, "none") } -func (f *fakeCloud) VerifyRedirect(ctx context.Context, domain string) (*models.DomainRedirect, *errx.Error) { - return f.GetRedirect(ctx, domain) +func (f *fakeCloud) VerifyRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.DomainRedirect, *errx.Error) { + return f.GetRedirect(ctx, orgID, domain) } -func (f *fakeCloud) DeleteRedirect(_ context.Context, domain string) *errx.Error { +func (f *fakeCloud) DeleteRedirect(_ context.Context, _ uuid.UUID, domain string) *errx.Error { if xerr := f.fail(); xerr != nil { return xerr } @@ -171,7 +173,7 @@ func TestCloudServedRedirectStopsWhenTheLinkEnds(t *testing.T) { if !r.Verified { t.Fatal("not live") } - s.MarkCloudUnlinked(ctx) + s.MarkCloudUnlinked(ctx, uuid.Nil) if row := repo.rows[r.ID]; row.Verified || row.LastError != unlinkedMessage { t.Fatalf("still live after the link ended: %+v", row) } @@ -217,7 +219,7 @@ func TestLinkedRedirectsProveOwnershipOnCloud(t *testing.T) { s, repo := newTest(dns) ctx := context.Background() creator := uuid.New() - inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: uuid.New(), CreatedBy: &creator} + inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: uuid.New(), CreatedBy: &creator, RemoteOrganizationID: &creator} // No mailbox on Cloud is needed: the instance's workspace sends from there. r, xerr := s.LinkedSet(ctx, inst, "frost.io", models.DomainRedirectRequest{TargetURL: "frost.se"}) @@ -410,6 +412,7 @@ func TestARefusedNewLinkedRowDoesNotStayBehind(t *testing.T) { s, repo := newTest(dns) ctx := context.Background() inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: uuid.New()} + inst.RemoteOrganizationID = &inst.OrganizationID // Another workspace already serves the domain verified, so the new row cannot verify. other := &models.DomainRedirect{ID: uuid.New(), OrganizationID: uuid.New(), Domain: "frost.io", TargetURL: "https://x.com", Verified: true, ServedBy: models.RedirectServedByInstance} repo.rows[other.ID] = other @@ -500,7 +503,8 @@ func TestTheSweepReleasesCloudRedirectsNothingHereHas(t *testing.T) { cloud := newFakeCloud() s.WireCloud(cloud) ctx := context.Background() - old := &models.DomainRedirect{Domain: "gone.io", CreatedAt: time.Now().Add(-time.Hour)} + id := uuid.Nil + old := &models.DomainRedirect{CloudLinkInstanceID: &id, Domain: "gone.io", CreatedAt: time.Now().Add(-time.Hour)} young := &models.DomainRedirect{Domain: "saving.io", CreatedAt: time.Now()} cloud.rows["gone.io"], cloud.rows["saving.io"] = old, young if _, xerr := s.SetRedirect(ctx, uuid.New(), uuid.New(), "acme.io", RedirectInput{TargetURL: "acme.com", ServedBy: models.RedirectServedByCloud}); xerr != nil { @@ -546,6 +550,7 @@ func TestARefusedUpdateOfALinkedRowPutsItBack(t *testing.T) { s, repo := newTest(dns) ctx := context.Background() inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: uuid.New()} + inst.RemoteOrganizationID = &inst.OrganizationID if _, xerr := s.LinkedSet(ctx, inst, "frost.io", models.DomainRedirectRequest{TargetURL: "frost.se"}); xerr != nil { t.Fatal(xerr) } @@ -562,3 +567,39 @@ func TestARefusedUpdateOfALinkedRowPutsItBack(t *testing.T) { t.Fatalf("a refused update kept its target: %+v", got) } } + +func (f *fakeCloud) ReleaseRedirect(ctx context.Context, instanceID uuid.UUID, domain string) *errx.Error { + f.releasedInstances = append(f.releasedInstances, instanceID) + return f.DeleteRedirect(ctx, instanceID, domain) +} + +func TestLegacyCloudRedirectsCanBeUpdatedButNotCreated(t *testing.T) { + s, _ := newTest(baseDNS()) + inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: uuid.New()} + in := models.DomainRedirectRequest{TargetURL: "frost.se"} + if _, xerr := s.LinkedSet(context.Background(), inst, "frost.io", in); xerr == nil || xerr.Identifier != "pool_link_workspace_required" { + t.Fatalf("new legacy redirect = %v", xerr) + } + inst.RemoteOrganizationID = &inst.OrganizationID + if _, xerr := s.LinkedSet(context.Background(), inst, "frost.io", in); xerr != nil { + t.Fatal(xerr) + } + inst.RemoteOrganizationID = nil + if _, xerr := s.LinkedSet(context.Background(), inst, "frost.io", in); xerr != nil { + t.Fatalf("existing legacy redirect stopped working: %v", xerr) + } +} + +func TestSweepReleasesOnlyThePreviousLinksRedirect(t *testing.T) { + s, repo := newTest(baseDNS()) + cloud := newFakeCloud() + s.WireCloud(cloud) + oldID, currentID := uuid.New(), uuid.New() + domain := "moved.io" + repo.rows[uuid.New()] = &models.DomainRedirect{Domain: domain, ServedBy: models.RedirectServedByCloud, CloudLinkInstanceID: ¤tID} + cloud.rows[domain] = &models.DomainRedirect{Domain: domain, CloudLinkInstanceID: &oldID, CreatedAt: time.Now().Add(-time.Hour)} + s.reconcileCloud(context.Background()) + if len(cloud.releasedInstances) != 1 || cloud.releasedInstances[0] != oldID { + t.Fatal("sweep did not use original link") + } +} diff --git a/internal/infrastructure/db/migrations/000266_workspace_cloud_links.down.sql b/internal/infrastructure/db/migrations/000266_workspace_cloud_links.down.sql new file mode 100644 index 000000000..6e094f96a --- /dev/null +++ b/internal/infrastructure/db/migrations/000266_workspace_cloud_links.down.sql @@ -0,0 +1,21 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM cloud_link WHERE organization_id IS NOT NULL) + OR EXISTS (SELECT 1 FROM pool_link_instances WHERE remote_organization_id IS NOT NULL AND revoked_at IS NULL) THEN + RAISE EXCEPTION 'Disconnect workspace-scoped Cloud links before downgrading'; + END IF; +END $$; +ALTER TABLE domain_redirects DROP COLUMN cloud_link_instance_id; +UPDATE placement_tests SET remote_instance_id = NULL WHERE remote_test_id IS NOT NULL; +ALTER TABLE cloud_link_mailboxes DROP COLUMN instance_id; +DROP INDEX cloud_link_organization; +DROP INDEX cloud_link_legacy; +ALTER TABLE cloud_link DROP COLUMN organization_id; +ALTER TABLE cloud_link DROP CONSTRAINT cloud_link_pkey; +ALTER TABLE cloud_link ADD COLUMN id boolean PRIMARY KEY DEFAULT true CHECK (id); +ALTER TABLE pool_link_instances DROP COLUMN remote_organization_id; +ALTER TABLE pool_link_codes DROP COLUMN remote_organization_id; + +COMMIT; diff --git a/internal/infrastructure/db/migrations/000266_workspace_cloud_links.up.sql b/internal/infrastructure/db/migrations/000266_workspace_cloud_links.up.sql new file mode 100644 index 000000000..49b8388dc --- /dev/null +++ b/internal/infrastructure/db/migrations/000266_workspace_cloud_links.up.sql @@ -0,0 +1,31 @@ +BEGIN; + +ALTER TABLE pool_link_codes ADD COLUMN remote_organization_id uuid; +ALTER TABLE pool_link_instances ADD COLUMN remote_organization_id uuid; + +ALTER TABLE cloud_link DROP CONSTRAINT cloud_link_pkey; +ALTER TABLE cloud_link DROP COLUMN id; +ALTER TABLE cloud_link ADD PRIMARY KEY (instance_id); +ALTER TABLE cloud_link ADD COLUMN organization_id uuid REFERENCES organizations(id) ON DELETE RESTRICT; +CREATE UNIQUE INDEX cloud_link_organization ON cloud_link (organization_id) WHERE organization_id IS NOT NULL; +CREATE UNIQUE INDEX cloud_link_legacy ON cloud_link ((organization_id IS NULL)) WHERE organization_id IS NULL; + +ALTER TABLE cloud_link_mailboxes ADD COLUMN instance_id uuid; +UPDATE cloud_link_mailboxes SET instance_id = (SELECT instance_id FROM cloud_link); +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM cloud_link_mailboxes WHERE instance_id IS NULL) THEN + RAISE EXCEPTION 'Cloud enrollments have no instance link. Restore the link or remove orphan enrollments before upgrading'; + END IF; +END $$; +ALTER TABLE cloud_link_mailboxes ALTER COLUMN instance_id SET NOT NULL; +ALTER TABLE cloud_link_mailboxes ADD CONSTRAINT cloud_link_mailboxes_link_fk FOREIGN KEY (instance_id) REFERENCES cloud_link(instance_id) ON DELETE CASCADE; +CREATE INDEX cloud_link_mailboxes_instance ON cloud_link_mailboxes (instance_id); + +UPDATE placement_tests SET remote_instance_id = (SELECT instance_id FROM cloud_link WHERE organization_id IS NULL) +WHERE remote_test_id IS NOT NULL AND remote_instance_id IS NULL; + +ALTER TABLE domain_redirects ADD COLUMN cloud_link_instance_id uuid; +UPDATE domain_redirects SET cloud_link_instance_id = (SELECT instance_id FROM cloud_link WHERE organization_id IS NULL) +WHERE served_by = 'cloud' AND linked_instance_id IS NULL; + +COMMIT; diff --git a/internal/infrastructure/db/workspace_cloud_link_migration_test.go b/internal/infrastructure/db/workspace_cloud_link_migration_test.go new file mode 100644 index 000000000..a8b135552 --- /dev/null +++ b/internal/infrastructure/db/workspace_cloud_link_migration_test.go @@ -0,0 +1,107 @@ +package db + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestLiveWorkspaceCloudLinkMigrationPreservesExistingEnrollments(t *testing.T) { + dsn := os.Getenv("WARMBLY_TEST_DB") + if dsn == "" { + t.Skip("WARMBLY_TEST_DB not set") + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + schema := "cloud_links_" + uuid.New().String()[:8] + if _, err := conn.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + defer func() { _, _ = conn.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE") }() + if _, err := conn.Exec(ctx, "SET search_path TO "+schema); err != nil { + t.Fatal(err) + } + const fixture = ` + CREATE TABLE organizations (id uuid PRIMARY KEY); + CREATE TABLE pool_link_codes (id uuid PRIMARY KEY); + CREATE TABLE pool_link_instances (id uuid PRIMARY KEY, revoked_at timestamptz); + CREATE TABLE cloud_link (id boolean PRIMARY KEY DEFAULT true CHECK (id), instance_id uuid UNIQUE NOT NULL, token text NOT NULL); + CREATE TABLE cloud_link_mailboxes (email_account_id uuid PRIMARY KEY, remote_id uuid NOT NULL, health_state text); + CREATE TABLE placement_tests (id uuid PRIMARY KEY, remote_test_id uuid, remote_instance_id uuid); + CREATE TABLE domain_redirects (id uuid PRIMARY KEY, served_by text, linked_instance_id uuid); + INSERT INTO organizations VALUES ('10000000-0000-0000-0000-000000000001'); + INSERT INTO cloud_link (instance_id, token) VALUES ('20000000-0000-0000-0000-000000000001', 'sealed-fixture'); + INSERT INTO cloud_link_mailboxes VALUES ('30000000-0000-0000-0000-000000000001', '40000000-0000-0000-0000-000000000001', 'quarantined'); + INSERT INTO placement_tests VALUES ('50000000-0000-0000-0000-000000000001', '60000000-0000-0000-0000-000000000001', NULL); + INSERT INTO domain_redirects VALUES ('70000000-0000-0000-0000-000000000001', 'cloud', NULL); + ` + if _, err := conn.Exec(ctx, fixture); err != nil { + t.Fatal(err) + } + up, err := migrationsFS.ReadFile("migrations/000266_workspace_cloud_links.up.sql") + if err != nil { + t.Fatal(err) + } + down, err := migrationsFS.ReadFile("migrations/000266_workspace_cloud_links.down.sql") + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, string(up)); err != nil { + t.Fatal(err) + } + var preserved bool + if err := conn.QueryRow(ctx, `SELECT EXISTS ( + SELECT 1 FROM cloud_link l JOIN cloud_link_mailboxes m ON m.instance_id = l.instance_id + JOIN placement_tests p ON p.remote_instance_id = l.instance_id + JOIN domain_redirects d ON d.cloud_link_instance_id = l.instance_id + WHERE l.organization_id IS NULL AND l.token = 'sealed-fixture' AND m.health_state = 'quarantined' + )`).Scan(&preserved); err != nil || !preserved { + t.Fatalf("legacy data not preserved: %v", err) + } + if _, err := conn.Exec(ctx, `INSERT INTO cloud_link (instance_id, token, organization_id) + VALUES ('80000000-0000-0000-0000-000000000001', 'new-fixture', '10000000-0000-0000-0000-000000000001')`); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, `INSERT INTO cloud_link (instance_id, token, organization_id) + VALUES ('90000000-0000-0000-0000-000000000001', 'duplicate-fixture', '10000000-0000-0000-0000-000000000001')`); err == nil { + t.Fatal("workspace accepted two links") + } + if _, err := conn.Exec(ctx, string(down)); err == nil { + t.Fatal("unsafe downgrade was permitted") + } + if _, err := conn.Exec(ctx, "ROLLBACK"); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "DELETE FROM cloud_link WHERE organization_id IS NOT NULL"); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, string(down)); err != nil { + t.Fatal(err) + } + var token, state string + if err := conn.QueryRow(ctx, "SELECT token FROM cloud_link WHERE id = true").Scan(&token); err != nil || token != "sealed-fixture" { + t.Fatalf("downgrade lost token: %v", err) + } + if err := conn.QueryRow(ctx, "SELECT health_state FROM cloud_link_mailboxes").Scan(&state); err != nil || state != "quarantined" { + t.Fatalf("downgrade lost standing: %v", err) + } + if _, err := conn.Exec(ctx, "DELETE FROM cloud_link"); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, string(up)); err == nil { + t.Fatal("upgrade discarded orphaned enrollments") + } + if _, err := conn.Exec(ctx, "ROLLBACK"); err != nil { + t.Fatal(err) + } + if err := conn.QueryRow(ctx, "SELECT health_state FROM cloud_link_mailboxes").Scan(&state); err != nil || state != "quarantined" { + t.Fatalf("failed upgrade lost legacy enrollment: %v", err) + } +} diff --git a/internal/models/mailbox_import.go b/internal/models/mailbox_import.go index 4f7c7f590..b6c7ef37f 100644 --- a/internal/models/mailbox_import.go +++ b/internal/models/mailbox_import.go @@ -380,16 +380,17 @@ type TrackingDomainUse struct { // DomainRedirect sends a sending domain's root to the workspace's main website. type DomainRedirect struct { - ID uuid.UUID `json:"id"` - Domain string `json:"domain"` - TargetURL string `json:"target_url"` - IncludeWWW bool `json:"include_www"` - Verified bool `json:"verified"` - VerifiedAt *time.Time `json:"verified_at,omitempty"` - LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` - LastError string `json:"last_error,omitempty"` - CreatedAt time.Time `json:"created_at"` - Records []DNSRecord `json:"records"` + CloudLinkInstanceID *uuid.UUID `json:"-"` + ID uuid.UUID `json:"id"` + Domain string `json:"domain"` + TargetURL string `json:"target_url"` + IncludeWWW bool `json:"include_www"` + Verified bool `json:"verified"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + Records []DNSRecord `json:"records"` // ServedBy is who answers visitors: this instance or Warmbly Cloud. ServedBy RedirectServer `json:"served_by"` // ServeHost is the tracking host that answers, which a proxy routes the domain to. diff --git a/internal/models/placement.go b/internal/models/placement.go index 720c0867b..1ec30f683 100644 --- a/internal/models/placement.go +++ b/internal/models/placement.go @@ -366,9 +366,10 @@ type PlacementCloudSeed struct { // PlacementCloudStart is the cloud's answer: the seeds, once per test. type PlacementCloudStart struct { - TestIDs []uuid.UUID `json:"test_ids"` - Seeds []PlacementCloudSeed `json:"seeds"` - Usage PlacementUsage `json:"usage"` + InstanceID uuid.UUID `json:"-"` + TestIDs []uuid.UUID `json:"test_ids"` + Seeds []PlacementCloudSeed `json:"seeds"` + Usage PlacementUsage `json:"usage"` } // PlacementCloudSend reports one copy the instance sent, or failed to send. diff --git a/internal/models/poollink.go b/internal/models/poollink.go index eed1f9a0b..cd194e8dc 100644 --- a/internal/models/poollink.go +++ b/internal/models/poollink.go @@ -21,29 +21,31 @@ const ( // PoolLinkCode is one handshake row on the cloud side. type PoolLinkCode struct { - ID uuid.UUID `json:"id"` - UserCode string `json:"user_code"` - InstanceName string `json:"instance_name"` - InstanceURL string `json:"instance_url"` - InstanceVersion string `json:"instance_version"` - Status PoolLinkCodeStatus `json:"status"` - OrganizationID *uuid.UUID `json:"organization_id,omitempty"` - InstanceID *uuid.UUID `json:"instance_id,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` + RemoteOrganizationID *uuid.UUID `json:"remote_organization_id,omitempty"` + ID uuid.UUID `json:"id"` + UserCode string `json:"user_code"` + InstanceName string `json:"instance_name"` + InstanceURL string `json:"instance_url"` + InstanceVersion string `json:"instance_version"` + Status PoolLinkCodeStatus `json:"status"` + OrganizationID *uuid.UUID `json:"organization_id,omitempty"` + InstanceID *uuid.UUID `json:"instance_id,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` } // PoolLinkInstance is a linked self-hosted instance as the cloud sees it. type PoolLinkInstance struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - Name string `json:"name"` - URL string `json:"url"` - Version string `json:"version"` - CreatedBy *uuid.UUID `json:"created_by,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastSeenAt *time.Time `json:"last_seen_at,omitempty"` - RevokedAt *time.Time `json:"revoked_at,omitempty"` + RemoteOrganizationID *uuid.UUID `json:"remote_organization_id,omitempty"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Name string `json:"name"` + URL string `json:"url"` + Version string `json:"version"` + CreatedBy *uuid.UUID `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` // MailboxCount is filled by list endpoints only. MailboxCount int `json:"mailbox_count"` } @@ -61,13 +63,15 @@ type PoolLinkMailbox struct { // PoolLinkStartRequest is what a self-hosted instance sends to begin linking. type PoolLinkStartRequest struct { - InstanceName string `json:"instance_name"` - InstanceURL string `json:"instance_url"` - InstanceVersion string `json:"instance_version"` + RemoteOrganizationID uuid.UUID `json:"remote_organization_id"` + InstanceName string `json:"instance_name"` + InstanceURL string `json:"instance_url"` + InstanceVersion string `json:"instance_version"` } // PoolLinkStartResponse is the device-code grant. type PoolLinkStartResponse struct { + WorkspaceScoped bool `json:"workspace_scoped"` DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURL string `json:"verification_url"` @@ -245,8 +249,9 @@ type PoolLinkMailboxPatch struct { SMTPIMAP *SmtpImap `json:"smtp_imap,omitempty"` } -// CloudLink is the self-hosted instance's single link row; Token is never serialized. +// CloudLink belongs to a local workspace; a nil OrganizationID is a legacy instance-wide link. type CloudLink struct { + OrganizationID *uuid.UUID `json:"organization_id,omitempty"` CloudURL string `json:"cloud_url"` InstanceID uuid.UUID `json:"instance_id"` Token string `json:"-"` @@ -259,6 +264,7 @@ type CloudLink struct { // CloudLinkMailbox marks a local mailbox as warmed by the cloud. type CloudLinkMailbox struct { + InstanceID uuid.UUID `json:"-"` EmailAccountID uuid.UUID `json:"email_account_id"` RemoteID uuid.UUID `json:"remote_id"` EnrolledAt time.Time `json:"enrolled_at"` @@ -285,9 +291,10 @@ type CloudLinkOAuthStart struct { // CloudLinkStatus is the self-hosted dashboard's view of the link. type CloudLinkStatus struct { - Connected bool `json:"connected"` - Link *CloudLink `json:"link,omitempty"` - Info *PoolLinkInstanceInfo `json:"info,omitempty"` + LegacyConnected bool `json:"legacy_connected"` + Connected bool `json:"connected"` + Link *CloudLink `json:"link,omitempty"` + Info *PoolLinkInstanceInfo `json:"info,omitempty"` // Reachable is false when the cloud could not be contacted; Link still comes from the local row. Reachable bool `json:"reachable"` Error string `json:"error,omitempty"` @@ -297,6 +304,7 @@ type CloudLinkStatus struct { // CloudLinkMailboxRow merges a local mailbox with its cloud warmup state. type CloudLinkMailboxRow struct { + Legacy bool `json:"legacy"` ID uuid.UUID `json:"id"` Email string `json:"email"` Name string `json:"name"` diff --git a/internal/repository/cloud_link_standing_live_test.go b/internal/repository/cloud_link_standing_live_test.go index 3f8a4126c..e941f09d5 100644 --- a/internal/repository/cloud_link_standing_live_test.go +++ b/internal/repository/cloud_link_standing_live_test.go @@ -22,7 +22,7 @@ func TestLiveCloudLinkStandingGatesTheInstance(t *testing.T) { handle, _ := liveContactDB(t) lifecycle := NewSendLifecycleRepository(handle) - if _, err := links.Enroll(ctx, f.sender, f.sender, false); err != nil { + if _, err := links.Enroll(ctx, f.sender, f.sender, liveCloudLink(t, f), false); err != nil { t.Fatalf("Enroll: %v", err) } until := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second) @@ -169,7 +169,7 @@ func TestLiveCloudLinkStandingEdgeCases(t *testing.T) { f := newPoolLinkFixture(t) ctx := context.Background() links := NewCloudLinkRepository(f.pool, nil) - if _, err := links.Enroll(ctx, f.sender, f.sender, false); err != nil { + if _, err := links.Enroll(ctx, f.sender, f.sender, liveCloudLink(t, f), false); err != nil { t.Fatalf("Enroll: %v", err) } @@ -231,7 +231,7 @@ func TestLiveCloudLinkStandingExpiredHoldDoesNotMaskALiveOne(t *testing.T) { if _, err := f.pool.Exec(ctx, `UPDATE warmup_pool_participants SET health_state = 'quarantined', blocked_until = $2 WHERE email_account_id = $1`, f.sender, past); err != nil { t.Fatal(err) } - if _, err := links.Enroll(ctx, f.sender, f.sender, false); err != nil { + if _, err := links.Enroll(ctx, f.sender, f.sender, liveCloudLink(t, f), false); err != nil { t.Fatal(err) } if _, err := links.SetStanding(ctx, f.sender, &models.WarmupHealthInfo{State: "throttled"}, true); err != nil { @@ -252,3 +252,13 @@ func TestLiveCloudLinkStandingExpiredHoldDoesNotMaskALiveOne(t *testing.T) { t.Fatalf("an ended throttle masked the live watch: %s", state) } } + +func liveCloudLink(t *testing.T, f *poolLinkFixture) uuid.UUID { + t.Helper() + id := uuid.New() + if _, err := f.pool.Exec(context.Background(), `INSERT INTO cloud_link (cloud_url, instance_id, token, organization_id) VALUES ('https://example.test', $1, 'fixture', $2)`, id, f.org); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM cloud_link WHERE instance_id = $1`, id) }) + return id +} diff --git a/internal/repository/mailbox_sources_live_test.go b/internal/repository/mailbox_sources_live_test.go index 096ed6ca9..f608dd469 100644 --- a/internal/repository/mailbox_sources_live_test.go +++ b/internal/repository/mailbox_sources_live_test.go @@ -318,13 +318,15 @@ func TestLiveDomainRedirectServing(t *testing.T) { } } - if list, _ := redirects.CloudServedDomains(ctx); list[domain] { + if list, _ := redirects.CloudServedDomains(ctx); list[domain] != uuid.Nil { t.Fatal("a redirect served here is listed as Cloud's") } // The link ending stops every cloud-served row. mustImport(t, redirects.Upsert(ctx, cloudRow, &f.owner)) - mustImport(t, redirects.UnverifyCloudServed(ctx, "unlinked")) + _, bindErr := pool.Exec(ctx, `UPDATE domain_redirects SET cloud_link_instance_id = $2 WHERE id = $1`, cloudRow.ID, uuid.Nil) + mustImport(t, bindErr) + mustImport(t, redirects.UnverifyCloudServed(ctx, uuid.Nil, "unlinked")) if got, _ = redirects.Get(ctx, f.org, domain); got.Verified || got.LastError != "unlinked" { t.Fatalf("still live after the link ended: %+v", got) } diff --git a/internal/repository/pg_cloudlink.go b/internal/repository/pg_cloudlink.go index 4b02445d7..b024ba9e8 100644 --- a/internal/repository/pg_cloudlink.go +++ b/internal/repository/pg_cloudlink.go @@ -19,14 +19,18 @@ type CloudLinkRepository interface { // handshake is one-time: a token that cannot be written is gone, and the // link is left standing on the cloud with nobody holding it. CanStore() error - Get(ctx context.Context) (*models.CloudLink, error) + Get(ctx context.Context, orgID *uuid.UUID) (*models.CloudLink, error) + GetByInstance(ctx context.Context, instanceID uuid.UUID) (*models.CloudLink, error) + GetForRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.CloudLink, error) + BindRedirect(ctx context.Context, orgID uuid.UUID, domain string, instanceID uuid.UUID) error + ListLinks(ctx context.Context) ([]models.CloudLink, error) Put(ctx context.Context, link *models.CloudLink) error - Delete(ctx context.Context) error - SetSyncResult(ctx context.Context, at time.Time, lastError string) error + Delete(ctx context.Context, instanceID uuid.UUID) error + SetSyncResult(ctx context.Context, instanceID uuid.UUID, at time.Time, lastError string) error - Enroll(ctx context.Context, accountID, remoteID uuid.UUID, managed bool) (*models.CloudLinkMailbox, error) + Enroll(ctx context.Context, accountID, remoteID, instanceID uuid.UUID, managed bool) (*models.CloudLinkMailbox, error) Unenroll(ctx context.Context, accountID uuid.UUID) error - UnenrollAll(ctx context.Context) error + UnenrollAll(ctx context.Context, instanceID uuid.UUID) error GetByAccount(ctx context.Context, accountID uuid.UUID) (*models.CloudLinkMailbox, error) List(ctx context.Context) ([]models.CloudLinkMailbox, error) ListForOrg(ctx context.Context, orgID uuid.UUID, accountID *uuid.UUID) ([]models.CloudLinkMailbox, error) @@ -62,16 +66,15 @@ func (r *cloudLinkRepository) CanStore() error { return nil } -func (r *cloudLinkRepository) Get(ctx context.Context) (*models.CloudLink, error) { - query := `SELECT cloud_url, instance_id, token, organization_name, connected_by, connected_at, last_synced_at, last_error FROM cloud_link WHERE id = true` +const cloudLinkColumns = `cloud_url, instance_id, token, organization_name, connected_by, connected_at, last_synced_at, last_error, organization_id` + +func (r *cloudLinkRepository) scanLink(row pgx.Row) (*models.CloudLink, error) { var l models.CloudLink var sealed string - err := r.db.QueryRow(ctx, query).Scan(&l.CloudURL, &l.InstanceID, &sealed, &l.OrganizationName, &l.ConnectedBy, &l.ConnectedAt, &l.LastSyncedAt, &l.LastError) - if err != nil { + if err := row.Scan(&l.CloudURL, &l.InstanceID, &sealed, &l.OrganizationName, &l.ConnectedBy, &l.ConnectedAt, &l.LastSyncedAt, &l.LastError, &l.OrganizationID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } - db.CaptureError(err, query, nil, "queryrow") return nil, err } if r.encrypt == nil { @@ -85,6 +88,48 @@ func (r *cloudLinkRepository) Get(ctx context.Context) (*models.CloudLink, error return &l, nil } +func (r *cloudLinkRepository) Get(ctx context.Context, orgID *uuid.UUID) (*models.CloudLink, error) { + query := `SELECT ` + cloudLinkColumns + ` FROM cloud_link + WHERE organization_id = $1 OR organization_id IS NULL + ORDER BY organization_id IS NULL LIMIT 1` + return r.scanLink(r.db.QueryRow(ctx, query, orgID)) +} + +func (r *cloudLinkRepository) GetByInstance(ctx context.Context, instanceID uuid.UUID) (*models.CloudLink, error) { + return r.scanLink(r.db.QueryRow(ctx, `SELECT `+cloudLinkColumns+` FROM cloud_link WHERE instance_id = $1`, instanceID)) +} + +func (r *cloudLinkRepository) ListLinks(ctx context.Context) ([]models.CloudLink, error) { + rows, err := r.db.Query(ctx, `SELECT `+cloudLinkColumns+` FROM cloud_link ORDER BY connected_at`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.CloudLink{} + for rows.Next() { + l, err := r.scanLink(rows) + if err != nil { + return nil, err + } + out = append(out, *l) + } + return out, rows.Err() +} + +func (r *cloudLinkRepository) GetForRedirect(ctx context.Context, orgID uuid.UUID, domain string) (*models.CloudLink, error) { + query := `SELECT ` + cloudLinkColumns + ` FROM cloud_link WHERE instance_id = COALESCE( + (SELECT cloud_link_instance_id FROM domain_redirects WHERE organization_id = $1 AND domain = $2), + (SELECT instance_id FROM cloud_link WHERE organization_id = $1 LIMIT 1) + )` + return r.scanLink(r.db.QueryRow(ctx, query, orgID, domain)) +} + +func (r *cloudLinkRepository) BindRedirect(ctx context.Context, orgID uuid.UUID, domain string, instanceID uuid.UUID) error { + _, err := r.db.Exec(ctx, `UPDATE domain_redirects SET cloud_link_instance_id = $3 + WHERE organization_id = $1 AND domain = $2 AND served_by = 'cloud' AND cloud_link_instance_id IS NULL`, orgID, domain, instanceID) + return err +} + func (r *cloudLinkRepository) Put(ctx context.Context, link *models.CloudLink) error { if r.encrypt == nil { return errNoLinkEncrypter @@ -94,43 +139,39 @@ func (r *cloudLinkRepository) Put(ctx context.Context, link *models.CloudLink) e return err } query := ` - INSERT INTO cloud_link (id, cloud_url, instance_id, token, organization_name, connected_by, connected_at, last_error) - VALUES (true, $1, $2, $3, $4, $5, NOW(), '') - ON CONFLICT (id) DO UPDATE SET - cloud_url = EXCLUDED.cloud_url, instance_id = EXCLUDED.instance_id, token = EXCLUDED.token, - organization_name = EXCLUDED.organization_name, connected_by = EXCLUDED.connected_by, - connected_at = NOW(), last_synced_at = NULL, last_error = '' - RETURNING connected_at - ` - if err := r.db.QueryRow(ctx, query, link.CloudURL, link.InstanceID, sealed, link.OrganizationName, link.ConnectedBy).Scan(&link.ConnectedAt); err != nil { + INSERT INTO cloud_link (cloud_url, instance_id, token, organization_name, connected_by, organization_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING connected_at + ` + if err := r.db.QueryRow(ctx, query, link.CloudURL, link.InstanceID, sealed, link.OrganizationName, link.ConnectedBy, link.OrganizationID).Scan(&link.ConnectedAt); err != nil { db.CaptureError(err, query, nil, "queryrow") return err } return nil } -func (r *cloudLinkRepository) Delete(ctx context.Context) error { - if _, err := r.db.Exec(ctx, `DELETE FROM cloud_link WHERE id = true`); err != nil { +func (r *cloudLinkRepository) Delete(ctx context.Context, instanceID uuid.UUID) error { + if _, err := r.db.Exec(ctx, `DELETE FROM cloud_link WHERE instance_id = $1`, instanceID); err != nil { db.CaptureError(err, "delete cloud_link", nil, "exec") return err } return nil } -func (r *cloudLinkRepository) SetSyncResult(ctx context.Context, at time.Time, lastError string) error { - _, err := r.db.Exec(ctx, `UPDATE cloud_link SET last_synced_at = CASE WHEN $2 = '' THEN $1 ELSE last_synced_at END, last_error = $2 WHERE id = true`, at, lastError) +func (r *cloudLinkRepository) SetSyncResult(ctx context.Context, instanceID uuid.UUID, at time.Time, lastError string) error { + _, err := r.db.Exec(ctx, `UPDATE cloud_link SET last_synced_at = CASE WHEN $2 = '' THEN $1 ELSE last_synced_at END, last_error = $2 WHERE instance_id = $3`, at, lastError, instanceID) return err } -func (r *cloudLinkRepository) Enroll(ctx context.Context, accountID, remoteID uuid.UUID, managed bool) (*models.CloudLinkMailbox, error) { +func (r *cloudLinkRepository) Enroll(ctx context.Context, accountID, remoteID, instanceID uuid.UUID, managed bool) (*models.CloudLinkMailbox, error) { query := ` - INSERT INTO cloud_link_mailboxes (email_account_id, remote_id, managed) - VALUES ($1, $2, $3) - ON CONFLICT (email_account_id) DO UPDATE SET remote_id = EXCLUDED.remote_id, managed = EXCLUDED.managed - RETURNING email_account_id, remote_id, enrolled_at, managed + INSERT INTO cloud_link_mailboxes (email_account_id, remote_id, managed, instance_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (email_account_id) DO UPDATE SET remote_id = EXCLUDED.remote_id, managed = EXCLUDED.managed, instance_id = EXCLUDED.instance_id + RETURNING email_account_id, remote_id, enrolled_at, managed, instance_id ` var m models.CloudLinkMailbox - if err := r.db.QueryRow(ctx, query, accountID, remoteID, managed).Scan(&m.EmailAccountID, &m.RemoteID, &m.EnrolledAt, &m.Managed); err != nil { + if err := r.db.QueryRow(ctx, query, accountID, remoteID, managed, instanceID).Scan(&m.EmailAccountID, &m.RemoteID, &m.EnrolledAt, &m.Managed, &m.InstanceID); err != nil { db.CaptureError(err, query, nil, "queryrow") return nil, err } @@ -145,8 +186,8 @@ func (r *cloudLinkRepository) Unenroll(ctx context.Context, accountID uuid.UUID) return nil } -func (r *cloudLinkRepository) UnenrollAll(ctx context.Context) error { - _, err := r.db.Exec(ctx, `DELETE FROM cloud_link_mailboxes`) +func (r *cloudLinkRepository) UnenrollAll(ctx context.Context, instanceID uuid.UUID) error { + _, err := r.db.Exec(ctx, `DELETE FROM cloud_link_mailboxes WHERE instance_id = $1`, instanceID) return err } @@ -191,7 +232,7 @@ func (r *cloudLinkRepository) IsEnrolled(ctx context.Context, accountID uuid.UUI } const cloudLinkMailboxColumns = `email_account_id, remote_id, enrolled_at, managed, - health_state, health_pool_type, health_reason, health_score, blocked_until, health_evaluated_at` + health_state, health_pool_type, health_reason, health_score, blocked_until, health_evaluated_at, instance_id` func scanCloudLinkMailbox(row pgx.Row) (*models.CloudLinkMailbox, error) { var m models.CloudLinkMailbox @@ -199,7 +240,7 @@ func scanCloudLinkMailbox(row pgx.Row) (*models.CloudLinkMailbox, error) { var score float64 var blockedUntil, evaluatedAt *time.Time if err := row.Scan(&m.EmailAccountID, &m.RemoteID, &m.EnrolledAt, &m.Managed, - &state, &poolType, &reason, &score, &blockedUntil, &evaluatedAt); err != nil { + &state, &poolType, &reason, &score, &blockedUntil, &evaluatedAt, &m.InstanceID); err != nil { return nil, err } if state != nil { diff --git a/internal/repository/pg_danger_zone.go b/internal/repository/pg_danger_zone.go index e8b00da89..e7d7e8e88 100644 --- a/internal/repository/pg_danger_zone.go +++ b/internal/repository/pg_danger_zone.go @@ -109,6 +109,23 @@ func (r *dangerZoneRepository) CreatePending(ctx context.Context, d *models.Sche } defer tx.Rollback(ctx) + var linked bool + var check string + switch d.ResourceType { + case models.DeletionResourceOrganization: + check = `SELECT EXISTS (SELECT 1 FROM cloud_link WHERE organization_id = $1)` + case models.DeletionResourceUser: + check = `SELECT EXISTS (SELECT 1 FROM cloud_link cl JOIN organizations o ON o.id = cl.organization_id WHERE o.owner_user_id = $1)` + } + if check != "" { + if err := tx.QueryRow(ctx, check, d.ResourceID).Scan(&linked); err != nil { + return err + } + if linked { + return ErrOrganizationCloudLinked + } + } + const insertQ = ` INSERT INTO scheduled_deletions ( id, resource_type, resource_id, organization_id, diff --git a/internal/repository/pg_domain_redirect.go b/internal/repository/pg_domain_redirect.go index 3690bdb81..9d88b8091 100644 --- a/internal/repository/pg_domain_redirect.go +++ b/internal/repository/pg_domain_redirect.go @@ -39,9 +39,9 @@ type DomainRedirectRepository interface { // SetRemote mirrors Warmbly Cloud's host and records for a cloud-served row. SetRemote(ctx context.Context, id uuid.UUID, host string, records []models.DNSRecord) error // UnverifyCloudServed stops every cloud-served row claiming to be live, when the link to Cloud ends. - UnverifyCloudServed(ctx context.Context, lastError string) error + UnverifyCloudServed(ctx context.Context, instanceID uuid.UUID, lastError string) error // CloudServedDomains are the domains this instance has Cloud serve, across workspaces. - CloudServedDomains(ctx context.Context) (map[string]bool, error) + CloudServedDomains(ctx context.Context) (map[string]uuid.UUID, error) // CloudServedElsewhere reports another workspace on this instance having the domain served by Cloud. CloudServedElsewhere(ctx context.Context, orgID uuid.UUID, domain string) (bool, error) // Due are rows whose last check is older than their state allows. @@ -123,7 +123,8 @@ func upsertRedirect(ctx context.Context, q queryRower, d *models.DomainRedirect, reach_hint = CASE WHEN domain_redirects.served_by = EXCLUDED.served_by THEN domain_redirects.reach_hint ELSE '' END, reach_detail = CASE WHEN domain_redirects.served_by = EXCLUDED.served_by THEN domain_redirects.reach_detail ELSE '' END, reach_proxy = CASE WHEN domain_redirects.served_by = EXCLUDED.served_by THEN domain_redirects.reach_proxy ELSE '' END, - reach_checked_at = CASE WHEN domain_redirects.served_by = EXCLUDED.served_by THEN domain_redirects.reach_checked_at END + reach_checked_at = CASE WHEN domain_redirects.served_by = EXCLUDED.served_by THEN domain_redirects.reach_checked_at END, + cloud_link_instance_id = CASE WHEN EXCLUDED.served_by = 'cloud' AND domain_redirects.served_by = 'cloud' THEN domain_redirects.cloud_link_instance_id END WHERE domain_redirects.linked_instance_id IS NOT DISTINCT FROM EXCLUDED.linked_instance_id RETURNING ` + redirectColumns if err := scanRedirect(q.QueryRow(ctx, query, d.ID, d.OrganizationID, strings.ToLower(d.Domain), d.TargetURL, d.IncludeWWW, d.VerifyToken, createdBy, @@ -310,33 +311,36 @@ func (r *domainRedirectRepository) SetRemote(ctx context.Context, id uuid.UUID, return nil } -func (r *domainRedirectRepository) UnverifyCloudServed(ctx context.Context, lastError string) error { +func (r *domainRedirectRepository) UnverifyCloudServed(ctx context.Context, instanceID uuid.UUID, lastError string) error { query := `UPDATE domain_redirects SET verified = false, verified_at = NULL, last_error = $1, updated_at = now(), - reach_status = NULL, reach_hint = '', reach_detail = '', reach_proxy = '', reach_checked_at = NULL - WHERE served_by = 'cloud'` - if _, err := r.DB.Exec(ctx, query, lastError); err != nil { + reach_status = NULL, reach_hint = '', reach_detail = '', reach_proxy = '', reach_checked_at = NULL, cloud_link_instance_id = NULL + WHERE served_by = 'cloud' AND cloud_link_instance_id = $2` + if _, err := r.DB.Exec(ctx, query, lastError, instanceID); err != nil { db.CaptureError(err, query, nil, "exec") return err } return nil } -func (r *domainRedirectRepository) CloudServedDomains(ctx context.Context) (map[string]bool, error) { - query := `SELECT domain FROM domain_redirects WHERE served_by = 'cloud'` +func (r *domainRedirectRepository) CloudServedDomains(ctx context.Context) (map[string]uuid.UUID, error) { + query := `SELECT domain, cloud_link_instance_id FROM domain_redirects WHERE served_by = 'cloud'` rows, err := r.DB.Query(ctx, query) if err != nil { db.CaptureError(err, query, nil, "query") return nil, err } defer rows.Close() - out := map[string]bool{} + out := map[string]uuid.UUID{} for rows.Next() { var d string - if err := rows.Scan(&d); err != nil { + var id *uuid.UUID + if err := rows.Scan(&d, &id); err != nil { return nil, err } - out[d] = true + if id != nil { + out[d] = *id + } } return out, rows.Err() } diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 52098f7d4..44c454817 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -2,12 +2,14 @@ package repository import ( "context" + "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" ) @@ -218,8 +220,14 @@ func (r *organizationRepository) UpdateAvatar(ctx context.Context, orgID uuid.UU } // Delete deletes an organization +var ErrOrganizationCloudLinked = errors.New("disconnect workspace Cloud link before deleting") + func (r *organizationRepository) Delete(ctx context.Context, id uuid.UUID) error { _, err := r.db.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, id) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.ConstraintName == "cloud_link_organization_id_fkey" { + return ErrOrganizationCloudLinked + } return err } @@ -560,12 +568,14 @@ func (r *organizationRepository) GetUserPendingInvitations(ctx context.Context, } // DeleteInvitation deletes an invitation + func (r *organizationRepository) DeleteInvitation(ctx context.Context, id uuid.UUID) error { _, err := r.db.Exec(ctx, `DELETE FROM organization_invitations WHERE id = $1`, id) return err } // DeleteExpiredInvitations deletes all expired invitations + func (r *organizationRepository) DeleteExpiredInvitations(ctx context.Context) error { _, err := r.db.Exec(ctx, `DELETE FROM organization_invitations WHERE expires_at < NOW()`) return err diff --git a/internal/repository/pg_placement.go b/internal/repository/pg_placement.go index cbb5a4f27..dbc14a60b 100644 --- a/internal/repository/pg_placement.go +++ b/internal/repository/pg_placement.go @@ -87,6 +87,7 @@ type PlacementBundle struct { // PlacementRemoteReport is a delivered cloud probe the instance still has to // tell the cloud about. type PlacementRemoteReport struct { + InstanceID uuid.UUID ResultID uuid.UUID RemoteTestID uuid.UUID RemoteSeedID uuid.UUID @@ -978,7 +979,7 @@ func (r *placementRepository) ListRemoteReports(ctx context.Context, limit int) limit = 200 } rows, err := r.db.Query(ctx, ` - SELECT pr.id, pt.remote_test_id, pr.remote_seed_id, pr.message_id, pr.sent_at, pr.folder, pr.error + SELECT pr.id, pt.remote_test_id, pt.remote_instance_id, pr.remote_seed_id, pr.message_id, pr.sent_at, pr.folder, pr.error FROM placement_results pr JOIN placement_tests pt ON pt.id = pr.test_id WHERE pt.remote_test_id IS NOT NULL @@ -995,7 +996,7 @@ func (r *placementRepository) ListRemoteReports(ctx context.Context, limit int) var out []PlacementRemoteReport for rows.Next() { var rep PlacementRemoteReport - if err := rows.Scan(&rep.ResultID, &rep.RemoteTestID, &rep.RemoteSeedID, &rep.MessageID, &rep.SentAt, &rep.Folder, &rep.Error); err != nil { + if err := rows.Scan(&rep.ResultID, &rep.RemoteTestID, &rep.InstanceID, &rep.RemoteSeedID, &rep.MessageID, &rep.SentAt, &rep.Folder, &rep.Error); err != nil { return nil, err } out = append(out, rep) diff --git a/internal/repository/pg_poollink.go b/internal/repository/pg_poollink.go index e3804889d..149efdcd4 100644 --- a/internal/repository/pg_poollink.go +++ b/internal/repository/pg_poollink.go @@ -54,11 +54,11 @@ func NewPoolLinkRepository(db *pgxpool.Pool) PoolLinkRepository { return &poolLinkRepository{db: db} } -const poolLinkCodeColumns = `id, user_code, instance_name, instance_url, instance_version, status, organization_id, instance_id, expires_at, created_at` +const poolLinkCodeColumns = `id, user_code, instance_name, instance_url, instance_version, status, organization_id, instance_id, expires_at, created_at, remote_organization_id` func scanPoolLinkCode(row pgx.Row) (*models.PoolLinkCode, error) { var c models.PoolLinkCode - if err := row.Scan(&c.ID, &c.UserCode, &c.InstanceName, &c.InstanceURL, &c.InstanceVersion, &c.Status, &c.OrganizationID, &c.InstanceID, &c.ExpiresAt, &c.CreatedAt); err != nil { + if err := row.Scan(&c.ID, &c.UserCode, &c.InstanceName, &c.InstanceURL, &c.InstanceVersion, &c.Status, &c.OrganizationID, &c.InstanceID, &c.ExpiresAt, &c.CreatedAt, &c.RemoteOrganizationID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -69,10 +69,10 @@ func scanPoolLinkCode(row pgx.Row) (*models.PoolLinkCode, error) { func (r *poolLinkRepository) CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.PoolLinkStartRequest, expiresAt time.Time) (*models.PoolLinkCode, error) { query := ` - INSERT INTO pool_link_codes (device_code_hash, user_code, instance_name, instance_url, instance_version, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO pool_link_codes (device_code_hash, user_code, instance_name, instance_url, instance_version, expires_at, remote_organization_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING ` + poolLinkCodeColumns - c, err := scanPoolLinkCode(r.db.QueryRow(ctx, query, deviceCodeHash, userCode, req.InstanceName, req.InstanceURL, req.InstanceVersion, expiresAt)) + c, err := scanPoolLinkCode(r.db.QueryRow(ctx, query, deviceCodeHash, userCode, req.InstanceName, req.InstanceURL, req.InstanceVersion, expiresAt, req.RemoteOrganizationID)) if err != nil { db.CaptureError(err, query, nil, "queryrow") return nil, err @@ -127,9 +127,9 @@ func (r *poolLinkRepository) ClaimCode(ctx context.Context, deviceCodeHash strin SET status = 'claimed', instance_token = NULL FROM picked WHERE p.id = picked.id - RETURNING p.id, p.user_code, p.instance_name, p.instance_url, p.instance_version, p.status, p.organization_id, p.instance_id, p.expires_at, p.created_at, picked.instance_token AS token + RETURNING p.id, p.user_code, p.instance_name, p.instance_url, p.instance_version, p.status, p.organization_id, p.instance_id, p.expires_at, p.created_at, p.remote_organization_id, picked.instance_token AS token ) - SELECT id, user_code, instance_name, instance_url, instance_version, status, organization_id, instance_id, expires_at, created_at, COALESCE(token, '') FROM claimed + SELECT id, user_code, instance_name, instance_url, instance_version, status, organization_id, instance_id, expires_at, created_at, remote_organization_id, COALESCE(token, '') FROM claimed UNION ALL SELECT ` + poolLinkCodeColumns + `, '' FROM pool_link_codes WHERE device_code_hash = $1 AND expires_at > NOW() AND NOT EXISTS (SELECT 1 FROM claimed) @@ -137,7 +137,7 @@ func (r *poolLinkRepository) ClaimCode(ctx context.Context, deviceCodeHash strin ` var c models.PoolLinkCode var token string - err := r.db.QueryRow(ctx, query, deviceCodeHash).Scan(&c.ID, &c.UserCode, &c.InstanceName, &c.InstanceURL, &c.InstanceVersion, &c.Status, &c.OrganizationID, &c.InstanceID, &c.ExpiresAt, &c.CreatedAt, &token) + err := r.db.QueryRow(ctx, query, deviceCodeHash).Scan(&c.ID, &c.UserCode, &c.InstanceName, &c.InstanceURL, &c.InstanceVersion, &c.Status, &c.OrganizationID, &c.InstanceID, &c.ExpiresAt, &c.CreatedAt, &c.RemoteOrganizationID, &token) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, "", nil @@ -169,11 +169,11 @@ func (r *poolLinkRepository) DeleteExpiredCodes(ctx context.Context) error { return err } -const poolLinkInstanceColumns = `id, organization_id, name, url, version, created_by, created_at, last_seen_at, revoked_at` +const poolLinkInstanceColumns = `id, organization_id, name, url, version, created_by, created_at, last_seen_at, revoked_at, remote_organization_id` func scanPoolLinkInstance(row pgx.Row) (*models.PoolLinkInstance, error) { var i models.PoolLinkInstance - if err := row.Scan(&i.ID, &i.OrganizationID, &i.Name, &i.URL, &i.Version, &i.CreatedBy, &i.CreatedAt, &i.LastSeenAt, &i.RevokedAt); err != nil { + if err := row.Scan(&i.ID, &i.OrganizationID, &i.Name, &i.URL, &i.Version, &i.CreatedBy, &i.CreatedAt, &i.LastSeenAt, &i.RevokedAt, &i.RemoteOrganizationID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -182,17 +182,31 @@ func scanPoolLinkInstance(row pgx.Row) (*models.PoolLinkInstance, error) { return &i, nil } +var ErrCloudWorkspaceLinked = errors.New("cloud workspace already has an active link") + func (r *poolLinkRepository) CreateInstance(ctx context.Context, inst *models.PoolLinkInstance, tokenHash string) error { - query := ` - INSERT INTO pool_link_instances (id, organization_id, name, url, version, token_hash, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING created_at - ` - if err := r.db.QueryRow(ctx, query, inst.ID, inst.OrganizationID, inst.Name, inst.URL, inst.Version, tokenHash, inst.CreatedBy).Scan(&inst.CreatedAt); err != nil { - db.CaptureError(err, query, nil, "queryrow") + tx, err := r.db.Begin(ctx) + if err != nil { return err } - return nil + defer func() { _ = tx.Rollback(ctx) }() + // Serialize approvals per Cloud workspace, including checks against legacy links. + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, inst.OrganizationID.String()); err != nil { + return err + } + var linked bool + if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pool_link_instances WHERE organization_id = $1 AND revoked_at IS NULL)`, inst.OrganizationID).Scan(&linked); err != nil { + return err + } + if linked { + return ErrCloudWorkspaceLinked + } + query := `INSERT INTO pool_link_instances (id, organization_id, name, url, version, token_hash, created_by, remote_organization_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING created_at` + if err := tx.QueryRow(ctx, query, inst.ID, inst.OrganizationID, inst.Name, inst.URL, inst.Version, tokenHash, inst.CreatedBy, inst.RemoteOrganizationID).Scan(&inst.CreatedAt); err != nil { + return err + } + return tx.Commit(ctx) } func (r *poolLinkRepository) GetInstanceByTokenHash(ctx context.Context, tokenHash string) (*models.PoolLinkInstance, error) { @@ -219,7 +233,7 @@ func (r *poolLinkRepository) GetInstance(ctx context.Context, id uuid.UUID) (*mo func (r *poolLinkRepository) ListInstances(ctx context.Context, orgID uuid.UUID) ([]models.PoolLinkInstance, error) { query := ` - SELECT i.id, i.organization_id, i.name, i.url, i.version, i.created_by, i.created_at, i.last_seen_at, i.revoked_at, + SELECT i.id, i.organization_id, i.name, i.url, i.version, i.created_by, i.created_at, i.last_seen_at, i.revoked_at, i.remote_organization_id, (SELECT COUNT(*) FROM pool_link_mailboxes m WHERE m.instance_id = i.id) FROM pool_link_instances i WHERE i.organization_id = $1 AND i.revoked_at IS NULL @@ -234,7 +248,7 @@ func (r *poolLinkRepository) ListInstances(ctx context.Context, orgID uuid.UUID) out := []models.PoolLinkInstance{} for rows.Next() { var i models.PoolLinkInstance - if err := rows.Scan(&i.ID, &i.OrganizationID, &i.Name, &i.URL, &i.Version, &i.CreatedBy, &i.CreatedAt, &i.LastSeenAt, &i.RevokedAt, &i.MailboxCount); err != nil { + if err := rows.Scan(&i.ID, &i.OrganizationID, &i.Name, &i.URL, &i.Version, &i.CreatedBy, &i.CreatedAt, &i.LastSeenAt, &i.RevokedAt, &i.RemoteOrganizationID, &i.MailboxCount); err != nil { return nil, err } out = append(out, i) diff --git a/internal/repository/workspace_cloud_links_live_test.go b/internal/repository/workspace_cloud_links_live_test.go new file mode 100644 index 000000000..3a0f23b99 --- /dev/null +++ b/internal/repository/workspace_cloud_links_live_test.go @@ -0,0 +1,127 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/encrypt" +) + +func TestLiveCloudWorkspaceApprovalsAreSerialized(t *testing.T) { + f := newPoolLinkFixture(t) + ctx := context.Background() + repo := NewPoolLinkRepository(f.pool) + const count = 12 + var wg sync.WaitGroup + errs := make(chan error, count) + ids := make(chan uuid.UUID, count) + for i := range count { + wg.Add(1) + go func(i int) { + defer wg.Done() + org := uuid.New() + inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: f.org, RemoteOrganizationID: &org, CreatedBy: &f.user, Name: "Workspace"} + err := repo.CreateInstance(ctx, inst, fmt.Sprintf("fixture-%s-%d", f.org, i)) + if err == nil { + ids <- inst.ID + } + errs <- err + }(i) + } + wg.Wait() + close(errs) + close(ids) + wins, refused := 0, 0 + for err := range errs { + switch { + case err == nil: + wins++ + case errors.Is(err, ErrCloudWorkspaceLinked): + refused++ + default: + t.Fatalf("approval failed unexpectedly: %v", err) + } + } + if wins != 1 || refused != count-1 { + t.Fatalf("approved %d, rejected %d", wins, refused) + } + for id := range ids { + if err := repo.RevokeInstance(ctx, id); err != nil { + t.Fatal(err) + } + } + legacy := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: f.org, CreatedBy: &f.user, Name: "Legacy"} + if err := repo.CreateInstance(ctx, legacy, "legacy-fixture-"+f.org.String()); err != nil { + t.Fatal(err) + } + org := uuid.New() + scoped := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: f.org, RemoteOrganizationID: &org, CreatedBy: &f.user, Name: "Scoped"} + if err := repo.CreateInstance(ctx, scoped, "scoped-fixture-"+f.org.String()); !errors.Is(err, ErrCloudWorkspaceLinked) { + t.Fatalf("legacy link didn't reserve its workspace: %v", err) + } +} + +func TestLiveWorkspaceLinksPreserveLegacyEnrollmentOwnership(t *testing.T) { + f := newPoolLinkFixture(t) + ctx := context.Background() + enc, err := encrypt.NewEncrypter(make([]byte, 32)) + if err != nil { + t.Fatal(err) + } + repo := NewCloudLinkRepository(f.pool, enc) + legacy := &models.CloudLink{InstanceID: uuid.New(), CloudURL: "https://cloud.test", Token: "legacy-fixture"} + scoped := &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &f.org, CloudURL: "https://cloud.test", Token: "scoped-fixture"} + t.Cleanup(func() { + for _, l := range []*models.CloudLink{legacy, scoped} { + _ = repo.Delete(ctx, l.InstanceID) + } + }) + for _, l := range []*models.CloudLink{legacy, scoped} { + if err := repo.Put(ctx, l); err != nil { + t.Fatal(err) + } + } + if _, err := repo.Enroll(ctx, f.sender, uuid.New(), legacy.InstanceID, true); err != nil { + t.Fatal(err) + } + if _, err := repo.Enroll(ctx, f.recipient, uuid.New(), scoped.InstanceID, false); err != nil { + t.Fatal(err) + } + got, err := repo.Get(ctx, &f.org) + if err != nil || got == nil || got.InstanceID != scoped.InstanceID || got.Token != "scoped-fixture" { + t.Fatalf("workspace resolution = %v, %v", got, err) + } + other := uuid.New() + got, err = repo.Get(ctx, &other) + if err != nil || got == nil || got.InstanceID != legacy.InstanceID { + t.Fatalf("legacy fallback = %v, %v", got, err) + } + if got, err := repo.GetForRedirect(ctx, other, "unused.test"); err != nil || got != nil { + t.Fatalf("new redirect used legacy link = %v, %v", got, err) + } + if err := NewOrganizationRepository(f.pool).Delete(ctx, f.org); !errors.Is(err, ErrOrganizationCloudLinked) { + t.Fatalf("linked workspace deletion = %v", err) + } + d := &models.ScheduledDeletion{ResourceType: models.DeletionResourceOrganization, ResourceID: f.org} + if err := NewDangerZoneRepository(f.pool).CreatePending(ctx, d); !errors.Is(err, ErrOrganizationCloudLinked) { + t.Fatalf("linked workspace deletion schedule = %v", err) + } + d.ResourceType, d.ResourceID = models.DeletionResourceUser, f.user + if err := NewDangerZoneRepository(f.pool).CreatePending(ctx, d); !errors.Is(err, ErrOrganizationCloudLinked) { + t.Fatalf("linked owner deletion schedule = %v", err) + } + if err := repo.Delete(ctx, scoped.InstanceID); err != nil { + t.Fatal(err) + } + if m, err := repo.GetByAccount(ctx, f.sender); err != nil || m == nil || m.InstanceID != legacy.InstanceID { + t.Fatalf("legacy enrollment removed = %v, %v", m, err) + } + if m, err := repo.GetByAccount(ctx, f.recipient); err != nil || m != nil { + t.Fatalf("scoped enrollment not removed = %v, %v", m, err) + } +} diff --git a/scripts/check-pages-build.sh b/scripts/check-pages-build.sh index 7576541f2..5f39b777e 100755 --- a/scripts/check-pages-build.sh +++ b/scripts/check-pages-build.sh @@ -59,9 +59,9 @@ for app in web admin; do [ -f "$app/public/_redirects" ] || fail "$app/public/_redirects is missing; deep links would 404 on a static host" if [ "$app" = web ]; then [ -f "$app/public/404.html" ] || fail "web needs a 404.html to disable Pages' implicit SPA fallback for assets" - grep -qE '^/app/\*[[:space:]]+/index\.html[[:space:]]+200' "$app/public/_redirects" \ + grep -qE '^/app/\*[[:space:]]+/[[:space:]]+200' "$app/public/_redirects" \ || fail "web has no dashboard deep-link rewrite" - if grep -qE '^/\*[[:space:]]+/index\.html' "$app/public/_redirects"; then + if grep -qE '^/\*[[:space:]]+/([[:space:]]|index\.html)' "$app/public/_redirects"; then fail "web rewrites missing assets to HTML" fi else diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 59f217fbf..7321d6c80 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -466,7 +466,7 @@ export default function AddressesPage() { canWarmup={canWarmup} onAdd={() => p?.setAddEmail(true)} onConnectCloud={cloud.manageable && !cloud.connected ? () => setCloudDialog(true) : undefined} - cloudConnected={cloud.connected} + cloudConnected={cloud.workspaceConnected} /> )} @@ -538,7 +538,7 @@ export default function AddressesPage() { findings={advisor.get(box.id)} canWarmup={canWarmup} cloud={cloud.connected ? cloud.rowFor(box.id) : undefined} - cloudConnected={cloud.connected} + cloudConnected={cloud.workspaceConnected} retiring={retiringDomain.has(box.id)} onRetiring={() => openMigration(retiringDomain.get(box.id) ?? null)} checked={selected.includes(box.id)} diff --git a/web/src/app/app/settings/warmbly-cloud/ConnectFlow.tsx b/web/src/app/app/settings/warmbly-cloud/ConnectFlow.tsx index 38bd8bbbd..b327e852c 100644 --- a/web/src/app/app/settings/warmbly-cloud/ConnectFlow.tsx +++ b/web/src/app/app/settings/warmbly-cloud/ConnectFlow.tsx @@ -1,4 +1,4 @@ -// The three-step link flow: link this instance to a Warmbly Cloud workspace, +// Link the selected local workspace to its own Warmbly Cloud workspace, // pick the mailboxes the cloud should warm, done. Steps slide directionally // like NewCampaignDialog; a step cannot be skipped ahead of. diff --git a/web/src/app/app/settings/warmbly-cloud/MailboxTable.tsx b/web/src/app/app/settings/warmbly-cloud/MailboxTable.tsx index b062c6620..a026f8002 100644 --- a/web/src/app/app/settings/warmbly-cloud/MailboxTable.tsx +++ b/web/src/app/app/settings/warmbly-cloud/MailboxTable.tsx @@ -19,7 +19,7 @@ import { TableSurface, Toggle } from "../_components/SectionShell"; import { providerLabel, providerSupported } from "./providers"; import { cloudWarmupPaused } from "@/lib/cloudWarmup"; -export default function MailboxTable() { +export default function MailboxTable({ allowEnrollment = true }: { allowEnrollment?: boolean }) { const rows = useCloudLinkMailboxes(); const enroll = useEnrollCloudLinkMailbox(); const unenroll = useUnenrollCloudLinkMailbox(); @@ -92,6 +92,7 @@ export default function MailboxTable() {

{providerLabel(row.provider)} {row.managed && " · signed in through Warmbly Cloud"} + {row.legacy && " · legacy connection"} {!supported && " · signed in with this instance's own OAuth app; add it again through Warmbly Cloud to warm it"} {row.enrolled && !cloud && " · waiting for the cloud"} {cloud?.errors && cloud.errors.length > 0 && ( @@ -147,7 +148,7 @@ export default function MailboxTable() { {busy === row.id ? ( ) : ( - flip(row)} /> + flip(row)} /> )} diff --git a/web/src/app/app/settings/warmbly-cloud/page.tsx b/web/src/app/app/settings/warmbly-cloud/page.tsx index dd728b6a1..eb68dc1f5 100644 --- a/web/src/app/app/settings/warmbly-cloud/page.tsx +++ b/web/src/app/app/settings/warmbly-cloud/page.tsx @@ -11,6 +11,7 @@ import { CheckIcon, CloudIcon, ExternalLinkIcon, Loader2Icon, RefreshCwIcon, Spa import { useInstanceAdmin, usePermission } from "@/hooks/usePermission"; import { useConfirm } from "@/hooks/context/confirm"; import { NoAccess } from "@/components/layout/NoAccess"; +import useCurrentOrganization from "@/lib/api/hooks/app/organizations/useCurrentOrganization"; import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -25,6 +26,7 @@ import LinkedInstances from "./LinkedInstances"; export default function WarmblyCloudSettingsPage() { const canManage = usePermission("MANAGE_SETTINGS"); const authConfig = useAuthConfig(); + const organization = useCurrentOrganization(); const instanceAdmin = useInstanceAdmin(); if (!canManage) return ; if (authConfig.data && !authConfig.data.self_hosted) { @@ -42,14 +44,14 @@ export default function WarmblyCloudSettingsPage() {

{instanceAdmin.holdsAdmin - ? "The link covers the whole instance, so managing it needs a session with two-factor authentication. Turn on 2FA or add a passkey under Settings > Security, then sign in again." - : "The link covers the whole instance, so an administrator of this instance manages it and the mailboxes it warms."} + ? "Managing a workspace's Cloud connection needs a session with two-factor authentication. Turn on 2FA or add a passkey under Settings > Security, then sign in again." + : "An administrator of this instance manages this workspace's Cloud connection."}

); } - return ; + return ; } function SelfHostedCloud() { @@ -73,6 +75,7 @@ function SelfHostedCloud() { } const st = status.data; const plan = st.info?.plan; + const legacy = st.connected && !st.link?.organization_id; return ( + {legacy && ( +
+ +
+ )} + {st.legacy_connected && !legacy && ( +
+ +
+ )} {showFlow ? ( { setFlow(false); void status.refetch(); @@ -124,7 +146,7 @@ function SelfHostedCloud() { /> {plan && ( -
+
)} @@ -132,20 +154,20 @@ function SelfHostedCloud() { eyebrow="Mailboxes" description="Enrolled mailboxes are warmed by Warmbly Cloud; their local warmup stops. Campaigns keep sending from this server." > - +