diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 04b37b751f..510679acac 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -229,6 +229,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled(); }, ); + + describe("cloud-managed adapter code install floor", () => { + beforeEach(() => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + }); + + it.each(["install", "reinstall"] as const)( + "floors adapter %s off for instance admins on cloud-managed instances", + async (routeName) => { + resetInstalledExternalAdapterState(); + if (routeName !== "install") { + seedInstalledExternalAdapter(); + } + const app = createApp(instanceAdmin); + + const res = await sendMutatingRequest(app, routeName); + + expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403); + expect(res.body.details).toMatchObject({ code: "adapter_install_platform_managed" }); + expect(mocks.execFile).not.toHaveBeenCalled(); + expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled(); + }, + ); + }); }); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index f7f5375d46..1045d00aa3 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1492,6 +1492,39 @@ describeEmbeddedPostgres("authorization service", () => { expect(sessionDecision).toMatchObject({ allowed: true, reason: "allow_instance_admin" }); }); + it("trusts the computed isInstanceAdmin flag on cloud_tenant actors", async () => { + // The trusted-header resolver is the only code path that can set + // isInstanceAdmin on a cloud_tenant actor (stack owner + + // enableOwnerInstanceAdmin). The authorization service must honor the + // computed flag without consulting instance_user_roles. + const tenantCompany = await createCompany(db, "CloudTenantOwnerAdmin"); + const otherCompany = await createCompany(db, "CloudTenantOwnerAdminOther"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, otherCompany.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: tenantCompany.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "owner", + }); + // Deliberately NO instanceUserRoles row: elevation is computed, not stored. + + const decision = await authorizationService(db).decide({ + actor: { + type: "board", + userId, + companyIds: [tenantCompany.id], + isInstanceAdmin: true, + source: "cloud_tenant", + }, + action: "tasks:assign", + resource: { type: "issue", companyId: otherCompany.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + expect(decision).toMatchObject({ allowed: true, reason: "allow_instance_admin" }); + }); + it("denies simple-mode assignment to a target agent from another company", async () => { const sourceCompany = await createCompany(db, "AssignmentSource"); const targetCompany = await createCompany(db, "AssignmentTarget"); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 5b5e1672f4..c6c33353b3 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1,7 +1,7 @@ import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { environmentRoutes } from "../routes/environments.js"; import { errorHandler } from "../middleware/index.js"; @@ -27,6 +27,7 @@ const mockProjectService = vi.hoisted(() => ({ const mockInstanceSettingsService = vi.hoisted(() => ({ listCompanyIds: vi.fn(), + getGeneral: vi.fn(), })); const mockEnvironmentService = vi.hoisted(() => ({ @@ -228,6 +229,8 @@ describe("environment routes", () => { mockProjectService.getById.mockReset(); mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset(); + mockInstanceSettingsService.getGeneral.mockReset(); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "any" }); mockEnvironmentService.list.mockReset(); mockEnvironmentService.list.mockResolvedValue([]); mockEnvironmentService.getById.mockReset(); @@ -383,6 +386,434 @@ describe("environment routes", () => { }); }); + describe("platform-provisioned environment floor on cloud-managed instances", () => { + function createPlatformSandboxEnvironment() { + const now = new Date("2026-04-16T05:00:00.000Z"); + return { + id: "env-managed-1", + companyId: "company-1", + name: "Daytona", + description: "Managed sandbox environment", + driver: "sandbox", + status: "active" as const, + config: { + provider: "daytona", + image: "custom-image:latest", + target: "us", + apiKey: "must-never-echo", + }, + envVars: { DAYTONA_API_KEY: "must-never-echo" }, + metadata: { managedByPaperclip: true, managedSandboxProvider: "daytona" }, + createdAt: now, + updatedAt: now, + }; + } + + const ownerAdminActor = { + type: "board", + userId: "owner-1", + source: "cloud_tenant", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "owner" }], + isInstanceAdmin: true, + }; + + // A minimal valid managed-config document whose `environments` entry makes + // the managed-sandbox provisioner own the marked sandbox slot row. + const MANAGED_CONFIG_WITH_SANDBOX_ENTRY = JSON.stringify({ + v: 1, + mode: "cloud", + catalogVersion: "2026.720.0", + features: {}, + plugins: { autoInstall: [] }, + environments: [{ name: "Sandbox", provider: "daytona" }], + }); + + beforeEach(() => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + }); + + it("never echoes env vars or credential-shaped config keys to instance admins", async () => { + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp(ownerAdminActor); + + const res = await request(app).get("/api/environments/env-managed-1"); + + expect(res.status).toBe(200); + expect(res.body.envVars).toEqual({}); + expect(res.body.config).toEqual({ + provider: "daytona", + image: "custom-image:latest", + target: "us", + }); + expect(res.body.metadata).toMatchObject({ managedByPaperclip: true }); + expect(JSON.stringify(res.body)).not.toContain("must-never-echo"); + }); + + it("exposes structural config to restricted company readers instead of blanking it", async () => { + mockEnvironmentService.list.mockResolvedValue([createPlatformSandboxEnvironment()]); + const app = createApp({ + type: "board", + userId: "user-2", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(200); + expect(res.body[0].config).toEqual({ + provider: "daytona", + image: "custom-image:latest", + target: "us", + }); + expect(res.body[0].envVars).toEqual({}); + expect(res.body[0].metadata).toMatchObject({ managedByPaperclip: true }); + }); + + it("rejects updates to platform-provisioned rows, including for instance admins", async () => { + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp(ownerAdminActor); + + const res = await request(app).patch("/api/environments/env-managed-1").send({ name: "Renamed" }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("allows a marker-clear-only patch to unblock a row with a stale legacy kubernetes marker", async () => { + // A sandbox row carrying only the legacy wrapper marker does not hold + // the managed sandbox slot (`environments_managed_sandbox_idx` keys on + // `managedByPaperclip`), and with the persisted execution mode not + // forcing kubernetes nothing selects rows by that marker either — so + // the marker is a stale leftover, not live platform state. + const staleRow = { + ...createPlatformSandboxEnvironment(), + id: "env-legacy-1", + metadata: { managedKubernetesSandbox: true }, + }; + mockEnvironmentService.getById.mockResolvedValue(staleRow); + mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} }); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-legacy-1") + .send({ metadata: { managedKubernetesSandbox: false } }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalled(); + }); + + it("allows a marker-clear-only patch on a non-slot driver with a stale platform marker", async () => { + const staleRow = { + ...createPlatformSandboxEnvironment(), + id: "env-stale-ssh-1", + driver: "ssh", + metadata: { managedByPaperclip: true }, + }; + mockEnvironmentService.getById.mockResolvedValue(staleRow); + mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} }); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-stale-ssh-1") + .send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalled(); + }); + + it("refuses the marker-clear patch on the sandbox slot row while managed provisioning is configured", async () => { + // With a managed-config `environments` entry, driver=sandbox + + // managedByPaperclip is THE provisioner-owned slot row, adopted and + // refreshed on every boot — clearing its markers would reclassify it + // tenant-managed and let the next PATCH/DELETE bypass the write floor. + process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_WITH_SANDBOX_ENTRY; + try { + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-managed-1") + .send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + } finally { + delete process.env.PAPERCLIP_MANAGED_CONFIG; + } + }); + + it("refuses the marker-clear patch on the sandbox slot row under the forced kubernetes execution mode", async () => { + // PAPERCLIP_EXECUTION_MODE=kubernetes is the other bootstrap path that + // owns (adopts and refreshes) the single marked sandbox row. + process.env.PAPERCLIP_EXECUTION_MODE = "kubernetes"; + try { + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-managed-1") + .send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + } finally { + delete process.env.PAPERCLIP_EXECUTION_MODE; + } + }); + + it("refuses the marker-clear patch on a kubernetes-marked row while the persisted execution mode forces kubernetes", async () => { + // `findKubernetesEnvironment` selects sandbox rows by the legacy + // marker alone whenever the persisted executionMode forces kubernetes + // — including when the bootstrap env that seeded the setting is gone + // (rollback / config drift, which the heartbeat handles explicitly). + // Clearing the marker would declassify the live runtime row. + mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "kubernetes" }); + mockEnvironmentService.getById.mockResolvedValue({ + ...createPlatformSandboxEnvironment(), + id: "env-legacy-1", + metadata: { managedKubernetesSandbox: true }, + }); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-legacy-1") + .send({ metadata: { managedKubernetesSandbox: false } }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("refuses the marker-clear patch on the kubernetes-managed row when only the persisted execution mode remains forced", async () => { + // Drift variant with the fully-stamped managed row: no managed-config + // entry and no bootstrap env, but the persisted executionMode still + // forces kubernetes, so the marker keeps selecting this row for runs. + mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "kubernetes" }); + mockEnvironmentService.getById.mockResolvedValue({ + ...createPlatformSandboxEnvironment(), + metadata: { + managedByPaperclip: true, + managedSandboxProvider: "kubernetes", + managedKubernetesSandbox: true, + }, + }); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-managed-1") + .send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("allows the marker-clear patch on a marked sandbox row when no provisioning path is configured", async () => { + // Without a managed-config `environments` entry or a forced execution + // mode, nothing on this instance provisions a sandbox environment, so a + // platform marker on a sandbox row can only be a stale leftover of the + // old unrestricted API — the recovery hatch must apply or the row is + // locked forever. + const staleRow = createPlatformSandboxEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(staleRow); + mockEnvironmentService.update.mockResolvedValue({ ...staleRow, metadata: {} }); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-managed-1") + .send({ metadata: { managedByPaperclip: false, managedKubernetesSandbox: false } }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalled(); + }); + + it("refuses the marker-clear patch on the managed local row", async () => { + // The local slot needs no configuration check: on a cloud-managed + // instance `ensureLocalEnvironment` adopts and stamps the single local + // row from every caller, so its markers are always live platform state. + mockEnvironmentService.getById.mockResolvedValue({ + ...createPlatformSandboxEnvironment(), + id: "env-local-1", + driver: "local", + metadata: { managedByPaperclip: true, defaultForInstance: true }, + }); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-local-1") + .send({ metadata: { managedByPaperclip: false } }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("rejects non-marker-only patches to platform-provisioned rows even from instance admins", async () => { + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-managed-1") + .send({ name: "Renamed", metadata: { managedByPaperclip: false } }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("rejects deletes of platform-provisioned rows, including for instance admins", async () => { + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp(ownerAdminActor); + + const res = await request(app).delete("/api/environments/env-managed-1"); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); + expect(mockEnvironmentService.getDeleteBlastRadius).not.toHaveBeenCalled(); + expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled(); + }); + + it("rejects creates that stamp platform markers so tenants cannot self-lock rows", async () => { + const app = createApp(ownerAdminActor); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Fake managed", + driver: "sandbox", + config: { provider: "daytona" }, + metadata: { managedByPaperclip: true }, + }); + + expect(res.status).toBe(422); + expect(res.body.details).toMatchObject({ code: "environment_platform_marker_reserved" }); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + + it("rejects tenant patches that stamp platform markers so the row cannot become locked", async () => { + const tenantEnvironment = { + ...createPlatformSandboxEnvironment(), + id: "env-tenant-1", + metadata: { source: "manual" }, + }; + mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment); + const app = createApp(ownerAdminActor); + + const res = await request(app) + .patch("/api/environments/env-tenant-1") + .send({ metadata: { source: "manual", managedKubernetesSandbox: true } }); + + expect(res.status).toBe(422); + expect(res.body.details).toMatchObject({ code: "environment_platform_marker_reserved" }); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("accepts platform markers in client payloads on self-hosted instances", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + const existing = { + ...createPlatformSandboxEnvironment(), + id: "env-tenant-1", + metadata: { source: "manual" }, + }; + mockEnvironmentService.getById.mockResolvedValue(existing); + mockEnvironmentService.update.mockResolvedValue({ + ...existing, + metadata: { source: "manual", managedByPaperclip: true }, + }); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }); + + const res = await request(app) + .patch("/api/environments/env-tenant-1") + .send({ metadata: { source: "manual", managedByPaperclip: true } }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalled(); + }); + + it("still updates tenant-created environments for instance admins on cloud-managed instances", async () => { + const tenantEnvironment = { + ...createPlatformSandboxEnvironment(), + id: "env-tenant-1", + metadata: { source: "manual" }, + }; + mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment); + mockEnvironmentService.update.mockResolvedValue({ ...tenantEnvironment, name: "Renamed" }); + const app = createApp(ownerAdminActor); + + const res = await request(app).patch("/api/environments/env-tenant-1").send({ name: "Renamed" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Renamed"); + expect(mockEnvironmentService.update).toHaveBeenCalled(); + }); + + it("does not floor writes to platform-marked rows on self-hosted instances", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + const existing = createPlatformSandboxEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(existing); + mockEnvironmentService.update.mockResolvedValue({ ...existing, name: "Renamed" }); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }); + + const res = await request(app).patch("/api/environments/env-managed-1").send({ name: "Renamed" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Renamed"); + }); + + it("leaves tenant-created environments unfloored for instance admins", async () => { + const tenantEnvironment = { + ...createPlatformSandboxEnvironment(), + id: "env-tenant-1", + metadata: { source: "manual" }, + }; + mockEnvironmentService.getById.mockResolvedValue(tenantEnvironment); + const app = createApp(ownerAdminActor); + + const res = await request(app).get("/api/environments/env-tenant-1"); + + expect(res.status).toBe(200); + expect(res.body.envVars).toEqual({ DAYTONA_API_KEY: "must-never-echo" }); + expect(res.body.config.apiKey).toBe("must-never-echo"); + }); + + it("does not floor platform-marked rows on self-hosted instances", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }); + + const res = await request(app).get("/api/environments/env-managed-1"); + + expect(res.status).toBe(200); + expect(res.body.envVars).toEqual({ DAYTONA_API_KEY: "must-never-echo" }); + expect(res.body.config.apiKey).toBe("must-never-echo"); + }); + }); + it("rejects non-admin blast-radius reads for instance-scoped environments", async () => { const app = createApp({ type: "board", diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 4b3ef48810..4ce7c8f825 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -530,6 +530,79 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(rows[0]?.updatedAt.toISOString()).toBe(archivedAt.toISOString()); }); + it("adopts a pre-existing local row on a cloud-managed instance by stamping the platform marker", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + const [existing] = await db + .insert(environments) + .values({ + name: "Tenant Local", + driver: "local", + status: "active", + config: { shell: "zsh" }, + metadata: { owner: "operator" }, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-01T00:00:00.000Z"), + }) + .returning(); + + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + try { + const adopted = await svc.ensureLocalEnvironment(companyId); + + expect(adopted.id).toBe(existing?.id); + expect(adopted.name).toBe("Tenant Local"); + expect(adopted.metadata).toEqual({ owner: "operator", managedByPaperclip: true }); + + // Re-ensuring an already-adopted row must not rewrite it. + const adoptedRow = await db + .select() + .from(environments) + .where(eq(environments.driver, "local")) + .then((rows) => rows[0]); + const reused = await svc.ensureLocalEnvironment(companyId); + expect(reused.metadata).toEqual({ owner: "operator", managedByPaperclip: true }); + const reusedRow = await db + .select() + .from(environments) + .where(eq(environments.driver, "local")) + .then((rows) => rows[0]); + expect(reusedRow?.updatedAt.toISOString()).toBe(adoptedRow?.updatedAt.toISOString()); + } finally { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + } + }); + + it("does not stamp the platform marker on self-hosted instances (regression)", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(environments).values({ + name: "Tenant Local", + driver: "local", + status: "active", + config: {}, + metadata: { owner: "operator" }, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const ensured = await svc.ensureLocalEnvironment(companyId); + + expect(ensured.metadata).toEqual({ owner: "operator" }); + }); + it("deduplicates concurrent default local environment creation", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/__tests__/instance-database-backups-routes.test.ts b/server/src/__tests__/instance-database-backups-routes.test.ts index 87774a82e4..2d06223148 100644 --- a/server/src/__tests__/instance-database-backups-routes.test.ts +++ b/server/src/__tests__/instance-database-backups-routes.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/index.js"; import { instanceDatabaseBackupRoutes, @@ -146,4 +146,50 @@ describe("instance database backup routes", () => { expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Database backup already in progress" }); }); + + describe("cloud-managed floor", () => { + beforeEach(() => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + }); + + it("floors the manual trigger off for every instance admin on a cloud-managed instance", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }, + service, + ); + + const res = await request(app).post("/api/instance/database-backups").send({}); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "database_backups_platform_managed" }); + expect(service.runManualBackup).not.toHaveBeenCalled(); + }); + + it("floors the manual trigger off for a computed cloud_tenant owner-admin", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "owner-1", + source: "cloud_tenant", + isInstanceAdmin: true, + companyIds: ["company-1"], + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(403); + + expect(service.runManualBackup).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 71ea0135a1..a0adb781c6 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(), @@ -631,4 +631,99 @@ describe("instance settings routes", () => { expect(res.status).toBe(403); expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled(); }); + + describe("executionMode floor on cloud-managed instances", () => { + const adminActor = { + type: "board", + userId: "owner-1", + source: "cloud_tenant", + isInstanceAdmin: true, + companyIds: ["company-1"], + }; + + beforeEach(() => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + }); + + it("rejects a write that changes executionMode", async () => { + mockInstanceSettingsService.getGeneral.mockResolvedValue({ + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + executionMode: "kubernetes", + }); + const app = await createApp(adminActor); + + const res = await request(app) + .patch("/api/instance/settings/general") + .send({ executionMode: "any" }); + + expect(res.status).toBe(403); + expect(res.body.details).toMatchObject({ code: "execution_mode_platform_managed" }); + expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled(); + }); + + it("rejects pinning executionMode when the platform left it unrestricted", async () => { + const app = await createApp(adminActor); + + const res = await request(app) + .patch("/api/instance/settings/general") + .send({ executionMode: "kubernetes" }); + + expect(res.status).toBe(403); + expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled(); + }); + + it("allows a same-value executionMode echo so full-object settings forms keep working", async () => { + mockInstanceSettingsService.getGeneral.mockResolvedValue({ + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + executionMode: "kubernetes", + }); + const app = await createApp(adminActor); + + const res = await request(app) + .patch("/api/instance/settings/general") + .send({ executionMode: "kubernetes", keyboardShortcuts: true }); + + expect(res.status).toBe(200); + expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ + executionMode: "kubernetes", + keyboardShortcuts: true, + }); + }); + + it("allows general-settings writes that do not touch executionMode", async () => { + const app = await createApp(adminActor); + + const res = await request(app) + .patch("/api/instance/settings/general") + .send({ keyboardShortcuts: true }); + + expect(res.status).toBe(200); + expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); + expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ keyboardShortcuts: true }); + }); + + it("keeps executionMode writable on self-hosted instances", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + const app = await createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }); + + const res = await request(app) + .patch("/api/instance/settings/general") + .send({ executionMode: "kubernetes" }); + + expect(res.status).toBe(200); + expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ executionMode: "kubernetes" }); + }); + }); }); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 1c47205b6f..8a38433227 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -49,6 +49,7 @@ describe("instance settings service", () => { enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: false, + enableOwnerInstanceAdmin: false, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 5a60ccee90..8ad775b489 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -17,6 +17,7 @@ import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@pap import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "./logger.js"; import { boardAuthService } from "../services/board-auth.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; import { forbidden, unprocessable } from "../errors.js"; @@ -379,15 +380,43 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa * Whether this instance is managed by a Paperclip Cloud control plane. * When the tenant server token is configured, the control plane owns the * user/identity lifecycle for this instance: users arrive through trusted - * headers (resolveCloudTenantActor) and are deliberately never granted - * instance_admin. Surfaces that assume a self-hosted operator will claim - * the instance (e.g. the first-admin bootstrap gate) should treat a - * cloud-managed instance as already set up. + * headers (resolveCloudTenantActor) and are deliberately never granted the + * `instance_admin` DB role. The only elevation a cloud tenant can carry is + * computed per request at the trusted-header boundary (owner stack role + + * the `enableOwnerInstanceAdmin` flag) and floored by code on + * platform-owned surfaces. Surfaces that assume a self-hosted operator + * will claim the instance (e.g. the first-admin bootstrap gate) should + * treat a cloud-managed instance as already set up. */ export function isCloudManagedInstance(): boolean { return Boolean(process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim()); } +/** + * Whether the trusted-header actor being resolved should carry computed + * instance-admin elevation: only the stack `owner` role elevates, and only + * while `enableOwnerInstanceAdmin` is enabled. The flag is resolved through + * the instance-settings service so the cloud managed-config overlay applies + * (the harness can turn elevation off fleet-wide without touching tenant + * DBs). Fails closed: a settings read error means no elevation. + */ +async function resolveOwnerInstanceAdmin( + db: Db, + stackRole: "owner" | "admin" | "member" | "support", +): Promise { + if (stackRole !== "owner") return false; + try { + const experimental = await instanceSettingsService(db).getExperimental(); + return experimental.enableOwnerInstanceAdmin === true; + } catch (err) { + logger.warn( + { err }, + "Failed to resolve enableOwnerInstanceAdmin for cloud tenant owner; treating elevation as disabled", + ); + return false; + } +} + export async function resolveCloudTenantActor(db: Db, req: Request): Promise { const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); if (!expectedToken) return null; @@ -500,7 +529,12 @@ export async function resolveCloudTenantActor(db: Db, req: Request): Promise | null; + selectThrows?: boolean; +} = {}) { + const membershipRow = + options.membershipRow ?? { companyId: "company-x", membershipRole: "owner", status: "active" }; + const settingsRow = + options.settingsRow === undefined + ? { + id: "00000000-0000-0000-0000-000000000001", + singletonKey: "default", + defaultEnvironmentId: null, + general: {}, + experimental: {}, + createdAt: new Date(), + updatedAt: new Date(), + } + : options.settingsRow; const insertedTables: unknown[] = []; const deletedTables: unknown[] = []; const chain: Record = {}; @@ -27,10 +47,33 @@ function createFakeDb(membershipRow = { companyId: "company-x", membershipRole: deletedTables.push(table); return chain; }, + select: () => { + if (options.selectThrows) throw new Error("select unavailable"); + return { + from: (table: unknown) => ({ + where: () => ({ + then: (resolve: (v: unknown) => unknown) => + Promise.resolve(table === instanceSettings && settingsRow ? [settingsRow] : []).then(resolve), + }), + }), + }; + }, } as unknown as Db; return { db, insertedTables, deletedTables }; } +function settingsRowWith(experimental: Record) { + return { + id: "00000000-0000-0000-0000-000000000001", + singletonKey: "default", + defaultEnvironmentId: null, + general: {}, + experimental, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + function fakeReq(headers: Record): Request { const lower: Record = {}; for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; @@ -45,15 +88,32 @@ const VALID_HEADERS = { "x-paperclip-cloud-stack-role": "owner", }; +const MANAGED_CONFIG_FLAG_ON = JSON.stringify({ + v: 1, + mode: "cloud", + catalogVersion: "test", + features: { enableOwnerInstanceAdmin: true }, + plugins: { autoInstall: [] }, +}); + +const MANAGED_CONFIG_FLAG_OFF = JSON.stringify({ + v: 1, + mode: "cloud", + catalogVersion: "test", + features: { enableOwnerInstanceAdmin: false }, + plugins: { autoInstall: [] }, +}); + describe("resolveCloudTenantActor (shared-pool hardening)", () => { beforeEach(() => { process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; }); afterEach(() => { delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_MANAGED_CONFIG; }); - it("never grants instance admin", async () => { + it("does not grant instance admin by default (flag off)", async () => { const { db, insertedTables } = createFakeDb(); const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); expect(actor).not.toBeNull(); @@ -94,7 +154,9 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => { }); it("maps a non-owner stack role through to the membership without elevating", async () => { - const { db } = createFakeDb({ companyId: "company-y", membershipRole: "member", status: "active" }); + const { db } = createFakeDb({ + membershipRow: { companyId: "company-y", membershipRole: "member", status: "active" }, + }); const actor = await resolveCloudTenantActor( db, fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "member" }), @@ -102,4 +164,69 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => { expect(actor!.isInstanceAdmin).toBe(false); expect(actor?.memberships?.[0]?.membershipRole).toBe("member"); }); + + describe("owner instance-admin elevation (enableOwnerInstanceAdmin)", () => { + it("elevates the owner while the flag is enabled, still without any role row", async () => { + const { db, insertedTables, deletedTables } = createFakeDb({ + settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: true }), + }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor!.isInstanceAdmin).toBe(true); + expect(actor!.source).toBe("cloud_tenant"); + // Elevation is computed, never persisted: no instance_user_roles insert, + // and the stale-row purge still runs on every authentication. + expect(insertedTables).not.toContain(instanceUserRoles); + expect(deletedTables).toContain(instanceUserRoles); + }); + + it("does not elevate the owner while the flag is disabled", async () => { + const { db } = createFakeDb({ + settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: false }), + }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor!.isInstanceAdmin).toBe(false); + }); + + it.each(["member", "admin", "support"] as const)( + "never elevates the %s stack role even with the flag enabled", + async (stackRole) => { + const { db } = createFakeDb({ + membershipRow: { companyId: "company-y", membershipRole: "member", status: "active" }, + settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: true }), + }); + const actor = await resolveCloudTenantActor( + db, + fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": stackRole }), + ); + expect(actor).not.toBeNull(); + expect(actor!.isInstanceAdmin).toBe(false); + }, + ); + + it("resolves the flag through the managed overlay: overlay on elevates over a DB value of off", async () => { + process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_FLAG_ON; + const { db } = createFakeDb({ + settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: false }), + }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor!.isInstanceAdmin).toBe(true); + }); + + it("resolves the flag through the managed overlay: overlay off wins over a DB value of on", async () => { + process.env.PAPERCLIP_MANAGED_CONFIG = MANAGED_CONFIG_FLAG_OFF; + const { db } = createFakeDb({ + settingsRow: settingsRowWith({ enableOwnerInstanceAdmin: true }), + }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor!.isInstanceAdmin).toBe(false); + }); + + it("fails closed when the settings read errors: actor resolves without elevation", async () => { + const { db, deletedTables } = createFakeDb({ selectThrows: true }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor).not.toBeNull(); + expect(actor!.isInstanceAdmin).toBe(false); + expect(deletedTables).toContain(instanceUserRoles); + }); + }); }); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 07132c4f2c..fe0d6d6cbf 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -43,11 +43,29 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; +import { forbidden } from "../errors.js"; +import { isCloudManagedInstance } from "../middleware/auth.js"; import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; const execFileAsync = promisify(execFile); +/** + * Floor: on cloud-managed instances adapter code is bundled into the platform + * image; fetching and loading external adapter packages at runtime stays off + * for every actor, including instance admins. Adapter code executes in the + * server process, so a runtime install would let an instance admin read the + * platform trust anchors from the process environment (mirrors the + * bundled-only plugin install floor in plugin-install-guard.ts). + */ +function assertAdapterCodeInstallAllowed() { + if (isCloudManagedInstance()) { + throw forbidden("Adapter installation is platform-managed on cloud-managed instances", { + code: "adapter_install_platform_managed", + }); + } +} + // --------------------------------------------------------------------------- // Request / Response types // --------------------------------------------------------------------------- @@ -232,6 +250,7 @@ export function adapterRoutes() { */ router.post("/adapters/install", async (req, res) => { assertInstanceAdmin(req); + assertAdapterCodeInstallAllowed(); const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; @@ -569,6 +588,7 @@ export function adapterRoutes() { // package name, but without the risk of losing the store record. router.post("/adapters/:type/reinstall", async (req, res) => { assertInstanceAdmin(req); + assertAdapterCodeInstallAllowed(); const type = req.params.type; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 86286d4b1a..e26f687ccc 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -15,6 +15,10 @@ import { updateEnvironmentSchema, } from "@paperclipai/shared"; import { conflict, forbidden, unprocessable } from "../errors.js"; +import { isCloudManagedInstance } from "../middleware/auth.js"; +import { getManagedInstanceConfig, SECRET_LIKE_CONFIG_KEY_PATTERN } from "../services/managed-config.js"; +import { parseExecutionPolicyBootstrapEnv } from "../services/execution-policy-bootstrap.js"; +import { isExecutionForcedToKubernetes } from "../services/execution-allowlist.js"; import { validate } from "../middleware/validate.js"; import { logger } from "../middleware/logger.js"; import { @@ -50,6 +54,211 @@ import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Whether this environment row was provisioned by the platform: the + * managed-config environment provisioner marker, or the legacy managed + * Kubernetes wrapper marker (rows created by builds that predate the + * generalized provisioner and have not been adopted yet). + */ +export function isPlatformProvisionedEnvironment(environment: { + metadata: Record | null; +}): boolean { + return ( + environment.metadata?.managedByPaperclip === true || + environment.metadata?.managedKubernetesSandbox === true + ); +} + +function redactSecretLikeConfigKeys(value: Record): Record { + const result: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (SECRET_LIKE_CONFIG_KEY_PATTERN.test(key)) continue; + if (isPlainRecord(child)) { + result[key] = redactSecretLikeConfigKeys(child); + } else if (Array.isArray(child)) { + result[key] = child.map((element) => + isPlainRecord(element) ? redactSecretLikeConfigKeys(element) : element, + ); + } else { + result[key] = child; + } + } + return result; +} + +/** + * Floor view of a platform-provisioned environment on a cloud-managed + * instance: env vars and credential-shaped config keys are never echoed — to + * ANY actor, including instance admins — while structural config (provider, + * image, template, region, ...) and the managed markers in `metadata` stay + * visible so admin surfaces can render the environment and show its + * platform-managed state. + */ +export function applyPlatformProvisionedEnvironmentFloor | null; + envVars?: Record | null; + metadata: Record | null; +}>(environment: T): T { + return { + ...environment, + config: redactSecretLikeConfigKeys(isPlainRecord(environment.config) ? environment.config : {}), + ...(Object.prototype.hasOwnProperty.call(environment, "envVars") ? { envVars: {} } : {}), + }; +} + +const PLATFORM_PROVISIONED_MARKER_KEYS = [ + "managedByPaperclip", + "managedKubernetesSandbox", +] as const; + +/** + * Whether some bootstrap path on this instance currently owns the managed + * sandbox slot: the managed-config `environments` section + * (`applyManagedEnvironments`) or the forced execution-mode bootstrap + * (`PAPERCLIP_EXECUTION_MODE=kubernetes`). Both adopt and refresh the + * marked sandbox row on every boot. Fails closed: an unparseable document + * or env value counts as configured, keeping the slot protected (a + * malformed value refuses startup anyway, so a running server never hits + * the catch). + */ +function isManagedSandboxProvisioningConfigured(): boolean { + try { + if ((getManagedInstanceConfig()?.environments.length ?? 0) > 0) return true; + } catch { + return true; + } + try { + return parseExecutionPolicyBootstrapEnv(process.env) !== null; + } catch { + return true; + } +} + +/** + * Whether this row occupies a provisioner-owned environment slot whose + * platform markers are LIVE state — i.e. some platform code path converges + * the markers on this exact row, so a marker here is never a stale + * leftover: + * + * - The single local row (`environments_local_driver_idx`): on a + * cloud-managed instance `ensureLocalEnvironment` adopts and stamps it + * from every caller (company creation, the heartbeat, run + * orchestration), so the local slot is platform-owned unconditionally. + * - The single marked sandbox row (`environments_managed_sandbox_idx`): + * owned only while a managed-sandbox bootstrap path is configured — + * `ensureManagedSandboxEnvironment` then adopts and refreshes whichever + * row holds the slot on every boot. + * - Any sandbox row bearing the legacy kubernetes marker while the + * persisted instance execution policy forces kubernetes: + * `findKubernetesEnvironment` selects marked rows (newest first) for + * every forced run, so under that policy each marked row is — or on the + * newest row's removal becomes — the live runtime row. This holds even + * with no bootstrap path configured: the per-run guard reads the + * persisted `executionMode`, which can outlive the env that seeded it. + * + * With no provisioning path configured and no forced-kubernetes policy the + * platform holds no claim on any sandbox row, so a platform marker there + * is stale by definition and stays recoverable via the marker-clear + * escape hatch below. + */ +async function isPlatformSlotEnvironment( + environment: { driver: string; metadata: Record | null }, + options: { isForcedKubernetesExecution: () => Promise }, +): Promise { + if (environment.driver === "local") return true; + if (environment.driver !== "sandbox") return false; + if ( + environment.metadata?.managedByPaperclip === true && + isManagedSandboxProvisioningConfigured() + ) { + return true; + } + return ( + environment.metadata?.managedKubernetesSandbox === true && + (await options.isForcedKubernetesExecution()) + ); +} + +/** + * Returns true when the PATCH body is a metadata-only write whose only + * purpose is to clear platform markers (setting them to null/false). This is + * the escape hatch for rows that had these markers stamped through the old + * unrestricted API before the floor was introduced. It never applies to a + * row whose slot markers are live (see `isPlatformSlotEnvironment`) — + * clearing a live slot row's markers would reclassify it as + * tenant-managed and lift the write floor in two steps. + */ +function isPlatformMarkerClearOnlyPatch(body: unknown): boolean { + if (!isPlainRecord(body)) return false; + const bodyKeys = Object.keys(body); + if (bodyKeys.length !== 1 || bodyKeys[0] !== "metadata") return false; + const metadata = body.metadata; + if (!isPlainRecord(metadata)) return false; + const metaKeys = Object.keys(metadata); + if (metaKeys.length === 0) return false; + return metaKeys.every( + (key) => + (PLATFORM_PROVISIONED_MARKER_KEYS as readonly string[]).includes(key) && + (metadata[key] === null || metadata[key] === false), + ); +} + +/** + * Floor: on cloud-managed instances, platform-provisioned rows are + * platform-owned runtime state — no actor, including instance admins, may + * update or delete them. Binds to the persisted row's markers, so a patch + * cannot strip the marker to lift the floor. + * + * Exception: a metadata-only patch that only clears the marker keys is + * allowed so tenants can recover rows stamped with stale markers by the old + * unrestricted API — for every row except those whose markers are live + * platform state (see `isPlatformSlotEnvironment`): there, clearing the + * markers would let the very next write reclassify the row as + * tenant-managed and bypass this floor. Every marker outside a live slot + * is stale by construction, so no row is ever locked unrecoverably. + */ +async function assertPlatformProvisionedEnvironmentWritable( + environment: { driver: string; metadata: Record | null }, + options?: { + patchBody: unknown; + isForcedKubernetesExecution: () => Promise; + }, +): Promise { + if (!isCloudManagedInstance() || !isPlatformProvisionedEnvironment(environment)) return; + if ( + options !== undefined && + isPlatformMarkerClearOnlyPatch(options.patchBody) && + !(await isPlatformSlotEnvironment(environment, options)) + ) return; + throw forbidden("Platform-provisioned environments are platform-managed on cloud-managed instances", { + code: "environment_platform_managed", + }); +} + +/** + * Floor: on cloud-managed instances the platform markers in `metadata` are + * reserved to the platform provisioner (which writes them at the service + * layer, not through these routes). A client payload that sets them to a + * truthy value is rejected — otherwise a tenant could stamp its own row as + * platform-provisioned and permanently lock it behind the write floor above. + * Clearing (null/false) is allowed so stale markers can be removed. + */ +function assertNoClientPlatformProvisionedMarkers(metadata: unknown): void { + if (!isCloudManagedInstance() || !isPlainRecord(metadata)) return; + for (const key of PLATFORM_PROVISIONED_MARKER_KEYS) { + if (metadata[key] !== undefined && metadata[key] !== null && metadata[key] !== false) { + throw unprocessable( + `metadata.${key} is reserved to the platform on cloud-managed instances`, + { code: "environment_platform_marker_reserved" }, + ); + } + } +} + export function environmentRoutes( db: Db, options: { pluginWorkerManager?: PluginWorkerManager } = {}, @@ -118,6 +327,15 @@ export function environmentRoutes( envVars?: Record | null; metadata: Record | null; }>(req: Request, environment: T): T { + // Floor: on cloud-managed instances, platform-provisioned rows use one + // view for every reader — instance admins (including computed + // owner-admins) never see env vars or credential-shaped config keys, and + // restricted readers gain the structural fields the redacted view used to + // blank (the platform config carries no secrets by the managed-config + // contract). + if (isCloudManagedInstance() && isPlatformProvisionedEnvironment(environment)) { + return applyPlatformProvisionedEnvironmentFloor(environment); + } return canReadFullInstanceEnvironment(req) ? environment : redactEnvironmentForRestrictedView(environment); @@ -633,6 +851,7 @@ export function environmentRoutes( router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCanAccessInstanceEnvironments(req); + assertNoClientPlatformProvisionedMarkers(req.body.metadata); if (req.body.driver === "local") { const existingLocal = await svc.list({ driver: "local" }); if (existingLocal.length > 0) { @@ -682,7 +901,7 @@ export function environmentRoutes( status: environment.status, }, }); - res.status(201).json(environment); + res.status(201).json(presentEnvironmentForRead(req, environment)); }); router.get("/environments/:id", async (req, res) => { @@ -725,6 +944,14 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } + await assertPlatformProvisionedEnvironmentWritable(existing, { + patchBody: req.body, + isForcedKubernetesExecution: async () => + isExecutionForcedToKubernetes({ + executionMode: (await instanceSettings.getGeneral()).executionMode, + }), + }); + assertNoClientPlatformProvisionedMarkers(req.body.metadata); const actor = getActorInfo(req); const nextDriver = req.body.driver ?? existing.driver; const nextName = req.body.name ?? existing.name; @@ -809,9 +1036,10 @@ export function environmentRoutes( entityId: environment.id, details: summarizeEnvironmentUpdate(patch as Record, environment), }); + const presented = presentEnvironmentForRead(req, environment); res.json(customImageReconciliation.action === "none" - ? environment - : { ...environment, customImageReconciliation }); + ? presented + : { ...presented, customImageReconciliation }); }); router.delete("/environments/:id", async (req, res) => { @@ -821,6 +1049,7 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } + await assertPlatformProvisionedEnvironmentWritable(existing); const actor = getActorInfo(req); const impact = await svc.getDeleteBlastRadius(existing.id); if (!impact) { @@ -873,7 +1102,7 @@ export function environmentRoutes( status: removed.status, }, }); - res.json(removed); + res.json(presentEnvironmentForRead(req, removed)); }); router.post("/environments/:id/probe", async (req, res) => { diff --git a/server/src/routes/instance-database-backups.ts b/server/src/routes/instance-database-backups.ts index a7fbb5ac83..641881c5e5 100644 --- a/server/src/routes/instance-database-backups.ts +++ b/server/src/routes/instance-database-backups.ts @@ -1,5 +1,7 @@ import { Router } from "express"; import type { BackupRetentionPolicy, RunDatabaseBackupResult } from "@paperclipai/db"; +import { forbidden } from "../errors.js"; +import { isCloudManagedInstance } from "../middleware/auth.js"; import { assertInstanceAdmin } from "./authz.js"; export type InstanceDatabaseBackupTrigger = "manual" | "scheduled"; @@ -22,6 +24,15 @@ export function instanceDatabaseBackupRoutes(service: InstanceDatabaseBackupServ router.post("/instance/database-backups", async (req, res) => { assertInstanceAdmin(req); + // Floor: on cloud-managed instances database backups are platform-owned. + // The manual trigger stays off for every actor, including computed + // owner-admins — the result would also echo the server-side backup + // directory path, which managed tenants must not see. + if (isCloudManagedInstance()) { + throw forbidden("Database backups are platform-managed on cloud-managed instances", { + code: "database_backups_platform_managed", + }); + } const result = await service.runManualBackup(); res.status(201).json(result); }); diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 209cc477fb..405f1817c4 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -7,6 +7,7 @@ import { patchInstanceGeneralSettingsSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; +import { isCloudManagedInstance } from "../middleware/auth.js"; import { validate } from "../middleware/validate.js"; import { heartbeatService, instanceSettingsService, logActivity } from "../services/index.js"; import { environmentService } from "../services/environments.js"; @@ -84,6 +85,24 @@ export function instanceSettingsRoutes(db: Db) { validate(patchInstanceGeneralSettingsSchema), async (req, res) => { assertCanManageInstanceSettings(req); + // Floor: on cloud-managed instances the execution mode is pinned by the + // platform (the execution-policy bootstrap writes it at boot). No + // instance admin — including a computed owner-admin — may change it: a + // forced provider switch would strand runs on a provider the platform + // never provisioned. Same-value writes pass so settings forms that echo + // the full general-settings object keep working. Absent and "any" both + // mean unrestricted, so they compare equal. + if ( + isCloudManagedInstance() && + Object.prototype.hasOwnProperty.call(req.body, "executionMode") + ) { + const current = await svc.getGeneral(); + if ((req.body.executionMode ?? "any") !== (current.executionMode ?? "any")) { + throw forbidden("executionMode is platform-managed on cloud-managed instances", { + code: "execution_mode_platform_managed", + }); + } + } const updated = await svc.updateGeneral(req.body); const actor = getActorInfo(req); const companyIds = await svc.listCompanyIds(); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 4ca0522947..5c228fd7d8 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1542,13 +1542,16 @@ export function authorizationService(db: Db) { explanation: "Allowed because the actor is the local implicit board.", }); } - // cloud_tenant actors are company-scoped by contract and must never be - // elevated — not even via stale instance_admin rows left behind by - // deployments that ran the pre-hardening cloud_tenant path. + // A cloud_tenant actor's computed `isInstanceAdmin` flag is trusted: it + // can only be set by the attested trusted-header resolver (stack owner + + // `enableOwnerInstanceAdmin`). The `instance_user_roles` DB lookup stays + // excluded for cloud_tenant actors, so a stale or hand-inserted + // instance_admin row left behind by deployments that ran the + // pre-hardening cloud_tenant path still elevates nothing. if ( !input.actor.ignoreInstanceAdmin && - input.actor.source !== "cloud_tenant" && - (input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId)) + (input.actor.isInstanceAdmin || + (input.actor.source !== "cloud_tenant" && await isInstanceAdmin(input.actor.userId))) ) { return allow({ action: input.action, diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index d9a4466168..0689365ca1 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -28,6 +28,7 @@ import { type UpdateEnvironment, } from "@paperclipai/shared"; import { conflict } from "../errors.js"; +import { isCloudManagedInstance } from "../middleware/auth.js"; type EnvironmentRow = typeof environments.$inferSelect; type EnvironmentLeaseRow = typeof environmentLeases.$inferSelect; @@ -403,6 +404,21 @@ export function environmentService(db: Db) { return row ? toEnvironmentLease(row) : null; }, + /** + * Idempotently ensure THE local-driver environment row; the partial + * unique index `environments_local_driver_idx` enforces at most one per + * instance. + * + * On a cloud-managed instance an existing row is additionally ADOPTED — + * stamped `managedByPaperclip: true` (other metadata preserved) — so the + * single local slot is platform-owned there by construction, mirroring + * `ensureManagedSandboxEnvironment`'s adoption of the sandbox slot. This + * is what lets the environment-routes write floor treat a local row's + * platform markers as live state rather than a stale leftover: every + * caller (company creation, the heartbeat, run orchestration) converges + * the marker. Self-hosted instances keep the historical behavior: + * an existing row is returned untouched. + */ ensureLocalEnvironment: async (_companyId?: string): Promise => { const now = new Date(); const insert = () => @@ -444,6 +460,19 @@ export function environmentService(db: Db) { if (!existing) { throw new Error("Failed to ensure local environment"); } + const existingMetadata = (existing.metadata ?? {}) as Record; + if (isCloudManagedInstance() && existingMetadata.managedByPaperclip !== true) { + const adopted = await db + .update(environments) + .set({ + metadata: { ...existingMetadata, managedByPaperclip: true }, + updatedAt: new Date(), + }) + .where(eq(environments.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + return toEnvironment(adopted); + } return toEnvironment(existing); }, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 76e2d1c13d..0a99e87ba5 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -231,6 +231,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true, enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true, + enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false, enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false, worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null, worktreeRunExecutionActivationInstanceId: @@ -265,6 +266,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIssueGraphLivenessAutoRecovery: false, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableOwnerInstanceAdmin: false, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, diff --git a/server/src/services/managed-config.ts b/server/src/services/managed-config.ts index b723930201..8b4ec896b3 100644 --- a/server/src/services/managed-config.ts +++ b/server/src/services/managed-config.ts @@ -76,9 +76,11 @@ export interface ManagedEnvironmentSpec { * reach a managed instance as process environment variables (every bundled * sandbox provider falls back to its env var when `config` omits the key, * e.g. `DAYTONA_API_KEY`), so any secret-looking config key in the document - * is a misrouted credential and fails startup. + * is a misrouted credential and fails startup. The environments API reuses + * this pattern to floor credential-shaped config keys out of + * platform-provisioned environment responses on managed instances. */ -const SECRET_LIKE_CONFIG_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential)/i; +export const SECRET_LIKE_CONFIG_KEY_PATTERN = /(api[-_]?key|token|secret|password|credential)/i; function findSecretLikeConfigKey(value: Record, path: string): string | null { for (const [key, child] of Object.entries(value)) { diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 0aa45bb515..31ad7b272e 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -94,6 +94,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { issueGraphLivenessAutoRecoveryLookbackHours: 24, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, + enableOwnerInstanceAdmin: false, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null,