Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion cdk/src/constructs/linear-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ const WEBHOOK_PROCESSOR_TIMEOUT_SECONDS = 120;
/** Webhook-processor Lambda memory (MB). */
const WEBHOOK_PROCESSOR_MEMORY_MB = 512;

/** Remove-workspace Lambda timeout (seconds). 10s matches the sibling
* link/webhook request handlers — the teardown is a bounded sequence
* (registry lookup → registry revoke → secret delete → optional row purge).
* The lookup scan is the only paginating phase, and it is bounded by the
* registry's documented tens-of-rows scale, so 10s is comfortable. */
const REMOVE_WORKSPACE_TIMEOUT_SECONDS = 10;

/**
* Properties for LinearIntegration construct.
*/
Expand Down Expand Up @@ -478,6 +485,42 @@ export class LinearIntegration extends Construct {
});
this.userMappingTable.grantReadWriteData(linkFn);

// --- Workspace removal (Cognito-authenticated, admin-only) ---
// Backs `bgagent linear remove-workspace <slug>`: revokes/purges the
// registry row and deletes the per-workspace OAuth secret. Keeping the
// DDB + Secrets Manager grants on this Lambda's role — not on every CLI
// user — is the whole point of routing removal through the API (see
// issue #306). Project mappings are intentionally NOT touched: mapping
// rows carry no workspace id, so they cannot be attributed to a
// workspace (removal is by project id).
const removeWorkspaceFn = new lambda.NodejsFunction(this, 'RemoveWorkspaceFn', {
entry: path.join(handlersDir, 'linear-remove-workspace.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
timeout: Duration.seconds(REMOVE_WORKSPACE_TIMEOUT_SECONDS),
environment: {
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName,
},
bundling: commonBundling,
});
this.workspaceRegistryTable.grantReadWriteData(removeWorkspaceFn);
// Delete the per-workspace OAuth secret created by the CLI at setup time
// (`bgagent-linear-oauth-<slug>`). The concrete name isn't known at synth
// time (operators add workspaces by slug at runtime), so scope to the
// documented prefix — same wildcard the webhook Lambdas already use.
removeWorkspaceFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['secretsmanager:DeleteSecret'],
resources: [
Stack.of(this).formatArn({
service: 'secretsmanager',
resource: 'secret',
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
resourceName: 'bgagent-linear-oauth-*',
}),
],
}));

// ═══════════════════════════════════════════════════════════════════════════
// API Gateway Routes
// ═══════════════════════════════════════════════════════════════════════════
Expand All @@ -500,6 +543,15 @@ export class LinearIntegration extends Construct {
cognitoAuthOptions,
);

// DELETE /v1/linear/workspaces/{slug} — Cognito-authenticated, admin-only.
const workspacesResource = linear.addResource('workspaces');
const workspaceBySlug = workspacesResource.addResource('{slug}');
workspaceBySlug.addMethod(
'DELETE',
new apigw.LambdaIntegration(removeWorkspaceFn, { allowTestInvoke: false }),
cognitoAuthOptions,
);

// ═══════════════════════════════════════════════════════════════════════════
// cdk-nag suppressions
// ═══════════════════════════════════════════════════════════════════════════
Expand All @@ -522,7 +574,7 @@ export class LinearIntegration extends Construct {
},
]);

const allFunctions = [webhookFn, webhookProcessorFn, linkFn];
const allFunctions = [webhookFn, webhookProcessorFn, linkFn, removeWorkspaceFn];
for (const fn of allFunctions) {
NagSuppressions.addResourceSuppressions(fn, [
{
Expand Down
472 changes: 472 additions & 0 deletions cdk/src/handlers/linear-remove-workspace.ts

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions cdk/src/handlers/shared/linear-oauth-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,32 @@ const SECRET_CACHE_TTL_MS = 60_000;
/** Refresh threshold: refresh tokens with <60s remaining. */
const REFRESH_THRESHOLD_SECONDS = 60;

/** Why a registry row was latched `revoked`. */
type LinearRevocationReason = 'refresh_token_rejected' | 'vault_consent_required';
/**
* Why a registry row was latched `revoked`.
*
* The complete vocabulary, so the re-probe guard below can be read as an
* exhaustive statement about *every* reason a row can carry. Two members are
* written by this module (see the constants under it); `admin_removed` is
* written by `linear-remove-workspace.ts` when an operator deliberately
* deregisters a workspace, and is deliberately NOT
* `VAULT_CONSENT_REVOCATION_REASON` — that is the one reason the guard
* re-probes instead of refusing, so an admin removal stays terminal: no later
* vault probe can un-latch it. "We inferred the grant is gone" and "a human
* said take this workspace out" must not be the same string.
*
* Exported as a **type only** on purpose. The removal handler needs the
* vocabulary but must not take a value dependency on this module: a value
* import would pull the whole resolver (SNS alerting, DDB + Secrets Manager
* clients, the refresh path) into that Lambda's esbuild bundle, and it would
* register in the `agent.test.ts` "no Linear-minting handler is unwired"
* census, whose value is precisely that a new value import there is a test
* failure rather than a production 401. `import type` is erased, so it costs
* nothing at runtime and still fails the build if this union changes.
*/
export type LinearRevocationReason =
| 'refresh_token_rejected'
| 'vault_consent_required'
| 'admin_removed';

/**
* `revoked_reason` written when the vault answered with an authorization URL
Expand All @@ -80,6 +104,11 @@ const VAULT_CONSENT_REVOCATION_REASON: LinearRevocationReason = 'vault_consent_r
/** `revoked_reason` written when Linear itself rejected the refresh token. */
const REFRESH_REJECTED_REVOCATION_REASON: LinearRevocationReason = 'refresh_token_rejected';

// No constant for `admin_removed`: this module never writes it. Its writer
// declares it locally, typed by the union above, in
// `handlers/linear-remove-workspace.ts` — see the note on the union for why
// that direction of the dependency is type-only.

/** Registry row status values. Anything else (missing, unknown
* string) is treated as `revoked` so a corrupt or partially-written
* row blocks resolution rather than silently granting access. */
Expand Down
2 changes: 2 additions & 0 deletions cdk/src/handlers/shared/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const ErrorCode = {
WEBHOOK_ALREADY_REVOKED: 'WEBHOOK_ALREADY_REVOKED',
API_KEY_NOT_FOUND: 'API_KEY_NOT_FOUND',
API_KEY_ALREADY_REVOKED: 'API_KEY_ALREADY_REVOKED',
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
SECRET_DELETE_FAILED: 'SECRET_DELETE_FAILED',
REPO_NOT_ONBOARDED: 'REPO_NOT_ONBOARDED',
BUDGET_EXCEEDED: 'BUDGET_EXCEEDED',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
Expand Down
104 changes: 102 additions & 2 deletions cdk/test/constructs/linear-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,114 @@ describe('LinearIntegration construct', () => {
});
});

test('creates three Lambda functions (webhook, processor, link)', () => {
template.resourceCountIs('AWS::Lambda::Function', 3);
test('creates four Lambda functions (webhook, processor, link, remove-workspace)', () => {
template.resourceCountIs('AWS::Lambda::Function', 4);
});

test('creates API Gateway resources under /linear', () => {
template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'linear' });
template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'webhook' });
template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'link' });
template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'workspaces' });
template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: '{slug}' });
});

test('DELETE /linear/workspaces/{slug} is Cognito-authorized (pinned to the {slug} resource)', () => {
// Pin the method to the {slug} resource so "any authorized DELETE
// anywhere" cannot satisfy this — the DELETE must be on the
// workspace-by-slug path specifically.
const slugResources = template.findResources('AWS::ApiGateway::Resource', {
Properties: { PathPart: '{slug}' },
});
const slugLogicalIds = Object.keys(slugResources);
expect(slugLogicalIds).toHaveLength(1);
const slugId = slugLogicalIds[0];

const deleteMethods = template.findResources('AWS::ApiGateway::Method', {
Properties: { HttpMethod: 'DELETE' },
});
const onSlug = Object.values(deleteMethods).filter(
(m) => (m.Properties as { ResourceId?: { Ref?: string } }).ResourceId?.Ref === slugId,
);
expect(onSlug).toHaveLength(1);
expect((onSlug[0].Properties as { AuthorizationType?: string }).AuthorizationType).toBe('COGNITO_USER_POOLS');
});

// Locate the RemoveWorkspaceFn INDEPENDENTLY of any IAM policy: it is the
// ONLY Lambda whose environment is registry-only — it carries
// `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` but none of the sibling markers
// (`LINEAR_PROJECT_MAPPING_TABLE_NAME` on the processor,
// `LINEAR_WEBHOOK_SECRET_ARN` on the webhook receiver,
// `LINEAR_USER_MAPPING_TABLE_NAME` on the link handler). We then read the
// role off the FUNCTION resource itself (`Role: Fn::GetAtt[<roleId>]`), so
// the derived `role` is bound to the function's own identity — NOT read out
// of the DeleteSecret policy. This lets the secret-prefix test assert the
// grant lands on THIS role and genuinely fail if a future edit attaches the
// DeleteSecret grant to the wrong role.
function findRemoveWorkspaceFn(): { logicalId: string; role: string } {
const fns = template.findResources('AWS::Lambda::Function');
const matches = Object.entries(fns).filter(([, fn]) => {
const vars =
(fn.Properties as { Environment?: { Variables?: Record<string, unknown> } })
.Environment?.Variables ?? {};
return (
'LINEAR_WORKSPACE_REGISTRY_TABLE_NAME' in vars &&
!('LINEAR_PROJECT_MAPPING_TABLE_NAME' in vars) &&
!('LINEAR_WEBHOOK_SECRET_ARN' in vars) &&
!('LINEAR_USER_MAPPING_TABLE_NAME' in vars)
);
});
expect(matches).toHaveLength(1);
const [logicalId, fn] = matches[0];
const role = (fn.Properties as { Role?: { 'Fn::GetAtt'?: [string, string] } })
.Role?.['Fn::GetAtt']?.[0];
expect(role).toBeDefined();
return { logicalId, role: role! };
}

test('remove-workspace handler wires ONLY the workspace registry (no project mapping table)', () => {
// B2: the mapping-cleanup path was dropped, so the remove-workspace
// function must NOT carry the project-mapping table env var (that was
// the dead grant + no-op cleanup the reviewer flagged).
const { logicalId } = findRemoveWorkspaceFn();
const fn = template.findResources('AWS::Lambda::Function')[logicalId];
const vars = (fn.Properties as { Environment: { Variables: Record<string, unknown> } })
.Environment.Variables;
expect(vars).toHaveProperty('LINEAR_WORKSPACE_REGISTRY_TABLE_NAME');
expect(vars).not.toHaveProperty('LINEAR_PROJECT_MAPPING_TABLE_NAME');
});

test('remove-workspace role can delete ONLY the bgagent-linear-oauth-* secret prefix (scope pinned to the role)', () => {
// Bind the DeleteSecret grant to the remove-workspace role AND pin the
// resource ARN to the bgagent-linear-oauth-* prefix, so a future
// widening of that wildcard (or attaching DeleteSecret to another role)
// fails this test.
const { role } = findRemoveWorkspaceFn();
const policies = template.findResources('AWS::IAM::Policy');
const deletePolicies = Object.values(policies).filter((p) => {
const doc = (p.Properties as { PolicyDocument: { Statement: Array<{ Action?: unknown }> } })
.PolicyDocument;
return doc.Statement.some((s) => {
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
return actions.includes('secretsmanager:DeleteSecret');
});
});
expect(deletePolicies).toHaveLength(1);

const policy = deletePolicies[0];
// The policy is attached to the remove-workspace role only.
const roleRefs = ((policy.Properties as { Roles?: Array<{ Ref?: string }> }).Roles ?? [])
.map((r) => r.Ref);
expect(roleRefs).toContain(role);
Comment thread
isadeks marked this conversation as resolved.

// The DeleteSecret statement's resource ends with the documented prefix.
const stmt = (policy.Properties as {
PolicyDocument: { Statement: Array<{ Action?: unknown; Resource?: unknown }> };
}).PolicyDocument.Statement.find((s) => {
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
return actions.includes('secretsmanager:DeleteSecret');
})!;
expect(JSON.stringify(stmt.Resource)).toContain('bgagent-linear-oauth-*');
});

test('creates one Secrets Manager secret (webhook signing) — OAuth tokens are CLI-created at runtime', () => {
Expand Down
Loading
Loading