mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-04 15:16:49 -07:00
feat(workspaces): implement IAP auth primitives and multi-tenancy in Hub
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user