feat(workspaces): implement IAP auth primitives and multi-tenancy in Hub

This commit is contained in:
mkorwel
2026-03-19 09:22:13 -07:00
parent 1ccd86cfd7
commit a0db453abe
5 changed files with 112 additions and 20 deletions
+2
View File
@@ -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;
@@ -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();
});
});
@@ -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();
};
@@ -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');
});
@@ -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);