From a0db453abee391d9b5c20f745849f412da1f1933 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 09:22:13 -0700 Subject: [PATCH] feat(workspaces): implement IAP auth primitives and multi-tenancy in Hub --- packages/workspace-manager/src/index.ts | 2 + .../src/middleware/iap.test.ts | 44 +++++++++++++++++ .../workspace-manager/src/middleware/iap.ts | 49 +++++++++++++++++++ .../src/routes/workspaceRoutes.test.ts | 2 +- .../src/routes/workspaceRoutes.ts | 35 ++++++------- 5 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 packages/workspace-manager/src/middleware/iap.test.ts create mode 100644 packages/workspace-manager/src/middleware/iap.ts diff --git a/packages/workspace-manager/src/index.ts b/packages/workspace-manager/src/index.ts index 1766a1d027..2e60f3df0b 100644 --- a/packages/workspace-manager/src/index.ts +++ b/packages/workspace-manager/src/index.ts @@ -6,9 +6,11 @@ import express from 'express'; import { workspaceRouter } from './routes/workspaceRoutes.js'; +import { iapMiddleware } from './middleware/iap.js'; export const app = express(); app.use(express.json()); +app.use(iapMiddleware); const PORT = process.env['PORT'] || 8080; diff --git a/packages/workspace-manager/src/middleware/iap.test.ts b/packages/workspace-manager/src/middleware/iap.test.ts new file mode 100644 index 0000000000..487250ef3c --- /dev/null +++ b/packages/workspace-manager/src/middleware/iap.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { iapMiddleware } from './iap.js'; +import type { Request, Response } from 'express'; + +describe('iapMiddleware', () => { + it('should extract user info from IAP headers', () => { + const req = { + header: vi.fn((name) => { + if (name === 'x-goog-authenticated-user-email') return 'accounts.google.com:test@google.com'; + if (name === 'x-goog-authenticated-user-id') return 'accounts.google.com:12345'; + return undefined; + }), + } as unknown as Request; + const res = {} as Response; + const next = vi.fn(); + + iapMiddleware(req, res, next); + + expect((req as any).user).toEqual({ + id: '12345', + email: 'test@google.com', + }); + expect(next).toHaveBeenCalled(); + }); + + it('should fall back to dev user if headers missing in non-prod', () => { + const req = { + header: vi.fn(() => undefined), + } as unknown as Request; + const res = {} as Response; + const next = vi.fn(); + + iapMiddleware(req, res, next); + + expect((req as any).user.id).toBe('dev-user-id'); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/packages/workspace-manager/src/middleware/iap.ts b/packages/workspace-manager/src/middleware/iap.ts new file mode 100644 index 0000000000..28b1fbf8fe --- /dev/null +++ b/packages/workspace-manager/src/middleware/iap.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Request, Response, NextFunction } from 'express'; + +export interface AuthenticatedRequest extends Request { + user: { + id: string; + email: string; + }; +} + +/** + * Middleware to extract user identity from Google IAP headers. + * In a real production environment, the JWT signature should also be verified. + */ +export const iapMiddleware = (req: Request, res: Response, next: NextFunction) => { + const userEmail = req.header('x-goog-authenticated-user-email'); + const userId = req.header('x-goog-authenticated-user-id'); + + // If running locally or without IAP, use a dev user + if (!userEmail || !userId) { + if (process.env['NODE_ENV'] === 'production') { + res.status(401).json({ error: 'Missing IAP authentication headers' }); + return; + } + + (req as AuthenticatedRequest).user = { + id: 'dev-user-id', + email: 'dev-user@google.com', + }; + next(); + return; + } + + // Remove the "accounts.google.com:" prefix if present + const cleanId = userId.replace('accounts.google.com:', ''); + const cleanEmail = userEmail.replace('accounts.google.com:', ''); + + (req as AuthenticatedRequest).user = { + id: cleanId, + email: cleanEmail, + }; + + next(); +}; diff --git a/packages/workspace-manager/src/routes/workspaceRoutes.test.ts b/packages/workspace-manager/src/routes/workspaceRoutes.test.ts index d638c0764d..6677c6d7df 100644 --- a/packages/workspace-manager/src/routes/workspaceRoutes.test.ts +++ b/packages/workspace-manager/src/routes/workspaceRoutes.test.ts @@ -45,7 +45,7 @@ describe('Workspace Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe('test-workspace'); - expect(response.body.owner_id).toBe('default-user'); + expect(response.body.owner_id).toBe('dev-user-id'); expect(response.body.status).toBe('PROVISIONING'); }); diff --git a/packages/workspace-manager/src/routes/workspaceRoutes.ts b/packages/workspace-manager/src/routes/workspaceRoutes.ts index 22e4327772..9d4b148d10 100644 --- a/packages/workspace-manager/src/routes/workspaceRoutes.ts +++ b/packages/workspace-manager/src/routes/workspaceRoutes.ts @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import { WorkspaceService } from '../services/workspaceService.js'; import { ComputeService } from '../services/computeService.js'; +import type { AuthenticatedRequest } from '../middleware/iap.js'; const router = Router(); const workspaceService = new WorkspaceService(); @@ -21,11 +22,10 @@ const CreateWorkspaceSchema = z.object({ zone: z.string().optional().default('us-west1-a'), }); -const DEFAULT_OWNER = 'default-user'; // TODO: Replace with IAP identity middleware - -router.get('/', async (_req, res) => { +router.get('/', async (req, res) => { try { - const workspaces = await workspaceService.listWorkspaces(DEFAULT_OWNER); + const authReq = req as AuthenticatedRequest; + const workspaces = await workspaceService.listWorkspaces(authReq.user.id); res.json(workspaces); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -35,6 +35,7 @@ router.get('/', async (_req, res) => { router.post('/', async (req, res) => { try { + const authReq = req as AuthenticatedRequest; const validation = CreateWorkspaceSchema.safeParse(req.body); if (!validation.success) { res.status(400).json({ error: validation.error.format() }); @@ -46,7 +47,7 @@ router.post('/', async (req, res) => { const instanceName = `workspace-${workspaceId.slice(0, 8)}`; const workspaceData = { - owner_id: DEFAULT_OWNER, + owner_id: authReq.user.id, name, instance_name: instanceName, status: 'PROVISIONING', @@ -59,17 +60,15 @@ router.post('/', async (req, res) => { await workspaceService.createWorkspace(workspaceId, workspaceData); // 2. Trigger GCE provisioning (Async) - computeService - .createWorkspaceInstance({ - instanceName, - machineType, - imageTag, - zone, - }) - .catch((err) => { + computeService.createWorkspaceInstance({ + instanceName, + machineType, + imageTag, + zone, + }).catch(err => { // eslint-disable-next-line no-console console.error(`Failed to provision GCE instance ${instanceName}:`, err); - }); + }); res.status(201).json({ id: workspaceId, ...workspaceData }); } catch (error) { @@ -80,6 +79,7 @@ router.post('/', async (req, res) => { router.delete('/:id', async (req, res) => { try { + const authReq = req as AuthenticatedRequest; const { id } = req.params; const workspace = await workspaceService.getWorkspace(id); @@ -89,16 +89,13 @@ router.delete('/:id', async (req, res) => { } // SECURITY: Ownership Check - if (workspace.owner_id !== DEFAULT_OWNER) { + if (workspace.owner_id !== authReq.user.id) { res.status(403).json({ error: 'Unauthorized to delete this workspace' }); return; } // 1. Delete GCE instance - await computeService.deleteWorkspaceInstance( - workspace.instance_name, - workspace.zone, - ); + await computeService.deleteWorkspaceInstance(workspace.instance_name, workspace.zone); // 2. Delete from state store await workspaceService.deleteWorkspace(id);