diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index 99dd5532268..d0660cbd5d7 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -24,6 +24,13 @@ show an error rather than treating the user as signed out. Self-hosted authenticated instances retain their instance sign-in form, and `local_trusted` instances retain their normal access path. +Cloud stack Admins can change experimental settings on their dedicated instance. +This permission comes from the current trusted Cloud stack role and does not +grant other instance-admin powers. Members, Support users, and agents cannot +change experiments. Operator-hidden settings and Cloud-managed feature values +remain protected. Staging allows saved overrides for its editable experiments; +production retains its managed feature policy. + ## 1. Purpose Paperclip supports two runtime modes: diff --git a/maps/agent-access-map/instance-operations.md b/maps/agent-access-map/instance-operations.md index cc3dcb22217..c82e6e1dc33 100644 --- a/maps/agent-access-map/instance-operations.md +++ b/maps/agent-access-map/instance-operations.md @@ -64,6 +64,8 @@ API: `present` — representative operation IDs: - `GET /api/instance/settings`. - `PATCH /api/instance/settings`. +- `GET /api/instance/settings/experimental`. +- `PATCH /api/instance/settings/experimental`. Agent access: `restricted` — Board/admin instance settings; CLI configure/env; local configuration files. @@ -81,15 +83,22 @@ own policy and access review. Gaps: Settings APIs cover parts of configuration; pre-start database/binding/storage configuration is local operator tooling. -Cloud staging exposes experimental controls to instance admins and permits saved -overrides of Cloud feature defaults. This exception requires a managed instance +Cloud stack Admins may update experimental settings through the trusted Cloud +actor without receiving other instance-admin powers. This uses the current +stack role on each request; company membership alone does not grant it. +Members, Support users, agents, and ordinary self-hosted sessions remain denied. +Cloud staging exposes experimental controls to instance admins and stack Admins. +It permits saved overrides of Cloud feature defaults. This exception requires a managed instance and a provisioner-supplied `*.staging.paperclip.app` primary hostname; request headers do not establish staging. Ordinary board members and agents still cannot -mutate instance settings. Only an explicit feature allowlist is editable. Cloud +mutate instance settings. Only an explicit feature allowlist is editable on staging. Cloud owner-admin elevation and managed-sandbox execution policy remain managed, and production policy and other Cloud floors remain. Local [route tests](../../server/src/__tests__/instance-settings-routes.test.ts) -cover staging admin access, non-admin denial and a spoofed request hostname; +cover stack Admin experiment access and auditing, denial for other instance +controls and actor sources, hidden-setting restrictions, and a spoofed request hostname; +the [Cloud actor tests](../../server/src/middleware/cloud-tenant-actor.test.ts) +cover role changes and preserved instance-admin isolation; no live Cloud or agent-credential verification is claimed. ## `diagnostics` diff --git a/maps/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md index f28d75d3e48..7591aa8d079 100644 --- a/maps/feature-map/instance-operations.md +++ b/maps/feature-map/instance-operations.md @@ -61,7 +61,10 @@ Automated: [experimental settings](../../ui/src/pages/InstanceExperimentalSettin Manual: Change a harmless experimental setting, reload, and confirm the affected route honors it. Back up a disposable instance, inspect the reported file and metadata, and restore using the documented procedure into a second isolated target. Verify a known record after restoration. Cloud staging: On a managed `*.staging.paperclip.app` instance, verify experiments -are visible and editable by an instance admin, and changes survive reload. +are visible and editable by a Cloud stack Admin or instance admin, and changes survive reload. +As a stack Admin, verify a harmless experiment saves while general instance +settings and task-drain operations remain denied. Demote that user to Member +and verify the next experimental write is denied. Production keeps its hidden controls and managed feature values. Staging retains other Cloud restrictions. [Settings policy tests](../../server/src/__tests__/instance-settings-managed-overlay.test.ts), [route tests](../../server/src/__tests__/instance-settings-routes.test.ts), and diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 033b74b724a..dbc3524c0cb 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -520,6 +520,60 @@ describe("instance settings routes", () => { expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); }); + describe("Cloud stack admin experimental settings access", () => { + afterEach(() => vi.unstubAllEnvs()); + + const stackAdmin = { + type: "board", + userId: "stack-admin-1", + source: "cloud_tenant", + companyIds: ["company-1"], + isInstanceAdmin: false, + canManageExperimentalSettings: true, + }; + + it("allows a Cloud stack admin to enable fast responses and audits the change", async () => { + const app = createApp(stackAdmin); + await request(app).patch("/api/instance/settings/experimental") + .send({ enableFastResponses: true }).expect(200); + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ enableFastResponses: true }); + expect(mockLogActivity).toHaveBeenCalledWith(mockDb, expect.objectContaining({ + actorId: "stack-admin-1", + action: "instance.settings.experimental_updated", + details: expect.objectContaining({ changedKeys: ["enableFastResponses"] }), + })); + }); + + it.each([ + ["patch", "/api/instance/settings", { defaultEnvironmentId: null }], + ["patch", "/api/instance/settings/general", { censorUsernameInLogs: true }], + ["post", "/api/instance/task-drain", {}], + ] as const)("keeps %s %s restricted to instance admins", async (method, path, body) => { + const app = createApp(stackAdmin); + await request(app)[method](path).send(body).expect(403); + }); + + it.each([ + { ...stackAdmin, source: "session" }, + { ...stackAdmin, source: "mcp_oauth" }, + { ...stackAdmin, type: "agent" }, + { ...stackAdmin, canManageExperimentalSettings: false }, + ])("rejects actors without trusted Cloud experimental authority (%j)", async (actor) => { + const app = createApp(actor); + await request(app).patch("/api/instance/settings/experimental") + .send({ enableFastResponses: true }).expect(403); + expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); + }); + + it("keeps operator-hidden experimental settings locked for stack admins", async () => { + vi.stubEnv("PAPERCLIP_HIDDEN_SETTINGS", "instance.experimental.enableFastResponses"); + const app = createApp(stackAdmin); + await request(app).patch("/api/instance/settings/experimental") + .send({ enableFastResponses: true }).expect(403); + expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); + }); + }); + it("allows local board users to read and update general settings", async () => { const app = await createApp(localAdminActor()); @@ -878,7 +932,10 @@ describe("instance settings routes", () => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "tenant-token"); vi.stubEnv("PAPERCLIP_PRIMARY_HOST", "team.staging.paperclip.app"); vi.stubEnv("PAPERCLIP_HIDDEN_SETTINGS", "instance.experimental,instance.experimental.*,instance.general.censorUsernameInLogs"); - const app = createApp({ type: "board", userId: "admin-1", source: "session", isInstanceAdmin: true }); + const app = createApp({ + type: "board", userId: "admin-1", source: "cloud_tenant", + isInstanceAdmin: false, canManageExperimentalSettings: true, + }); await request(app).patch("/api/instance/settings/experimental").send({ enableChatConnectors: true }).expect(200); expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ enableChatConnectors: true }); await request(app).patch("/api/instance/settings/general").send({ censorUsernameInLogs: true }).expect(403); @@ -895,7 +952,7 @@ describe("instance settings routes", () => { expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); }); - it("still requires instance admin access on staging", async () => { + it("still denies ordinary board members on staging", async () => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "tenant-token"); vi.stubEnv("PAPERCLIP_PRIMARY_HOST", "team.staging.paperclip.app"); const app = createApp({ type: "board", userId: "member-1", source: "session", isInstanceAdmin: false }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index fba7462aa1e..06de08e8ee5 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -788,6 +788,7 @@ async function resolveCloudTenantActorOnce( // company-scoped. Turning the flag off de-elevates on the next request — // there is no role row to clean up. isInstanceAdmin: await resolveOwnerInstanceAdmin(db, stackRole), + canManageExperimentalSettings: stackRole === "admin", source: "cloud_tenant", }; } diff --git a/server/src/middleware/cloud-tenant-actor.test.ts b/server/src/middleware/cloud-tenant-actor.test.ts index 6bf5f55ccd9..fd0bc35b4a3 100644 --- a/server/src/middleware/cloud-tenant-actor.test.ts +++ b/server/src/middleware/cloud-tenant-actor.test.ts @@ -270,6 +270,34 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => { expect(actor?.memberships?.[0]?.membershipRole).toBe("member"); }); + it.each(["admin", "member", "support"])( + "grants experimental settings access only to the admin stack role (%s)", + async (stackRole) => { + const { db, insertedTables } = createFakeDb(); + const actor = await resolveCloudTenantActor( + db, + fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": stackRole }), + ); + expect(actor?.canManageExperimentalSettings).toBe(stackRole === "admin"); + expect(actor?.isInstanceAdmin).toBe(false); + expect(insertedTables).not.toContain(instanceUserRoles); + }, + ); + + it("removes experimental settings access on the next request after a stack admin is demoted", async () => { + const { db } = createFakeDb(); + const admin = await resolveCloudTenantActor( + db, + fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "admin" }), + ); + const member = await resolveCloudTenantActor( + db, + fakeReq({ ...VALID_HEADERS, "x-paperclip-cloud-stack-role": "member" }), + ); + expect(admin?.canManageExperimentalSettings).toBe(true); + expect(member?.canManageExperimentalSettings).toBe(false); + }); + describe("company membership union", () => { // The primary company id is derived from the stack id; resolve it once // through the same code path instead of duplicating the hash here. diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 800d2261822..e08c66b6d06 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -96,6 +96,17 @@ function assertCanManageInstanceSettings(req: Request) { throw forbidden("Instance admin access required"); } +function assertCanManageExperimentalSettings(req: Request) { + if ( + req.actor.type === "board" + && req.actor.source === "cloud_tenant" + && req.actor.canManageExperimentalSettings + ) { + return; + } + assertCanManageInstanceSettings(req); +} + // A task-drain start or stop reads the live drain state, writes an audit // transaction, and only then mutates the process-local drain state. The // audit write is an async gap: two overlapping requests can commit their @@ -252,9 +263,6 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag ); router.get("/instance/settings/experimental", async (req, res) => { - // Experimental settings are readable by any authenticated org member - // or instance admin. Updating them remains instance-admin only because - // this payload includes instance-wide operational controls. assertBoardOrgAccess(req); res.json(await svc.getExperimental()); }); @@ -263,7 +271,7 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag "/instance/settings/experimental", validate(patchInstanceExperimentalSettingsSchema), async (req, res) => { - assertCanManageInstanceSettings(req); + assertCanManageExperimentalSettings(req); // Hiding the whole Experimental page floors every toggle; otherwise // only individually hidden keys are floored. const hidden = getHiddenSettings(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a2fc87792a7..f06557f4b8b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6894,8 +6894,9 @@ registry.registerPath({ path: "/api/instance/settings/experimental", tags: ["instance"], summary: "Update experimental instance settings", + description: "Requires instance-admin access or a trusted Cloud stack admin. Operator-hidden settings and Cloud-managed feature restrictions still apply. This does not grant access to other instance administration operations.", request: { body: jsonBody(patchInstanceExperimentalSettingsSchema) }, - responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); registry.registerPath({ diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 653997d632f..d3ae48c4c6f 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -25,6 +25,7 @@ declare global { status?: string; }>; isInstanceAdmin?: boolean; + canManageExperimentalSettings?: boolean; keyId?: string; keyScope?: AgentApiKeyScope; runId?: string;