diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 35c1a3fdce4b..7b70f9223c6e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,7 @@ import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; +import Migration0061 from "./Migrations/061_RevokeLegacyCloudConnectSessions.ts"; /** * Migration loader with all migrations defined inline. @@ -148,6 +149,7 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], + [61, "RevokeLegacyCloudConnectSessions", Migration0061], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5e45ed05d302..718fa1948518 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 60 }, (_, index) => index + 1), + Array.from({ length: 61 }, (_, index) => index + 1), ); }), ); @@ -32,6 +32,7 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "RevokeLegacyCloudConnectSessions"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -58,6 +59,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, + { migration_id: 61, name: "RevokeLegacyCloudConnectSessions" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.test.ts b/apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.test.ts new file mode 100644 index 000000000000..a8fa54e363f2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.test.ts @@ -0,0 +1,81 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as TestClock from "effect/testing/TestClock"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; + +const now = "2026-10-08T20:00:00.000Z"; +// Minted for a current client by a server from before the split. +const preSplit = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", +]; +// An older client narrows its request to the scopes it knows. +const narrowedPreSplit = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "relay:read", +]; +const standard = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "filesystem:read", + "filesystem:write", + "relay:read", +]; + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))( + "061_RevokeLegacyCloudConnectSessions", + (it) => { + it.effect("revokes only live T3 Connect sessions minted before the scope split", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 60 }); + const insert = ( + id: string, + subject: string, + scopes: ReadonlyArray, + expiresAt: string, + ) => + sql` + INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) + VALUES (${id}, ${subject}, ${JSON.stringify(scopes)}, 'dpop-access-token', + '2026-10-08T19:39:38.320Z', ${expiresAt}) + `; + yield* insert("legacy-connect", "cloud-connect", preSplit, "2026-10-08T20:39:38.320Z"); + yield* insert( + "narrowed-connect", + "cloud-connect", + narrowedPreSplit, + "2026-10-08T20:39:38.320Z", + ); + yield* insert("current-connect", "cloud-connect", standard, "2026-10-08T20:39:38.320Z"); + yield* insert("expired-connect", "cloud-connect", preSplit, "2026-10-08T19:00:00.000Z"); + // A paired client keeps the grant the user chose, even a pre-split one. + yield* insert("paired", "one-time-token", preSplit, "2026-11-07T19:39:38.320Z"); + + yield* TestClock.setTime(Date.parse(now)); + yield* runMigrations({ toMigrationInclusive: 61 }); + + const rows = yield* sql<{ readonly sessionId: string; readonly revokedAt: string | null }>` + SELECT session_id AS "sessionId", revoked_at AS "revokedAt" + FROM auth_sessions ORDER BY session_id + `; + assert.deepStrictEqual(rows, [ + { sessionId: "current-connect", revokedAt: null }, + { sessionId: "expired-connect", revokedAt: null }, + { sessionId: "legacy-connect", revokedAt: now }, + { sessionId: "narrowed-connect", revokedAt: now }, + { sessionId: "paired", revokedAt: null }, + ]); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.ts b/apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.ts new file mode 100644 index 000000000000..ac3ff15b80d8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.ts @@ -0,0 +1,40 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +// The scope vocabulary before permissions were split. Frozen here so later +// scope changes cannot alter which sessions this migration matched. +const preSplitScopes = JSON.stringify([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "access:read", + "access:write", + "relay:read", + "relay:write", +]); + +/** + * T3 Connect sessions carry whatever grant the server chose when it minted + * them, not one the user picked. Sessions minted before the split hold only + * the broad scopes, which no longer imply file access and the other newly + * separated permissions. Revoking them makes clients mint a replacement through + * the normal cloud flow, which receives the current standard grant. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = DateTime.formatIso(yield* DateTime.now); + + yield* sql` + UPDATE auth_sessions + SET revoked_at = ${now} + WHERE subject = 'cloud-connect' + AND revoked_at IS NULL + AND expires_at > ${now} + AND NOT EXISTS ( + SELECT 1 FROM json_each(auth_sessions.scopes) + WHERE value NOT IN (SELECT value FROM json_each(${preSplitScopes})) + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 675d5f5db021..7e0e67d86cc3 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,6 +41,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "RevokeLegacyCloudConnectSessions"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -124,6 +125,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "RevokeLegacyCloudConnectSessions"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 132b65548125..7d860b8838c7 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -64,7 +64,13 @@ group's `RpcScopeAuthorization` middleware checks it before any handler runs. Scope changes must not prevent older clients from connecting. Token exchange intersects recognized requests with the pairing grant; retired and unknown names are dropped. A request with no granted scopes fails before consuming the link. -Stored credentials are never expanded when scopes split. +Stored credentials are never expanded when scopes split. A paired session keeps +its recorded grant. A T3 Connect session holding only pre-split scopes is instead +revoked once by +[migration 61](../../apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.ts), +because the server chose its grant. The client mints a replacement through the +normal cloud flow and receives whatever it requests from the current standard +grant, so an older client that asks for pre-split scopes gets them back. Auth responses keep `scopes` within the original wire vocabulary and include `permissions` for the exact grant. New clients use `permissions` when present, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 87c8ee9bea67..41e18679b5df 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -269,7 +269,9 @@ and the agent it runs can use Git however the environment allows. Settings changes, provider management, and environment maintenance can be granted separately from access administration. New standard pairings include these permissions. Existing clients can stay connected after an update, but newly separated -features may require pairing again with the permissions they need. Older clients +features may require pairing again with the permissions they need. Devices +connected through T3 Connect do not need pairing again: once their T3 Code app is +up to date, they receive the new permissions on their own. Older clients may show controls that the server denies. Create a fresh pairing link to change a client's permissions.