From ca3db33745656c8562d8d032e241b8469b813328 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Fri, 4 Sep 2026 13:13:03 -0400 Subject: [PATCH 1/9] feat(linear): record the owning workspace on project mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linear project-to-repo mapping table is keyed on `linear_project_id` alone and carries no workspace attribute, so nothing ties a mapping to the tenant that owns it. The webhook processor looks the repository up from the body-supplied `projectId` with no way to check it against the workspace whose signature the delivery arrived with, which lets any onboarded workspace name another workspace's project and steer an agent task at that workspace's repository. This records the missing value so a later change can enforce it; no read path changes behaviour yet. `onboard-project` now resolves the owning workspace by asking Linear which onboarded workspace's own token can see the project, rather than accepting it as a flag. The resolution is what the enforcement path will check deliveries against, so a typo'd flag would durably write the cross-tenant mapping the check exists to prevent — and `organization.id` is read from the same authenticated response that resolved the project, not from the workspace we assumed we were asking. `--slug` narrows the search; `--workspace-id` records an owner without verifying it, for when the Linear API is unreachable. `backfill-project-workspaces` fills in mappings that predate the field, one pass per workspace rather than one lookup per row, and paginates the project listing (`list-projects` stops at 100, which for a backfill would silently leave the overflow unresolved). The update is conditional on the row still existing and still having no workspace id, so it neither resurrects a deleted mapping nor overwrites a concurrent `onboard-project`. A project id claimed by two workspaces is skipped and named rather than assigned to whichever answered first. `platform doctor` reports mappings with no owning workspace, capped at ten named ids, so an operator can tell whether the backfill is finished before enforcement is turned on. --- cli/src/commands/linear.ts | 441 +++++++++++++++++- cli/src/platform-doctor.ts | 83 ++++ .../commands/linear-project-workspace.test.ts | 298 ++++++++++++ cli/test/platform-doctor.test.ts | 70 +++ docs/guides/LINEAR_SETUP_GUIDE.md | 4 + .../content/docs/using/Linear-setup-guide.md | 4 + 6 files changed, 897 insertions(+), 3 deletions(-) create mode 100644 cli/test/commands/linear-project-workspace.test.ts diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 4982d3f84..6706a29b7 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -28,7 +28,7 @@ import { ResourceExistsException, SecretsManagerClient, } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { DynamoDBDocumentClient, PutCommand, ScanCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { Command } from 'commander'; import { ApiClient } from '../api-client'; import { loadConfig, loadCredentials } from '../config'; @@ -574,6 +574,257 @@ export async function findWorkspaceRowBySlug( return (result.Items ?? []).find((item) => item.workspace_slug === slug); } +/** + * Every `active` row in the workspace registry. + * + * Same unbounded-Scan reasoning as {@link findWorkspaceRowBySlug}: the registry holds + * one small row per install. Rows whose `status` is anything other than `active` are + * dropped here rather than by the caller, matching the runtime resolver's fail-closed + * reading of that column (a half-written row is not an install). Exported for tests. + */ +export async function listActiveWorkspaceRows( + ddb: DynamoDBDocumentClient, + registryTableName: string, +): Promise>> { + const rows: Array> = []; + let lastKey: Record | undefined; + do { + const page = await ddb.send(new ScanCommand({ + TableName: registryTableName, + ExclusiveStartKey: lastKey, + })); + for (const item of page.Items ?? []) { + if (item.status === 'active') rows.push(item); + } + lastKey = page.LastEvaluatedKey; + } while (lastKey); + return rows; +} + +/** + * Slugs of every onboarded workspace, registry first. + * + * The registry is authoritative when present because it carries `status`, so a revoked + * install is not offered as a candidate. Installs predating the registry have only the + * `bgagent-linear-oauth-*` secrets, so that prefix listing is the fallback — the same + * two-source order `list-projects` uses. + */ +export async function listOnboardedWorkspaceSlugs(args: { + readonly sm: SecretsManagerClient; + readonly ddb?: DynamoDBDocumentClient; + readonly registryTableName?: string; +}): Promise { + if (args.ddb && args.registryTableName) { + const rows = await listActiveWorkspaceRows(args.ddb, args.registryTableName); + const slugs = rows + .map((r) => r.workspace_slug as string | undefined) + .filter((s): s is string => Boolean(s)); + if (slugs.length > 0) return slugs; + } + + // ListSecretsCommand caps at 100 per page; paginate so a deployment with more + // matching secrets than that does not silently miss installs after page one. + const collected: string[] = []; + let nextToken: string | undefined; + do { + const listed = await args.sm.send(new ListSecretsCommand({ + Filters: [{ Key: 'name', Values: [LINEAR_OAUTH_SECRET_PREFIX] }], + MaxResults: 100, + NextToken: nextToken, + })); + for (const s of listed.SecretList ?? []) { + const name = s.Name ?? ''; + if (name.startsWith(LINEAR_OAUTH_SECRET_PREFIX)) { + collected.push(name.slice(LINEAR_OAUTH_SECRET_PREFIX.length)); + } + } + nextToken = listed.NextToken; + } while (nextToken); + return collected; +} + +/** Outcome of resolving a usable Linear access token for one workspace. */ +export type WorkspaceTokenResult = + | { readonly kind: 'token'; readonly accessToken: string } + | { readonly kind: 'unavailable'; readonly reason: string }; + +/** + * Resolve a usable Linear access token for one workspace, vault first. + * + * Vault-before-Secrets-Manager mirrors the runtime resolver, and the order matters for + * more than preference: a vault-managed workspace holds no usable Secrets Manager + * token, so reading the bundle first reports a bare 401 on precisely the workspaces + * that are healthy. + * + * Returns a reason rather than throwing because every caller iterates workspaces and + * must keep going when one is unreachable — a single unreadable install should narrow + * the answer, not abort the command. + */ +export async function resolveWorkspaceAccessToken(args: { + readonly slug: string; + readonly sm: SecretsManagerClient; + readonly ddb?: DynamoDBDocumentClient; + readonly registryTableName?: string; + readonly region: string; + readonly vaultWorkloadName: string; +}): Promise { + const { slug, sm, ddb, registryTableName, vaultWorkloadName } = args; + + if (ddb && registryTableName) { + const row = await findWorkspaceRowBySlug(ddb, registryTableName, slug).catch(() => undefined); + const providerName = row?.provider_name as string | undefined; + if (providerName) { + const workspaceId = row?.linear_workspace_id as string | undefined; + const recorded = row?.vault_user_id as string | undefined; + const userId = recorded + ?? (workspaceId ? linearVaultUserId(workspaceId) : linearVaultUserIdForSlug(slug)); + const minted = await mintLinearTokenFromVault({ + region: args.region, + workloadName: vaultWorkloadName, + providerName, + userId, + }); + if (minted.kind === 'token') return { kind: 'token', accessToken: minted.accessToken }; + } + } + + try { + const resp = await sm.send(new GetSecretValueCommand({ SecretId: linearOauthSecretName(slug) })); + const stored = JSON.parse(resp.SecretString ?? '{}') as { access_token?: string }; + if (!stored.access_token) { + return { kind: 'unavailable', reason: `secret ${linearOauthSecretName(slug)} is missing access_token` }; + } + return { kind: 'token', accessToken: stored.access_token }; + } catch (err) { + return { + kind: 'unavailable', + reason: `failed to read ${linearOauthSecretName(slug)}: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +/** Which workspace a project belongs to, as Linear itself reports it. */ +export type ProjectOwnerResult = + | { readonly kind: 'found'; readonly slug: string; readonly workspaceId: string } + | { readonly kind: 'not-found'; readonly searched: readonly string[]; readonly errors: readonly string[] }; + +/** + * Ask Linear which onboarded workspace owns `projectId`. + * + * Resolved from the provider rather than taken as an operator flag on purpose. The + * owning workspace is the value the webhook path will later check a delivery against, + * so a typo'd flag would durably write a mapping that points one tenant's project at + * another tenant's repository — the exact state the check exists to make unreachable. + * Linear answering "this project is visible to this workspace's token" is the only + * authority on the question that does not depend on the operator being careful. + * + * `organization.id` comes from the same authenticated response as the project rather + * than from the registry row, so the recorded id is the workspace Linear says owns the + * project, not the workspace we assumed we were asking. + */ +export async function findProjectOwnerWorkspace(args: { + readonly projectId: string; + readonly slugs: readonly string[]; + readonly sm: SecretsManagerClient; + readonly ddb?: DynamoDBDocumentClient; + readonly registryTableName?: string; + readonly region: string; + readonly vaultWorkloadName: string; + readonly fetchImpl?: typeof fetch; +}): Promise { + const doFetch = args.fetchImpl ?? fetch; + const errors: string[] = []; + + for (const slug of args.slugs) { + const token = await resolveWorkspaceAccessToken({ ...args, slug }); + if (token.kind !== 'token') { + errors.push(`${slug}: ${token.reason}`); + continue; + } + + try { + const res = await doFetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token.accessToken}`, + }, + body: JSON.stringify({ + query: 'query($id: String!) { organization { id } project(id: $id) { id } }', + variables: { id: args.projectId }, + }), + }); + if (!res.ok) { + errors.push(`${slug}: Linear API returned ${res.status}`); + continue; + } + const body = await res.json() as { + data?: { organization?: { id?: string }; project?: { id?: string } | null }; + }; + // A workspace whose token cannot see the project answers `project: null` with a + // 200 — that is the "not this workspace" signal, not an error worth reporting. + const foundId = body.data?.project?.id; + const orgId = body.data?.organization?.id; + if (foundId === args.projectId && orgId) { + return { kind: 'found', slug, workspaceId: orgId }; + } + } catch (err) { + errors.push(`${slug}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return { kind: 'not-found', searched: args.slugs, errors }; +} + +/** + * Every project id visible to one workspace's token, with its organization id. + * + * Paginated deliberately. `list-projects` asks for `projects(first: 100)` and stops, + * which is fine for a human browsing but not for a backfill: a workspace with more + * than a page of projects would leave the overflow unresolved and silently keep the + * mappings the enforcement path is about to start rejecting. + */ +export async function listWorkspaceProjectIds(args: { + readonly accessToken: string; + readonly fetchImpl?: typeof fetch; +}): Promise<{ readonly workspaceId?: string; readonly projectIds: string[] }> { + const doFetch = args.fetchImpl ?? fetch; + const projectIds: string[] = []; + let workspaceId: string | undefined; + let cursor: string | undefined; + + do { + const res = await doFetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${args.accessToken}`, + }, + body: JSON.stringify({ + query: 'query($after: String) { organization { id } ' + + 'projects(first: 250, after: $after) { nodes { id } pageInfo { hasNextPage endCursor } } }', + variables: { after: cursor ?? null }, + }), + }); + if (!res.ok) throw new CliError(`Linear API returned ${res.status}`); + const body = await res.json() as { + data?: { + organization?: { id?: string }; + projects?: { + nodes?: Array<{ id: string }>; + pageInfo?: { hasNextPage?: boolean; endCursor?: string }; + }; + }; + }; + workspaceId ??= body.data?.organization?.id; + for (const n of body.data?.projects?.nodes ?? []) projectIds.push(n.id); + const pageInfo = body.data?.projects?.pageInfo; + cursor = pageInfo?.hasNextPage ? pageInfo.endCursor : undefined; + } while (cursor); + + return { workspaceId, projectIds }; +} + export function makeLinearCommand(): Command { const linear = new Command('linear') .description('Manage Linear integration'); @@ -2105,6 +2356,12 @@ export function makeLinearCommand(): Command { .requiredOption('--repo ', 'GitHub repository the mapped project should route tasks to') .option('--label