feat: integrate workspace-scoped Cloud ownership with durable consent restrictive standing and send safety while preserving legacy enrollments and renumbering unreleased upgrade migrations

This commit is contained in:
Matthew Meszaros
2026-10-07 16:32:30 +00:00
committed by Devin AI
91 changed files with 1745 additions and 481 deletions
+1 -1
View File
@@ -606,7 +606,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.
- `GET /admin/warmup-content/overview` includes effective generation controls and requires `view_warmup_pool`; `PUT /admin/warmup-content/settings` writes effective controls with `manage_settings`. These are platform-admin routes, not organization API-key scopes. See [effective generation policy and stopping](/development/warmup-generation/).
- `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.
+10
View File
@@ -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 |
@@ -74,7 +74,7 @@ Hard bounce/complaint/suppression and authentication failures remain negative ev
## Upgrade, rollback and portability
1. Back up and preserve released migrations 1 through 265 byte-for-byte. Apply the contiguous additive 266 through 273 sequence; no new environment variable is required. Migration 273 preserves historical executor starts as attempt evidence and refuses rollback while attempts from the last 24 hours remain.
1. Back up and preserve released migrations 1 through 265 byte-for-byte and main's workspace-link migration 266. Apply the contiguous additive 267 through 274 sequence; no new environment variable is required. Migration 274 preserves historical executor starts as attempt evidence and refuses rollback while attempts from the last 24 hours remain.
2. Pause dispatch during a mixed control-plane rollout. Upgrade every backend/result consumer, including Cloud replicas, before relying on generation stop, exact-parent resolution, current participation or correlated managed consent.
3. Drain/reconcile previously accepted work, then upgrade execution workers. Protocol `warmup_send_protocol=2` is required for shared reservations, execution-time action authority and cryptographic diagnostics. Protocol 1 supports only its previous lineage capability. Old JSON and Avro payloads remain readable; old capability is not stronger cancellation/admission support. Use matching Kafka-tagged binaries where applicable.
4. Recheck holds, actual configured provider, worker liveness, schedules, directional participation, shared/rolling limits and Cloud-side settings before resuming. New mailboxes default off; existing NULL participation stays legacy rather than silently relabeling historical consent. No migration rewrites legacy AI provenance as reviewed. Legacy NULL-provenance sources do not count toward new versioned selection/readiness; existing exact-parent ambiguity stays closed.
@@ -54,7 +54,7 @@ These examples review hypothetical wording, not customer documents, relationship
Apply the additive content-provenance migration before starting the new backend. Deploy every backend instance before relying on `generation_enabled=false`; mixed old backends do not honor the new stop or resolve the new static scenario namespace. Pause warmup dispatch through existing controls during a mixed-version rollout, and resume only after all control-plane replicas understand exact-parent resolution. Deploy the matching admin frontend after the backend. Keep provider credentials while existing jobs drain. No migration replaces saved models, existing job IDs, source IDs or customer credentials, and no live efficacy claim follows from local tests.
For the integrated release, preserve released migrations through 265 and apply additive migrations 266 through 272 in order. Upgrade all result consumers and control-plane replicas, then execution workers. New shared dispatch requires an active worker reporting `warmup_send_protocol=2`; protocol 1 remains only its earlier lineage capability. Duplicate commands replay a durable terminal result rather than executing another native send. An unresolved native outcome holds the mailbox until reconciliation, not a timed blind reset. Known legacy throttle codes produce a conservative mailbox/provider cooldown without fabricated HTTP/SMTP evidence. Authentication, permanent and conflict holds need confirmed repair/operator evidence, not elapsed time or an unrelated success.
For the integrated release, preserve released migrations through 265 and main's workspace-link migration 266, then apply additive migrations 267 through 274 in order. Upgrade all result consumers and control-plane replicas, then execution workers. New shared dispatch requires an active worker reporting `warmup_send_protocol=2`; protocol 1 remains only its earlier lineage capability. Duplicate commands replay a durable terminal result rather than executing another native send. An unresolved native outcome holds the mailbox until reconciliation, not a timed blind reset. Known legacy throttle codes produce a conservative mailbox/provider cooldown without fabricated HTTP/SMTP evidence. Authentication, permanent and conflict holds need confirmed repair/operator evidence, not elapsed time or an unrelated success.
### Resolving a repair hold
+18 -8
View File
@@ -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
@@ -150,13 +160,13 @@ 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 the instance's enrollments and lets eligible local warmup take over after confirmed cleanup. Instance-originated SMTP mailboxes and their credentials are deleted on the cloud. An OAuth mailbox belongs to the cloud workspace: unlinking removes the instance's access, not that workspace's mailbox or provider grant. Its native cloud warmup configuration also remains: pause or delete it separately on Warmbly Cloud if you want that participation or its stored credentials removed. 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** removes only the selected workspace link's enrollments and lets eligible local warmup take over after confirmed cleanup. Other workspace links and retained legacy links are unaffected. Instance-originated SMTP mailboxes and their credentials are deleted on the cloud. An OAuth mailbox belongs to the cloud workspace: unlinking removes that connection's access, not the workspace's mailbox or provider grant. Its native cloud warmup configuration also remains: pause or delete it separately on Warmbly Cloud if you want that participation or its stored credentials removed. Root redirects using the disconnected link stop at the same time. Removing a legacy instance-wide link remains a separate administrator action. On Warmbly Cloud, **Settings > Linked instances** lists the connections linked to a workspace and can unlink any of them from the other side.
SMTP enrollment, mailbox removal and instance disconnect first record a durable local intent. While enrollment or removal is pending, the mailbox is not admitted to local warmup. The regular standing sync retries pending work after an outage or restart, using the same mailbox identity rather than creating another. Remote removal must be acknowledged, or confirmed already absent, before local enrollment is dropped. A failed local cleanup stays pending and is retried.
@@ -198,14 +208,14 @@ The schema downgrade refuses to discard any managed consent history, including c
Redis still holds the short-lived browser session, PKCE verifier, browser binding and consent result, with a ten-minute TTL. The cloud's ordinary encrypted mailbox credential store holds provider credentials under its existing retention rules. The instance only caches brokered access tokens in memory for at most ten minutes; an already-issued worker token can remain valid until its provider expiry.
Consent restrictions/history travel in a [workspace export](/guides/workspace-export-import/). Import resets source instance IDs, remote IDs, session hashes, planned account handles and queued activation handles; live cloud account references on the local consent record are reset too. This prevents an imported pending operation from resuming against the source instance, while retaining its restrictive state. The instance-wide link itself does not travel: reconnect and explicitly authorize any new managed operation after moving. Imported history is not a fresh consent or a positive provider verdict.
Consent restrictions/history travel in a [workspace export](/guides/workspace-export-import/). Import resets source instance IDs, remote IDs, session hashes, planned account handles and queued activation handles; live cloud account references on the local consent record are reset too. This prevents an imported pending operation from resuming against the source instance, while retaining its restrictive state. Workspace links and legacy instance-wide links do not travel: reconnect and explicitly authorize any new managed operation after moving. Imported history is not a fresh consent or a positive provider verdict.
## 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
@@ -93,6 +93,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 |
+30 -5
View File
@@ -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
}
@@ -252,7 +272,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
+1 -4
View File
@@ -1687,10 +1687,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())
@@ -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 {
+7 -7
View File
@@ -56,8 +56,8 @@ func (s *service) FinishOAuth(ctx context.Context, orgID, userID uuid.UUID, sess
return s.finishManagedOAuth(ctx, orgID, userID, session)
}
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
}
@@ -109,7 +109,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
}
@@ -139,7 +139,7 @@ func (s *service) removeManaged(ctx context.Context, orgID string, m *models.Clo
if err := s.repo.BeginRemoval(ctx, m.EmailAccountID); err != nil {
return errx.InternalError()
}
l, err := s.repo.Get(ctx)
l, err := s.repo.GetByInstance(ctx, m.InstanceID)
if err != nil {
return errx.InternalError()
}
@@ -174,7 +174,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
}
@@ -199,7 +199,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
}
@@ -224,7 +224,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
}
+13 -9
View File
@@ -18,8 +18,8 @@ import (
var ErrManagedProtocol = errx.NewWithIdentifier(errx.Conflict, "cloud_link_managed_protocol", "Warmbly Cloud must support durable managed consent before connecting or adopting another mailbox. Existing mailboxes are unchanged.")
func (s *service) managedLink(ctx context.Context) (*models.CloudLink, repository.CloudManagedConsentRepository, *errx.Error) {
l, xerr := s.link(ctx)
func (s *service) managedLink(ctx context.Context, orgID uuid.UUID) (*models.CloudLink, repository.CloudManagedConsentRepository, *errx.Error) {
l, xerr := s.newLink(ctx, orgID)
if xerr != nil {
return nil, nil, xerr
}
@@ -41,7 +41,7 @@ func (s *service) startManagedOAuth(ctx context.Context, orgID, userID uuid.UUID
if provider != models.InboxProviderGoogle && provider != models.InboxProviderOutlook {
return nil, errx.ErrEmailOnboardProvider
}
l, r, xerr := s.managedLink(ctx)
l, r, xerr := s.managedLink(ctx, orgID)
if xerr != nil {
return nil, xerr
}
@@ -80,18 +80,21 @@ func (s *service) finishManagedOAuth(ctx context.Context, orgID, userID uuid.UUI
if err != nil {
return nil, errx.InternalError()
}
if c == nil || c.Kind != "oauth" || (c.State != "pending" && c.State != "unknown" && c.State != "active") || !time.Now().Before(c.ExpiresAt) {
if c == nil || c.InstanceID == nil || c.Kind != "oauth" || (c.State != "pending" && c.State != "unknown" && c.State != "active") || !time.Now().Before(c.ExpiresAt) {
return nil, ErrOAuthSession
}
l, xerr := s.link(ctx)
if xerr != nil {
return nil, xerr
l, err := s.repo.GetByInstance(ctx, *c.InstanceID)
if err != nil {
return nil, errx.InternalError()
}
if l == nil {
return nil, ErrOAuthSession
}
return s.completeManagedConsent(ctx, l, r, c)
}
func (s *service) adoptManagedMailbox(ctx context.Context, orgID, userID, cloudAccountID uuid.UUID) (*models.Email, *errx.Error) {
l, r, xerr := s.managedLink(ctx)
l, r, xerr := s.managedLink(ctx, orgID)
if xerr != nil {
return nil, xerr
}
@@ -127,6 +130,7 @@ func (s *service) adoptManagedMailbox(ctx context.Context, orgID, userID, cloudA
func (s *service) completeManagedConsent(ctx context.Context, l *models.CloudLink, r repository.CloudManagedConsentRepository, c *models.CloudManagedConsent) (*models.Email, *errx.Error) {
if c.InstanceID == nil || *c.InstanceID != l.InstanceID || c.RemoteID == nil || c.PlannedAccountID == nil || c.UserID == nil || l.DisconnectPending ||
(l.OrganizationID != nil && *l.OrganizationID != c.OrganizationID) ||
(c.State != "pending" && c.State != "unknown" && c.State != "active") {
return nil, ErrOAuthSession
}
@@ -185,7 +189,7 @@ func (s *service) completeManagedConsent(ctx context.Context, l *models.CloudLin
if err := r.SetManagedConsentState(ctx, c.OrganizationID, c.ID, c.State, &acc.ID); err != nil {
return nil, errx.InternalError()
}
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 {
return nil, errx.InternalError()
}
if state.Health == nil || !knownHealthState(state.Health.State) {
@@ -105,11 +105,11 @@ func (r *consentFaultRepo) ManagedConsentAuthorized(context.Context, *models.Clo
func (r *consentFaultRepo) CanBrokerManagedToken(context.Context, uuid.UUID) (bool, error) {
return r.allowed && len(r.consents) > 0 && r.consents[0].State == "active", nil
}
func (r *consentFaultRepo) Enroll(_ context.Context, account, remote uuid.UUID, managed bool) (*models.CloudLinkMailbox, error) {
func (r *consentFaultRepo) Enroll(_ context.Context, account, remote, instance uuid.UUID, managed bool) (*models.CloudLinkMailbox, error) {
if r.confirmErr != nil {
return nil, r.confirmErr
}
r.mailbox = &models.CloudLinkMailbox{EmailAccountID: account, RemoteID: remote, Managed: managed, EnrollmentState: "active"}
r.mailbox = &models.CloudLinkMailbox{EmailAccountID: account, RemoteID: remote, InstanceID: instance, Managed: managed, EnrollmentState: "active"}
return r.mailbox, nil
}
func (r *consentFaultRepo) SetStanding(context.Context, uuid.UUID, *models.WarmupHealthInfo, bool) (models.WarmupHealthState, error) {
@@ -153,7 +153,7 @@ type consentFixture struct {
func newConsentFixture(t *testing.T) *consentFixture {
t.Helper()
f := &consentFixture{org: uuid.New(), user: uuid.New(), cloud: uuid.New(), protocol: models.ManagedConsentProtocol}
r := &consentFaultRepo{enrollmentFaultRepo: &enrollmentFaultRepo{stubLinkRepo: &stubLinkRepo{link: &models.CloudLink{InstanceID: uuid.New()}}}, allowed: true}
r := &consentFaultRepo{enrollmentFaultRepo: &enrollmentFaultRepo{stubLinkRepo: &stubLinkRepo{link: &models.CloudLink{InstanceID: uuid.New(), OrganizationID: &f.org}}}, allowed: true}
f.r = r
f.emails = &consentAccounts{accounts: map[uuid.UUID]*models.Email{}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, q *http.Request) {
+9 -8
View File
@@ -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
}
+29
View File
@@ -5,6 +5,8 @@ import (
"errors"
"net/http"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
@@ -12,6 +14,33 @@ import (
type reconciliationKey struct{}
func (s *service) revokeAccountConsents(ctx context.Context, orgID, accountID uuid.UUID, m *models.CloudLinkMailbox) *errx.Error {
r, ok := s.repo.(repository.CloudManagedConsentRepository)
if !ok {
return nil
}
if m != nil {
if err := r.RevokeManagedConsents(ctx, m.InstanceID, &accountID); err != nil {
return errx.InternalError()
}
return nil
}
seen := map[uuid.UUID]bool{}
for _, org := range []*uuid.UUID{&orgID, nil} {
l, err := s.repo.Get(ctx, org)
if err != nil {
return errx.InternalError()
}
if l != nil && !seen[l.InstanceID] {
if err := r.RevokeManagedConsents(ctx, l.InstanceID, &accountID); err != nil {
return errx.InternalError()
}
seen[l.InstanceID] = true
}
}
return nil
}
func reconciliationLocked(ctx context.Context) bool { return ctx.Value(reconciliationKey{}) != nil }
func (s *service) reconcileLocked(ctx context.Context, fn func(context.Context) *errx.Error) *errx.Error {
+16 -6
View File
@@ -20,18 +20,18 @@ type enrollmentFaultRepo struct {
confirmErr error
}
func (r *enrollmentFaultRepo) BeginEnrollment(_ context.Context, account, remote uuid.UUID) error {
func (r *enrollmentFaultRepo) BeginEnrollment(_ context.Context, account, remote, instance uuid.UUID) error {
if r.beginErr != nil {
return r.beginErr
}
if r.mailbox != nil && r.mailbox.EnrollmentState == "pending_remove" {
return errors.New("removal pending")
}
r.mailbox = &models.CloudLinkMailbox{EmailAccountID: account, RemoteID: remote, EnrollmentState: "pending_enroll"}
r.mailbox = &models.CloudLinkMailbox{EmailAccountID: account, RemoteID: remote, InstanceID: instance, EnrollmentState: "pending_enroll"}
return nil
}
func (r *enrollmentFaultRepo) Enroll(_ context.Context, _, _ uuid.UUID, _ bool) (*models.CloudLinkMailbox, error) {
func (r *enrollmentFaultRepo) Enroll(_ context.Context, _, _, _ uuid.UUID, _ bool) (*models.CloudLinkMailbox, error) {
if r.confirmErr != nil {
return nil, r.confirmErr
}
@@ -54,8 +54,18 @@ func (r *enrollmentFaultRepo) Unenroll(ctx context.Context, account uuid.UUID) e
return nil
}
func (r *enrollmentFaultRepo) InvalidateStanding(context.Context, uuid.UUID) error { return nil }
func (r *enrollmentFaultRepo) SetSyncResult(context.Context, time.Time, string) error { return nil }
func (r *enrollmentFaultRepo) InvalidateStanding(context.Context, uuid.UUID) error { return nil }
func (r *enrollmentFaultRepo) SetSyncResult(context.Context, uuid.UUID, time.Time, string) error {
return nil
}
func (r *enrollmentFaultRepo) ListLinks(context.Context) ([]models.CloudLink, error) {
return []models.CloudLink{*r.link}, nil
}
func (r *enrollmentFaultRepo) ListForOrg(ctx context.Context, _ uuid.UUID, _ *uuid.UUID) ([]models.CloudLinkMailbox, error) {
return r.List(ctx)
}
type enrollmentEmails struct{ stubEmails }
@@ -91,7 +101,7 @@ func TestEnrollmentRecordsIntentBeforeRemoteAndRetriesAmbiguousConfirmation(t *t
}
}))
t.Cleanup(srv.Close)
r := &enrollmentFaultRepo{stubLinkRepo: &stubLinkRepo{link: &models.CloudLink{CloudURL: srv.URL}}}
r := &enrollmentFaultRepo{stubLinkRepo: &stubLinkRepo{link: &models.CloudLink{CloudURL: srv.URL, OrganizationID: &org}}}
if failure == "local_confirmation" {
r.confirmErr = errors.New("write unavailable")
}
+69 -28
View File
@@ -6,6 +6,9 @@ import (
"net/url"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
@@ -19,28 +22,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 +56,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 +72,49 @@ 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 {
log.Warn().Str("instance_id", l.InstanceID.String()).Str("code", xerr.ResponseCode()).Msg("cloud link: redirect listing failed")
continue
}
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 +126,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
}
@@ -29,12 +29,16 @@ 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
}
func (r *stubLinkRepo) WithReconciliationLock(_ context.Context, fn func() error) error { return fn() }
func (r *stubLinkRepo) SetDisconnectPending(context.Context) error {
func (r *stubLinkRepo) SetDisconnectPending(context.Context, uuid.UUID) error {
r.link.DisconnectPending = true
return nil
}
+155 -95
View File
@@ -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)
@@ -117,19 +119,20 @@ type Service interface {
SetParticipation(ctx context.Context, orgID, accountID uuid.UUID, participation models.DiagnosticParticipation) (*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)
@@ -152,10 +155,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 {
@@ -163,22 +166,45 @@ type service struct {
emails repository.EmailRepository
emailSvc email.EmailService
mu sync.Mutex
pending *PendingConnect
tokens map[uuid.UUID]cachedToken
offer cachedOffer
mu sync.Mutex
pending map[uuid.UUID]*PendingConnect
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, tokens: map[uuid.UUID]cachedToken{}}
return &service{pending: map[uuid.UUID]*PendingConnect{}, repo: repo, emails: emails, emailSvc: emailSvc, 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()
}
@@ -195,9 +221,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()
}
@@ -206,21 +232,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
@@ -238,13 +273,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,
@@ -254,22 +294,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
@@ -289,10 +337,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
@@ -301,7 +350,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 {
@@ -312,8 +361,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()
}
@@ -327,15 +376,29 @@ func linkAlreadyGone(xerr *errx.Error) bool {
return false
}
func (s *service) Disconnect(ctx context.Context) *errx.Error {
func (s *service) Disconnect(ctx context.Context, orgID uuid.UUID, legacy bool) *errx.Error {
if !reconciliationLocked(ctx) {
return s.reconcileLocked(ctx, s.Disconnect)
return s.reconcileLocked(ctx, func(ctx context.Context) *errx.Error { return s.Disconnect(ctx, orgID, legacy) })
}
l, xerr := s.link(ctx)
if xerr != nil {
return xerr
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 := s.repo.SetDisconnectPending(ctx); err != nil {
if err != nil {
return errx.InternalError()
}
if l == nil {
return ErrNotConnected
}
if !legacy && l.OrganizationID == nil {
return ErrLegacyLink
}
if err := s.repo.SetDisconnectPending(ctx, l.InstanceID); err != nil {
return errx.InternalError()
}
if r, ok := s.repo.(repository.CloudManagedConsentRepository); ok {
@@ -362,6 +425,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 {
if err := s.carryStanding(ctx, m); err != nil {
return errx.InternalError()
@@ -381,19 +447,26 @@ func (s *service) Disconnect(ctx context.Context) *errx.Error {
return errx.InternalError()
}
}
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.
for _, m := range released {
s.syncLocalPool(ctx, m.EmailAccountID)
}
s.forgetOffer()
s.mu.Lock()
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
}
@@ -412,7 +485,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()
}
@@ -423,8 +496,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 {
@@ -442,6 +517,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.EnrollmentState = e.EnrollmentState
@@ -517,7 +593,7 @@ func (s *service) Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*mode
})
return row, xerr
}
l, xerr := s.link(ctx)
l, xerr := s.newLink(ctx, orgID)
if xerr != nil {
return nil, xerr
}
@@ -556,14 +632,14 @@ func (s *service) Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*mode
}
var state models.PoolLinkMailboxState
if err := s.repo.BeginEnrollment(ctx, acc.ID, acc.ID); err != nil {
if err := s.repo.BeginEnrollment(ctx, acc.ID, acc.ID, l.InstanceID); err != nil {
return nil, errx.InternalError()
}
s.syncLocalPool(ctx, acc.ID)
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 {
// The durable intent remains unavailable locally until retry confirms enrollment.
return nil, errx.InternalError()
}
@@ -595,28 +671,20 @@ func (s *service) Unenroll(ctx context.Context, orgID, accountID uuid.UUID) *err
if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil {
return xerr
}
if r, ok := s.repo.(repository.CloudManagedConsentRepository); ok {
l, err := s.repo.Get(ctx)
if err != nil {
return errx.InternalError()
}
if l != nil {
if err := r.RevokeManagedConsents(ctx, l.InstanceID, &accountID); err != nil {
return errx.InternalError()
}
}
}
m, err := s.repo.GetByAccount(ctx, accountID)
if err != nil {
return errx.InternalError()
}
if xerr := s.revokeAccountConsents(ctx, orgID, accountID, m); xerr != nil {
return xerr
}
if m == nil {
return nil
}
if m.Managed {
return s.removeManaged(ctx, orgID.String(), m)
}
l, err := s.repo.Get(ctx)
l, err := s.repo.GetByInstance(ctx, m.InstanceID)
if err != nil {
return errx.InternalError()
}
@@ -633,21 +701,13 @@ func (s *service) RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUI
if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil {
return xerr
}
if r, ok := s.repo.(repository.CloudManagedConsentRepository); ok {
l, err := s.repo.Get(ctx)
if err != nil {
return errx.InternalError()
}
if l != nil {
if err := r.RevokeManagedConsents(ctx, l.InstanceID, &accountID); err != nil {
return errx.InternalError()
}
}
}
m, err := s.repo.GetByAccount(ctx, accountID)
if err != nil {
return errx.InternalError()
}
if xerr := s.revokeAccountConsents(ctx, orgID, accountID, m); xerr != nil {
return xerr
}
if m == nil {
return nil
}
@@ -657,7 +717,7 @@ func (s *service) RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUI
if err := s.carryStanding(ctx, *m); err != nil {
return errx.InternalError()
}
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()
@@ -682,10 +742,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
}
@@ -699,6 +755,10 @@ func (s *service) SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID,
if m.EnrollmentState == "pending_remove" {
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
@@ -711,11 +771,7 @@ func (s *service) SetParticipation(ctx context.Context, orgID, accountID uuid.UU
if !participation.Valid() {
return nil, errx.ErrInvalid
}
l, xerr := s.link(ctx)
if xerr != nil {
return nil, xerr
}
if _, xerr = s.ownedAccount(ctx, orgID, accountID); xerr != nil {
if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil {
return nil, xerr
}
m, err := s.repo.GetByAccount(ctx, accountID)
@@ -725,6 +781,10 @@ func (s *service) SetParticipation(ctx context.Context, orgID, accountID uuid.UU
if m == nil || m.EnrollmentState == "pending_remove" {
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()+"/participation", models.PoolLinkMailboxPatch{Participation: &participation}, &state); xerr != nil {
return nil, xerr
+43 -13
View File
@@ -60,6 +60,14 @@ func (s *service) carryStanding(ctx context.Context, m models.CloudLinkMailbox)
return s.repo.CarryStanding(ctx, m.EmailAccountID, h)
}
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) {
if !reconciliationLocked(ctx) {
var changes []models.CloudLinkStandingChange
@@ -70,33 +78,55 @@ func (s *service) SyncStanding(ctx context.Context) ([]models.CloudLinkStandingC
})
return changes, xerr
}
l, err := s.repo.Get(ctx)
links, err := s.repo.ListLinks(ctx)
if err != nil {
return nil, errx.InternalError()
}
if l == nil {
return nil, nil
}
if l.DisconnectPending {
return nil, s.Disconnect(ctx)
}
if xerr := s.reconcileManagedConsents(ctx, l); xerr != nil {
return nil, xerr
}
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
groups := mailboxGroups(enrolled)
for i := range links {
l := &links[i]
part, xerr := s.reconcileLinkStanding(ctx, l, groups[l.InstanceID])
if xerr != nil {
lastError = xerr
continue
}
changes = append(changes, part...)
}
return changes, lastError
}
func (s *service) reconcileLinkStanding(ctx context.Context, l *models.CloudLink, enrolled []models.CloudLinkMailbox) ([]models.CloudLinkStandingChange, *errx.Error) {
if l.DisconnectPending {
orgID := uuid.Nil
if l.OrganizationID != nil {
orgID = *l.OrganizationID
}
return nil, s.Disconnect(ctx, orgID, l.OrganizationID == nil)
}
if xerr := s.reconcileManagedConsents(ctx, l); xerr != nil {
return nil, xerr
}
if xerr := s.reconcileEnrollments(ctx, l, enrolled); xerr != nil {
return nil, xerr
}
enrolled, err = s.repo.List(ctx)
rows, err := s.repo.List(ctx)
if err != nil {
return nil, errx.InternalError()
}
enrolled = mailboxGroups(rows)[l.InstanceID]
if len(enrolled) == 0 {
return nil, nil
}
return s.syncStanding(ctx, l, enrolled)
}
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
+7 -1
View File
@@ -22,7 +22,13 @@ 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) ListLinks(context.Context) ([]models.CloudLink, error) {
return []models.CloudLink{*r.link}, nil
}
func (r *standingRepo) WithReconciliationLock(_ context.Context, fn func() error) error { return fn() }
func (r *standingRepo) InvalidateStanding(context.Context, uuid.UUID) error { return nil }
+43 -39
View File
@@ -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
}
@@ -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 {
@@ -0,0 +1,267 @@
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
}
type workspaceConsentRepo struct {
*consentFaultRepo
workspace *models.CloudLink
}
func (r *workspaceConsentRepo) Get(_ context.Context, org *uuid.UUID) (*models.CloudLink, error) {
if org != nil {
return r.workspace, nil
}
return r.link, nil
}
func TestManagedCompletionUsesConsentedLegacyInstanceAlongsideNewWorkspaceLink(t *testing.T) {
f := newConsentFixture(t)
ctx := context.Background()
start, xerr := f.s.StartOAuth(ctx, f.org, f.user, models.InboxProviderGoogle)
if xerr != nil {
t.Fatal(xerr)
}
f.r.link.OrganizationID = nil
f.s.repo = &workspaceConsentRepo{consentFaultRepo: f.r, workspace: &models.CloudLink{
InstanceID: uuid.New(), OrganizationID: &f.org, CloudURL: "http://127.0.0.1:1",
}}
account, xerr := f.s.FinishOAuth(ctx, f.org, f.user, start.Session)
if xerr != nil || account == nil || f.r.mailbox == nil || f.r.mailbox.InstanceID != f.r.link.InstanceID {
t.Fatalf("completion lost its original instance: %+v, %v", account, xerr)
}
if f.finishes != 1 || f.emails.creates != 1 {
t.Fatal("legacy consent was not completed exactly once")
}
f.r.link = nil
if _, xerr := f.s.FinishOAuth(ctx, f.org, f.user, start.Session); xerr != ErrOAuthSession || f.finishes != 1 {
t.Fatalf("revoked source link reused the new workspace connection: %v", xerr)
}
}
func (r *workspaceLinkRepo) WithReconciliationLock(_ context.Context, fn func() error) error {
return fn()
}
func (r *workspaceLinkRepo) SetDisconnectPending(_ context.Context, id uuid.UUID) error {
r.links[id].DisconnectPending = true
return nil
}
func (r *workspaceLinkRepo) CarryStanding(context.Context, uuid.UUID, *models.WarmupHealthInfo) error {
return nil
}
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) ListLinks(context.Context) ([]models.CloudLink, error) {
out := make([]models.CloudLink, 0, len(r.links))
for _, link := range r.links {
out = append(out, *link)
}
return out, nil
}
func TestRedirectListingPreservesHealthyLinkOwnershipWhenAnotherLinkFails(t *testing.T) {
t.Setenv("APP_ENV", "dev")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "Bearer unavailable" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": []models.DomainRedirect{{Domain: "healthy.example"}}})
}))
defer srv.Close()
healthy := &models.CloudLink{InstanceID: uuid.New(), CloudURL: srv.URL, Token: "healthy"}
unavailable := &models.CloudLink{InstanceID: uuid.New(), CloudURL: srv.URL, Token: "unavailable"}
s := &service{repo: &workspaceLinkRepo{links: map[uuid.UUID]*models.CloudLink{healthy.InstanceID: healthy, unavailable.InstanceID: unavailable}}}
rows, xerr := s.ListRedirects(context.Background())
if xerr != nil || len(rows) != 1 || rows[0].Domain != "healthy.example" || rows[0].CloudLinkInstanceID == nil || *rows[0].CloudLinkInstanceID != healthy.InstanceID {
t.Fatalf("healthy redirects not preserved: %+v, %v", rows, xerr)
}
}
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,
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 || legacy.DisconnectPending || otherLink.DisconnectPending {
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)
}
}
+6
View File
@@ -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")
}
@@ -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")
}
})
}
}
+13 -8
View File
@@ -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")
}
+2 -2
View File
@@ -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.",
},
{
@@ -1020,7 +1020,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.",
+1 -1
View File
@@ -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
}
}
+10 -5
View File
@@ -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
}
+7 -6
View File
@@ -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
+7
View File
@@ -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.
+1 -1
View File
@@ -248,7 +248,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
+39 -1
View File
@@ -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)
+12
View File
@@ -104,6 +104,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 req.Protocol != 0 {
if req.Protocol != models.ManagedConsentProtocol || req.RemoteID == uuid.Nil || len(req.Session) < 32 || len(req.Session) > 128 {
return nil, ErrBadRequest
@@ -401,6 +404,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
@@ -534,6 +540,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()
@@ -543,6 +552,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.Protocol != 0 {
if req.Protocol != models.ManagedConsentProtocol || req.RemoteID == uuid.Nil || req.EmailAccountID == uuid.Nil {
return nil, ErrBadRequest
+26 -7
View File
@@ -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.")
@@ -170,7 +173,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.
@@ -204,6 +210,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),
@@ -268,18 +275,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 {
@@ -435,6 +450,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
+3 -3
View File
@@ -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
}
}
+8 -4
View File
@@ -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
+20 -15
View File
@@ -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}
+57 -16
View File
@@ -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: &currentID}
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")
}
}
@@ -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;
@@ -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;
@@ -111,7 +111,7 @@ INSERT INTO public.warmup_reputation_ledger
cloud_health_score, cloud_source_account_id, cloud_source_instance_id, standing_until)
SELECT DISTINCT ON (ea.organization_id, lower(btrim(ea.email)))
ea.organization_id, lower(btrim(ea.email)), clm.health_state, clm.blocked_until,
clm.health_reason, clm.health_score, ea.id, (SELECT instance_id FROM public.cloud_link WHERE id = true), now()
clm.health_reason, clm.health_score, ea.id, clm.instance_id, now()
FROM public.cloud_link_mailboxes clm JOIN public.email_accounts ea ON ea.id = clm.email_account_id
WHERE ea.organization_id IS NOT NULL AND clm.health_state IN ('quarantined', 'blocked')
AND (clm.blocked_until IS NULL OR clm.blocked_until > now())
@@ -60,6 +60,7 @@ func TestLiveSendRecoveryUpgradesReleased265WithoutInventingHistory(t *testing.T
}
defer conn.Close(context.Background())
user, org, mailbox, sentTask, unknownTask := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
instance := uuid.New()
for _, row := range []struct {
sql string
args []any
@@ -68,6 +69,8 @@ func TestLiveSendRecoveryUpgradesReleased265WithoutInventingHistory(t *testing.T
{`INSERT INTO organizations (id,name,slug,owner_user_id) VALUES ($1,'Legacy',$2,$3)`, []any{org, org.String(), user}},
{`INSERT INTO email_accounts (id,user_id,organization_id,email,name,signature_plain,signature_html,provider,status,warmup_max,campaign_limit,send_lifecycle,send_lifecycle_reason) VALUES ($1,$2,$3,$4,'Legacy','','','smtp_imap','inactive',17,23,'resting','existing health hold')`, []any{mailbox, user, org, mailbox.String() + "@example.test"}},
{`INSERT INTO tasks (id,email_account_id,task_type,status,message_id) VALUES ($1,$3,'email','completed','<known@provider.test>'),($2,$3,'email','completed','')`, []any{sentTask, unknownTask, mailbox}},
{`INSERT INTO cloud_link (cloud_url,instance_id,token,organization_name,connected_by) VALUES ('https://cloud.example.test',$1,'sealed-fixture','Legacy',$2)`, []any{instance, user}},
{`INSERT INTO cloud_link_mailboxes (email_account_id,remote_id,health_state,health_reason) VALUES ($1,$1,'blocked','preexisting Cloud restriction')`, []any{mailbox}},
} {
if _, err := conn.Exec(ctx, row.sql, row.args...); err != nil {
t.Fatal(err)
@@ -117,6 +120,17 @@ func TestLiveSendRecoveryUpgradesReleased265WithoutInventingHistory(t *testing.T
}
assertLegacy := func() {
t.Helper()
var retained 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 email_accounts ea ON ea.id = m.email_account_id
JOIN warmup_reputation_ledger h ON h.organization_id = ea.organization_id AND h.email = lower(btrim(ea.email))
WHERE ea.id = $1 AND l.instance_id = $2 AND l.organization_id IS NULL AND l.token = 'sealed-fixture'
AND m.health_state = 'blocked' AND m.enrollment_state = 'active' AND m.standing_observed_at IS NULL
AND h.cloud_health_state = 'blocked' AND h.cloud_source_instance_id = $2
)`, mailbox, instance).Scan(&retained); err != nil || !retained {
t.Fatalf("upgrade lost legacy Cloud ownership, restriction, or fabricated fresh standing: %v, %v", retained, err)
}
var status, lifecycle, reason string
var warmupMax, campaignLimit int
var hold bool
@@ -149,7 +163,7 @@ func TestLiveSendRecoveryUpgradesReleased265WithoutInventingHistory(t *testing.T
if err := conn.QueryRow(ctx, `SELECT test_mode FROM email_accounts WHERE id=$1`, mailbox).Scan(&legacyMode); err != nil || legacyMode != nil {
t.Fatal("upgrade changed legacy consent", legacyMode, err)
}
admissionDown, err := migrationsFS.ReadFile("migrations/000272_shared_send_admission.down.sql")
admissionDown, err := migrationsFS.ReadFile("migrations/000273_shared_send_admission.down.sql")
if err != nil {
t.Fatal(err)
}
@@ -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)
}
}
+11 -10
View File
@@ -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.
+4 -3
View File
@@ -398,9 +398,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.
+34 -26
View File
@@ -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"`
@@ -268,8 +272,9 @@ func (p DiagnosticParticipation) Valid() bool {
(p.SharedDailyLimit == nil || *p.SharedDailyLimit >= 0) && (p.RollingRecipientLimit == nil || *p.RollingRecipientLimit >= 0)
}
// CloudLink is the self-hosted instance's single link row; Token is never serialized.
// CloudLink belongs to a 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:"-"`
@@ -283,6 +288,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"`
@@ -311,9 +317,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"`
@@ -323,6 +330,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"`
@@ -14,6 +14,7 @@ import (
func TestLiveCloudReconciliationLockPreservesCommittedIntentWithASingleConnectionPool(t *testing.T) {
f := newPoolLinkFixture(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
instance := liveCloudLink(t, f)
defer cancel()
cfg := f.pool.Config()
cfg.MaxConns = 1
@@ -29,7 +30,7 @@ func TestLiveCloudReconciliationLockPreservesCommittedIntentWithASingleConnectio
go func() {
firstDone <- r.WithReconciliationLock(ctx, func() error {
defer close(entered)
if err := r.BeginEnrollment(ctx, f.sender, f.sender); err != nil {
if err := r.BeginEnrollment(ctx, f.sender, f.sender, instance); err != nil {
return err
}
entered <- struct{}{}
@@ -75,7 +76,7 @@ func TestLiveCloudStandingFreshnessIsBoundedWithoutInventingAProviderBlock(t *te
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.Fatal(err)
}
assertState := func(want models.WarmupHealthState, reason string) {
@@ -120,13 +121,15 @@ func TestLiveCloudPositiveObservationCannotClearAHoldFromAnotherLink(t *testing.
f := newPoolLinkFixture(t)
ctx := context.Background()
oldLink, newLink := uuid.New(), uuid.New()
if _, err := f.pool.Exec(ctx, `INSERT INTO cloud_link (id, cloud_url, instance_id, token, organization_name, connected_by)
VALUES (true, 'https://cloud.example.invalid', $1, 'fixture-sealed', 'Fixture', $2)`, oldLink, f.user); err != nil {
if _, err := f.pool.Exec(ctx, `INSERT INTO cloud_link (cloud_url, instance_id, token, organization_name, connected_by)
VALUES ('https://cloud.example.invalid', $1, 'fixture-sealed', 'Fixture', $2)`, oldLink, f.user); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = f.pool.Exec(ctx, `DELETE FROM cloud_link WHERE id = true`) })
t.Cleanup(func() {
_, _ = f.pool.Exec(ctx, `DELETE FROM cloud_link WHERE instance_id IN ($1, $2)`, oldLink, newLink)
})
r := NewCloudLinkRepository(f.pool, nil)
if _, err := r.Enroll(ctx, f.sender, f.sender, false); err != nil {
if _, err := r.Enroll(ctx, f.sender, f.sender, oldLink, false); err != nil {
t.Fatal(err)
}
assertState := func(want models.WarmupHealthState) {
@@ -145,7 +148,17 @@ func TestLiveCloudPositiveObservationCannotClearAHoldFromAnotherLink(t *testing.
if _, err := r.SetStanding(ctx, f.sender, &models.WarmupHealthInfo{State: "blocked"}, false); err != nil {
t.Fatal(err)
}
if _, err := f.pool.Exec(ctx, `UPDATE cloud_link SET instance_id = $1 WHERE id = true`, newLink); err != nil {
if err := r.Unenroll(ctx, f.sender); err != nil {
t.Fatal(err)
}
if err := r.Delete(ctx, oldLink); err != nil {
t.Fatal(err)
}
if _, err := f.pool.Exec(ctx, `INSERT INTO cloud_link (cloud_url, instance_id, token, organization_id)
VALUES ('https://cloud.example.invalid', $1, 'fixture-sealed', $2)`, newLink, f.org); err != nil {
t.Fatal(err)
}
if _, err := r.Enroll(ctx, f.sender, f.sender, newLink, false); err != nil {
t.Fatal(err)
}
if _, err := r.SetStanding(ctx, f.sender, &models.WarmupHealthInfo{State: "healthy"}, false); err != nil {
@@ -165,7 +178,7 @@ func TestLiveCloudHoldSurvivesUnlinkLocalHealthReviewAndMembershipChange(t *test
if err := f.warmup.MoveToPool(ctx, models.WarmupPoolFreeID, f.sender, "sender_receiver"); 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: "blocked", Reason: "cloud_review"}, false); err != nil {
@@ -202,16 +215,17 @@ func TestLiveCloudEnrollmentIntentDoesNotReverseAnExplicitOptOut(t *testing.T) {
f := newPoolLinkFixture(t)
ctx := context.Background()
links := NewCloudLinkRepository(f.pool, nil)
if err := links.BeginEnrollment(ctx, f.sender, f.sender); err != nil {
instance := liveCloudLink(t, f)
if err := links.BeginEnrollment(ctx, f.sender, f.sender, instance); err != nil {
t.Fatal(err)
}
if err := links.BeginRemoval(ctx, f.sender); err != nil {
t.Fatal(err)
}
if err := links.BeginEnrollment(ctx, f.sender, f.sender); err == nil {
if err := links.BeginEnrollment(ctx, f.sender, f.sender, instance); err == nil {
t.Fatal("re-enrollment reversed pending opt-out")
}
if _, err := links.Enroll(ctx, f.sender, f.sender, false); err == nil {
if _, err := links.Enroll(ctx, f.sender, f.sender, instance, false); err == nil {
t.Fatal("late acknowledgment reversed pending opt-out")
}
m, err := links.GetByAccount(ctx, f.sender)
@@ -224,7 +238,7 @@ func TestLiveCloudEnrollmentIntentDoesNotReverseAnExplicitOptOut(t *testing.T) {
if enrolled, err := links.IsEnrolled(ctx, f.sender); err != nil || enrolled {
t.Fatalf("completed opt-out stayed enrolled: %v, %v", enrolled, err)
}
if err := links.BeginEnrollment(ctx, f.sender, f.sender); err != nil {
if err := links.BeginEnrollment(ctx, f.sender, f.sender, instance); err != nil {
t.Fatal(err)
}
}
@@ -242,7 +256,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)
@@ -392,7 +406,7 @@ func TestLiveCloudLinkStandingEdgeCases(t *testing.T) {
if err := f.warmup.MoveToPool(ctx, models.WarmupPoolFreeID, f.sender, "sender_receiver"); 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.Fatalf("Enroll: %v", err)
}
@@ -451,7 +465,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 {
@@ -472,3 +486,69 @@ func TestLiveCloudLinkStandingExpiredHoldDoesNotMaskALiveOne(t *testing.T) {
t.Fatalf("an ended throttle masked the live watch: %s", state)
}
}
func TestLiveCloudWorkspaceDisconnectDoesNotHoldOrBrokerAnotherWorkspace(t *testing.T) {
f, other := newPoolLinkFixture(t), newPoolLinkFixture(t)
ctx := context.Background()
a, b := liveCloudLink(t, f), liveCloudLink(t, other)
links := NewCloudLinkRepository(f.pool, nil)
for _, row := range []struct{ account, instance uuid.UUID }{{f.sender, a}, {other.sender, b}} {
if _, err := links.Enroll(ctx, row.account, row.account, row.instance, true); err != nil {
t.Fatal(err)
}
if _, err := links.SetStanding(ctx, row.account, &models.WarmupHealthInfo{State: "healthy"}, false); err != nil {
t.Fatal(err)
}
}
if err := links.SetDisconnectPending(ctx, a); err != nil {
t.Fatal(err)
}
for _, row := range []struct {
account uuid.UUID
state models.WarmupHealthState
allowed bool
}{{f.sender, models.WarmupHealthBlocked, false}, {other.sender, models.WarmupHealthHealthy, true}} {
state, _, err := f.warmup.GetHealthState(ctx, row.account)
if err != nil || state != row.state {
t.Fatalf("workspace standing = %s, %v; want %s", state, err, row.state)
}
allowed, err := links.(CloudManagedConsentRepository).CanBrokerManagedToken(ctx, row.account)
if err != nil || allowed != row.allowed {
t.Fatalf("workspace token authority = %v, %v; want %v", allowed, err, row.allowed)
}
}
if err := links.BeginEnrollment(ctx, other.sender, other.sender, a); err == nil {
t.Fatal("enrollment moved a mailbox to another workspace's instance")
}
if _, err := links.Enroll(ctx, other.sender, other.sender, a, true); err == nil {
t.Fatal("confirmation moved a mailbox to another workspace's instance")
}
if err := links.BeginEnrollment(ctx, f.recipient, f.recipient, b); err == nil {
t.Fatal("new enrollment used another workspace's instance")
}
if _, err := links.Enroll(ctx, f.recipient, f.recipient, b, true); err == nil {
t.Fatal("new confirmation used another workspace's instance")
}
if m, err := links.GetByAccount(ctx, f.recipient); err != nil || m != nil {
t.Fatalf("foreign workspace enrollment persisted: %+v, %v", m, err)
}
c := &models.CloudManagedConsent{OrganizationID: f.org, UserID: &f.user, InstanceID: &b}
allowed, err := links.(CloudManagedConsentRepository).ManagedConsentAuthorized(ctx, c)
if err != nil || allowed {
t.Fatalf("foreign workspace instance authorized = %v, %v", allowed, err)
}
}
func liveCloudLink(t *testing.T, f *poolLinkFixture) uuid.UUID {
t.Helper()
var existing uuid.UUID
if err := f.pool.QueryRow(context.Background(), `SELECT instance_id FROM cloud_link WHERE organization_id = $1`, f.org).Scan(&existing); err == nil {
return existing
}
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
}
+10 -6
View File
@@ -26,16 +26,20 @@ func (r *cloudLinkRepository) WithReconciliationLock(ctx context.Context, fn fun
return fn()
}
func (r *cloudLinkRepository) SetDisconnectPending(ctx context.Context) error {
_, err := r.db.Exec(ctx, `UPDATE cloud_link SET disconnect_pending = true WHERE id = true`)
func (r *cloudLinkRepository) SetDisconnectPending(ctx context.Context, instanceID uuid.UUID) error {
_, err := r.db.Exec(ctx, `UPDATE cloud_link SET disconnect_pending = true WHERE instance_id = $1`, instanceID)
return err
}
func (r *cloudLinkRepository) BeginEnrollment(ctx context.Context, accountID, remoteID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `INSERT INTO cloud_link_mailboxes (email_account_id, remote_id, enrollment_state)
VALUES ($1, $2, 'pending_enroll') ON CONFLICT (email_account_id) DO UPDATE
func (r *cloudLinkRepository) BeginEnrollment(ctx context.Context, accountID, remoteID, instanceID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `INSERT INTO cloud_link_mailboxes (email_account_id, remote_id, instance_id, enrollment_state)
SELECT ea.id, $2, l.instance_id, 'pending_enroll' FROM email_accounts ea JOIN cloud_link l
ON (l.organization_id = ea.organization_id OR l.organization_id IS NULL)
WHERE ea.id = $1 AND l.instance_id = $3 AND NOT l.disconnect_pending
ON CONFLICT (email_account_id) DO UPDATE
SET enrollment_state = 'pending_enroll'
WHERE cloud_link_mailboxes.enrollment_state <> 'pending_remove' AND NOT cloud_link_mailboxes.managed`, accountID, remoteID)
WHERE cloud_link_mailboxes.enrollment_state <> 'pending_remove' AND NOT cloud_link_mailboxes.managed
AND cloud_link_mailboxes.instance_id = EXCLUDED.instance_id`, accountID, remoteID, instanceID)
if err == nil && tag.RowsAffected() != 1 {
return errors.New("cloud enrollment removal pending")
}
@@ -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)
}
+4 -3
View File
@@ -46,7 +46,7 @@ func scanManagedConsent(row pgx.Row) (*models.CloudManagedConsent, error) {
func (r *cloudLinkRepository) ManagedConsentAuthorized(ctx context.Context, c *models.CloudManagedConsent) (bool, error) {
var allowed bool
err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM organizations o JOIN cloud_link l ON l.id = true
err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM organizations o JOIN cloud_link l ON (l.organization_id = o.id OR l.organization_id IS NULL)
WHERE o.id = $1 AND o.risk_state IN ('trusted', 'watch') AND l.instance_id = $2 AND NOT l.disconnect_pending
AND (o.owner_user_id = $3 OR EXISTS (SELECT 1 FROM organization_members m
WHERE m.organization_id = o.id AND m.user_id = $3 AND m.accepted_at IS NOT NULL AND (m.permissions::integer & $4) <> 0)))`,
@@ -102,7 +102,8 @@ func (r *cloudLinkRepository) CanBrokerManagedToken(ctx context.Context, account
var allowed bool
err := r.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM email_accounts ea
JOIN organizations o ON o.id = ea.organization_id JOIN cloud_link_mailboxes clm ON clm.email_account_id = ea.id
JOIN cloud_link l ON l.id = true LEFT JOIN LATERAL (`+warmupStandingSQL("ea.id")+`) standing ON true
JOIN cloud_link l ON l.instance_id = clm.instance_id AND (l.organization_id = o.id OR l.organization_id IS NULL)
LEFT JOIN LATERAL (`+warmupStandingSQL("ea.id")+`) standing ON true
WHERE ea.id = $1 AND ea.status = 'active' AND o.risk_state IN ('trusted','watch')
AND clm.managed AND clm.enrollment_state = 'active' AND NOT l.disconnect_pending
AND clm.standing_observed_at > NOW() - INTERVAL '15 minutes'
@@ -119,7 +120,7 @@ func (r *cloudLinkRepository) SetManagedConsentState(ctx context.Context, orgID,
AND (consent_state IN ('pending','unknown') OR consent_state = $3 OR $3 IN ('pending_remove','revoked','expired'))
AND ($3 <> 'active' OR (cloud_account_id IS NOT NULL AND planned_account_id=$4 AND EXISTS (SELECT 1 FROM email_accounts a
WHERE a.id=$4 AND a.organization_id=cloud_managed_consents.organization_id AND a.provider=cloud_managed_consents.provider AND a.status='active' AND a.auth_method='oauth')))
AND ($3 <> 'active' OR EXISTS (SELECT 1 FROM organizations o JOIN cloud_link l ON l.id = true
AND ($3 <> 'active' OR EXISTS (SELECT 1 FROM organizations o JOIN cloud_link l ON (l.organization_id = o.id OR l.organization_id IS NULL)
WHERE o.id = cloud_managed_consents.organization_id AND o.risk_state IN ('trusted','watch')
AND l.instance_id = cloud_managed_consents.instance_id AND NOT l.disconnect_pending
AND (o.owner_user_id = cloud_managed_consents.user_id OR EXISTS (SELECT 1 FROM organization_members m
@@ -119,7 +119,7 @@ func TestLiveManagedLocalConsentRetainsActorAuthorityAndPortableRestriction(t *t
if _, err := f.pool.Exec(ctx, `INSERT INTO cloud_link (cloud_url,instance_id,token) VALUES ('https://cloud.test',$1,'fixture')`, instance); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = f.pool.Exec(ctx, `DELETE FROM cloud_link WHERE id = true`) })
t.Cleanup(func() { _, _ = f.pool.Exec(ctx, `DELETE FROM cloud_link WHERE instance_id = $1`, instance) })
r := NewCloudLinkRepository(f.pool, nil).(CloudManagedConsentRepository)
c := &models.CloudManagedConsent{ID: uuid.New(), OrganizationID: f.org, UserID: &f.user, InstanceID: &instance, RemoteID: &remote, PlannedAccountID: &planned, SessionHash: &hash, Kind: "oauth", Provider: models.InboxProviderGoogle, ExpiresAt: time.Now().Add(time.Minute)}
if err := r.CreateManagedConsent(ctx, c); err != nil {
@@ -176,7 +176,7 @@ func TestLiveManagedTokenUsesCurrentConsentAndObservedStanding(t *testing.T) {
if _, err := f.pool.Exec(ctx, `INSERT INTO cloud_link (cloud_url,instance_id,token) VALUES ('https://cloud.test',$1,'fixture')`, instance); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = f.pool.Exec(ctx, `DELETE FROM cloud_link`) })
t.Cleanup(func() { _, _ = f.pool.Exec(ctx, `DELETE FROM cloud_link WHERE instance_id = $1`, instance) })
if _, err := f.pool.Exec(ctx, `UPDATE email_accounts SET provider='gmail',auth_method='oauth' WHERE id=$1`, f.sender); err != nil {
t.Fatal(err)
}
@@ -192,7 +192,7 @@ func TestLiveManagedTokenUsesCurrentConsentAndObservedStanding(t *testing.T) {
if err := r.SetManagedConsentState(ctx, f.org, c.ID, "active", &f.sender); err != nil {
t.Fatal(err)
}
if _, err := base.Enroll(ctx, f.sender, remote, true); err != nil {
if _, err := base.Enroll(ctx, f.sender, remote, instance, true); err != nil {
t.Fatal(err)
}
assertAllowed := func(want bool) {
@@ -49,7 +49,7 @@ func TestLiveReleased265AttemptUpgradePreservesLegacyConsentAndFailedAttempts(t
exec(`INSERT INTO users(id,email,first_name,last_name)VALUES($1,'upgrade@example.test','Upgrade','Fixture')`, user)
exec(`INSERT INTO organizations(id,name,slug,owner_user_id)VALUES($1,'Upgrade','upgrade-fixture',$2)`, org, user)
exec(`INSERT INTO email_accounts(id,user_id,organization_id,email,name,signature_plain,signature_html,provider,status,campaign_limit,min_wait_time,timezone,warmup)VALUES($1,$2,$3,'legacy@example.test','Legacy','','','smtp_imap','active',50,600,'UTC',NOW()-INTERVAL '40 days')`, mailbox, user, org)
if err = m.Migrate(272); err != nil {
if err = m.Migrate(273); err != nil {
t.Fatal(err)
}
var legacy bool
@@ -77,7 +77,7 @@ func TestLiveReleased265AttemptUpgradePreservesLegacyConsentAndFailedAttempts(t
t.Fatal("repeat startup", err)
}
assertLedger()
down, err := os.ReadFile("../infrastructure/db/migrations/000273_outbound_attempt_evidence.down.sql")
down, err := os.ReadFile("../infrastructure/db/migrations/000274_outbound_attempt_evidence.down.sql")
if err != nil {
t.Fatal(err)
}
@@ -101,7 +101,7 @@ func TestLiveReleased265AttemptUpgradePreservesLegacyConsentAndFailedAttempts(t
}
assertLedger()
version, dirty, err := m.Version()
if err != nil || dirty || version != 273 {
if err != nil || dirty || version != 274 {
t.Fatal("final migration state", version, dirty, err)
}
}
+82 -40
View File
@@ -19,19 +19,23 @@ 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
WithReconciliationLock(ctx context.Context, fn func() error) error
SetDisconnectPending(ctx context.Context) error
BeginEnrollment(ctx context.Context, accountID, remoteID uuid.UUID) error
SetDisconnectPending(ctx context.Context, instanceID uuid.UUID) error
BeginEnrollment(ctx context.Context, accountID, remoteID, instanceID uuid.UUID) error
BeginRemoval(ctx context.Context, accountID uuid.UUID) error
InvalidateStanding(ctx context.Context, accountID uuid.UUID) 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)
@@ -67,16 +71,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, disconnect_pending 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, disconnect_pending`
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, &l.DisconnectPending)
if err != nil {
if err := row.Scan(&l.CloudURL, &l.InstanceID, &sealed, &l.OrganizationName, &l.ConnectedBy, &l.ConnectedAt, &l.LastSyncedAt, &l.LastError, &l.OrganizationID, &l.DisconnectPending); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
db.CaptureError(err, query, nil, "queryrow")
return nil, err
}
if r.encrypt == nil {
@@ -90,6 +93,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
@@ -99,48 +144,45 @@ 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)
INSERT INTO cloud_link_mailboxes (email_account_id, remote_id, managed, instance_id)
SELECT ea.id, $2, $3, l.instance_id FROM email_accounts ea JOIN cloud_link l
ON (l.organization_id = ea.organization_id OR l.organization_id IS NULL)
WHERE ea.id = $1 AND l.instance_id = $4 AND NOT l.disconnect_pending
ON CONFLICT (email_account_id) DO UPDATE SET remote_id = EXCLUDED.remote_id, managed = EXCLUDED.managed, enrollment_state = 'active'
WHERE cloud_link_mailboxes.enrollment_state <> 'pending_remove'
RETURNING email_account_id, remote_id, enrolled_at, managed
`
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 {
WHERE cloud_link_mailboxes.enrollment_state <> 'pending_remove' AND cloud_link_mailboxes.instance_id = EXCLUDED.instance_id
RETURNING ` + cloudLinkMailboxColumns
m, err := scanCloudLinkMailbox(r.db.QueryRow(ctx, query, accountID, remoteID, managed, instanceID))
if err != nil {
db.CaptureError(err, query, nil, "queryrow")
return nil, err
}
return &m, nil
return m, nil
}
func (r *cloudLinkRepository) Unenroll(ctx context.Context, accountID uuid.UUID) error {
@@ -151,8 +193,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
}
@@ -197,7 +239,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,
enrollment_state, standing_observed_at`
func scanCloudLinkMailbox(row pgx.Row) (*models.CloudLinkMailbox, error) {
@@ -206,7 +248,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, &m.EnrollmentState, &m.StandingObservedAt); err != nil {
&state, &poolType, &reason, &score, &blockedUntil, &evaluatedAt, &m.InstanceID, &m.EnrollmentState, &m.StandingObservedAt); err != nil {
return nil, err
}
if state != nil {
@@ -308,12 +350,12 @@ func storeCloudHold(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, h *mode
cloud_blocked_until = NULL, cloud_health_reason = NULL, cloud_health_score = 0, cloud_source_account_id = NULL, cloud_source_instance_id = NULL
FROM email_accounts ea WHERE ea.id = $1 AND l.organization_id = ea.organization_id
AND l.email = lower(btrim(ea.email)) AND l.cloud_source_account_id = $1
AND l.cloud_source_instance_id IS NOT DISTINCT FROM (SELECT instance_id FROM cloud_link WHERE id = true)`, accountID)
AND l.cloud_source_instance_id IS NOT DISTINCT FROM (SELECT instance_id FROM cloud_link_mailboxes WHERE email_account_id = $1)`, accountID)
return err
}
_, err := tx.Exec(ctx, `INSERT INTO warmup_reputation_ledger
(organization_id, email, cloud_health_state, cloud_blocked_until, cloud_health_reason, cloud_health_score, cloud_source_account_id, cloud_source_instance_id, standing_until)
SELECT organization_id, lower(btrim(email)), $2, $3, $4, $5, $1, (SELECT instance_id FROM cloud_link WHERE id = true), NOW() FROM email_accounts WHERE id = $1
SELECT organization_id, lower(btrim(email)), $2, $3, $4, $5, $1, (SELECT instance_id FROM cloud_link_mailboxes WHERE email_account_id = $1), NOW() FROM email_accounts WHERE id = $1
ON CONFLICT (organization_id, email) DO UPDATE SET
cloud_health_state = EXCLUDED.cloud_health_state, cloud_blocked_until = EXCLUDED.cloud_blocked_until,
cloud_health_reason = EXCLUDED.cloud_health_reason, cloud_health_score = EXCLUDED.cloud_health_score,
+17
View File
@@ -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,
+16 -12
View File
@@ -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()
}
+10
View File
@@ -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
+3 -2
View File
@@ -88,6 +88,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
@@ -1016,7 +1017,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
@@ -1033,7 +1034,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)
+34 -20
View File
@@ -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)
+1 -1
View File
@@ -20,7 +20,7 @@ func warmupStandingRankSQL(col string) string {
// Columns: health_state, blocked_until, last_health_score,
// last_health_reason, last_health_evaluated_at.
func warmupStandingSQL(accountExpr string) string {
unknown := `(clm.enrollment_state <> 'active' OR EXISTS (SELECT 1 FROM cloud_link WHERE disconnect_pending)
unknown := `(clm.enrollment_state <> 'active' OR EXISTS (SELECT 1 FROM cloud_link WHERE instance_id = clm.instance_id AND disconnect_pending)
OR clm.health_state IS NULL OR clm.health_state NOT IN ('healthy', 'watch', 'throttled', 'quarantined', 'blocked') OR clm.standing_observed_at IS NULL
OR clm.standing_observed_at <= NOW() - INTERVAL '15 minutes' OR clm.standing_observed_at > NOW())
AND NOT (COALESCE(clm.health_state IN ('quarantined', 'blocked'), false)
@@ -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)
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ for app in web admin; do
[ -f "$app/public/404.html" ] || fail "web needs a 404.html to disable Pages' implicit SPA fallback for assets"
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
+10
View File
@@ -26,6 +26,16 @@
Referrer-Policy: no-referrer
Cache-Control: no-store
# Pages canonicalizes the .html asset to this extensionless URL.
/mail-preview
! Content-Security-Policy
Content-Security-Policy: default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; style-src 'unsafe-inline'; img-src data: https: http:; font-src data: https:; media-src https:
! X-Frame-Options
X-Frame-Options: SAMEORIGIN
! Referrer-Policy
Referrer-Policy: no-referrer
Cache-Control: no-store
# config.js is the runtime configuration and must never outlive a change to it; same rule as the nginx image.
/config.js
Cache-Control: no-store
+43 -1
View File
@@ -35,9 +35,51 @@ for (const path of [...roots.filter((root) => root !== '/'), '/auth/login/confir
assert.equal(rule?.target, '/', `Pages does not serve SPA route ${path}`);
assert.equal(rule?.code, '200');
}
for (const path of ['/assets/missing.js', '/assets/layout-aTnJVElG.js', '/config.js', '/favicon.ico', '/mail-preview.html', '/missing-resource']) {
for (const path of ['/assets/missing.js', '/assets/layout-aTnJVElG.js', '/config.js', '/favicon.ico', '/mail-preview.html', '/mail-preview', '/missing-resource']) {
assert.equal(match(path), undefined, `${path} must not rewrite to HTML`);
}
assert.match(read('../public/404.html'), /<!doctype html>/i);
assert.doesNotMatch(read('../public/_headers'), /Cache-Control:.*immutable/);
const headerRules = [];
for (const line of read('../public/_headers').split('\n')) {
if (!line.trim() || line.trim().startsWith('#')) continue;
if (line.startsWith('/')) {
headerRules.push({ source: line.trim(), headers: [] });
} else {
headerRules.at(-1).headers.push(line.trim());
}
}
const headersFor = (path) => {
const headers = new Map();
for (const { source, headers: lines } of headerRules) {
if (!(source.endsWith('*') ? path.startsWith(source.slice(0, -1)) : path === source)) continue;
for (const line of lines) {
if (line.startsWith('! ')) {
headers.delete(line.slice(2).toLowerCase());
} else {
const colon = line.indexOf(':');
const name = line.slice(0, colon).toLowerCase();
const value = line.slice(colon + 1).trim();
headers.set(name, headers.has(name) ? `${headers.get(name)}, ${value}` : value);
}
}
}
return headers;
};
const previewHeaders = headersFor('/mail-preview.html');
// Pages redirects .html assets to extensionless URLs; both must carry the same isolated policy.
assert.deepEqual(headersFor('/mail-preview'), previewHeaders, 'Pages canonical preview URL must retain the .html preview headers');
assert.equal(previewHeaders.get('x-frame-options'), 'SAMEORIGIN');
assert.equal(previewHeaders.get('referrer-policy'), 'no-referrer');
assert.equal(previewHeaders.get('cache-control'), 'no-store');
assert.match(previewHeaders.get('content-security-policy'), /(?:^|; )script-src 'none';/);
assert.match(previewHeaders.get('content-security-policy'), /(?:^|; )frame-ancestors 'self';/);
assert.match(previewHeaders.get('content-security-policy'), /(?:^|; )img-src data: https: http:;/);
assert.ok(!previewHeaders.get('content-security-policy').includes(','), 'preview must have one policy, not an intersection with the dashboard policy');
for (const path of ['/', '/app/unibox/inbox', '/mail-preview-other', '/mail-preview/other', '/assets/missing.js']) {
const headers = headersFor(path);
assert.equal(headers.get('x-frame-options'), 'DENY', `${path} must retain dashboard clickjacking protection`);
assert.match(headers.get('content-security-policy'), /(?:^|; )frame-ancestors 'none'$/, `${path} must not use the preview policy`);
}
console.log('Pages SPA deep links and missing-asset separation passed');
+2 -2
View File
@@ -467,7 +467,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}
/>
)}
</div>
@@ -539,7 +539,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)}
@@ -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.
@@ -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() {
<p className="text-[11px] text-slate-400 truncate">
{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 ? (
<Loader2Icon className="w-3.5 h-3.5 animate-spin text-slate-400" />
) : (
<Toggle on={row.enrolled} disabled={!supported} onChange={() => flip(row)} />
<Toggle on={row.enrolled} disabled={!supported || (!row.enrolled && !allowEnrollment)} onChange={() => flip(row)} />
)}
</div>
</td>
+32 -10
View File
@@ -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 <NoAccess feature="Warmbly Cloud" permissionLabel="Manage settings" />;
if (authConfig.data && !authConfig.data.self_hosted) {
@@ -42,14 +44,14 @@ export default function WarmblyCloudSettingsPage() {
<Section eyebrow="Connection">
<p className="text-[12.5px] text-slate-500 leading-relaxed">
{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."}
</p>
</Section>
</SectionShell>
);
}
return <SelfHostedCloud />;
return <SelfHostedCloud key={organization.data?.id} />;
}
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 (
<SectionShell
@@ -91,11 +94,30 @@ function SelfHostedCloud() {
) : undefined
}
>
{legacy && (
<Section eyebrow="Legacy connection" description="Existing enrolled mailboxes keep working on the old instance-wide connection. New mailboxes need a separate connection for this workspace, using a Cloud workspace with no active link. The old Cloud workspace stays occupied until the legacy link is disconnected.">
<button type="button" onClick={() => setFlow(true)} className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium">
Connect this workspace
</button>
</Section>
)}
{st.legacy_connected && !legacy && (
<Section eyebrow="Legacy instance link" description="Some existing mailboxes may still use the old instance-wide connection. Keep it until those mailboxes have moved. Disconnecting it affects all local workspaces, not this workspace's new connection.">
<button type="button" className="h-7 px-2.5 rounded-md text-[12px] text-rose-600 hover:bg-rose-50" onClick={() => confirm.show("Disconnect the legacy instance link across all workspaces? Existing mailboxes still using it will stop using Cloud, and its managed mailbox mirrors will be removed.", async () => {
try {
await disconnect.mutateAsync(true);
toast.success("Legacy link disconnected");
} catch (e) {
toast.error(buildError(e as AppError));
}
})}>Disconnect legacy link</button>
</Section>
)}
<AnimatePresence mode="wait" initial={false}>
{showFlow ? (
<motion.div key="flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<ConnectFlow
status={st}
status={legacy ? { ...st, connected: false } : st}
onFinished={() => {
setFlow(false);
void status.refetch();
@@ -124,7 +146,7 @@ function SelfHostedCloud() {
/>
</Section>
{plan && (
<Section eyebrow="Plan" description="Billed on Warmbly Cloud, for the workspace this instance is linked to.">
<Section eyebrow="Plan" description="Billed on Warmbly Cloud. Each connected local workspace needs its own Cloud workspace and plan.">
<PlanCard plan={plan} />
</Section>
)}
@@ -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."
>
<MailboxTable />
<MailboxTable allowEnrollment={!legacy} />
</Section>
<Section eyebrow="Disconnect">
<Row
danger
label="Disconnect from Warmbly Cloud"
description="Every enrolled mailbox is removed from the pool and the cloud deletes its credentials. Local warmup takes over again."
label={legacy ? "Disconnect legacy instance link" : "Disconnect this workspace from Warmbly Cloud"}
description={legacy ? "Stops all mailboxes still using this legacy link across every local workspace. Workspace-scoped connections are not affected." : "Stops only mailboxes and redirects using this connection. Older legacy enrollments and other workspaces are not affected. Managed mailbox mirrors using this link are removed."}
>
<button
type="button"
onClick={() =>
confirm.show("Disconnect this instance from Warmbly Cloud? All enrolled mailboxes stop warming in the pool.", async () => {
confirm.show(legacy ? "Disconnect the legacy instance link? All mailboxes still using it, across all workspaces, will stop warming on Cloud." : "Disconnect this workspace from Warmbly Cloud? Mailboxes and redirects using this connection will stop using Cloud.", async () => {
try {
await disconnect.mutateAsync();
await disconnect.mutateAsync(legacy);
setFlow(null);
toast.success("Disconnected");
} catch (e) {
@@ -17,6 +17,9 @@ export default function CloudPoolBanner({ onConnect, mailboxCount }: { onConnect
if (!pool.manageable || pool.loading) return null;
if (pool.connected && !pool.workspaceConnected) {
return <div className="px-3 py-2 text-[12.5px] text-slate-600">Existing Cloud mailboxes keep warming. <Link to="/app/settings/warmbly-cloud" className="font-medium text-sky-700 underline">Connect this workspace</Link> to add more.</div>;
}
if (pool.connected) {
const plan = pool.plan;
const limit = plan?.mailbox_limit ?? null;
@@ -41,7 +41,7 @@ export default function CloudWarmupCard({ mailboxId, email, provider }: { mailbo
}
};
if (!pool.connected) {
if (!pool.connected || (!pool.workspaceConnected && !pool.isEnrolled(mailboxId))) {
return (
<div className="px-5 py-3 flex items-center gap-2.5 text-[12px] text-slate-500">
<CloudIcon className="w-3.5 h-3.5 text-sky-600 shrink-0" />
@@ -6,7 +6,7 @@ import type { PoolLinkRedirectOffer } from "@/lib/api/models/app/cloudlink/Cloud
import type { RedirectServer } from "@/lib/api/models/app/emails/SendingDomain";
export interface CloudServing {
/** Only a self-hosted instance's administrator picks, since the link is instance-wide; Warmbly Cloud always serves its own. */
/** Only a self-hosted instance's administrator picks, since it forwards credentials off the server; Warmbly Cloud always serves its own. */
choosable: boolean;
connected: boolean;
offer: PoolLinkRedirectOffer | null;
@@ -25,7 +25,7 @@ export function useCloudServing(): CloudServing {
choosable,
connected,
offer,
canServe: connected && !!offer?.available && offer.used < offer.limit,
canServe: connected && !!status.data?.link?.organization_id && !!offer?.available && offer.used < offer.limit,
};
}
+1
View File
@@ -32,6 +32,7 @@ export default function useCloudPool() {
selfHosted,
manageable,
connected,
workspaceConnected: connected && !!status.data?.link?.organization_id,
reachable: status.data?.reachable === true,
orgName: status.data?.link?.organization_name ?? "",
plan,
+1 -1
View File
@@ -75,7 +75,7 @@ export function allowedCallbackOrigins(): string[] {
export default function useMailboxOAuth(options: MailboxOAuthOptions = {}) {
const qc = useQueryClient();
const pool = useCloudPool();
const viaCloud = pool.connected;
const viaCloud = pool.workspaceConnected;
const gmailAvailable = useGmailOAuthConnect(viaCloud);
const [busy, setBusy] = React.useState<MailboxOAuthProvider | null>(null);
@@ -29,8 +29,8 @@ export async function pollCloudLinkConnect(): Promise<CloudLinkPollResult> {
return await Request<CloudLinkPollResult>({ method: "POST", url: "/cloud-link/connect/poll", authorization: true });
}
export async function disconnectCloudLink(): Promise<void> {
await Request<void>({ method: "DELETE", url: "/cloud-link", authorization: true });
export async function disconnectCloudLink(legacy = false): Promise<void> {
await Request<void>({ method: "DELETE", url: "/cloud-link", params: legacy ? { legacy: true } : undefined, authorization: true });
}
export async function listCloudLinkMailboxes(): Promise<CloudLinkMailboxRow[]> {
@@ -21,6 +21,7 @@ import {
revokePoolLinkInstance,
} from "@/lib/api/client/app/cloudlink/poolLink";
import useCurrentOrganization from "@/lib/api/hooks/app/organizations/useCurrentOrganization";
import { SENDING_DOMAINS_KEY } from "@/lib/api/hooks/app/emails/useSendingDomains";
export const CLOUD_LINK_KEY = ["cloud-link"];
@@ -30,11 +31,13 @@ export const POOL_LINK_KEY = ["pool-link"];
// Each read asks Warmbly Cloud, so a surface that only needs the redirect offer can take a longer staleTime.
export function useCloudLinkStatus(enabled = true, staleTime = 10_000) {
return useQuery({ queryKey: [...CLOUD_LINK_KEY, "status"], queryFn: getCloudLinkStatus, enabled, staleTime });
const organization = useCurrentOrganization();
return useQuery({ queryKey: [...CLOUD_LINK_KEY, organization.data?.id, "status"], queryFn: getCloudLinkStatus, enabled: enabled && !!organization.data?.id, staleTime });
}
export function useCloudLinkMailboxes(enabled = true) {
return useQuery({ queryKey: [...CLOUD_LINK_KEY, "mailboxes"], queryFn: listCloudLinkMailboxes, enabled, staleTime: 10_000 });
const organization = useCurrentOrganization();
return useQuery({ queryKey: [...CLOUD_LINK_KEY, organization.data?.id, "mailboxes"], queryFn: listCloudLinkMailboxes, enabled: enabled && !!organization.data?.id, staleTime: 10_000 });
}
export function useStartCloudLinkConnect() {
@@ -54,7 +57,7 @@ export function usePollCloudLinkConnect() {
export function useDisconnectCloudLink() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => disconnectCloudLink(),
mutationFn: (legacy: boolean) => disconnectCloudLink(legacy),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: CLOUD_LINK_KEY });
void qc.invalidateQueries({ queryKey: ["emails"] });
@@ -95,7 +98,8 @@ export function useCloudLinkMailboxLifecycle() {
}
export function useCloudWorkspaceMailboxes(enabled = true) {
return useQuery({ queryKey: [...CLOUD_LINK_KEY, "workspace-mailboxes"], queryFn: listCloudWorkspaceMailboxes, enabled, staleTime: 10_000 });
const organization = useCurrentOrganization();
return useQuery({ queryKey: [...CLOUD_LINK_KEY, organization.data?.id, "workspace-mailboxes"], queryFn: listCloudWorkspaceMailboxes, enabled: enabled && !!organization.data?.id, staleTime: 10_000 });
}
export function useAdoptCloudMailbox() {
@@ -133,7 +137,8 @@ export function useDenyPoolLinkCode() {
}
export function usePoolLinkInstances(enabled = true) {
return useQuery({ queryKey: [...POOL_LINK_KEY, "instances"], queryFn: listPoolLinkInstances, enabled, staleTime: 10_000 });
const organization = useCurrentOrganization();
return useQuery({ queryKey: [...POOL_LINK_KEY, organization.data?.id, "instances"], queryFn: listPoolLinkInstances, enabled: enabled && !!organization.data?.id, staleTime: 10_000 });
}
export function useRevokePoolLinkInstance() {
@@ -161,6 +161,8 @@ export interface DiagnosticParticipation {
}
export interface CloudLink {
/** Absent on a legacy instance-wide connection. */
organization_id?: string | null;
cloud_url: string;
instance_id: string;
organization_name: string;
@@ -170,6 +172,7 @@ export interface CloudLink {
}
export interface CloudLinkStatus {
legacy_connected?: boolean;
connected: boolean;
link?: CloudLink | null;
info?: PoolLinkInstanceInfo | null;
@@ -193,6 +196,7 @@ export interface CloudLinkPollResult {
}
export interface CloudLinkMailboxRow {
legacy?: boolean;
id: string;
email: string;
name: string;