Skip to content
Open
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
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,28 @@ impersonation, audit log; sliceable by org + user). Design + decisions:
`RoleGate` requires admin for all mutating verbs + the audit GET.
`AuditMiddleware` records every mutation. Keep new mutating routes under this
gate; never add a write path that bypasses RoleGate/audit.
- **Warehouse provisioning has exactly ONE implementation.** The console's
Provision-warehouse page (`ui/src/pages/ProvisionWarehouse.tsx`, route
`/orgs/provision`) posts to `POST /api/v1/orgs/:id/provision` — the route
`controlplane/provisioning.RegisterAPI` mounts on the SAME audited `/api/v1`
group (`multitenant.go`), i.e. the exact endpoint the PostHog backend
(Django) calls. Same handler, validation, transaction and analytics event ⇒
an operator-provisioned warehouse is identical to a user-provisioned one by
construction. NEVER add an admin-local provisioning handler (gin only panics
on an exactly-matching duplicate, so a near-miss path would fork the contract
silently): `provisioning.TestProvisioningAPIRouteTopology` pins the shared
route set and `admin.TestAdminAPIRegistersNoProvisioningRoutes` pins the
absence of a twin. Same for the siblings the console drives —
`warehouse/status`, `database-name/check`, `reset-password`, `deprovision`.
The client-side validation mirror (`ui/src/lib/provision.ts`) is a courtesy:
LOOSER than the server is safe, STRICTER blocks a body the PostHog backend
may legitimately send. Audit actions for these are `warehouse.provision` /
`warehouse.deprovision` / `warehouse.reset_password` (`auditActionFor`; keep
`ui/src/lib/audit.ts` labels in sync). Touching any of this → update
`controlplane/admin/provision_parity_test.go`, `audit_test.go`,
`controlplane/provisioning/api_topology_test.go`,
`ui/src/lib/provision.test.ts`, `ui/src/pages/ProvisionWarehouse.test.tsx`,
AND the `admin_provision_parity` assertion in `tests/mw-dev/e2e/harness.sh`.
- **Impersonation is a real session** (`impersonate.go` + `admin_providers.go`):
it reuses `SessionManager.CreateSessionWithProtocol` (workers trust the CP — no
password) and **always** `DestroySession` in a defer. Admin-only, every
Expand Down
50 changes: 47 additions & 3 deletions controlplane/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,44 @@ Added for the console:
| `POST /api/v1/operators` | admin | add/update an operator (`{email, role}`; last-admin demotion → 409) |
| `DELETE /api/v1/operators/:email` | admin | remove an operator (removing the last admin → 409) |

### Warehouse provisioning — one implementation, two callers

The console's **Provision warehouse** page (`ui/src/pages/ProvisionWarehouse.tsx`,
route `/orgs/provision`) has **no provisioning backend of its own**. It posts to
`POST /api/v1/orgs/:id/provision` — the route
`controlplane/provisioning.RegisterAPI` mounts on *this* router group (see
`multitenant.go`), i.e. the exact endpoint, handler, validation, transaction and
analytics event the PostHog backend (Django) calls. A warehouse an operator
creates from the console is therefore identical to one a user creates, by
construction rather than by convention.

Consequences to preserve:

- **Never add an admin-local provisioning handler.** Two implementations would
drift, and the drift would only show up as differently-shaped tenants in
production. `provisioning.TestProvisioningAPIRouteTopology` pins the shared
route set and `admin.TestAdminAPIRegistersNoProvisioningRoutes` pins the
absence of an admin-side twin (gin only panics on an exactly-matching
duplicate, so a near-miss path would fork silently).
- Because those routes live on the audited admin group, an operator's provision
and the PostHog backend's provision write the same audit action — they differ
only by actor (`internal-secret` vs the SSO email). The actions are
`warehouse.provision` / `warehouse.deprovision` / `warehouse.reset_password`
(`auditActionFor`; keep `ui/src/lib/audit.ts` labels in sync).
- The client-side validation mirror (`ui/src/lib/provision.ts`) is a courtesy
only. Keeping it LOOSER than the server is safe (the error moves to submit
time); making it STRICTER blocks a body the PostHog backend may legitimately
send, which is exactly the divergence this surface exists to prevent.
- The form hard-codes `ducklake.enabled=true` (the server rejects `false`) and
omits absent optional fields rather than sending empty strings, so the request
is byte-identical to the backend's for the same intent.

The console also drives the sibling lifecycle endpoints on the same shared API:
`GET /orgs/:id/warehouse/status` (the post-provision poll), `GET
/database-name/check` (uniqueness pre-flight), `POST /orgs/:id/reset-password`
(root rotation — the recovery path when a provision response is lost, since only
the bcrypt hash is stored) and `POST /orgs/:id/deprovision`.

### Cross-CP live-state aggregation (`live_aggregate.go` + `controlplane/live_aggregator.go`)

Live session/query state is **in-memory per CP** — each replica only knows the
Expand Down Expand Up @@ -148,9 +186,11 @@ embedded, under Vite, or under the Go devserver.

**Backend:** `authz_test.go` (SSO role mapping, RoleGate, SQL classifier),
`dashboard_test.go` (TokenSet / break-glass login / cookie), `api_test.go` +
`api_postgres_test.go` (CRUD), `models_api_test.go` (redaction). e2e: the
`admin_*` / `impersonation_*` / `models_explorer_api` assertions in
`tests/e2e-mw-dev/harness.sh`.
`api_postgres_test.go` (CRUD), `models_api_test.go` (redaction),
`audit_test.go` (action mapping, incl. the warehouse lifecycle actions),
`provision_parity_test.go` (no admin-side provisioning twin). e2e: the
`admin_*` (incl. `admin_provision_parity`) / `impersonation_*` /
`models_explorer_api` assertions in `tests/mw-dev/e2e/harness.sh`.

**Frontend** (`ui/`, Vitest + Testing Library — `just ui-test`, CI job
`ui-tests`): the dashboard's data-derivation logic has shipped wrong more than
Expand All @@ -161,3 +201,7 @@ of inline JSX. `src/lib/fleet.test.ts` pins the worker-fleet/load math
`src/pages/Overview.test.tsx` renders the page with mocked hooks and asserts the
Workers card + leak warning. New derivation/display logic on a page **must** get
a `*.test.ts(x)` here — keep computed values out of the JSX so they're testable.
The provisioning form follows the same rule for a stronger reason: the REQUEST it
builds has to match the PostHog backend's, so the body construction and the
validation mirror live in `src/lib/provision.ts` (`provision.test.ts`) and
`src/pages/ProvisionWarehouse.test.tsx` asserts the page posts exactly that body.
12 changes: 12 additions & 0 deletions controlplane/admin/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ func auditActionFor(method, path string) string {
case hasSeg(segs, "secrets"):
// /orgs/:id/users/:username/secrets/:name.
return "secret." + verb
case last == "provision":
// POST /orgs/:id/provision — the ONE warehouse-creation path,
// shared verbatim by the PostHog backend and the admin console
// (provisioning/api.go). Distinct from "warehouse.update" (a
// config-row edit) because it creates real infrastructure.
return "warehouse.provision"
case last == "deprovision":
// POST /orgs/:id/deprovision — asynchronous duckling teardown.
return "warehouse.deprovision"
case last == "reset-password":
// POST /orgs/:id/reset-password — rotates the org's root login.
return "warehouse.reset_password"
case hasSeg(segs, "warehouse"):
// PUT /warehouse and PATCH /warehouse/pinning both map here.
return "warehouse." + verb
Expand Down
9 changes: 9 additions & 0 deletions controlplane/admin/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ func TestAuditActionFor(t *testing.T) {
{"org team update", http.MethodPut, "/api/v1/orgs/acme/teams/7", "team.update"},
{"org team delete", http.MethodDelete, "/api/v1/orgs/acme/teams/7", "team.delete"},
{"warehouse pinning patch", http.MethodPatch, "/api/v1/orgs/acme/warehouse/pinning", "warehouse.update"},
// The provisioning API's mutating routes are mounted on the SAME audited
// admin group, so an operator provisioning from the console and the
// PostHog backend provisioning over the internal secret write the same
// action code — distinguishable only by actor. Before these cases they
// all fell through to the generic "org.create".
{"warehouse provision", http.MethodPost, "/api/v1/orgs/acme/provision", "warehouse.provision"},
{"warehouse provision no prefix", http.MethodPost, "/orgs/:id/provision", "warehouse.provision"},
{"warehouse deprovision", http.MethodPost, "/api/v1/orgs/acme/deprovision", "warehouse.deprovision"},
{"warehouse reset password", http.MethodPost, "/api/v1/orgs/acme/reset-password", "warehouse.reset_password"},
{"org create", http.MethodPost, "/api/v1/orgs", "org.create"},
{"org update", http.MethodPut, "/api/v1/orgs/acme", "org.update"},
{"org delete", http.MethodDelete, "/api/v1/orgs/acme", "org.delete"},
Expand Down
43 changes: 43 additions & 0 deletions controlplane/admin/provision_parity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//go:build kubernetes

package admin

import (
"strings"
"testing"
)

// TestAdminAPIRegistersNoProvisioningRoutes is the other half of the "one
// provisioning path" tripwire (see
// provisioning.TestProvisioningAPIRouteTopology).
//
// The admin console provisions warehouses by calling the SAME endpoints the
// PostHog backend calls — POST /orgs/:id/provision, POST /orgs/:id/deprovision,
// POST /orgs/:id/reset-password, GET /orgs/:id/warehouse/status, GET
// /database-name/check — which the provisioning package registers on this very
// router group (see controlplane/multitenant.go). That shared registration is
// what guarantees a console-provisioned warehouse is identical to a
// user-provisioned one: same validation, same defaults, same transaction, same
// analytics event.
//
// If the admin package ever grows its own provisioning handler, gin would
// panic on the duplicate route — but only for an exactly-matching path. A
// near-miss (e.g. /orgs/:id/provision-warehouse) would silently fork the
// contract, so assert the absence explicitly.
func TestAdminAPIRegistersNoProvisioningRoutes(t *testing.T) {
r := newTestAPIRouter(&fakeAPIStore{})

// Substrings that would indicate an admin-local warehouse lifecycle
// implementation. "warehouse" alone is fine — PUT /orgs/:id/warehouse and
// PATCH /orgs/:id/warehouse/pinning edit the CONFIG ROW of an
// already-provisioned warehouse; they do not create or destroy infra.
forbidden := []string{"provision", "reset-password", "database-name", "warehouse/status"}

for _, ri := range r.Routes() {
for _, f := range forbidden {
if strings.Contains(ri.Path, f) {
t.Fatalf("admin API registered %s %s: warehouse provisioning must stay in controlplane/provisioning so the console and the PostHog backend share one implementation", ri.Method, ri.Path)
}
}
}
}
4 changes: 4 additions & 0 deletions controlplane/admin/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NotFound } from "@/pages/NotFound";
import { Overview } from "@/pages/Overview";
import { Orgs } from "@/pages/Orgs";
import { OrgDetail } from "@/pages/OrgDetail";
import { ProvisionWarehouse } from "@/pages/ProvisionWarehouse";
import { OrgTeams } from "@/pages/OrgTeams";
import { ReshardForm } from "@/pages/ReshardForm";
import { Reshards } from "@/pages/Reshards";
Expand Down Expand Up @@ -34,6 +35,9 @@ export default function App() {
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/orgs" element={<Orgs />} />
{/* Static segment before the :id route so "provision" is never read as
an org id. */}
<Route path="/orgs/provision" element={<ProvisionWarehouse />} />
<Route path="/orgs/:id" element={<OrgDetail />} />
<Route path="/orgs/:id/reshard" element={<ReshardForm />} />
<Route path="/org-teams" element={<OrgTeams />} />
Expand Down
31 changes: 31 additions & 0 deletions controlplane/admin/ui/src/components/Copyable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";

// A labelled value with a copy-to-clipboard button. Used for once-only
// credentials (the provision response, a password reset) where re-reading the
// value is impossible, so copying has to be one click and obviously available.
export function Copyable({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="flex items-center gap-1 break-all font-mono text-xs">
{value}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
title={`Copy ${label}`}
aria-label={`Copy ${label}`}
onClick={() => {
void navigator.clipboard?.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
});
}}
>
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
</button>
</span>
</div>
);
}
63 changes: 63 additions & 0 deletions controlplane/admin/ui/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ClusterStatus,
ClusterSummary,
CreateUserBody,
DatabaseNameCheck,
DucklingDriftResponse,
DucklingMetadataResponse,
ErrorEntry,
Expand All @@ -39,6 +40,7 @@ import type {
OrgUser,
OrgUserSecret,
PromRangeResponse,
ProvisionBody,
QueryDetail,
ReshardLogEntry,
ReshardOperation,
Expand All @@ -47,6 +49,7 @@ import type {
SessionStatus,
StartReshardBody,
UpdateUserBody,
WarehouseStatus,
WorkerStatus,
} from "@/types/api";

Expand Down Expand Up @@ -174,6 +177,66 @@ export function useDeprovisionWarehouse(id: string) {
});
}

// ---- warehouse provisioning (the PostHog-backend endpoints) ----

// POST /orgs/:id/provision — the SAME call the PostHog backend makes. Returns
// 202 plus the root password, which is readable exactly once, so the caller
// must surface it before navigating away.
export function useProvisionWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (v: { org: string; body: ProvisionBody }) => api.provisionWarehouse(v.org, v.body),
onSuccess: (_res, v) => {
qc.invalidateQueries({ queryKey: ["orgs"] });
qc.invalidateQueries({ queryKey: ["orgs", v.org, "warehouse"] });
qc.invalidateQueries({ queryKey: ["org-teams"] });
},
});
}

// GET /orgs/:id/warehouse/status — the lifecycle view the PostHog backend
// polls. 404 (no warehouse row yet) resolves to null rather than erroring.
// `enabled` lets the provision page start polling only once it has submitted.
export function useWarehouseStatus(id: string | undefined, enabled = true) {
return useQuery<WarehouseStatus | null>({
queryKey: ["orgs", id, "warehouse-status"],
queryFn: () => tolerate404<WarehouseStatus | null>(null)(api.getWarehouseStatus(id!)),
enabled: !!id && enabled,
refetchInterval: POLL.normal,
});
}

// GET /database-name/check — debounced global uniqueness pre-flight for the
// provision form. The server is authoritative (it 409s a taken name at
// provision time); this only gives the operator the answer before submit.
export function useDatabaseNameAvailable(name: string) {
const debounced = useDebounced(name.trim(), 350);
return useQuery<DatabaseNameCheck | null>({
queryKey: ["database-name-check", debounced],
queryFn: () => tolerate404<DatabaseNameCheck | null>(null)(api.checkDatabaseName(debounced)),
enabled: debounced !== "",
staleTime: 10_000,
});
}

// POST /orgs/:id/reset-password — rotates the org's root login. The recovery
// path when a provision response was lost; the new plaintext is returned once.
export function useResetWarehousePassword(id: string) {
return useMutation({
mutationFn: () => api.resetWarehousePassword(id),
});
}

// useDebounced returns `value` after it has stopped changing for `ms`.
function useDebounced<T>(value: T, ms: number): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const t = setTimeout(() => setSettled(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return settled;
}

// ---- org teams ----

// All teams across every org (the Org teams nav page). 404-tolerant so a
Expand Down
24 changes: 24 additions & 0 deletions controlplane/admin/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ClusterSummary,
CreateUserBody,
CPInstance,
DatabaseNameCheck,
DucklingDriftResponse,
DucklingMetadataResponse,
ErrorEntry,
Expand All @@ -32,16 +33,20 @@ import type {
OrgUser,
OrgUserSecret,
PromRangeResponse,
ProvisionBody,
ProvisionResult,
QueryDetail,
QueryResult,
ReshardLogEntry,
ReshardOperation,
ReshardTargetsResponse,
ResetPasswordResult,
RunningQuery,
SessionStatus,
StartReshardBody,
UpdateUserBody,
UserKillResult,
WarehouseStatus,
WorkerStatus,
} from "@/types/api";

Expand Down Expand Up @@ -137,6 +142,25 @@ export const api = {
getWarehouse: (id: string) => get<ManagedWarehouse>(`/orgs/${enc(id)}/warehouse`),
updateWarehouse: (id: string, body: Partial<ManagedWarehouse>) =>
put<ManagedWarehouse>(`/orgs/${enc(id)}/warehouse`, body),
// ---- warehouse lifecycle: the PostHog-backend endpoints, verbatim ----
//
// provisioning/api.go registers these on this same /api/v1 group, so the
// console and the PostHog backend hit ONE implementation. Never reimplement
// any of them behind an admin-only route: an operator-provisioned warehouse
// must be indistinguishable from a user-provisioned one.

// Starts asynchronous provisioning (202). The response carries the root
// password, readable exactly once — it is never stored in plaintext.
provisionWarehouse: (id: string, body: ProvisionBody) =>
post<ProvisionResult>(`/orgs/${enc(id)}/provision`, body),
// Lifecycle view the PostHog backend polls; 404 until a warehouse row exists.
getWarehouseStatus: (id: string) => get<WarehouseStatus>(`/orgs/${enc(id)}/warehouse/status`),
// Global database-name uniqueness pre-flight (the provision form's check).
checkDatabaseName: (name: string) => get<DatabaseNameCheck>("/database-name/check", { name }),
// Rotates the org's root password; returns the new plaintext once. 409s
// unless the warehouse is ready.
resetWarehousePassword: (id: string) =>
post<ResetPasswordResult>(`/orgs/${enc(id)}/reset-password`, {}),
// Kicks off asynchronous teardown of the org's duckling (202). 409s when the
// warehouse state isn't ready/failed/provisioning.
deprovisionWarehouse: (id: string) =>
Expand Down
5 changes: 5 additions & 0 deletions controlplane/admin/ui/src/lib/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const ACTION_LABELS: Record<string, string> = {
"warehouse.create": "Created warehouse config",
"warehouse.update": "Updated warehouse config",
"warehouse.delete": "Deleted warehouse config",
// Lifecycle actions on the shared provisioning API — real infrastructure,
// as opposed to the config-row edits above.
"warehouse.provision": "Provisioned warehouse",
"warehouse.deprovision": "Deprovisioned warehouse",
"warehouse.reset_password": "Reset warehouse root password",

"team.create": "Created org team",
"team.update": "Updated org team",
Expand Down
Loading
Loading