Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 62 additions & 37 deletions products/data_warehouse/backend/managed_warehouse_connection.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"""Expose a managed Duckgres warehouse in the SQL editor as a read-only Postgres connection.
"""Expose a managed Duckgres warehouse in the SQL editor as a project-scoped Postgres connection.

Each member team gets a Postgres ``ExternalDataSource`` pointed at the organization's
``DuckgresServer``. Duckgres issues a distinct database login for each project and enforces
read-only access to the project's schemas, so both HogQL and raw SQL stay inside that boundary.
``DuckgresServer``. Duckgres issues a distinct database login for each project and confines it to
that project's schemas, so both HogQL and raw SQL stay inside that boundary.

The login is Duckgres's ``project_user``: read/write **within** the project's own namespaces.
Writes are the default because a project connection is the user's own warehouse, not a report of
it. The project boundary is identical to the read-only ``project_reader`` it replaces — Duckgres
derives the same namespaces for both modes, and write authorization does not widen the set of
reachable relations, so a project still cannot read or write another project's schemas.

Setup happens in two steps:

1. ``ensure_managed_warehouse_direct_source`` creates the initially empty source row when a team
Expand Down Expand Up @@ -50,6 +57,13 @@

MANAGED_WAREHOUSE_SOURCE_DESCRIPTION = "Managed warehouse (auto-provisioned)"

# The Duckgres access mode backing a project connection, recorded on the source's
# connection_metadata. It is also the migration marker: a source whose stored kind differs from
# this constant is re-credentialed and its discovered catalog is dropped, which is how sources
# moved off the org root credential and then off the read-only project_reader. Changing this
# value re-runs that handshake for every existing source, so change it only with that intent.
PROJECT_CREDENTIAL_KIND = "project_user"


def _managed_source_queryset(team_id: int) -> QuerySet[ExternalDataSource]:
return ExternalDataSource._base_manager.filter(
Expand Down Expand Up @@ -79,7 +93,7 @@ def _ensure_managed_source_locked(
server: DuckgresServer,
username: str,
password: str,
reader_configured: bool,
credential_configured: bool,
) -> ExternalDataSource | None:
# Deliberately includes soft-deleted rows: a re-enabled membership revives its tombstoned
# source (the caller's credential pre-read filters deleted=False, so a revived row always
Expand All @@ -90,9 +104,10 @@ def _ensure_managed_source_locked(
if existing is not None:
update_fields: list[str] = []
connection_metadata = dict(existing.connection_metadata or {})
if connection_metadata.get("credential_kind") != "project_reader":
# Old managed sources used the org root credential, so discard any catalog
# entries discovered before Duckgres enforced the project boundary.
if connection_metadata.get("credential_kind") != PROJECT_CREDENTIAL_KIND:
# The source is on a superseded credential — the org root login, or the read-only
# project_reader. Its catalog was discovered as a different principal, so discard
# the entries and let reconcile rediscover them as the current one.
now = timezone.now()
DataWarehouseTable.raw_objects.filter(
team_id=team_id, external_data_source_id=existing.id, deleted=False
Expand All @@ -104,24 +119,24 @@ def _ensure_managed_source_locked(
if existing.access_method != ExternalDataSource.AccessMethod.DIRECT:
existing.access_method = ExternalDataSource.AccessMethod.DIRECT
update_fields.append("access_method")
if existing.direct_query_enabled != reader_configured:
existing.direct_query_enabled = reader_configured
if existing.direct_query_enabled != credential_configured:
existing.direct_query_enabled = credential_configured
update_fields.append("direct_query_enabled")
if (
connection_metadata.get("engine") != "duckdb"
or connection_metadata.get("system_managed") is not True
or connection_metadata.get("credential_kind") != "project_reader"
or connection_metadata.get("credential_kind") != PROJECT_CREDENTIAL_KIND
):
existing.connection_metadata = {
**connection_metadata,
"engine": "duckdb",
"system_managed": True,
"credential_kind": "project_reader",
"reader_configured": reader_configured,
"credential_kind": PROJECT_CREDENTIAL_KIND,
"credential_configured": credential_configured,
}
update_fields.append("connection_metadata")
elif connection_metadata.get("reader_configured") is not reader_configured:
existing.connection_metadata = {**connection_metadata, "reader_configured": reader_configured}
elif connection_metadata.get("credential_configured") is not credential_configured:
existing.connection_metadata = {**connection_metadata, "credential_configured": credential_configured}
update_fields.append("connection_metadata")
if existing.deleted:
existing.deleted = False
Expand All @@ -143,12 +158,12 @@ def _ensure_managed_source_locked(
description=MANAGED_WAREHOUSE_SOURCE_DESCRIPTION,
access_method=ExternalDataSource.AccessMethod.DIRECT,
created_via=ExternalDataSource.CreatedVia.WEB,
direct_query_enabled=reader_configured,
direct_query_enabled=credential_configured,
connection_metadata={
"engine": "duckdb",
"system_managed": True,
"credential_kind": "project_reader",
"reader_configured": reader_configured,
"credential_kind": PROJECT_CREDENTIAL_KIND,
"credential_configured": credential_configured,
},
)

Expand All @@ -157,7 +172,7 @@ def _membership_table_suffix(*, team_id: int, organization_id: str | UUID) -> st
"""The team's warehouse table suffix from its duckgres control-plane row.

Raises RuntimeError when the control plane can't answer (callers treat that like the
reader handshake being unavailable and retry on a later sweep) and ValueError when the
credential handshake being unavailable and retry on a later sweep) and ValueError when the
team has no backfill-enabled row or sits on the legacy shared tables — neither can be
exposed as a per-project query connection.
"""
Expand All @@ -176,7 +191,13 @@ def _membership_table_suffix(*, team_id: int, organization_id: str | UUID) -> st


def ensure_managed_warehouse_direct_source(*, team_id: int, organization_id: str | UUID) -> ExternalDataSource:
"""Create or refresh the team's restricted live-query source from its membership."""
"""Create or refresh the team's project-scoped live-query source from its membership.

A source already holding a current-kind credential is left alone. One on a superseded kind
(org root, or the read-only project_reader) is re-credentialed here: it gets a fresh username
and password and stays `direct_query_enabled=False` until the Duckgres handshake below
confirms them, so a half-migrated source is never queryable.
"""
Comment on lines 193 to +200

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct diagnosis, and greptile flagged the same thing — this is the hazard I called out in the PR description as a hard deploy-order requirement. Agreed it is real: on a kind mismatch the source is re-credentialed, disabled and its catalog dropped before the handshake, so a 404 leaves existing connections dark.

On the two suggested fixes, though — I think the first one would regress a property this file is deliberately built around, and I want to flag that before anyone implements it.

"Mint credentials first, then atomically swap" breaks the concurrency design. The DB write is not just bookkeeping, it is the claim: ensure_managed_warehouse_direct_source persists the credential under select_for_update so a concurrent sweep reads it back and reuses it instead of minting a second password. test_concurrent_credential_setup_reuses_the_persisted_credential pins exactly that (both re-entrant calls must request the same password). If the handshake moves first, two sweeps mint different passwords; Duckgres's upsert is last-write-wins and so is the row write, and the two can interleave such that the persisted password is not the one Duckgres ended up storing — a permanently broken connection, which is worse than the transient one we are trying to avoid.

"Treat 404 as keep using project_reader" does not help by itself, because by the time the 404 comes back the wipe has already committed. It would have to be combined with deferring the mutation, which lands back on the problem above.

A fix that preserves the claim-first property would need the new credential staged separately (e.g. a pending-credential field) and promoted only after a successful handshake — real design work, not a review-comment change.

Given that, my inclination is to keep the current behaviour and treat deploy order as the control, because:

  • the PR cannot deliver anything until feat(controlplane): add project_user, a read/write project-scoped login duckgres#999 ships regardless — the endpoint has to exist for the feature to work at all;
  • the exposure is bounded to the rollout window, and self-heals on the next sweep after Duckgres is up;
  • the wipe-then-handshake shape is pre-existing (it is how the earlier root -> project_reader migration worked), so this is not a new class of risk, just a newly-triggered one.

If the team would rather not depend on deploy sequencing, the cheapest safe alternative is to merge this behind a flag and flip it after Duckgres is deployed. Happy to do that — flagging the tradeoff rather than picking unilaterally.

from posthog.ducklake.models import DuckgresServer # noqa: PLC0415

table_suffix = _membership_table_suffix(team_id=team_id, organization_id=organization_id)
Expand All @@ -187,58 +208,62 @@ def ensure_managed_warehouse_direct_source(*, team_id: int, organization_id: str

existing = _managed_source_queryset(team_id).select_for_update().filter(deleted=False).first()
existing_metadata = existing.connection_metadata if existing is not None else None
has_reader_credentials = (
has_project_credentials = (
existing is not None
and isinstance(existing_metadata, dict)
and existing_metadata.get("credential_kind") == "project_reader"
and existing_metadata.get("credential_kind") == PROJECT_CREDENTIAL_KIND
and isinstance(existing.job_inputs, dict)
and existing.job_inputs.get("user")
and existing.job_inputs.get("password")
)
if has_reader_credentials:
if has_project_credentials:
assert existing is not None
assert isinstance(existing_metadata, dict)
assert isinstance(existing.job_inputs, dict)
reader_configured = existing_metadata.get("reader_configured") is True
credential_configured = existing_metadata.get("credential_configured") is True
username = str(existing.job_inputs["user"])
password = str(existing.job_inputs["password"])
else:
reader_configured = False
username = f"posthog_team_{team_id}"
credential_configured = False
# Duckgres derives this name from the team and owns it: the read/write project_user
# is `posthog_team_<id>_rw`, distinct from the read-only `posthog_team_<id>`. The
# handshake below asserts the name Duckgres returns matches, so a drift in either
# derivation fails loudly instead of silently configuring the wrong login.
username = f"posthog_team_{team_id}_rw"
password = secrets.token_urlsafe(32)

source = _ensure_managed_source_locked(
team_id=team_id,
server=server,
username=username,
password=password,
reader_configured=reader_configured,
credential_configured=credential_configured,
)
if source is None:
raise RuntimeError("Failed to create the managed warehouse query source")
if reader_configured:
if credential_configured:
return source
source_id = source.id

credentials = managed_warehouse.configure_project_reader(
credentials = managed_warehouse.configure_project_user(
Comment thread
veria-ai[bot] marked this conversation as resolved.
organization_id=organization_id,
team_id=team_id,
table_suffix=table_suffix,
password=password,
)
if credentials != {"username": username, "password": password}:
raise RuntimeError("Managed warehouse reader credentials did not match the requested credentials")
raise RuntimeError("Managed warehouse project credentials did not match the requested credentials")

with transaction.atomic():
DuckgresServer.objects.select_for_update().get(organization_id=organization_id)
Team.objects.select_for_update().only("id").get(id=team_id, organization_id=organization_id)
source = _managed_source_queryset(team_id).select_for_update().filter(id=source_id, deleted=False).first()
if source is None or not isinstance(source.job_inputs, dict):
raise RuntimeError("Managed warehouse query source changed while its reader was configured")
raise RuntimeError("Managed warehouse query source changed while its credential was configured")
if source.job_inputs.get("user") != username or source.job_inputs.get("password") != password:
raise RuntimeError("Managed warehouse query source changed while its reader was configured")
raise RuntimeError("Managed warehouse query source changed while its credential was configured")
connection_metadata = dict(source.connection_metadata or {})
source.connection_metadata = {**connection_metadata, "reader_configured": True}
source.connection_metadata = {**connection_metadata, "credential_configured": True}
source.direct_query_enabled = True
source.save(update_fields=["connection_metadata", "direct_query_enabled", "updated_at"])
return source
Expand All @@ -255,7 +280,7 @@ def reconcile_managed_warehouse_tables(*, team_id: int, organization_id: str | U
except RuntimeError:
# The credential handshake needs the warehouse control plane; while an org is still
# provisioning this fails on every sweep, so skip quietly and let the next run retry.
logger.info("Managed warehouse reader handshake not possible yet", team_id=team_id)
logger.info("Managed warehouse credential handshake not possible yet", team_id=team_id)
return

with transaction.atomic():
Expand All @@ -271,11 +296,11 @@ def reconcile_managed_warehouse_tables(*, team_id: int, organization_id: str | U
source_config = dict(source.job_inputs or {})
source_api_version = source.api_version

# The allowlist mirrors the live Duckgres org-team row (the same row its reader policy is
# The allowlist mirrors the live Duckgres org-team row (the same row its access policy is
# derived from), so hand-set layouts — legacy overrides like team 2's posthog.events, custom
# schema names like devex — stay in sync instead of assuming the suffix-derived scheme.
# Introspection also runs AS the reader, so this filter is defense in depth, not the boundary.
namespaces = managed_warehouse.project_reader_namespaces(organization_id=organization_id, team_id=team_id)
# Introspection also runs AS the project login, so this filter is defense in depth, not the boundary.
namespaces = managed_warehouse.project_namespaces(organization_id=organization_id, team_id=team_id)
if namespaces is None:
return
allowed_schemas, allowed_relations = namespaces
Expand Down Expand Up @@ -346,7 +371,7 @@ def _managed_sources_for_org(organization_id: str | UUID) -> QuerySet[ExternalDa


def update_managed_warehouse_root_password(*, organization_id: str | UUID, password: str) -> None:
"""Refresh the internal root writer without changing project reader credentials."""
"""Refresh the internal root credential without touching any project login."""
from posthog.ducklake.models import DuckgresServer # noqa: PLC0415

with transaction.atomic():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,15 @@ def _row_team_id(row: dict) -> int | None:
return None


def configure_project_reader(
def configure_project_user(
*, organization_id: UUID | str, team_id: int, table_suffix: str, password: str
) -> dict[str, str]:
"""Apply the project's read-only credential, creating its team row only when absent.
"""Apply the project's read/write credential, creating its team row only when absent.

Duckgres issues two team-scoped logins: `project_reader` (read-only) and `project_user`
(read/write). Both are scoped to exactly the same namespaces — the only difference is
whether writes are authorized — so a project connection gets the writable one and the
project boundary is unchanged.

The org-team row is Duckgres-owned state that also drives external-writer discovery
(viaduck/millpond write targets) and may be hand-set (break-glass edits, legacy layouts).
Expand Down Expand Up @@ -245,28 +250,29 @@ def configure_project_reader(
credential_response = _request(
"PUT",
organization_id,
f"/teams/{team_id}/project-reader",
f"/teams/{team_id}/project-user",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed handshake disables existing sources

When an existing source is reconciled before /teams/:id/project-user is available, the credential-kind migration commits catalog deletion and direct_query_enabled=False before this request fails, causing the connection to lose its tables and remain unavailable until a later successful sweep.

Knowledge Base Used: Batch exports and data warehouse bulk data movement

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/data_warehouse/backend/presentation/views/managed_warehouse.py
Line: 253

Comment:
**Failed handshake disables existing sources**

When an existing source is reconciled before `/teams/:id/project-user` is available, the credential-kind migration commits catalog deletion and `direct_query_enabled=False` before this request fails, causing the connection to lose its tables and remain unavailable until a later successful sweep.

**Knowledge Base Used:** [Batch exports and data warehouse bulk data movement](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog/-/docs/batch-exports-warehouse.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and thanks — this is the same hazard Copilot raised, and it is the one I called out in the PR description as a hard deploy-order requirement: PostHog/duckgres#999 must be deployed before this merges.

Full analysis in my reply to Copilot, short version: the obvious fix (handshake before mutating) would regress the concurrency property test_concurrent_credential_setup_reuses_the_persisted_credential pins — the DB write is the claim that stops a concurrent sweep minting a second password, so reordering it can leave the persisted password and the one Duckgres stored permanently out of sync. A correct fix needs the new credential staged and promoted after the handshake, which is design work rather than a review-comment change.

Current plan is to gate on deploy order (the feature cannot function before #999 ships anyway, and the state self-heals on the next sweep once Duckgres is up). If the team prefers not to rely on sequencing, I will put the migration behind a flag and flip it post-deploy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concurrency argument holds — the DB write being the claim is load-bearing for test_concurrent_credential_setup_reuses_the_persisted_credential, so naively reordering it trades one hazard for another. Your analysis is correct.

Between the two options: the deploy-order gate is fine given that the PR description already calls it out prominently with a hard warning, and the self-healing property means the window of breakage is bounded to the time between this merge and duckgres #999 landing. That's an acceptable operational risk as long as the merge isn't automated.

The flag approach is cleaner if there's any chance of the sequencing slipping (e.g., the duckgres PR stalls, someone merges this during an incident window without checking), but it does add a cleanup step. Given you've already documented the hazard clearly in the PR description, the deploy-order gate seems sufficient here — no further change needed from a review standpoint.

json_body={"password": password},
require_enabled=False,
)
if not status.is_success(credential_response.status_code) or not isinstance(credential_response.data, dict):
raise RuntimeError("Failed to create the project's managed warehouse reader")
raise RuntimeError("Failed to create the project's managed warehouse user")
username = credential_response.data.get("username")
response_password = credential_response.data.get("password")
if not isinstance(username, str) or not username or not isinstance(response_password, str) or not response_password:
raise RuntimeError("Managed warehouse reader response did not include credentials")
raise RuntimeError("Managed warehouse user response did not include credentials")
return {"username": username, "password": response_password}


def project_reader_namespaces(
*, organization_id: UUID | str, team_id: int
) -> tuple[set[str], set[tuple[str, str]]] | None:
"""Return the (whole schemas, legacy posthog-schema tables) the project's reader may see.
def project_namespaces(*, organization_id: UUID | str, team_id: int) -> tuple[set[str], set[tuple[str, str]]] | None:
"""Return the (whole schemas, legacy posthog-schema tables) the project's login may see.

Mirrors the Duckgres policy derivation from the org-team row: the reader is granted the row's
Mirrors the Duckgres policy derivation from the org-team row: the login is granted the row's
schema_name, its data-imports schema (override or `<schema>_data_imports`), the modeled-data
schema, and `posthog.<override>` for each non-NULL legacy events/persons override — including
overrides that spell the derived default name. None means no enabled row exists (fail closed).

Mode-independent by design: Duckgres derives the SAME namespaces for `project_reader` and
`project_user`, so this mirror is correct for both and must not gain a mode argument.
"""
row = _get_project_team_row(organization_id=organization_id, team_id=team_id)
if row is None or row.get("enabled") is not True:
Expand Down Expand Up @@ -427,11 +433,12 @@ def _register_provisioning_team(organization_id: UUID | str, team_id: int) -> No


def _ensure_direct_source(team_id: int, organization_id: UUID | str) -> None:
"""Best-effort: register the org's managed warehouse as the team's restricted query connection.
"""Best-effort: register the org's managed warehouse as the team's scoped query connection.

A managed warehouse speaks the Postgres wire protocol, so each member team gets an
ExternalDataSource pointed at the org server. Duckgres scopes its credential to the
project and enforces read-only SQL. A failure here must never block onboarding.
project: read/write inside the project's own namespaces, and nothing outside them.
A failure here must never block onboarding.
"""
try:
# Keep the data_warehouse/warehouse_sources stack off this adapter's import path.
Expand Down
Loading
Loading