fix(security): prevent ordinary workspace from self-minting a second org root (priv-esc)
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (staging) (pull_request) Has been skipped
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 21s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 55s
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
qa-review / approved (pull_request_target) Failing after 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
E2E Chat / E2E Chat (pull_request) Successful in 6s
security-review / approved (pull_request_target) Failing after 4s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 34s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request_target) Successful in 31s
Harness Replays / Harness Replays (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m16s
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (compile+skip) (pull_request) Successful in 22s
Harness Replays / detect-changes (pull_request) Successful in 10s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 6m21s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Has been skipped
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Has been skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 4m2s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Failing after 4m18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 41s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 25s
Check migration collisions / Migration version collision check (pull_request) Successful in 1m30s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m24s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m38s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5m20s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m27s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 32s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Successful in 1m24s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Failing after 7m8s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 6m18s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 7m29s
CI / Detect changes (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 21s
CI / Platform (Go) (pull_request) Successful in 4m2s
CI / Canvas (Next.js) (pull_request) Failing after 8m40s
CI / all-required (pull_request) Has been skipped
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (staging) (pull_request) Has been skipped
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 21s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 55s
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
qa-review / approved (pull_request_target) Failing after 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
E2E Chat / E2E Chat (pull_request) Successful in 6s
security-review / approved (pull_request_target) Failing after 4s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 34s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request_target) Successful in 31s
Harness Replays / Harness Replays (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m16s
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (compile+skip) (pull_request) Successful in 22s
Harness Replays / detect-changes (pull_request) Successful in 10s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 6m21s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Has been skipped
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Has been skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 4m2s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Failing after 4m18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 41s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 25s
Check migration collisions / Migration version collision check (pull_request) Successful in 1m30s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m24s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m38s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5m20s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m27s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 32s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Successful in 1m24s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Failing after 7m8s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 6m18s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 7m29s
CI / Detect changes (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 21s
CI / Platform (Go) (pull_request) Successful in 4m2s
CI / Canvas (Next.js) (pull_request) Failing after 8m40s
CI / all-required (pull_request) Has been skipped
CI / Canvas Deploy Status (pull_request) Has been skipped
Independent security review of #2385 found a privilege-escalation path: POST /registry/register is bootstrap-allowed for a fresh workspace id and wrote the caller-supplied kind, while workspaces_platform_root_check only enforces 'platform => parent_id IS NULL' (NOT a single root). So an ordinary in-VPC workspace could register a fresh UUID as {"kind":"platform"}, mint a second org root, and POST /workspaces/:id/restart it — the shared provision path then injects MOLECULE_API_KEY=ADMIN_TOKEN (tenant-wide org-admin credential) into any kind='platform' workspace, on self-host AND SaaS. That breaks the invariant that only the concierge gets the org MCP + admin token. Defense in depth: - migration 20260607000000_one_platform_root: partial UNIQUE index (kind) WHERE kind='platform' — at most one platform root per (single-org) tenant DB. isPlatformRootViolation now also maps the 23505 to a friendly 409. - registry.go Register: app-layer guard refusing to CREATE or PROMOTE a row to kind='platform' via the public path (reserve that for the AdminAuth/boot-gated install paths); a platform agent re-registering its already-platform row is unaffected. Placed after the token check to avoid side-channeling row existence. - corrected the false 'CHECK structurally guarantees one per org' claims in the 20260606 migration + integration-test header. Tests: - registry_test.go: rejects fresh kind=platform (403), rejects workspace->platform promotion (403), allows already-platform re-register (200). - kind_platform_root_integration_test.go: real-PG test that a SECOND platform root is rejected by the unique index (the CHECK alone accepts it). - canvas-topology-pure.test.ts: cover stripPlatformRootForMap (QA HIGH gap) — abs-position reparent math, platform-edge drop, grandchild preservation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,9 @@ import {
|
||||
PARENT_SIDE_PADDING,
|
||||
PARENT_HEADER_PADDING,
|
||||
PARENT_BOTTOM_PADDING,
|
||||
stripPlatformRootForMap,
|
||||
} from "../canvas-topology";
|
||||
import { WORKSPACE_KIND } from "../../lib/workspace-kind";
|
||||
|
||||
// Layout-math aliases so these assertions track the card-size constants
|
||||
// instead of hard-coding pixel values (which drift when the card size
|
||||
@@ -277,3 +279,74 @@ describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
expect(wide.width).toBeGreaterThan(narrow.width);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripPlatformRootForMap ───────────────────────────────────────────────────
|
||||
|
||||
describe("stripPlatformRootForMap", () => {
|
||||
// Minimal Node<WorkspaceNodeData> builder — only the fields the function reads.
|
||||
const node = (
|
||||
id: string,
|
||||
opts: { kind?: string; parentId?: string; x?: number; y?: number } = {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): any => ({
|
||||
id,
|
||||
position: { x: opts.x ?? 0, y: opts.y ?? 0 },
|
||||
parentId: opts.parentId,
|
||||
data: { kind: opts.kind ?? WORKSPACE_KIND.Workspace, parentId: opts.parentId ?? null },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const edge = (source: string, target: string): any => ({ id: `${source}->${target}`, source, target });
|
||||
|
||||
it("returns input unchanged when there is no platform node", () => {
|
||||
const nodes = [node("a"), node("b", { parentId: "a", x: 5, y: 5 })];
|
||||
const edges = [edge("a", "b")];
|
||||
const out = stripPlatformRootForMap(nodes, edges);
|
||||
expect(out.nodes).toBe(nodes); // same reference — no work done
|
||||
expect(out.edges).toBe(edges);
|
||||
});
|
||||
|
||||
it("removes the platform root, promotes its direct children to absolute positions, and drops platform-touching edges", () => {
|
||||
const platform = node("P", { kind: WORKSPACE_KIND.Platform, x: 100, y: 50 });
|
||||
const child = node("c", { parentId: "P", x: 10, y: 20 }); // RF-relative to P
|
||||
const grandchild = node("g", { parentId: "c", x: 5, y: 5 });
|
||||
const out = stripPlatformRootForMap(
|
||||
[platform, child, grandchild],
|
||||
[edge("P", "c"), edge("c", "g")],
|
||||
);
|
||||
|
||||
// Platform node is gone.
|
||||
expect(out.nodes.find((n) => n.id === "P")).toBeUndefined();
|
||||
|
||||
// Direct child promoted to top-level with absolute position (parentPos + childPos).
|
||||
const c = out.nodes.find((n) => n.id === "c")!;
|
||||
expect(c.parentId).toBeUndefined();
|
||||
expect(c.extent).toBeUndefined();
|
||||
expect(c.position).toEqual({ x: 110, y: 70 });
|
||||
expect(c.data.parentId).toBeNull();
|
||||
|
||||
// Grandchild (child of a non-platform node) is untouched.
|
||||
const g = out.nodes.find((n) => n.id === "g")!;
|
||||
expect(g.parentId).toBe("c");
|
||||
expect(g.position).toEqual({ x: 5, y: 5 });
|
||||
|
||||
// Edge touching the platform node dropped; the other preserved.
|
||||
expect(out.edges.map((e) => e.id)).toEqual(["c->g"]);
|
||||
});
|
||||
|
||||
it("leaves children of an ordinary (non-platform) parent untouched", () => {
|
||||
const platform = node("P", { kind: WORKSPACE_KIND.Platform });
|
||||
const ordinaryParent = node("op", { parentId: "P", x: 200, y: 0 });
|
||||
const grandchild = node("gc", { parentId: "op", x: 7, y: 9 });
|
||||
const out = stripPlatformRootForMap([platform, ordinaryParent, grandchild], []);
|
||||
|
||||
// op is a direct child of platform → promoted (absolute = 200+0, 0+0).
|
||||
const op = out.nodes.find((n) => n.id === "op")!;
|
||||
expect(op.parentId).toBeUndefined();
|
||||
expect(op.position).toEqual({ x: 200, y: 0 });
|
||||
|
||||
// gc's parent is the ordinary node, not platform → relative position preserved.
|
||||
const gc = out.nodes.find((n) => n.id === "gc")!;
|
||||
expect(gc.parentId).toBe("op");
|
||||
expect(gc.position).toEqual({ x: 7, y: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,17 @@
|
||||
//
|
||||
// Why this is NOT a sqlmock test
|
||||
// ------------------------------
|
||||
// The invariant "a platform agent must be the org root (parent_id IS NULL),
|
||||
// which structurally also means at most one platform agent per org" is enforced
|
||||
// by the workspaces_platform_root_check CHECK constraint in migration
|
||||
// 20260606000000_workspaces_kind. sqlmock cannot execute DDL or evaluate a CHECK
|
||||
// constraint, so only a real Postgres can prove the constraint actually rejects
|
||||
// a non-root platform agent and accepts a root one. The Register handler's
|
||||
// isPlatformRootViolation()/409 path depends on this constraint firing.
|
||||
// Two DB-level invariants back the platform agent:
|
||||
// - "a platform agent must be the org root (parent_id IS NULL)" — the
|
||||
// workspaces_platform_root_check CHECK in migration 20260606000000.
|
||||
// - "at most one platform agent per org" — the partial unique index
|
||||
// uniq_workspaces_one_platform_root in migration 20260607000000. The CHECK
|
||||
// does NOT bound the count (it permits multiple parentless platform rows);
|
||||
// the unique index does. This closes a privilege-escalation path (a rogue
|
||||
// second org root getting the org-admin token at provision time).
|
||||
// sqlmock cannot execute DDL or evaluate these, so only a real Postgres can
|
||||
// prove they fire. The Register handler's isPlatformRootViolation()/409 path
|
||||
// depends on both constraints.
|
||||
|
||||
package handlers
|
||||
|
||||
@@ -120,3 +124,64 @@ func TestIntegration_PlatformKind_RootAllowed_NonRootRejected(t *testing.T) {
|
||||
t.Fatalf("unknown kind wanted workspaces_kind_check rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_PlatformKind_SecondRootRejected proves the privilege-escalation
|
||||
// fix at the DB level: the workspaces_platform_root_check CHECK alone permits
|
||||
// MULTIPLE parentless platform rows; the partial unique index
|
||||
// uniq_workspaces_one_platform_root (migration 20260607000000) forbids a SECOND
|
||||
// platform root. Without it, an ordinary in-VPC workspace could register a fresh
|
||||
// UUID as kind='platform' and mint itself a second org root that then gets the
|
||||
// org-admin token at provision time. This is what the per-row CHECK could not
|
||||
// stop — only a real Postgres with the unique index proves it.
|
||||
func TestIntegration_PlatformKind_SecondRootRejected(t *testing.T) {
|
||||
conn := integrationDB_PlatformKind(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := fmt.Sprintf("itest-2root-%s", uuid.New().String()[:8])
|
||||
cleanup := func() {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
|
||||
t.Logf("cleanup (non-fatal): %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
cleanup()
|
||||
|
||||
// NOTE: the shared integration DB is single-org by construction, but a stray
|
||||
// platform row from another suite would make the FIRST insert below collide
|
||||
// instead of the second. Guard by asserting we start from zero platform rows
|
||||
// for our prefix and using a savepoint-free, prefix-scoped check.
|
||||
first := uuid.New().String()
|
||||
second := uuid.New().String()
|
||||
|
||||
// First parentless platform root: allowed.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, kind, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 'platform', 0, 'claude-code', 'online', NULL)
|
||||
`, first, prefix+"-first"); err != nil {
|
||||
// If this fails on the unique index, another platform root already exists
|
||||
// in the shared DB — skip rather than false-fail this isolation-sensitive case.
|
||||
if strings.Contains(err.Error(), "uniq_workspaces_one_platform_root") {
|
||||
t.Skipf("shared integration DB already has a platform root; cannot isolate: %v", err)
|
||||
}
|
||||
t.Fatalf("first platform root insert: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Second parentless platform root: the per-row CHECK is satisfied
|
||||
// (parent_id IS NULL), so ONLY the unique index can reject it.
|
||||
_, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, kind, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 'platform', 0, 'claude-code', 'online', NULL)
|
||||
`, second, prefix+"-second")
|
||||
if err == nil {
|
||||
t.Fatalf("second platform root accepted — uniq_workspaces_one_platform_root did not fire (privilege-escalation guard missing)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "uniq_workspaces_one_platform_root") {
|
||||
t.Fatalf("second platform root rejection wanted uniq_workspaces_one_platform_root, got: %v", err)
|
||||
}
|
||||
|
||||
// And isPlatformRootViolation maps it to the friendly 409 surface.
|
||||
if !isPlatformRootViolation(err) {
|
||||
t.Fatalf("isPlatformRootViolation should classify the unique-index violation as a platform-root 409, got false for: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
|
||||
|
||||
// errPlatformNotRoot is the client-facing message when a register call tried to
|
||||
// mark a non-root workspace as a platform agent.
|
||||
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null)"
|
||||
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null) and there can be only one per org"
|
||||
|
||||
// isPlatformRootViolation reports whether err is the DB rejecting a register
|
||||
// that tried to mark a non-root workspace as a platform agent (the
|
||||
@@ -175,7 +175,15 @@ const errPlatformNotRoot = "a platform agent must be the org root (parent_id mus
|
||||
// which structurally also guarantees one platform agent per org — is enforced
|
||||
// race-proof at the DB level; this is just the friendly surface.
|
||||
func isPlatformRootViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "workspaces_platform_root_check")
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
// workspaces_platform_root_check: tried to mark a non-root (parented) row
|
||||
// platform. uniq_workspaces_one_platform_root: tried to create a SECOND
|
||||
// platform root. Both surface as a friendly 409 instead of a raw 500.
|
||||
return strings.Contains(msg, "workspaces_platform_root_check") ||
|
||||
strings.Contains(msg, "uniq_workspaces_one_platform_root")
|
||||
}
|
||||
|
||||
// Returns a non-nil error suitable for including in a 400 Bad Request response.
|
||||
@@ -353,6 +361,32 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
return // 401 response already written by requireWorkspaceToken
|
||||
}
|
||||
|
||||
// SECURITY (privilege-escalation fix): the public register path must never
|
||||
// CREATE or PROMOTE a row to kind='platform'. The org root is minted only by
|
||||
// the AdminAuth/boot-gated install paths (InstallPlatformAgent /
|
||||
// EnsureSelfHostedPlatformAgent). Without this, an ordinary in-VPC workspace
|
||||
// could register a fresh UUID as {"kind":"platform"} (a bootstrap-allowed call,
|
||||
// parent_id defaults NULL so the per-row CHECK is satisfied) and then be
|
||||
// provisioned with the tenant org-admin token (MOLECULE_API_KEY=ADMIN_TOKEN).
|
||||
// A platform agent re-registering its already-platform row (or omitting kind)
|
||||
// is unaffected. uniq_workspaces_one_platform_root is the structural backstop;
|
||||
// this is the friendly app-layer guard. Placed after the token check so it
|
||||
// doesn't side-channel row existence (mirrors resolveDeliveryMode below).
|
||||
if payload.Kind == models.KindPlatform {
|
||||
var existingKind string
|
||||
kErr := db.DB.QueryRowContext(ctx,
|
||||
`SELECT kind FROM workspaces WHERE id = $1`, payload.ID).Scan(&existingKind)
|
||||
switch {
|
||||
case errors.Is(kErr, sql.ErrNoRows), kErr == nil && existingKind != models.KindPlatform:
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "kind='platform' may only be assigned by the platform-agent install path"})
|
||||
return
|
||||
case kErr != nil && !errors.Is(kErr, sql.ErrNoRows):
|
||||
log.Printf("Registry register: kind precheck failed for %s: %v", payload.ID, kErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the EFFECTIVE delivery mode for THIS register call: the
|
||||
// payload's explicit value wins; falling back to the existing row's
|
||||
// stored value; falling back to push (the schema default). Done AFTER
|
||||
|
||||
@@ -1700,12 +1700,12 @@ func TestRegister_InvalidKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_PlatformKind_PersistsKind verifies that a workspace registering
|
||||
// with kind="platform" has that value written through the upsert (the platform
|
||||
// agent self-registers as the org root). The platform==root invariant itself is
|
||||
// enforced by the workspaces_platform_root_check DB constraint and exercised by
|
||||
// the integration test, which sqlmock cannot enforce.
|
||||
func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
// TestRegister_AllowsAlreadyPlatformReRegister verifies that a workspace whose
|
||||
// row is ALREADY kind="platform" (pre-seeded by the AdminAuth/boot-gated install
|
||||
// path) may re-register through the public /registry/register path with
|
||||
// kind="platform", and the value is preserved through the upsert. This is the
|
||||
// legitimate platform-agent boot flow.
|
||||
func TestRegister_AllowsAlreadyPlatformReRegister(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
@@ -1718,6 +1718,12 @@ func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// SECURITY precheck: the row is already kind="platform", so the re-register
|
||||
// is allowed to proceed.
|
||||
mock.ExpectQuery("SELECT kind FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("platform"))
|
||||
|
||||
// delivery_mode="push" is set explicitly, so resolveDeliveryMode
|
||||
// short-circuits (no SELECT delivery_mode lookup). The upsert MUST carry
|
||||
// kind="platform" as the 6th arg.
|
||||
@@ -1751,7 +1757,83 @@ func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("platform register: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("already-platform re-register: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_RejectsFreshPlatformKind locks the privilege-escalation fix: the
|
||||
// public /registry/register path must NOT let a brand-new (fresh-id) workspace
|
||||
// declare kind="platform" and mint itself a second org root. It must be refused
|
||||
// (403) before any upsert — only the AdminAuth/boot-gated install paths may mint
|
||||
// the platform agent.
|
||||
func TestRegister_RejectsFreshPlatformKind(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewRegistryHandler(newTestBroadcaster())
|
||||
|
||||
const wsID = "ws-rogue-fresh"
|
||||
|
||||
// Bootstrap path — no live tokens (a fresh id).
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// SECURITY precheck: no existing row → empty result → sql.ErrNoRows → refuse.
|
||||
// No upsert / token issuance must follow.
|
||||
mock.ExpectQuery("SELECT kind FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"rogue"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("fresh kind=platform register: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_RejectsPlatformPromotion locks the other half of the fix: a row
|
||||
// that already exists as kind="workspace" must NOT be promotable to "platform"
|
||||
// via the public register path (which would later get it provisioned with the
|
||||
// org-admin token). Refused (403) before the upsert.
|
||||
func TestRegister_RejectsPlatformPromotion(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewRegistryHandler(newTestBroadcaster())
|
||||
|
||||
const wsID = "ws-ordinary"
|
||||
|
||||
// Has no live tokens for test simplicity (bootstrap-allowed call).
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// SECURITY precheck: existing row is kind="workspace" → refuse promotion.
|
||||
mock.ExpectQuery("SELECT kind FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("workspace"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"rogue"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("promote workspace->platform: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
|
||||
@@ -28,10 +28,12 @@ BEGIN
|
||||
END $$;
|
||||
|
||||
-- platform == org root, enforced at the DB level (race-proof). A platform agent
|
||||
-- MUST have parent_id IS NULL. Because an org is the subtree under a single
|
||||
-- parent_id IS NULL root (org_scope.go) and only a root may be 'platform', this
|
||||
-- also structurally guarantees at most ONE platform agent per org. The handler
|
||||
-- additionally pre-checks this to return a friendly 409 instead of a raw 23514.
|
||||
-- MUST have parent_id IS NULL. NOTE: this CHECK alone does NOT bound the number
|
||||
-- of platform rows — it permits multiple parentless platform roots. "At most one
|
||||
-- platform agent per org" is enforced by the partial unique index added in
|
||||
-- 20260607000000_one_platform_root (uniq_workspaces_one_platform_root); see that
|
||||
-- migration for the privilege-escalation rationale. The handler additionally
|
||||
-- pre-checks both to return a friendly 409 instead of a raw 23514/23505.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_platform_root_check') THEN
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS uniq_workspaces_one_platform_root;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Enforce AT MOST ONE platform agent (org root) per tenant DB — race-proof, in
|
||||
-- pure SQL. (RFC: docs/design/rfc-platform-agent.md; security fix.)
|
||||
--
|
||||
-- The prior 20260606000000_workspaces_kind migration added
|
||||
-- workspaces_platform_root_check = CHECK (kind <> 'platform' OR parent_id IS NULL),
|
||||
-- which only enforces "a platform row must be parentless". It does NOT prevent a
|
||||
-- SECOND parentless platform row. That gap is exploitable: POST /registry/register
|
||||
-- is bootstrap-allowed for a fresh workspace id, and the upsert wrote the
|
||||
-- caller-supplied kind — so an ordinary in-VPC workspace could register a new UUID
|
||||
-- as {"kind":"platform"} (parent_id defaults NULL → CHECK satisfied), mint itself a
|
||||
-- second org root, then POST /workspaces/:id/restart it. The shared provision path
|
||||
-- injects the tenant org-admin credential (MOLECULE_API_KEY=ADMIN_TOKEN) into any
|
||||
-- kind='platform' workspace, so that rogue root would gain full org-admin reach —
|
||||
-- a privilege escalation past the "only the concierge gets the org MCP + admin
|
||||
-- token" invariant.
|
||||
--
|
||||
-- A partial UNIQUE index over (kind) WHERE kind='platform' permits exactly one
|
||||
-- platform row table-wide. Because the per-tenant DB is single-org, that is "one
|
||||
-- platform agent per org". The legitimate install paths (InstallPlatformAgent /
|
||||
-- EnsureSelfHostedPlatformAgent) upsert the platform row by its fixed id, so they
|
||||
-- are unaffected; only a SECOND, differently-id'd platform row is rejected (23505),
|
||||
-- which the Register handler maps to a friendly 409. The handler also rejects the
|
||||
-- create/promote-via-register at the app layer; this index is the structural
|
||||
-- backstop that holds regardless of code path.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_workspaces_one_platform_root
|
||||
ON workspaces (kind)
|
||||
WHERE kind = 'platform';
|
||||
Reference in New Issue
Block a user