diff --git a/apps/api/migrations/2026-06-01-google-workspace-connections.sql b/apps/api/migrations/2026-06-01-google-workspace-connections.sql new file mode 100644 index 000000000..04c2e6413 --- /dev/null +++ b/apps/api/migrations/2026-06-01-google-workspace-connections.sql @@ -0,0 +1,67 @@ +-- Per-org Google Workspace connection for the Breeze identity tools. +-- One connection per org (resolved by org_id). Holds the service-account +-- credentials for domain-wide delegation. UNLIKE delegant_m365_connections, +-- this table DOES store a secret: service_account_key is the full SA JSON, +-- encrypted at rest by the application layer via secretCrypto (the column holds +-- ciphertext, never plaintext). admin_email is the super-admin the SA +-- impersonates for Admin SDK calls; Gmail/Calendar ops impersonate the target +-- end user at call time (not stored). +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS, guarded FK, DROP POLICY IF EXISTS +-- before CREATE. autoMigrate wraps each file in a transaction — no inner +-- BEGIN/COMMIT. +CREATE TABLE IF NOT EXISTS google_workspace_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + customer_domain VARCHAR(253) NOT NULL, + admin_email VARCHAR(320) NOT NULL, + service_account_email VARCHAR(320) NOT NULL, + service_account_key TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_by UUID, + last_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- One Google Workspace connection per org. +CREATE UNIQUE INDEX IF NOT EXISTS google_workspace_connections_org_uniq + ON google_workspace_connections (org_id); + +-- org_id -> organizations(id) FK with ON DELETE CASCADE so connections can't +-- orphan and org teardown auto-cleans them. Guarded for DBs that created the +-- table before the FK existed; fresh creates above already carry it. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'google_workspace_connections_org_id_fkey' + AND conrelid = 'google_workspace_connections'::regclass + ) THEN + ALTER TABLE google_workspace_connections + ADD CONSTRAINT google_workspace_connections_org_id_fkey + FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Row-Level Security: per-org tenant data, mirroring delegant_m365_connections +-- and c2c_connections. Canonical breeze_org_isolation_{select,insert,update, +-- delete} backed by public.breeze_has_org_access(org_id). ENABLE + FORCE so even +-- the table owner is bound. Idempotent — safe to re-run. +DROP POLICY IF EXISTS breeze_org_isolation_select ON google_workspace_connections; +DROP POLICY IF EXISTS breeze_org_isolation_insert ON google_workspace_connections; +DROP POLICY IF EXISTS breeze_org_isolation_update ON google_workspace_connections; +DROP POLICY IF EXISTS breeze_org_isolation_delete ON google_workspace_connections; + +ALTER TABLE google_workspace_connections ENABLE ROW LEVEL SECURITY; +ALTER TABLE google_workspace_connections FORCE ROW LEVEL SECURITY; + +CREATE POLICY breeze_org_isolation_select ON google_workspace_connections + FOR SELECT USING (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_insert ON google_workspace_connections + FOR INSERT WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_update ON google_workspace_connections + FOR UPDATE USING (public.breeze_has_org_access(org_id)) + WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_delete ON google_workspace_connections + FOR DELETE USING (public.breeze_has_org_access(org_id)); diff --git a/apps/api/migrations/2026-06-01-m365-connections.sql b/apps/api/migrations/2026-06-01-m365-connections.sql new file mode 100644 index 000000000..33112b7d3 --- /dev/null +++ b/apps/api/migrations/2026-06-01-m365-connections.sql @@ -0,0 +1,65 @@ +-- Per-org Microsoft 365 connection for the Breeze identity tools. +-- One connection per org (resolved by org_id). Holds the Azure AD app-registration +-- credentials for a client-credentials Graph flow: tenant_id + client_id identify +-- the app; client_secret is the app secret, encrypted at rest by the application +-- layer via secretCrypto (the column holds ciphertext, never plaintext). +-- Distinct from delegant_m365_connections (no secret) and c2c_connections (backup). +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS, guarded FK, DROP POLICY IF EXISTS +-- before CREATE. autoMigrate wraps each file in a transaction — no inner +-- BEGIN/COMMIT. +CREATE TABLE IF NOT EXISTS m365_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + tenant_id VARCHAR(64) NOT NULL, + client_id VARCHAR(64) NOT NULL, + client_secret TEXT NOT NULL, + display_name VARCHAR(256), + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_by UUID, + last_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- One Microsoft 365 connection per org. +CREATE UNIQUE INDEX IF NOT EXISTS m365_connections_org_uniq + ON m365_connections (org_id); + +-- org_id -> organizations(id) FK with ON DELETE CASCADE so connections can't +-- orphan and org teardown auto-cleans them. Guarded for DBs that created the +-- table before the FK existed; fresh creates above already carry it. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'm365_connections_org_id_fkey' + AND conrelid = 'm365_connections'::regclass + ) THEN + ALTER TABLE m365_connections + ADD CONSTRAINT m365_connections_org_id_fkey + FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Row-Level Security: per-org tenant data, mirroring google_workspace_connections +-- and delegant_m365_connections. Canonical breeze_org_isolation_{select,insert, +-- update,delete} backed by public.breeze_has_org_access(org_id). ENABLE + FORCE +-- so even the table owner is bound. Idempotent — safe to re-run. +DROP POLICY IF EXISTS breeze_org_isolation_select ON m365_connections; +DROP POLICY IF EXISTS breeze_org_isolation_insert ON m365_connections; +DROP POLICY IF EXISTS breeze_org_isolation_update ON m365_connections; +DROP POLICY IF EXISTS breeze_org_isolation_delete ON m365_connections; + +ALTER TABLE m365_connections ENABLE ROW LEVEL SECURITY; +ALTER TABLE m365_connections FORCE ROW LEVEL SECURITY; + +CREATE POLICY breeze_org_isolation_select ON m365_connections + FOR SELECT USING (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_insert ON m365_connections + FOR INSERT WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_update ON m365_connections + FOR UPDATE USING (public.breeze_has_org_access(org_id)) + WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_delete ON m365_connections + FOR DELETE USING (public.breeze_has_org_access(org_id)); diff --git a/apps/api/package.json b/apps/api/package.json index cdabf6e1d..736d5ac7c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,6 +35,11 @@ "@aws-sdk/client-s3": "^3.1065.0", "@aws-sdk/s3-request-presigner": "^3.1030.0", "@breeze/shared": "workspace:*", + "@elastic/elasticsearch": "^9.3.2", + "@googleapis/admin": "^31.0.0", + "@googleapis/calendar": "^15.0.0", + "@googleapis/gmail": "^17.0.0", + "@googleapis/licensing": "^5.0.1", "@hono/node-server": "^2.0.4", "@hono/node-ws": "^1.3.1", "@hono/zod-validator": "^0.8.0", @@ -46,6 +51,7 @@ "bullmq": "^5.78.0", "drizzle-orm": "^0.45.2", "firebase-admin": "^13.7.0", + "google-auth-library": "^10.6.2", "hono": "^4.12.25", "ioredis": "^5.10.1", "jose": "^6.1.3", diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 9c6ee2474..402e1df18 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -7,6 +7,16 @@ function envFlag(name: string, fallback = false): boolean { export const MCP_OAUTH_ENABLED = envFlag('MCP_OAUTH_ENABLED'); +// Google Workspace identity tools. Defaults OFF everywhere; an org must also +// have an explicit google_workspace_connections row before any tool is usable. +// Gates tool registration (aiAgentSdkTools.ts) and the connect routes. +export const GOOGLE_WORKSPACE_ENABLED = envFlag('GOOGLE_WORKSPACE_ENABLED', false); + +// Microsoft 365 identity tools. Defaults OFF everywhere; an org must also have +// an explicit m365_connections row before any tool is usable. Gates tool +// registration (aiAgentSdkTools.ts) and the connect routes. +export const M365_ENABLED = envFlag('M365_ENABLED', false); + // Read at call time so tests can flip `IS_HOSTED` per-test without `vi.resetModules()`. export function isHosted(): boolean { return envFlag('IS_HOSTED'); diff --git a/apps/api/src/db/schema/google.ts b/apps/api/src/db/schema/google.ts new file mode 100644 index 000000000..2a3c00383 --- /dev/null +++ b/apps/api/src/db/schema/google.ts @@ -0,0 +1,47 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +import { organizations } from './orgs'; + +/** + * Per-org Google Workspace connection for the Breeze identity tools. + * + * One connection per Breeze org (resolved by org_id). Holds the service-account + * credentials used for domain-wide delegation (DWD): + * - `adminEmail` is the super-admin the service account impersonates for Admin + * SDK Directory / Reports / Licensing calls. + * - Gmail/Calendar operations impersonate the TARGET end user instead (handled + * at call time in googleClient.ts, not stored here). + * + * `serviceAccountKey` is the full service-account JSON, encrypted at rest via + * secretCrypto (encryptSecret / decryptForColumn). It is a domain god-key — it + * must never be logged or returned from any read endpoint. + */ +export const googleWorkspaceConnections = pgTable( + 'google_workspace_connections', + { + id: uuid('id').primaryKey().defaultRandom(), + orgId: uuid('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + customerDomain: varchar('customer_domain', { length: 253 }).notNull(), + adminEmail: varchar('admin_email', { length: 320 }).notNull(), + serviceAccountEmail: varchar('service_account_email', { length: 320 }).notNull(), + serviceAccountKey: text('service_account_key').notNull(), + status: varchar('status', { length: 32 }).notNull().default('active'), + createdBy: uuid('created_by'), + lastVerifiedAt: timestamp('last_verified_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (t) => ({ + orgUniq: uniqueIndex('google_workspace_connections_org_uniq').on(t.orgId), + }) +); + +export type GoogleWorkspaceConnectionRow = typeof googleWorkspaceConnections.$inferSelect; diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index e16a59052..545cec413 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -62,6 +62,8 @@ export * from './applicationBackup'; export * from './hypervVms'; export * from './c2c'; export * from './delegant'; +export * from './google'; +export * from './m365'; export * from './sla'; export * from './drPlans'; export * from './localVault'; diff --git a/apps/api/src/db/schema/m365.test.ts b/apps/api/src/db/schema/m365.test.ts new file mode 100644 index 000000000..558820e3d --- /dev/null +++ b/apps/api/src/db/schema/m365.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { getTableConfig } from 'drizzle-orm/pg-core'; +import { m365Connections } from './m365'; + +describe('m365Connections schema', () => { + it('has the expected columns', () => { + const cfg = getTableConfig(m365Connections); + const cols = cfg.columns.map((c) => c.name).sort(); + expect(cols).toEqual( + [ + 'id', 'org_id', 'tenant_id', 'client_id', 'client_secret', 'display_name', + 'status', 'created_by', 'last_verified_at', 'created_at', 'updated_at', + ].sort() + ); + }); + + it('is named m365_connections', () => { + expect(getTableConfig(m365Connections).name).toBe('m365_connections'); + }); + + it('stores the client secret as the encrypted-at-rest column', () => { + const cfg = getTableConfig(m365Connections); + const secret = cfg.columns.find((c) => c.name === 'client_secret'); + expect(secret?.notNull).toBe(true); + }); +}); diff --git a/apps/api/src/db/schema/m365.ts b/apps/api/src/db/schema/m365.ts new file mode 100644 index 000000000..85af7c4de --- /dev/null +++ b/apps/api/src/db/schema/m365.ts @@ -0,0 +1,47 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +import { organizations } from './orgs'; + +/** + * Per-org Microsoft 365 connection for the Breeze identity tools. + * + * One connection per Breeze org (resolved by org_id). Holds the app-registration + * credentials for a client-credentials Graph flow: + * - `tenantId` + `clientId` identify the Azure AD app registration. + * - `clientSecret` is the app secret, encrypted at rest via secretCrypto + * (the column holds ciphertext, never plaintext). It is an admin god-key for + * the tenant — it must never be logged or returned from any read endpoint. + * + * Distinct from `delegant_m365_connections` (a lighter delegated-reference model + * that stores no secret) and `c2c_connections` (cloud-to-cloud backup). The + * token-acquisition logic is shared via services/c2cM365.ts. + */ +export const m365Connections = pgTable( + 'm365_connections', + { + id: uuid('id').primaryKey().defaultRandom(), + orgId: uuid('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + tenantId: varchar('tenant_id', { length: 64 }).notNull(), + clientId: varchar('client_id', { length: 64 }).notNull(), + clientSecret: text('client_secret').notNull(), + displayName: varchar('display_name', { length: 256 }), + status: varchar('status', { length: 32 }).notNull().default('active'), + createdBy: uuid('created_by'), + lastVerifiedAt: timestamp('last_verified_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (t) => ({ + orgUniq: uniqueIndex('m365_connections_org_uniq').on(t.orgId), + }) +); + +export type M365ConnectionRow = typeof m365Connections.$inferSelect; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 17dd91258..23778fa8c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -118,6 +118,8 @@ import { sensitiveDataRoutes } from './routes/sensitiveData'; import { peripheralControlRoutes } from './routes/peripheralControl'; import { browserSecurityRoutes } from './routes/browserSecurity'; import { c2cRoutes, m365CallbackRoute } from './routes/c2c'; +import { googleRoutes } from './routes/google'; +import { m365Routes } from './routes/m365'; import { drRoutes } from './routes/dr'; import { adminRoutes } from './routes/admin'; import { bootstrapPlatformAdmins } from './services/platformAdminBootstrap'; @@ -813,6 +815,8 @@ api.route('/peripherals', peripheralControlRoutes); api.route('/browser-security', browserSecurityRoutes); api.route('/', m365CallbackRoute); // Public callback (no auth) — must precede c2c group api.route('/c2c', c2cRoutes); +api.route('/google', googleRoutes); +api.route('/m365', m365Routes); api.route('/dr', drRoutes); api.route('/admin', adminRoutes); api.route('/admin', accountDeletionAdminRoutes); diff --git a/apps/api/src/routes/ai.m365session.test.ts b/apps/api/src/routes/ai.m365session.test.ts index 42d52528f..9de8eba4a 100644 --- a/apps/api/src/routes/ai.m365session.test.ts +++ b/apps/api/src/routes/ai.m365session.test.ts @@ -156,6 +156,20 @@ describe('AI M365 session binding', () => { expect(body.error).toBe('Invalid M365 connection'); }); + it('maps an Invalid device rejection to 400 (not 500)', async () => { + vi.mocked(createSession).mockRejectedValueOnce(new Error('Invalid device')); + + const res = await app.request('/ai/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token' }, + body: JSON.stringify({ deviceId: '44444444-4444-4444-4444-444444444444' }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Invalid device'); + }); + it('creates a session with NO connection (back-compat)', async () => { vi.mocked(createSession).mockResolvedValueOnce({ id: SESSION_ID, diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index f09f5439a..0957f3b59 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -88,7 +88,13 @@ function getOpenAISessionManager(): OpenAISessionManager { const createAiSessionSchema = sharedCreateAiSessionSchema.extend({ orgId: z.string().uuid().optional(), - delegantM365ConnectionId: z.string().uuid().optional() + delegantM365ConnectionId: z.string().uuid().optional(), + // Bind the session to a specific device (a "task on this computer"). The + // device is org-validated in createSession. + deviceId: z.string().uuid().optional(), + // Let the caller pick the approval posture (e.g. plan-first for open-ended + // device tasks). Defaults to the column default (per_step) when omitted. + approvalMode: z.enum(['per_step', 'action_plan', 'auto_approve', 'hybrid_plan']).optional() }); /** @@ -141,6 +147,7 @@ aiRoutes.post( const message = err instanceof Error ? err.message : 'Failed to create session'; if (message === 'Organization context required') return c.json({ error: message }, 400); if (message === 'Invalid M365 connection') return c.json({ error: message }, 400); + if (message === 'Invalid device') return c.json({ error: message }, 400); if (message === 'Access denied to this organization') return c.json({ error: message }, 403); return c.json({ error: message }, 500); } diff --git a/apps/api/src/routes/google.test.ts b/apps/api/src/routes/google.test.ts new file mode 100644 index 000000000..25bc9b5d9 --- /dev/null +++ b/apps/api/src/routes/google.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; + +// --- mutable mock state, set per-test --- +let selectRows: unknown[] = []; +let insertRows: unknown[] = []; +let deleteRows: unknown[] = []; +let parseThrows = false; +let dirGetThrows = false; + +vi.mock('../config/env', () => ({ GOOGLE_WORKSPACE_ENABLED: true })); +vi.mock('../services/permissions', () => ({ + PERMISSIONS: { + ORGS_READ: { resource: 'organizations', action: 'read' }, + ORGS_WRITE: { resource: 'organizations', action: 'write' }, + }, +})); +vi.mock('../middleware/auth', () => ({ + authMiddleware: vi.fn((c: any, next: any) => { + c.set('auth', { scope: 'organization', orgId: 'org-1', user: { id: 'user-1' } }); + return next(); + }), + requirePermission: vi.fn(() => (_c: any, next: any) => next()), + requireMfa: vi.fn(() => (_c: any, next: any) => next()), +})); +vi.mock('../db/schema/google', () => ({ googleWorkspaceConnections: { orgId: 'org_id' } })); +vi.mock('../services/auditEvents', () => ({ writeRouteAudit: vi.fn() })); +vi.mock('../services/sentry', () => ({ captureException: vi.fn() })); +vi.mock('../services/secretCrypto', () => ({ encryptSecret: vi.fn(() => 'ENCRYPTED-KEY') })); +vi.mock('./c2c/helpers', () => ({ resolveScopedOrgId: vi.fn(() => 'org-1') })); +vi.mock('../services/googleClient', () => ({ + parseServiceAccountKey: vi.fn(() => { + if (parseThrows) throw new Error('not valid JSON'); + return { client_email: 'sa@proj.iam.gserviceaccount.com', private_key: 'k' }; + }), + getDirectoryClient: vi.fn(() => ({ + users: { get: vi.fn(async () => { if (dirGetThrows) throw new Error('domain-wide delegation not authorized'); return { data: {} }; }) }, + })), + normalizeGoogleError: vi.fn((e: any) => ({ code: 'google_error', message: e?.message ?? String(e) })), +})); +vi.mock('../db', () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(async () => selectRows) })) })) })), + insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn(() => ({ returning: vi.fn(async () => insertRows) })) })) })), + delete: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(async () => deleteRows) })) })), + }, +})); + +import { googleRoutes } from './google'; +import { authMiddleware } from '../middleware/auth'; +import { encryptSecret } from '../services/secretCrypto'; + +function app() { + const a = new Hono(); + a.use('*', authMiddleware as any); + a.route('/google', googleRoutes); + return a; +} + +const storedRow = { + id: 'conn-1', orgId: 'org-1', customerDomain: 'example.com', adminEmail: 'admin@example.com', + serviceAccountEmail: 'sa@proj.iam.gserviceaccount.com', serviceAccountKey: 'ENCRYPTED-KEY', + status: 'active', createdBy: 'user-1', lastVerifiedAt: new Date('2026-06-01T00:00:00Z'), + createdAt: new Date('2026-06-01T00:00:00Z'), updatedAt: new Date('2026-06-01T00:00:00Z'), +}; +const validBody = { customerDomain: 'example.com', adminEmail: 'admin@example.com', serviceAccountKey: '{"type":"service_account"}' }; + +beforeEach(() => { + vi.clearAllMocks(); + selectRows = []; insertRows = []; deleteRows = []; + parseThrows = false; dirGetThrows = false; +}); + +describe('google connection routes', () => { + it('GET /connection with no row → connected:false', async () => { + const res = await app().request('/google/connection'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ connected: false }); + }); + + it('GET /connection with a row → connected:true and NEVER returns the service-account key', async () => { + selectRows = [storedRow]; + const res = await app().request('/google/connection'); + const body = await res.json(); + expect(body.connected).toBe(true); + expect(body.customerDomain).toBe('example.com'); + expect(body.serviceAccountEmail).toBe('sa@proj.iam.gserviceaccount.com'); + expect(body).not.toHaveProperty('serviceAccountKey'); + expect(JSON.stringify(body)).not.toContain('ENCRYPTED-KEY'); + }); + + it('POST /connection verifies via a live Directory call, encrypts the key, returns 201 without the key', async () => { + insertRows = [storedRow]; + const res = await app().request('/google/connection', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validBody), + }); + expect(res.status).toBe(201); + expect(encryptSecret).toHaveBeenCalledWith('{"type":"service_account"}'); + const body = await res.json(); + expect(body.connected).toBe(true); + expect(JSON.stringify(body)).not.toContain('service_account'); + expect(JSON.stringify(body)).not.toContain('ENCRYPTED-KEY'); + }); + + it('POST /connection returns 400 with a hint when the live Directory verify fails (bad DWD)', async () => { + dirGetThrows = true; + const res = await app().request('/google/connection', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validBody), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.hint).toBeTruthy(); + expect(encryptSecret).not.toHaveBeenCalled(); + }); + + it('POST /connection returns 400 when the service-account key is malformed', async () => { + parseThrows = true; + const res = await app().request('/google/connection', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validBody), + }); + expect(res.status).toBe(400); + expect(encryptSecret).not.toHaveBeenCalled(); + }); + + it('POST /connection rejects a missing service-account key (zod) with 400', async () => { + const res = await app().request('/google/connection', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ customerDomain: 'example.com', adminEmail: 'admin@example.com' }), + }); + expect(res.status).toBe(400); + }); + + it('DELETE /connection → connected:false', async () => { + deleteRows = [storedRow]; + const res = await app().request('/google/connection', { method: 'DELETE' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ connected: false }); + }); + + // Regression: the route module itself must attach authMiddleware. index.ts + // does NOT apply a global auth middleware to the /api/v1 group, so a route + // that forgets `.use('*', authMiddleware)` reaches requirePermission with no + // auth context and 401s every authenticated request. Mount the router WITHOUT + // the harness auth and assert the router invoked authMiddleware on its own. + it('attaches authMiddleware itself (regression: 401 for all callers when missing)', async () => { + const bare = new Hono(); + bare.route('/google', googleRoutes); + await bare.request('/google/connection'); + expect(authMiddleware).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/google.ts b/apps/api/src/routes/google.ts new file mode 100644 index 000000000..8400e25d5 --- /dev/null +++ b/apps/api/src/routes/google.ts @@ -0,0 +1,191 @@ +/** + * Google Workspace connection management for the Breeze identity tools. + * + * One connection per org. The service-account key is a domain god-key: it is + * encrypted at rest (secretCrypto), validated by a live Directory call before + * it is stored (fail-closed), and NEVER returned by any read endpoint. + * + * Gated by GOOGLE_WORKSPACE_ENABLED (whole group 404s when off) and by + * ORGS_WRITE + MFA on mutations, mirroring the c2c connection routes. + */ + +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { googleWorkspaceConnections } from '../db/schema/google'; +import { authMiddleware, requireMfa, requirePermission } from '../middleware/auth'; +import { writeRouteAudit } from '../services/auditEvents'; +import { captureException } from '../services/sentry'; +import { encryptSecret } from '../services/secretCrypto'; +import { resolveScopedOrgId } from './c2c/helpers'; +import { PERMISSIONS } from '../services/permissions'; +import { GOOGLE_WORKSPACE_ENABLED } from '../config/env'; +import { getDirectoryClient, parseServiceAccountKey, normalizeGoogleError } from '../services/googleClient'; + +export const googleRoutes = new Hono(); + +const requireOrgsRead = requirePermission(PERMISSIONS.ORGS_READ.resource, PERMISSIONS.ORGS_READ.action); +const requireOrgsWrite = requirePermission(PERMISSIONS.ORGS_WRITE.resource, PERMISSIONS.ORGS_WRITE.action); + +// Every endpoint requires an authenticated session (populates c.get('auth') for +// the requirePermission / requireMfa guards below). Without this the guards see +// no auth context and reject every request with 401. +googleRoutes.use('*', authMiddleware); + +// Whole group is dark unless the feature flag is on. +googleRoutes.use('*', async (c, next) => { + if (!GOOGLE_WORKSPACE_ENABLED) return c.json({ error: 'Google Workspace integration is not enabled' }, 404); + await next(); +}); + +const connectSchema = z.object({ + customerDomain: z.string().min(1).max(253), + adminEmail: z.string().email().max(320), + // Full service-account JSON (the file Google gives you). Validated + a live + // Directory call is made before it is stored. + serviceAccountKey: z.string().min(1).max(16384), +}); + +function toConnectionResponse(row: typeof googleWorkspaceConnections.$inferSelect) { + // Never include service_account_key. + return { + customerDomain: row.customerDomain, + adminEmail: row.adminEmail, + serviceAccountEmail: row.serviceAccountEmail, + status: row.status, + lastVerifiedAt: row.lastVerifiedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +// ── Get connection status ───────────────────────────────────────────────────── +googleRoutes.get('/connection', requireOrgsRead, async (c) => { + const auth = c.get('auth'); + const orgId = resolveScopedOrgId(auth, c.req.query('orgId')); + if (!orgId) return c.json({ error: 'orgId is required for this scope' }, 400); + + const [row] = await db + .select() + .from(googleWorkspaceConnections) + .where(eq(googleWorkspaceConnections.orgId, orgId)) + .limit(1); + + if (!row) return c.json({ connected: false }); + return c.json({ connected: true, ...toConnectionResponse(row) }); +}); + +// ── Create / replace connection ─────────────────────────────────────────────── +googleRoutes.post( + '/connection', + requireOrgsWrite, + requireMfa(), + zValidator('json', connectSchema), + async (c) => { + const auth = c.get('auth'); + const orgId = resolveScopedOrgId(auth, c.req.query('orgId')); + if (!orgId) return c.json({ error: 'orgId is required for this scope' }, 400); + + const payload = c.req.valid('json'); + + // Validate the key JSON + extract the service-account email. + let serviceAccountEmail: string; + try { + serviceAccountEmail = parseServiceAccountKey(payload.serviceAccountKey).client_email; + } catch (err) { + const norm = normalizeGoogleError(err); + return c.json({ error: norm.message }, 400); + } + + // Fail-closed: prove the key + domain-wide delegation actually work before + // storing, by reading the admin user via the Directory API. This also + // surfaces a misconfigured DWD grant immediately with a clear message. + try { + const dir = getDirectoryClient(payload.serviceAccountKey, payload.adminEmail); + await dir.users.get({ userKey: payload.adminEmail }); + } catch (err) { + const norm = normalizeGoogleError(err); + return c.json( + { + error: `Could not verify the Google connection: ${norm.message}`, + hint: 'Confirm domain-wide delegation is authorized for this service account and that the admin email is a super-admin in the domain.', + }, + 400, + ); + } + + const encryptedKey = encryptSecret(payload.serviceAccountKey); + if (!encryptedKey) return c.json({ error: 'Failed to encrypt the service-account key' }, 500); + + const now = new Date(); + const [row] = await db + .insert(googleWorkspaceConnections) + .values({ + orgId, + customerDomain: payload.customerDomain, + adminEmail: payload.adminEmail, + serviceAccountEmail, + serviceAccountKey: encryptedKey, + status: 'active', + createdBy: auth.user?.id ?? null, + lastVerifiedAt: now, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: googleWorkspaceConnections.orgId, + set: { + customerDomain: payload.customerDomain, + adminEmail: payload.adminEmail, + serviceAccountEmail, + serviceAccountKey: encryptedKey, + status: 'active', + lastVerifiedAt: now, + updatedAt: now, + }, + }) + .returning(); + + if (!row) { + captureException(new Error('google_workspace_connection upsert returned no row'), c); + return c.json({ error: 'Failed to save connection' }, 500); + } + + writeRouteAudit(c, { + orgId, + action: 'google.connection.upsert', + resourceType: 'google_workspace_connection', + resourceId: row.id, + resourceName: row.customerDomain, + details: { serviceAccountEmail, adminEmail: row.adminEmail }, + }); + + return c.json({ connected: true, ...toConnectionResponse(row) }, 201); + }, +); + +// ── Delete connection ───────────────────────────────────────────────────────── +googleRoutes.delete('/connection', requireOrgsWrite, requireMfa(), async (c) => { + const auth = c.get('auth'); + const orgId = resolveScopedOrgId(auth, c.req.query('orgId')); + if (!orgId) return c.json({ error: 'orgId is required for this scope' }, 400); + + const [row] = await db + .delete(googleWorkspaceConnections) + .where(eq(googleWorkspaceConnections.orgId, orgId)) + .returning(); + + if (!row) return c.json({ error: 'No Google connection to delete' }, 404); + + writeRouteAudit(c, { + orgId, + action: 'google.connection.delete', + resourceType: 'google_workspace_connection', + resourceId: row.id, + resourceName: row.customerDomain, + }); + + return c.json({ connected: false }); +}); diff --git a/apps/api/src/routes/m365.test.ts b/apps/api/src/routes/m365.test.ts new file mode 100644 index 000000000..7ca08350c --- /dev/null +++ b/apps/api/src/routes/m365.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; + +// --- mutable mock state, set per-test --- +let selectRows: unknown[] = []; +let insertRows: unknown[] = []; +let deleteRows: unknown[] = []; +let tokenResult: { accessToken: string; expiresIn: number }; +let graphResult: { ok: boolean; orgDisplayName?: string; error?: string }; +let tokenThrows = false; + +vi.mock('../config/env', () => ({ M365_ENABLED: true })); +vi.mock('../services/permissions', () => ({ + PERMISSIONS: { + ORGS_READ: { resource: 'organizations', action: 'read' }, + ORGS_WRITE: { resource: 'organizations', action: 'write' }, + }, +})); +vi.mock('../middleware/auth', () => ({ + authMiddleware: vi.fn((c: any, next: any) => { + c.set('auth', { scope: 'organization', orgId: 'org-1', user: { id: 'user-1' } }); + return next(); + }), + requirePermission: vi.fn(() => (_c: any, next: any) => next()), + requireMfa: vi.fn(() => (_c: any, next: any) => next()), +})); +vi.mock('../db/schema/m365', () => ({ m365Connections: { orgId: 'org_id' } })); +vi.mock('../services/auditEvents', () => ({ writeRouteAudit: vi.fn() })); +vi.mock('../services/sentry', () => ({ captureException: vi.fn() })); +vi.mock('../services/secretCrypto', () => ({ encryptSecret: vi.fn(() => 'ENCRYPTED-SECRET') })); +vi.mock('./c2c/helpers', () => ({ resolveScopedOrgId: vi.fn(() => 'org-1') })); +vi.mock('../services/c2cM365', () => ({ + acquireClientCredentialsToken: vi.fn(async () => { + if (tokenThrows) throw new Error('AADSTS7000215 invalid client secret'); + return tokenResult; + }), + testGraphAccess: vi.fn(async () => graphResult), + isM365TenantId: (x: string) => /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(x), +})); +vi.mock('../db', () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(async () => selectRows) })) })) })), + insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn(() => ({ returning: vi.fn(async () => insertRows) })) })) })), + delete: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(async () => deleteRows) })) })), + }, +})); + +import { m365Routes } from './m365'; +import { authMiddleware } from '../middleware/auth'; +import { encryptSecret } from '../services/secretCrypto'; +import { acquireClientCredentialsToken, testGraphAccess } from '../services/c2cM365'; + +function app() { + const a = new Hono(); + a.use('*', authMiddleware as any); + a.route('/m365', m365Routes); + return a; +} + +const storedRow = { + id: 'conn-1', orgId: 'org-1', tenantId: '11111111-1111-1111-1111-111111111111', clientId: 'client-1', + clientSecret: 'ENCRYPTED-SECRET', displayName: 'Contoso', status: 'active', + lastVerifiedAt: new Date('2026-06-01T00:00:00Z'), createdAt: new Date('2026-06-01T00:00:00Z'), + updatedAt: new Date('2026-06-01T00:00:00Z'), +}; + +beforeEach(() => { + vi.clearAllMocks(); + selectRows = []; insertRows = []; deleteRows = []; + tokenResult = { accessToken: 'tok', expiresIn: 3600 }; + graphResult = { ok: true, orgDisplayName: 'Contoso' }; + tokenThrows = false; +}); + +describe('m365 connection routes', () => { + it('GET /connection with no row → connected:false', async () => { + const res = await app().request('/m365/connection'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ connected: false }); + }); + + it('GET /connection with a row → connected:true and NEVER returns the secret', async () => { + selectRows = [storedRow]; + const res = await app().request('/m365/connection'); + const body = await res.json(); + expect(body.connected).toBe(true); + expect(body.tenantId).toBe('11111111-1111-1111-1111-111111111111'); + expect(body.displayName).toBe('Contoso'); + expect(body).not.toHaveProperty('clientSecret'); + expect(JSON.stringify(body)).not.toContain('ENCRYPTED-SECRET'); + }); + + it('POST /connection verifies via Graph, encrypts the secret, returns 201 without the secret', async () => { + insertRows = [storedRow]; + const res = await app().request('/m365/connection', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tenantId: '11111111-1111-1111-1111-111111111111', clientId: 'client-1', clientSecret: 'super-secret' }), + }); + expect(res.status).toBe(201); + expect(acquireClientCredentialsToken).toHaveBeenCalledOnce(); + expect(testGraphAccess).toHaveBeenCalledWith('tok'); + expect(encryptSecret).toHaveBeenCalledWith('super-secret'); + const body = await res.json(); + expect(body.connected).toBe(true); + expect(JSON.stringify(body)).not.toContain('super-secret'); + expect(JSON.stringify(body)).not.toContain('ENCRYPTED-SECRET'); + }); + + it('POST /connection returns 400 with a hint when Graph verification fails', async () => { + graphResult = { ok: false, error: 'insufficient privileges' }; + const res = await app().request('/m365/connection', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tenantId: '11111111-1111-1111-1111-111111111111', clientId: 'client-1', clientSecret: 'super-secret' }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('Could not verify'); + expect(body.hint).toBeTruthy(); + expect(encryptSecret).not.toHaveBeenCalled(); + }); + + it('POST /connection returns 400 when token acquisition throws (bad credentials)', async () => { + tokenThrows = true; + const res = await app().request('/m365/connection', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tenantId: '11111111-1111-1111-1111-111111111111', clientId: 'client-1', clientSecret: 'bad' }), + }); + expect(res.status).toBe(400); + expect(encryptSecret).not.toHaveBeenCalled(); + }); + + it('POST /connection rejects a missing client secret (zod) with 400', async () => { + const res = await app().request('/m365/connection', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tenantId: '11111111-1111-1111-1111-111111111111', clientId: 'client-1' }), + }); + expect(res.status).toBe(400); + }); + + it('POST /connection rejects a non-GUID tenant id with 400 before any token call', async () => { + const res = await app().request('/m365/connection', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tenantId: 'contoso.onmicrosoft.com', clientId: 'client-1', clientSecret: 'super-secret' }), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/tenant guid/i); + expect(encryptSecret).not.toHaveBeenCalled(); + }); + + it('DELETE /connection → connected:false', async () => { + deleteRows = [storedRow]; + const res = await app().request('/m365/connection', { method: 'DELETE' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ connected: false }); + }); + + // Regression: the route module itself must attach authMiddleware. index.ts + // does NOT apply a global auth middleware to the /api/v1 group, so a route + // that forgets `.use('*', authMiddleware)` reaches requirePermission with no + // auth context and 401s every authenticated request. Mount the router WITHOUT + // the harness auth and assert the router invoked authMiddleware on its own. + it('attaches authMiddleware itself (regression: 401 for all callers when missing)', async () => { + const bare = new Hono(); + bare.route('/m365', m365Routes); + await bare.request('/m365/connection'); + expect(authMiddleware).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/m365.ts b/apps/api/src/routes/m365.ts new file mode 100644 index 000000000..59bdec33e --- /dev/null +++ b/apps/api/src/routes/m365.ts @@ -0,0 +1,209 @@ +/** + * Microsoft 365 connection management for the Breeze identity tools. + * + * One connection per org. The app client secret is an admin god-key: it is + * encrypted at rest (secretCrypto), validated by a live Graph call before it is + * stored (fail-closed), and NEVER returned by any read endpoint. + * + * Gated by M365_ENABLED (whole group 404s when off) and by ORGS_WRITE + MFA on + * mutations, mirroring the Google Workspace connection routes. + */ + +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { m365Connections } from '../db/schema/m365'; +import { authMiddleware, requireMfa, requirePermission } from '../middleware/auth'; +import { writeRouteAudit } from '../services/auditEvents'; +import { captureException } from '../services/sentry'; +import { encryptSecret } from '../services/secretCrypto'; +import { resolveScopedOrgId } from './c2c/helpers'; +import { PERMISSIONS } from '../services/permissions'; +import { M365_ENABLED } from '../config/env'; +import { acquireClientCredentialsToken, testGraphAccess, isM365TenantId } from '../services/c2cM365'; + +export const m365Routes = new Hono(); + +const requireOrgsRead = requirePermission(PERMISSIONS.ORGS_READ.resource, PERMISSIONS.ORGS_READ.action); +const requireOrgsWrite = requirePermission(PERMISSIONS.ORGS_WRITE.resource, PERMISSIONS.ORGS_WRITE.action); + +// Every endpoint requires an authenticated session (populates c.get('auth') for +// the requirePermission / requireMfa guards below). Without this the guards see +// no auth context and reject every request with 401. +m365Routes.use('*', authMiddleware); + +// Whole group is dark unless the feature flag is on. +m365Routes.use('*', async (c, next) => { + if (!M365_ENABLED) return c.json({ error: 'Microsoft 365 integration is not enabled' }, 404); + await next(); +}); + +const connectSchema = z.object({ + tenantId: z.string().min(1).max(64), + clientId: z.string().min(1).max(64), + // Azure AD app client secret. Validated by a live Graph call, then encrypted. + clientSecret: z.string().min(1).max(2048), +}); + +function toConnectionResponse(row: typeof m365Connections.$inferSelect) { + // Never include client_secret. + return { + tenantId: row.tenantId, + clientId: row.clientId, + displayName: row.displayName, + status: row.status, + lastVerifiedAt: row.lastVerifiedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +// ── Get connection status ───────────────────────────────────────────────────── +m365Routes.get('/connection', requireOrgsRead, async (c) => { + const auth = c.get('auth'); + const orgId = resolveScopedOrgId(auth, c.req.query('orgId')); + if (!orgId) return c.json({ error: 'orgId is required for this scope' }, 400); + + const [row] = await db + .select() + .from(m365Connections) + .where(eq(m365Connections.orgId, orgId)) + .limit(1); + + if (!row) return c.json({ connected: false }); + return c.json({ connected: true, ...toConnectionResponse(row) }); +}); + +// ── Create / replace connection ─────────────────────────────────────────────── +m365Routes.post( + '/connection', + requireOrgsWrite, + requireMfa(), + zValidator('json', connectSchema), + async (c) => { + const auth = c.get('auth'); + const orgId = resolveScopedOrgId(auth, c.req.query('orgId')); + if (!orgId) return c.json({ error: 'orgId is required for this scope' }, 400); + + const payload = c.req.valid('json'); + + // Tenant id must be a canonical Entra tenant GUID (the M365TenantId brand + // acquireClientCredentialsToken now requires). Reject domain-form / malformed + // ids here, before they reach the token URL. + const tenantId = payload.tenantId; + if (!isM365TenantId(tenantId)) { + return c.json( + { + error: 'tenantId must be a Microsoft 365 tenant GUID', + hint: 'Use the Directory (tenant) ID (a GUID) from the Entra app registration Overview, not the contoso.onmicrosoft.com domain.', + }, + 400, + ); + } + + // Fail-closed: prove the app credentials work before storing, by acquiring a + // client-credentials token and making a live Graph call. Surfaces a bad + // secret or missing admin consent immediately with a clear message. + let displayName: string | null = null; + try { + const token = await acquireClientCredentialsToken({ + tenantId, + clientId: payload.clientId, + clientSecret: payload.clientSecret, + }); + const graphTest = await testGraphAccess(token.accessToken); + if (!graphTest.ok) { + return c.json( + { + error: `Could not verify the Microsoft 365 connection: ${graphTest.error ?? 'Graph access failed'}`, + hint: 'Confirm the app registration has admin consent for the required Graph application permissions and that the tenant/client id and secret are correct.', + }, + 400, + ); + } + displayName = graphTest.orgDisplayName ?? null; + } catch (err) { + return c.json( + { + error: `Could not verify the Microsoft 365 connection: ${err instanceof Error ? err.message : 'token acquisition failed'}`, + hint: 'Confirm the tenant id, client id, and client secret are correct and the app has admin consent.', + }, + 400, + ); + } + + const encryptedSecret = encryptSecret(payload.clientSecret); + if (!encryptedSecret) return c.json({ error: 'Failed to encrypt the client secret' }, 500); + + const now = new Date(); + const [row] = await db + .insert(m365Connections) + .values({ + orgId, + tenantId: payload.tenantId, + clientId: payload.clientId, + clientSecret: encryptedSecret, + displayName, + status: 'active', + createdBy: auth.user?.id ?? null, + lastVerifiedAt: now, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: m365Connections.orgId, + set: { + tenantId: payload.tenantId, + clientId: payload.clientId, + clientSecret: encryptedSecret, + displayName, + status: 'active', + lastVerifiedAt: now, + updatedAt: now, + }, + }) + .returning(); + + if (!row) { + captureException(new Error('m365_connection upsert returned no row'), c); + return c.json({ error: 'Failed to save connection' }, 500); + } + + writeRouteAudit(c, { + orgId, + action: 'm365.connection.upsert', + resourceType: 'm365_connection', + resourceId: row.id, + resourceName: row.tenantId, + details: { tenantId: row.tenantId, clientId: row.clientId }, + }); + + return c.json({ connected: true, ...toConnectionResponse(row) }, 201); + }, +); + +// ── Delete connection ───────────────────────────────────────────────────────── +m365Routes.delete('/connection', requireOrgsWrite, requireMfa(), async (c) => { + const auth = c.get('auth'); + const orgId = resolveScopedOrgId(auth, c.req.query('orgId')); + if (!orgId) return c.json({ error: 'orgId is required for this scope' }, 400); + + const [row] = await db + .delete(m365Connections) + .where(eq(m365Connections.orgId, orgId)) + .returning(); + + if (row) { + writeRouteAudit(c, { + orgId, + action: 'm365.connection.delete', + resourceType: 'm365_connection', + resourceId: row.id, + resourceName: row.tenantId, + }); + } + + return c.json({ connected: false }); +}); diff --git a/apps/api/src/services/aiAgent.deviceTask.test.ts b/apps/api/src/services/aiAgent.deviceTask.test.ts new file mode 100644 index 000000000..72d0ae13f --- /dev/null +++ b/apps/api/src/services/aiAgent.deviceTask.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Drive createSession through the real service logic (device binding) without a +// live DB. Mirrors aiAgent.m365.test.ts. +const selectMock = vi.fn(); +const insertMock = vi.fn(); + +vi.mock('../db', () => ({ + db: { + select: (...args: unknown[]) => selectMock(...args), + insert: (...args: unknown[]) => insertMock(...args), + }, +})); + +vi.mock('../db/schema', () => ({ + aiSessions: { id: 'aiSessions.id', orgId: 'aiSessions.orgId' }, + aiMessages: { sessionId: 'aiMessages.sessionId', createdAt: 'aiMessages.createdAt' }, + aiToolExecutions: {}, + delegantM365Connections: { id: 'delegantM365Connections.id', orgId: 'delegantM365Connections.orgId', status: 'delegantM365Connections.status' }, + devices: { id: 'devices.id', orgId: 'devices.orgId' }, +})); + +vi.mock('./aiAgentSystemPrompt', () => ({ AI_SYSTEM_PROMPT_BASE: 'base' })); +vi.mock('./brainDeviceContext', () => ({ getActiveDeviceContext: vi.fn().mockResolvedValue(null) })); + +import { createSession } from './aiAgent'; + +const DEVICE_ID = '44444444-4444-4444-4444-444444444444'; + +function devSelect(rows: unknown[]) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(rows) }), + }), + }; +} + +const auth: any = { + user: { id: 'user-1' }, + orgId: 'org-111', + accessibleOrgIds: ['org-111'], + canAccessOrg: (id: string) => id === 'org-111', + orgCondition: () => undefined, +}; + +describe('createSession device binding', () => { + beforeEach(() => vi.clearAllMocks()); + + it('rejects a device belonging to a different org', async () => { + selectMock.mockReturnValueOnce(devSelect([{ id: DEVICE_ID, orgId: 'org-OTHER' }])); + await expect(createSession(auth, { deviceId: DEVICE_ID })).rejects.toThrow('Invalid device'); + expect(insertMock).not.toHaveBeenCalled(); + }); + + it('rejects an unknown device', async () => { + selectMock.mockReturnValueOnce(devSelect([])); + await expect(createSession(auth, { deviceId: DEVICE_ID })).rejects.toThrow('Invalid device'); + }); + + it('rejects a same-org device in a site the caller cannot access (#1047 site-axis)', async () => { + selectMock.mockReturnValueOnce(devSelect([{ id: DEVICE_ID, orgId: 'org-111', siteId: 'site-OTHER' }])); + const siteRestricted: any = { ...auth, canAccessSite: (s: string | null) => s === 'site-ALLOWED' }; + await expect(createSession(siteRestricted, { deviceId: DEVICE_ID })).rejects.toThrow('Invalid device'); + expect(insertMock).not.toHaveBeenCalled(); + }); + + it('allows a same-org device whose site IS accessible (site-restricted caller)', async () => { + selectMock.mockReturnValueOnce(devSelect([{ id: DEVICE_ID, orgId: 'org-111', siteId: 'site-ALLOWED' }])); + const siteRestricted: any = { ...auth, canAccessSite: (s: string | null) => s === 'site-ALLOWED' }; + const valuesSpy = vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'sess-1' }]) }); + insertMock.mockReturnValueOnce({ values: valuesSpy }); + await createSession(siteRestricted, { deviceId: DEVICE_ID }); + expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ deviceId: DEVICE_ID })); + }); + + it('persists deviceId + approvalMode for a valid same-org device', async () => { + selectMock.mockReturnValueOnce(devSelect([{ id: DEVICE_ID, orgId: 'org-111' }])); + const valuesSpy = vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'sess-1' }]) }); + insertMock.mockReturnValueOnce({ values: valuesSpy }); + + await createSession(auth, { deviceId: DEVICE_ID, approvalMode: 'hybrid_plan' }); + + expect(valuesSpy).toHaveBeenCalledWith( + expect.objectContaining({ deviceId: DEVICE_ID, approvalMode: 'hybrid_plan' }), + ); + }); + + it('persists a null deviceId when none is supplied (no device lookup)', async () => { + const valuesSpy = vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'sess-1' }]) }); + insertMock.mockReturnValueOnce({ values: valuesSpy }); + + await createSession(auth, {}); + + expect(selectMock).not.toHaveBeenCalled(); + expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ deviceId: null })); + }); + + // A partner / multi-org caller has no home orgId; "Fix with AI" dispatches a + // device task without an explicit orgId. The session must anchor to the + // DEVICE's org — not auth.accessibleOrgIds[0], which is an unrelated org and + // made every dispatch fail the cross-org check with a 500 "Invalid device". + it('binds the session to the device org for a multi-org caller who passes no orgId', async () => { + const partner: any = { + user: { id: 'user-1' }, + orgId: null, + accessibleOrgIds: ['org-FIRST', 'org-DEVICE'], + canAccessOrg: (id: string) => id === 'org-FIRST' || id === 'org-DEVICE', + orgCondition: () => undefined, + }; + selectMock.mockReturnValueOnce(devSelect([{ id: DEVICE_ID, orgId: 'org-DEVICE', siteId: null }])); + const valuesSpy = vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'sess-1' }]) }); + insertMock.mockReturnValueOnce({ values: valuesSpy }); + + const result = await createSession(partner, { deviceId: DEVICE_ID }); + + expect(result.orgId).toBe('org-DEVICE'); + expect(valuesSpy).toHaveBeenCalledWith( + expect.objectContaining({ orgId: 'org-DEVICE', deviceId: DEVICE_ID }), + ); + }); + + // Anchoring to the device org must not weaken the explicit-scope contract: + // a caller who names an orgId that does not own the device is still rejected. + it('rejects when an explicit orgId does not match the device org', async () => { + const partner: any = { + user: { id: 'user-1' }, + orgId: null, + accessibleOrgIds: ['org-A', 'org-DEVICE'], + canAccessOrg: () => true, + orgCondition: () => undefined, + }; + selectMock.mockReturnValueOnce(devSelect([{ id: DEVICE_ID, orgId: 'org-DEVICE', siteId: null }])); + await expect( + createSession(partner, { deviceId: DEVICE_ID, orgId: 'org-A' }), + ).rejects.toThrow('Invalid device'); + expect(insertMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/aiAgent.ts b/apps/api/src/services/aiAgent.ts index 93362c468..9d0657066 100644 --- a/apps/api/src/services/aiAgent.ts +++ b/apps/api/src/services/aiAgent.ts @@ -7,7 +7,7 @@ */ import { db } from '../db'; -import { aiSessions, aiMessages, aiToolExecutions, delegantM365Connections } from '../db/schema'; +import { aiSessions, aiMessages, aiToolExecutions, delegantM365Connections, devices } from '../db/schema'; import { eq, and, desc, sql, type SQL } from 'drizzle-orm'; import type { AuthContext } from '../middleware/auth'; import type { AiPageContext, AiApprovalMode } from '@breeze/shared/types/ai'; @@ -30,9 +30,34 @@ export async function createSession( title?: string; orgId?: string; delegantM365ConnectionId?: string; + deviceId?: string; + approvalMode?: AiApprovalMode; } ): Promise<{ id: string; orgId: string; delegantM365ConnectionId: string | null }> { - const orgId = options.orgId ?? auth.orgId ?? auth.accessibleOrgIds?.[0] ?? null; + // A device-scoped task ("Fix with AI") anchors the session to the device's + // org. Resolve the device up front so its org can drive org selection for + // partner / multi-org callers who have no home orgId — otherwise the session + // would bind to accessibleOrgIds[0] (an unrelated org) and the cross-org + // check below would reject every dispatch with a 500. + let deviceRow: { id: string; orgId: string; siteId: string | null } | null = null; + if (options.deviceId) { + const rows = await db + .select({ id: devices.id, orgId: devices.orgId, siteId: devices.siteId }) + .from(devices) + .where(eq(devices.id, options.deviceId)) + .limit(1); + deviceRow = rows[0] ?? null; + } + + const orgId = + options.orgId ?? + // Anchor to the device's org when the caller can reach it; otherwise fall + // through so the opaque device check below rejects without leaking the + // device's existence to callers outside its org. + (deviceRow && auth.canAccessOrg(deviceRow.orgId) ? deviceRow.orgId : undefined) ?? + auth.orgId ?? + auth.accessibleOrgIds?.[0] ?? + null; if (!orgId) throw new Error('Organization context required'); if (orgId !== auth.orgId && !auth.canAccessOrg(orgId)) { throw new Error('Access denied to this organization'); @@ -58,6 +83,25 @@ export async function createSession( delegantM365ConnectionId = conn.id; } + // Cross-org validation (SECURITY-CRITICAL): a session may only be bound to a + // device that belongs to the session's org. This is what makes a dispatched + // "task on this computer" scoped — the device id is recorded on the session + // and surfaced in the system prompt/context for the agent and in the UI for + // the approving technician. + let deviceId: string | null = null; + if (options.deviceId) { + if (!deviceRow || deviceRow.orgId !== orgId) { + throw new Error('Invalid device'); + } + // Site-axis (SECURITY-CRITICAL, conforms to #1047): a site-restricted caller + // must not bind a session to a device outside their accessible sites, even + // within an org they can access. Opaque error mirrors the cross-org case. + if (auth.canAccessSite && !auth.canAccessSite(deviceRow.siteId)) { + throw new Error('Invalid device'); + } + deviceId = deviceRow.id; + } + const [session] = await db .insert(aiSessions) .values({ @@ -67,6 +111,8 @@ export async function createSession( title: options.title ?? null, contextSnapshot: options.pageContext ?? null, delegantM365ConnectionId, + deviceId, + ...(options.approvalMode ? { approvalMode: options.approvalMode } : {}), systemPrompt: await buildSystemPrompt(auth, options.pageContext) }) .returning(); diff --git a/apps/api/src/services/aiAgentSdkTools.googlegating.test.ts b/apps/api/src/services/aiAgentSdkTools.googlegating.test.ts new file mode 100644 index 000000000..be4c107fc --- /dev/null +++ b/apps/api/src/services/aiAgentSdkTools.googlegating.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { googleToolDefinitions } from './aiAgentSdkTools'; +import { getToolTier } from './aiTools'; +import { googleToolTiers } from './aiToolsGoogle'; + +// The Google Workspace helpdesk tools are only advertised to the model when +// GOOGLE_WORKSPACE_ENABLED is truthy. On instances without the flag they must +// not appear in the tool manifest at all (a per-org connection is still +// required at call time on top of this). +const getAuth = () => ({ user: { id: 'u1' }, orgId: 'o1' }) as any; +const getSession = () => undefined; + +// Regression: getToolTier MUST resolve every Google tool. The handlers are +// session-aware (not in the aiTools execution registry), so if getToolTier does +// not consult googleToolTiers, checkGuardrails sees tier=undefined → "Unknown +// tool" → every Google tool is blocked at runtime even though registration + +// handler unit tests pass. (Bug found + fixed 2026-06-01.) +describe('getToolTier resolves Google tools (guardrail gate)', () => { + it('returns the declared tier for every google_* tool, never undefined', () => { + const names = Object.keys(googleToolTiers); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expect(getToolTier(name), name).toBe(googleToolTiers[name]); + } + }); +}); + +describe('Google tool registration gating', () => { + const ORIG = process.env.GOOGLE_WORKSPACE_ENABLED; + afterEach(() => { + if (ORIG === undefined) delete process.env.GOOGLE_WORKSPACE_ENABLED; + else process.env.GOOGLE_WORKSPACE_ENABLED = ORIG; + }); + + it('registers no Google tools when the flag is unset', () => { + delete process.env.GOOGLE_WORKSPACE_ENABLED; + expect(googleToolDefinitions(getAuth, getSession, undefined, undefined)).toEqual([]); + }); + + it('treats a blank flag as disabled', () => { + process.env.GOOGLE_WORKSPACE_ENABLED = ' '; + expect(googleToolDefinitions(getAuth, getSession, undefined, undefined)).toEqual([]); + }); + + it('registers all 25 Google tools (correct names) when enabled', () => { + process.env.GOOGLE_WORKSPACE_ENABLED = 'true'; + const names = googleToolDefinitions(getAuth, getSession, undefined, undefined).map((t) => t.name); + expect(names).toEqual([ + 'google_lookup_user', + 'google_reset_password', + 'google_suspend_user', + 'google_restore_user', + 'google_signout', + 'google_list_user_groups', + 'google_add_to_group', + 'google_remove_from_group', + 'google_move_ou', + 'google_rename_user', + 'google_list_licenses', + 'google_assign_license', + 'google_remove_license', + 'google_reset_2sv', + 'google_add_mail_delegate', + 'google_remove_mail_delegate', + 'google_set_forwarding', + 'google_disable_forwarding', + 'google_set_vacation', + 'google_update_user', + 'google_share_calendar', + 'google_offboard_user', + 'google_wipe_mobile_device', + 'google_security_drift', + 'google_email_report', + ]); + }); +}); diff --git a/apps/api/src/services/aiAgentSdkTools.ts b/apps/api/src/services/aiAgentSdkTools.ts index ea5b0ec0a..289e10dc3 100644 --- a/apps/api/src/services/aiAgentSdkTools.ts +++ b/apps/api/src/services/aiAgentSdkTools.ts @@ -22,6 +22,18 @@ import { m365LookupUserHandler, m365RecentSigninsHandler, m365ListGroupMembershipsHandler, m365DisableUserHandler, m365ResetPasswordHandler, } from './aiToolsM365'; +import { + googleLookupUserHandler, googleResetPasswordHandler, googleSuspendUserHandler, + googleRestoreUserHandler, googleSignOutHandler, googleSetForwardingHandler, + googleDisableForwardingHandler, + googleSetVacationHandler, googleUpdateUserHandler, googleShareCalendarHandler, + googleOffboardUserHandler, googleWipeMobileDeviceHandler, + googleSecurityDriftHandler, googleEmailReportHandler, + googleListUserGroupsHandler, googleAddToGroupHandler, googleRemoveFromGroupHandler, + googleMoveOuHandler, googleRenameUserHandler, + googleResetTwoSvHandler, googleAddMailDelegateHandler, googleRemoveMailDelegateHandler, + googleListLicensesHandler, googleAssignLicenseHandler, googleRemoveLicenseHandler, +} from './aiToolsGoogle'; /** * Callback invoked before tool execution to enforce guardrails, RBAC, @@ -143,6 +155,32 @@ export const TOOL_TIERS = { m365_list_group_memberships: 1, m365_disable_user: 3, m365_reset_password: 3, + // Google Workspace helpdesk tools (DWD service-account-backed) + google_lookup_user: 1, + google_reset_password: 3, + google_suspend_user: 3, + google_restore_user: 3, + google_signout: 3, + google_set_forwarding: 3, + google_disable_forwarding: 3, + google_set_vacation: 3, + google_update_user: 3, + google_share_calendar: 3, + google_offboard_user: 3, + google_wipe_mobile_device: 3, + google_security_drift: 1, + google_email_report: 1, + google_list_user_groups: 1, + google_add_to_group: 3, + google_remove_from_group: 3, + google_move_ou: 3, + google_rename_user: 3, + google_reset_2sv: 3, + google_add_mail_delegate: 3, + google_remove_mail_delegate: 3, + google_list_licenses: 1, + google_assign_license: 3, + google_remove_license: 3, } as const satisfies Readonly> as Readonly>; // All tool names, prefixed for SDK MCP format @@ -472,12 +510,14 @@ export const __test__ = { makeSessionAwareHandler }; // ============================================ /** - * The Microsoft 365 helpdesk tool definitions, gated on the Delegant - * integration being configured. Returns [] (so the tools are NOT advertised to - * the model) on instances where DELEGANT_BASE_URL is unset/blank — without a - * configured Delegant endpoint + a seeded customer connection the tools can - * only ever no-op with `no_customer_selected`, so there's no reason to surface - * them. Read from process.env at call time so it tracks runtime config. + * The Microsoft 365 helpdesk tool definitions, gated on EITHER backend being + * usable: the direct app-only Graph path (M365_ENABLED + a per-org + * m365_connections row) OR the Delegant broker (DELEGANT_BASE_URL). Returns [] + * (so the tools are NOT advertised to the model) only when neither is + * configured — gating on DELEGANT_BASE_URL alone left the direct path dead in + * production (M365_ENABLED instances with a saved connection but no broker). + * Read from process.env at call time so it tracks runtime config (mirrors + * googleToolDefinitions). */ export function m365ToolDefinitions( getAuth: () => AuthContext, @@ -485,7 +525,10 @@ export function m365ToolDefinitions( onPreToolUse?: PreToolUseCallback, onPostToolUse?: PostToolUseCallback, ) { - if (!(process.env.DELEGANT_BASE_URL ?? '').trim()) return []; + const m365Flag = (process.env.M365_ENABLED ?? '').trim().toLowerCase(); + const m365Enabled = ['1', 'true', 'yes', 'on'].includes(m365Flag); + const delegantConfigured = !!(process.env.DELEGANT_BASE_URL ?? '').trim(); + if (!m365Enabled && !delegantConfigured) return []; return [ tool( 'm365_lookup_user', @@ -520,6 +563,200 @@ export function m365ToolDefinitions( ]; } +/** + * The Google Workspace helpdesk tool definitions, gated on + * GOOGLE_WORKSPACE_ENABLED. Returns [] (tools NOT advertised to the model) when + * the flag is off — without the flag + a per-org google_workspace_connections + * row the tools can only no-op with `no_google_connection`. Read from + * process.env at call time so it tracks runtime config (mirrors + * m365ToolDefinitions). + */ +export function googleToolDefinitions( + getAuth: () => AuthContext, + getActiveSession: (() => ActiveSession | undefined) | undefined, + onPreToolUse?: PreToolUseCallback, + onPostToolUse?: PostToolUseCallback, +) { + const flag = (process.env.GOOGLE_WORKSPACE_ENABLED ?? '').trim().toLowerCase(); + if (!['1', 'true', 'yes', 'on'].includes(flag)) return []; + return [ + tool( + 'google_lookup_user', + "Look up a Google Workspace user (profile, suspended/admin status, 2-step enrollment, last login, OU, aliases) for this organization's connected Workspace domain.", + { userEmail: z.string() }, + makeSessionAwareHandler('google_lookup_user', getAuth, getActiveSession, googleLookupUserHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_reset_password', + 'Reset a Google Workspace user\'s password (forces change at next sign-in). Returns a temporary password. Requires approval.', + { userEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_reset_password', getAuth, getActiveSession, googleResetPasswordHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_suspend_user', + 'Suspend (block sign-in for) a Google Workspace user. Requires approval.', + { userEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_suspend_user', getAuth, getActiveSession, googleSuspendUserHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_restore_user', + 'Restore (un-suspend) a Google Workspace user. Requires approval.', + { userEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_restore_user', getAuth, getActiveSession, googleRestoreUserHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_signout', + 'Sign a Google Workspace user out of all sessions (the supported substitute for "turn off login challenge", which has no API). Useful for lockout/offboarding. Requires approval.', + { userEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_signout', getAuth, getActiveSession, googleSignOutHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_list_user_groups', + "List the Google Workspace groups a user belongs to (email, name, id) in this organization's connected domain.", + { userEmail: z.string() }, + makeSessionAwareHandler('google_list_user_groups', getAuth, getActiveSession, googleListUserGroupsHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_add_to_group', + 'Add a Google Workspace user to a group. role is one of MEMBER, MANAGER, OWNER (default MEMBER). Requires approval.', + { userEmail: z.string(), groupEmail: z.string(), role: z.enum(['MEMBER', 'MANAGER', 'OWNER']).optional(), reason: z.string() }, + makeSessionAwareHandler('google_add_to_group', getAuth, getActiveSession, googleAddToGroupHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_remove_from_group', + 'Remove a Google Workspace user from a group. Requires approval.', + { userEmail: z.string(), groupEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_remove_from_group', getAuth, getActiveSession, googleRemoveFromGroupHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_move_ou', + 'Move a Google Workspace user into a different organizational unit (orgUnitPath, e.g. "/Sales" or "/"). Requires approval.', + { userEmail: z.string(), orgUnitPath: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_move_ou', getAuth, getActiveSession, googleMoveOuHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_rename_user', + 'Rename a Google Workspace user by changing their primary email (the old address is retained as an alias). Requires approval.', + { userEmail: z.string(), newPrimaryEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_rename_user', getAuth, getActiveSession, googleRenameUserHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_list_licenses', + 'List Google Workspace license assignments for a product (e.g. productId "Google-Apps") in this organization. Returns who holds which SKU.', + { productId: z.string() }, + makeSessionAwareHandler('google_list_licenses', getAuth, getActiveSession, googleListLicensesHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_assign_license', + 'Assign a Google Workspace license (productId + skuId) to a user. Requires approval.', + { userEmail: z.string(), productId: z.string(), skuId: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_assign_license', getAuth, getActiveSession, googleAssignLicenseHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_remove_license', + 'Remove a Google Workspace license (productId + skuId) from a user. Requires approval.', + { userEmail: z.string(), productId: z.string(), skuId: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_remove_license', getAuth, getActiveSession, googleRemoveLicenseHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_reset_2sv', + 'Turn off 2-step verification for a Google Workspace user so they can re-enroll (use when a user lost their second factor / is locked out). Requires approval.', + { userEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_reset_2sv', getAuth, getActiveSession, googleResetTwoSvHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_add_mail_delegate', + "Grant another user delegated access to a Google Workspace mailbox (read/send/manage). Requires approval.", + { userEmail: z.string(), delegateEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_add_mail_delegate', getAuth, getActiveSession, googleAddMailDelegateHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_remove_mail_delegate', + 'Remove a delegate from a Google Workspace mailbox. Requires approval.', + { userEmail: z.string(), delegateEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_remove_mail_delegate', getAuth, getActiveSession, googleRemoveMailDelegateHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_set_forwarding', + 'Enable Gmail forwarding from one user to another, optionally keeping a copy in the original mailbox. Requires approval.', + { userEmail: z.string(), forwardTo: z.string(), keepCopy: z.boolean().optional(), reason: z.string() }, + makeSessionAwareHandler('google_set_forwarding', getAuth, getActiveSession, googleSetForwardingHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_disable_forwarding', + "Turn OFF Gmail auto-forwarding for a user's mailbox. Optionally also remove the forwarding address (pass removeAddress=true and the forwardTo address). Requires approval.", + { userEmail: z.string(), forwardTo: z.string().optional(), removeAddress: z.boolean().optional(), reason: z.string() }, + makeSessionAwareHandler('google_disable_forwarding', getAuth, getActiveSession, googleDisableForwardingHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_set_vacation', + 'Set or clear a Google Workspace user\'s out-of-office / vacation responder. Requires approval.', + { userEmail: z.string(), enable: z.boolean().optional(), subject: z.string().optional(), message: z.string().optional(), reason: z.string() }, + makeSessionAwareHandler('google_set_vacation', getAuth, getActiveSession, googleSetVacationHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_update_user', + 'Update a Google Workspace user\'s profile (given/family name, recovery email/phone) and/or add or remove an email alias. Requires approval.', + { + userEmail: z.string(), + givenName: z.string().optional(), + familyName: z.string().optional(), + recoveryEmail: z.string().optional(), + recoveryPhone: z.string().optional(), + addAlias: z.string().optional(), + removeAlias: z.string().optional(), + reason: z.string(), + }, + makeSessionAwareHandler('google_update_user', getAuth, getActiveSession, googleUpdateUserHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_share_calendar', + "Share a Google Workspace user's calendar with another user. Inserts an ACL rule on the owner's calendar (default: their primary calendar). role is one of freeBusyReader, reader, writer, owner (default reader). Requires approval.", + { + ownerEmail: z.string(), + shareWithEmail: z.string(), + calendarId: z.string().optional(), + role: z.enum(['freeBusyReader', 'reader', 'writer', 'owner']).optional(), + reason: z.string(), + }, + makeSessionAwareHandler('google_share_calendar', getAuth, getActiveSession, googleShareCalendarHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_offboard_user', + 'Guided offboard of a departing Google Workspace user: best-effort sequence of optional out-of-office, mail forwarding to a manager (no copy kept), OAuth-token revoke, remove-from-all-groups, a SELECTIVE mobile account wipe (corporate data only, BYOD-safe — never a full device wipe), sign-out, then suspend. Each step is independent and reported. Requires approval.', + { + userEmail: z.string(), + forwardTo: z.string().optional(), + oooMessage: z.string().optional(), + accountWipeMobile: z.boolean().optional(), + removeFromGroups: z.boolean().optional(), + revokeTokens: z.boolean().optional(), + suspend: z.boolean().optional(), + reason: z.string(), + }, + makeSessionAwareHandler('google_offboard_user', getAuth, getActiveSession, googleOffboardUserHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_wipe_mobile_device', + 'STOLEN/LOST DEVICE ONLY: issue a FULL factory reset (admin_remote_wipe) to every mobile device enrolled to a user. This erases the ENTIRE device, not just corporate data. This is NOT for offboarding — offboard uses a selective account wipe. Requires approval.', + { userEmail: z.string(), reason: z.string() }, + makeSessionAwareHandler('google_wipe_mobile_device', getAuth, getActiveSession, googleWipeMobileDeviceHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_security_drift', + 'Read-only Google Workspace security posture for the connected domain: counts and lists of users with no 2-step verification, super-admins, suspended accounts, never-logged-in accounts, and accounts stale beyond staleDays (default 90). No changes are made.', + { staleDays: z.number().int().min(1).max(3650).optional() }, + makeSessionAwareHandler('google_security_drift', getAuth, getActiveSession, googleSecurityDriftHandler, onPreToolUse, onPostToolUse) + ), + tool( + 'google_email_report', + "Run the Google Workspace security-drift report and email it to the connection's own admin address (recipient is fixed to the admin, not arbitrary). Use when asked to email a Workspace report. staleDays optional (default 90).", + { staleDays: z.number().int().min(1).max(3650).optional() }, + makeSessionAwareHandler('google_email_report', getAuth, getActiveSession, googleEmailReportHandler, onPreToolUse, onPostToolUse) + ), + ]; +} + /** * Creates an SDK MCP server instance with all Breeze tools. * Auth context is fetched lazily via the getAuth thunk so all tool handlers @@ -1624,6 +1861,9 @@ export function createBreezeMcpServer( // approval) and onPostToolUse (ai_tool_executions persistence + // delegant_tool_call_id correlation). ...m365ToolDefinitions(getAuth, getActiveSession, onPreToolUse, onPostToolUse), + // Google Workspace helpdesk tools (gated on GOOGLE_WORKSPACE_ENABLED + a + // per-org connection). Same enforcement path as every other tool. + ...googleToolDefinitions(getAuth, getActiveSession, onPreToolUse, onPostToolUse), ]; return createSdkMcpServer({ diff --git a/apps/api/src/services/aiGuardrails.ts b/apps/api/src/services/aiGuardrails.ts index 76c9f6313..ae22f025f 100644 --- a/apps/api/src/services/aiGuardrails.ts +++ b/apps/api/src/services/aiGuardrails.ts @@ -385,6 +385,32 @@ const TOOL_PERMISSIONS: Record = { diff --git a/apps/api/src/services/aiTools.ts b/apps/api/src/services/aiTools.ts index 3f997b6e2..b9f6e082c 100644 --- a/apps/api/src/services/aiTools.ts +++ b/apps/api/src/services/aiTools.ts @@ -60,8 +60,9 @@ import { registerPamTools } from './aiToolsPam'; // M365 helpdesk tools are session-aware (handler signature includes a sessionId) // so they are NOT registered in the `aiTools` execution registry — they run via // makeSessionAwareHandler in the SDK server. Their tiers still must be visible to -// getToolTier so checkGuardrails can gate them; import the tier table for fallback. +// getToolTier so checkGuardrails can gate them; import the tier tables for fallback. import { m365ToolTiers } from './aiToolsM365'; +import { googleToolTiers } from './aiToolsGoogle'; // ============================================ // Shared Types @@ -250,7 +251,7 @@ export function getToolDefinitions(): Anthropic.Tool[] { } export function getToolTier(toolName: string): AiToolTier | undefined { - return aiTools.get(toolName)?.tier ?? m365ToolTiers[toolName]; + return aiTools.get(toolName)?.tier ?? m365ToolTiers[toolName] ?? googleToolTiers[toolName]; } /** diff --git a/apps/api/src/services/aiToolsGoogle.test.ts b/apps/api/src/services/aiToolsGoogle.test.ts new file mode 100644 index 000000000..4021fa3f7 --- /dev/null +++ b/apps/api/src/services/aiToolsGoogle.test.ts @@ -0,0 +1,592 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Keep errorString + authorizeGoogleConnection real; mock only the DB loaders +// and the key decryption. +vi.mock('./googleHelpers', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSession: vi.fn(), + loadGoogleConnection: vi.fn(), + decryptConnectionKey: vi.fn(() => 'KEYJSON'), + }; +}); +// Keep normalizeGoogleError real; mock only the client builders. +vi.mock('./googleClient', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDirectoryClient: vi.fn(), + getGmailClient: vi.fn(), + getCalendarClient: vi.fn(), + getLicensingClient: vi.fn(), + }; +}); +// Mock the email service used by google_email_report. +vi.mock('./email', () => ({ getEmailService: vi.fn() })); + +import * as helpers from './googleHelpers'; +import * as client from './googleClient'; +import * as emailSvc from './email'; +import { + googleLookupUserHandler, + googleResetPasswordHandler, + googleSuspendUserHandler, + googleSignOutHandler, + googleSetForwardingHandler, + googleDisableForwardingHandler, + googleSetVacationHandler, + googleUpdateUserHandler, + googleShareCalendarHandler, + googleOffboardUserHandler, + googleWipeMobileDeviceHandler, + googleSecurityDriftHandler, + googleEmailReportHandler, + computeSecurityDrift, + googleListUserGroupsHandler, + googleAddToGroupHandler, + googleRemoveFromGroupHandler, + googleMoveOuHandler, + googleRenameUserHandler, + googleResetTwoSvHandler, + googleAddMailDelegateHandler, + googleRemoveMailDelegateHandler, + googleListLicensesHandler, + googleAssignLicenseHandler, + googleRemoveLicenseHandler, +} from './aiToolsGoogle'; + +const auth = {} as any; +const SESSION = 'sess-1'; + +function armConnection(connOverride?: Record) { + (helpers.loadSession as any).mockResolvedValue({ orgId: 'org-A' }); + (helpers.loadGoogleConnection as any).mockResolvedValue({ + orgId: 'org-A', + status: 'active', + adminEmail: 'admin@x.com', + customerDomain: 'x.com', + ...connOverride, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + armConnection(); + (helpers.decryptConnectionKey as any).mockReturnValue('KEYJSON'); +}); + +describe('tier-3 guards', () => { + it('reset requires a reason', async () => { + const out = await googleResetPasswordHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + it('lookup requires a user email', async () => { + const out = await googleLookupUserHandler({}, auth, SESSION); + expect(out).toContain('missing_user'); + }); +}); + +describe('connection resolution', () => { + it('errors when no active connection for the org', async () => { + armConnection({ orgId: 'other-org' }); // authorize() fails (org mismatch) + const out = await googleLookupUserHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('no_google_connection'); + }); +}); + +describe('directory operations', () => { + it('lookup returns a profile summary', async () => { + (client.getDirectoryClient as any).mockReturnValue({ + users: { get: vi.fn().mockResolvedValue({ data: { primaryEmail: 'u@x.com', name: { fullName: 'U X' }, suspended: false } }) }, + }); + const out = await googleLookupUserHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('Google Workspace user profile'); + expect(out).toContain('u@x.com'); + }); + + it('reset password returns a temporary password and forces change', async () => { + const update = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ users: { update } }); + const out = await googleResetPasswordHandler({ userEmail: 'u@x.com', reason: 'locked out' }, auth, SESSION); + expect(out).toContain('Temporary password'); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ userKey: 'u@x.com', requestBody: expect.objectContaining({ changePasswordAtNextLogin: true }) }), + ); + }); + + it('suspend sets suspended=true', async () => { + const update = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ users: { update } }); + const out = await googleSuspendUserHandler({ userEmail: 'u@x.com', reason: 'offboard' }, auth, SESSION); + expect(out).toContain('Suspended'); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ requestBody: { suspended: true } })); + }); + + it('signout calls users.signOut and notes the login-challenge caveat', async () => { + const signOut = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ users: { signOut } }); + const out = await googleSignOutHandler({ userEmail: 'u@x.com', reason: 'lockout' }, auth, SESSION); + expect(signOut).toHaveBeenCalledWith({ userKey: 'u@x.com' }); + expect(out).toContain('login challenge'); + }); + + it('update_user adds an alias', async () => { + const insert = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ users: { update: vi.fn(), aliases: { insert, delete: vi.fn() } } }); + const out = await googleUpdateUserHandler({ userEmail: 'u@x.com', addAlias: 'nick@x.com', reason: 'rename' }, auth, SESSION); + expect(insert).toHaveBeenCalledWith({ userKey: 'u@x.com', requestBody: { alias: 'nick@x.com' } }); + expect(out).toContain('added alias nick@x.com'); + }); + + it('maps a 403 to a google_forbidden error string', async () => { + (client.getDirectoryClient as any).mockReturnValue({ + users: { get: vi.fn().mockRejectedValue({ code: 403, message: 'denied' }) }, + }); + const out = await googleLookupUserHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('google_forbidden'); + }); +}); + +describe('group membership (cluster 3)', () => { + it('add_to_group requires a reason', async () => { + const out = await googleAddToGroupHandler({ userEmail: 'u@x.com', groupEmail: 'g@x.com' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + + it('list_user_groups returns the user\'s groups', async () => { + const list = vi.fn().mockResolvedValue({ data: { groups: [{ email: 'g@x.com', name: 'G', id: 'grp-1' }] } }); + (client.getDirectoryClient as any).mockReturnValue({ groups: { list } }); + const out = await googleListUserGroupsHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(list).toHaveBeenCalledWith({ userKey: 'u@x.com', maxResults: 200 }); + expect(out).toContain('g@x.com'); + }); + + it('add_to_group inserts the member (defaults to MEMBER)', async () => { + const insert = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ members: { insert } }); + const out = await googleAddToGroupHandler({ userEmail: 'u@x.com', groupEmail: 'g@x.com', reason: 'onboard' }, auth, SESSION); + expect(insert).toHaveBeenCalledWith({ groupKey: 'g@x.com', requestBody: { email: 'u@x.com', role: 'MEMBER' } }); + expect(out).toContain('Added u@x.com to group g@x.com'); + }); + + it('remove_from_group deletes the member', async () => { + const del = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ members: { delete: del } }); + const out = await googleRemoveFromGroupHandler({ userEmail: 'u@x.com', groupEmail: 'g@x.com', reason: 'offboard' }, auth, SESSION); + expect(del).toHaveBeenCalledWith({ groupKey: 'g@x.com', memberKey: 'u@x.com' }); + expect(out).toContain('Removed u@x.com from group g@x.com'); + }); +}); + +describe('ou move + rename (cluster 3)', () => { + it('move_ou requires a reason', async () => { + const out = await googleMoveOuHandler({ userEmail: 'u@x.com', orgUnitPath: '/Sales' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + + it('move_ou updates orgUnitPath', async () => { + const update = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ users: { update } }); + const out = await googleMoveOuHandler({ userEmail: 'u@x.com', orgUnitPath: '/Sales', reason: 'team move' }, auth, SESSION); + expect(update).toHaveBeenCalledWith({ userKey: 'u@x.com', requestBody: { orgUnitPath: '/Sales' } }); + expect(out).toContain('Moved u@x.com to org unit /Sales'); + }); + + it('rename_user changes the primary email', async () => { + const update = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ users: { update } }); + const out = await googleRenameUserHandler({ userEmail: 'old@x.com', newPrimaryEmail: 'new@x.com', reason: 'name change' }, auth, SESSION); + expect(update).toHaveBeenCalledWith({ userKey: 'old@x.com', requestBody: { primaryEmail: 'new@x.com' } }); + expect(out).toContain('Renamed old@x.com to new@x.com'); + }); +}); + +describe('license management (cluster 3)', () => { + it('assign_license requires a reason', async () => { + const out = await googleAssignLicenseHandler( + { userEmail: 'u@x.com', productId: 'Google-Apps', skuId: '1010020027' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + + it('list_licenses returns assignments for a product', async () => { + const listForProduct = vi.fn().mockResolvedValue({ + data: { items: [{ userId: 'u@x.com', skuId: '1010020027', skuName: 'Business Standard' }] } + }); + (client.getLicensingClient as any).mockReturnValue({ licenseAssignments: { listForProduct } }); + const out = await googleListLicensesHandler({ productId: 'Google-Apps' }, auth, SESSION); + expect(listForProduct).toHaveBeenCalledWith({ productId: 'Google-Apps', customerId: 'my_customer', maxResults: 100 }); + expect(out).toContain('u@x.com'); + }); + + it('assign_license inserts the assignment', async () => { + const insert = vi.fn().mockResolvedValue({}); + (client.getLicensingClient as any).mockReturnValue({ licenseAssignments: { insert } }); + const out = await googleAssignLicenseHandler( + { userEmail: 'u@x.com', productId: 'Google-Apps', skuId: '1010020027', reason: 'onboard' }, auth, SESSION); + expect(insert).toHaveBeenCalledWith({ productId: 'Google-Apps', skuId: '1010020027', requestBody: { userId: 'u@x.com' } }); + expect(out).toContain('Assigned license Google-Apps/1010020027 to u@x.com'); + }); + + it('remove_license deletes the assignment', async () => { + const del = vi.fn().mockResolvedValue({}); + (client.getLicensingClient as any).mockReturnValue({ licenseAssignments: { delete: del } }); + const out = await googleRemoveLicenseHandler( + { userEmail: 'u@x.com', productId: 'Google-Apps', skuId: '1010020027', reason: 'offboard' }, auth, SESSION); + expect(del).toHaveBeenCalledWith({ productId: 'Google-Apps', skuId: '1010020027', userId: 'u@x.com' }); + expect(out).toContain('Removed license Google-Apps/1010020027 from u@x.com'); + }); +}); + +describe('2sv reset + mail delegation (cluster 3)', () => { + it('reset_2sv requires a reason', async () => { + const out = await googleResetTwoSvHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + + it('reset_2sv turns off two-step verification', async () => { + const turnOff = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ twoStepVerification: { turnOff } }); + const out = await googleResetTwoSvHandler({ userEmail: 'u@x.com', reason: 'lost phone' }, auth, SESSION); + expect(turnOff).toHaveBeenCalledWith({ userKey: 'u@x.com' }); + expect(out).toContain('Turned off 2-step verification for u@x.com'); + }); + + it('add_mail_delegate creates a delegate on the mailbox', async () => { + const create = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ users: { settings: { delegates: { create } } } }); + const out = await googleAddMailDelegateHandler( + { userEmail: 'owner@x.com', delegateEmail: 'asst@x.com', reason: 'coverage' }, auth, SESSION); + expect(create).toHaveBeenCalledWith({ userId: 'me', requestBody: { delegateEmail: 'asst@x.com' } }); + expect(out).toContain('Granted asst@x.com delegated access'); + }); + + it('remove_mail_delegate deletes the delegate', async () => { + const del = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ users: { settings: { delegates: { delete: del } } } }); + const out = await googleRemoveMailDelegateHandler( + { userEmail: 'owner@x.com', delegateEmail: 'asst@x.com', reason: 'done' }, auth, SESSION); + expect(del).toHaveBeenCalledWith({ userId: 'me', delegateEmail: 'asst@x.com' }); + expect(out).toContain("Removed asst@x.com's delegated access"); + }); +}); + +describe('gmail operations', () => { + it('forwarding without keep-copy uses disposition=archive', async () => { + const updateAutoForwarding = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ + users: { settings: { forwardingAddresses: { create: vi.fn().mockResolvedValue({ data: { verificationStatus: 'accepted' } }) }, updateAutoForwarding } }, + }); + const out = await googleSetForwardingHandler( + { userEmail: 'a@x.com', forwardTo: 'b@x.com', keepCopy: false, reason: 'leave' }, + auth, + SESSION, + ); + expect(updateAutoForwarding).toHaveBeenCalledWith( + expect.objectContaining({ requestBody: expect.objectContaining({ enabled: true, emailAddress: 'b@x.com', disposition: 'archive' }) }), + ); + expect(out).toContain('not keeping a copy'); + }); + + it('forwarding to an unverified destination returns a pending-verification error, not a false success', async () => { + const updateAutoForwarding = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ + users: { settings: { forwardingAddresses: { create: vi.fn().mockResolvedValue({ data: { verificationStatus: 'pending' } }) }, updateAutoForwarding } }, + }); + const out = await googleSetForwardingHandler({ userEmail: 'a@x.com', forwardTo: 'b@x.com', reason: 'leave' }, auth, SESSION); + const parsed = JSON.parse(out); + expect(parsed.error).toBe('forwarding_pending_verification'); + expect(parsed.message).toContain('not yet verified'); + }); + + it('forwarding surfaces a real create failure instead of enabling forwarding that cannot deliver', async () => { + const create = vi.fn().mockRejectedValue({ code: 403, message: 'denied' }); + const get = vi.fn().mockRejectedValue({ code: 404, message: 'no such address' }); + const updateAutoForwarding = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ + users: { settings: { forwardingAddresses: { create, get }, updateAutoForwarding } }, + }); + const out = await googleSetForwardingHandler({ userEmail: 'a@x.com', forwardTo: 'b@x.com', reason: 'leave' }, auth, SESSION); + expect(JSON.parse(out).error).toBe('google_forbidden'); + expect(updateAutoForwarding).not.toHaveBeenCalled(); + }); + + it('forwarding tolerates an already-existing address by reading its verification status', async () => { + const create = vi.fn().mockRejectedValue({ code: 409, message: 'exists' }); + const get = vi.fn().mockResolvedValue({ data: { verificationStatus: 'accepted' } }); + const updateAutoForwarding = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ + users: { settings: { forwardingAddresses: { create, get }, updateAutoForwarding } }, + }); + const out = await googleSetForwardingHandler({ userEmail: 'a@x.com', forwardTo: 'b@x.com', reason: 'leave' }, auth, SESSION); + expect(get).toHaveBeenCalled(); + expect(updateAutoForwarding).toHaveBeenCalled(); + expect(out).toContain('forwarding now'); + }); + + it('disable forwarding turns auto-forwarding off and removes the address when asked', async () => { + const updateAutoForwarding = vi.fn().mockResolvedValue({}); + const del = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ + users: { settings: { updateAutoForwarding, forwardingAddresses: { delete: del } } }, + }); + const out = await googleDisableForwardingHandler({ userEmail: 'a@x.com', forwardTo: 'b@x.com', removeAddress: true, reason: 'no longer needed' }, auth, SESSION); + expect(updateAutoForwarding).toHaveBeenCalledWith(expect.objectContaining({ requestBody: { enabled: false } })); + expect(del).toHaveBeenCalledWith({ userId: 'me', forwardingEmail: 'b@x.com' }); + expect(out).toContain('Disabled mail forwarding'); + }); + + it('vacation responder enables with a message', async () => { + const updateVacation = vi.fn().mockResolvedValue({}); + (client.getGmailClient as any).mockReturnValue({ users: { settings: { updateVacation } } }); + const out = await googleSetVacationHandler( + { userEmail: 'a@x.com', message: 'Out until Monday', reason: 'pto' }, + auth, + SESSION, + ); + expect(updateVacation).toHaveBeenCalledWith( + expect.objectContaining({ requestBody: expect.objectContaining({ enableAutoReply: true, responseBodyPlainText: 'Out until Monday' }) }), + ); + expect(out).toContain('Enabled the out-of-office'); + }); +}); + +describe('calendar sharing', () => { + it('requires a reason', async () => { + const out = await googleShareCalendarHandler( + { ownerEmail: 'a@x.com', shareWithEmail: 'b@x.com' }, + auth, + SESSION, + ); + expect(out).toContain('missing_reason'); + }); + + it('rejects an invalid role', async () => { + const out = await googleShareCalendarHandler( + { ownerEmail: 'a@x.com', shareWithEmail: 'b@x.com', role: 'admin', reason: 'share' }, + auth, + SESSION, + ); + expect(out).toContain('invalid_role'); + }); + + it('shares the primary calendar as reader by default, impersonating the owner', async () => { + const insert = vi.fn().mockResolvedValue({}); + (client.getCalendarClient as any).mockReturnValue({ acl: { insert } }); + const out = await googleShareCalendarHandler( + { ownerEmail: 'a@x.com', shareWithEmail: 'b@x.com', reason: 'team coverage' }, + auth, + SESSION, + ); + expect(client.getCalendarClient).toHaveBeenCalledWith('KEYJSON', 'a@x.com'); + expect(insert).toHaveBeenCalledWith({ + calendarId: 'primary', + requestBody: { role: 'reader', scope: { type: 'user', value: 'b@x.com' } }, + }); + expect(out).toContain("a@x.com's primary calendar"); + expect(out).toContain('as reader'); + }); + + it('honors an explicit calendarId and writer role', async () => { + const insert = vi.fn().mockResolvedValue({}); + (client.getCalendarClient as any).mockReturnValue({ acl: { insert } }); + const out = await googleShareCalendarHandler( + { ownerEmail: 'a@x.com', shareWithEmail: 'b@x.com', calendarId: 'team@x.com', role: 'writer', reason: 'shared cal' }, + auth, + SESSION, + ); + expect(insert).toHaveBeenCalledWith({ + calendarId: 'team@x.com', + requestBody: { role: 'writer', scope: { type: 'user', value: 'b@x.com' } }, + }); + expect(out).toContain('calendar team@x.com'); + expect(out).toContain('as writer'); + }); +}); + +describe('offboard workflow', () => { + function mockDir(overrides: Record = {}) { + return { + users: { signOut: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}) }, + tokens: { + list: vi.fn().mockResolvedValue({ data: { items: [{ clientId: 'app-1' }, { clientId: 'app-2' }] } }), + delete: vi.fn().mockResolvedValue({}), + }, + groups: { list: vi.fn().mockResolvedValue({ data: { groups: [{ id: 'grp-1' }] } }) }, + members: { delete: vi.fn().mockResolvedValue({}) }, + mobiledevices: { + list: vi.fn().mockResolvedValue({ data: { mobiledevices: [{ resourceId: 'dev-1' }] } }), + action: vi.fn().mockResolvedValue({}), + }, + ...overrides, + }; + } + + it('requires a reason', async () => { + const out = await googleOffboardUserHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + + it('runs the full sequence, account-wipes (not remote-wipes) mobile, and suspends last', async () => { + const dir = mockDir(); + const gmail = { + users: { + settings: { + updateVacation: vi.fn().mockResolvedValue({}), + forwardingAddresses: { create: vi.fn().mockResolvedValue({ data: { verificationStatus: 'accepted' } }) }, + updateAutoForwarding: vi.fn().mockResolvedValue({}), + }, + }, + }; + (client.getDirectoryClient as any).mockReturnValue(dir); + (client.getGmailClient as any).mockReturnValue(gmail); + + const out = await googleOffboardUserHandler( + { userEmail: 'leaver@x.com', forwardTo: 'mgr@x.com', oooMessage: 'I have left', reason: 'departure' }, + auth, + SESSION, + ); + + // selective account wipe, never a full remote wipe + expect(dir.mobiledevices.action).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'dev-1', requestBody: { action: 'admin_account_wipe' } }), + ); + expect(dir.mobiledevices.action).not.toHaveBeenCalledWith( + expect.objectContaining({ requestBody: { action: 'admin_remote_wipe' } }), + ); + // forwarding without a kept copy + expect(gmail.users.settings.updateAutoForwarding).toHaveBeenCalledWith( + expect.objectContaining({ requestBody: expect.objectContaining({ disposition: 'archive' }) }), + ); + expect(dir.tokens.delete).toHaveBeenCalledTimes(2); + expect(dir.members.delete).toHaveBeenCalledWith({ groupKey: 'grp-1', memberKey: 'leaver@x.com' }); + expect(dir.users.update).toHaveBeenCalledWith({ userKey: 'leaver@x.com', requestBody: { suspended: true } }); + expect(out).toContain('steps OK'); + expect(out).toContain('BYOD-safe'); + }); + + it('is best-effort: a failed step is reported but the rest still run + suspend', async () => { + const dir = mockDir({ groups: { list: vi.fn().mockRejectedValue({ code: 403, message: 'no group scope' }) } }); + (client.getDirectoryClient as any).mockReturnValue(dir); + + const out = await googleOffboardUserHandler({ userEmail: 'leaver@x.com', reason: 'departure' }, auth, SESSION); + expect(out).toContain('remove_from_groups: FAILED'); + // suspend still happened despite the group failure + expect(dir.users.update).toHaveBeenCalledWith({ userKey: 'leaver@x.com', requestBody: { suspended: true } }); + }); + + it('returns a structured error envelope when a step fails, so the audit records a FAILED mutation', async () => { + const dir = mockDir({ groups: { list: vi.fn().mockRejectedValue({ code: 403, message: 'no group scope' }) } }); + (client.getDirectoryClient as any).mockReturnValue(dir); + const out = await googleOffboardUserHandler({ userEmail: 'leaver@x.com', reason: 'departure' }, auth, SESSION); + const parsed = JSON.parse(out); + expect(parsed.error).toBe('offboard_incomplete'); + expect(parsed.message).toContain('FAILED'); + }); + + it('can skip optional steps via flags', async () => { + const dir = mockDir(); + (client.getDirectoryClient as any).mockReturnValue(dir); + await googleOffboardUserHandler( + { userEmail: 'leaver@x.com', reason: 'departure', accountWipeMobile: false, removeFromGroups: false, revokeTokens: false }, + auth, + SESSION, + ); + expect(dir.mobiledevices.action).not.toHaveBeenCalled(); + expect(dir.members.delete).not.toHaveBeenCalled(); + expect(dir.tokens.delete).not.toHaveBeenCalled(); + expect(dir.users.signOut).toHaveBeenCalled(); + }); +}); + +describe('stolen-device full wipe', () => { + it('requires a reason', async () => { + const out = await googleWipeMobileDeviceHandler({ userEmail: 'u@x.com' }, auth, SESSION); + expect(out).toContain('missing_reason'); + }); + + it('issues a FULL remote wipe and says it erases the whole device', async () => { + const action = vi.fn().mockResolvedValue({}); + (client.getDirectoryClient as any).mockReturnValue({ + mobiledevices: { list: vi.fn().mockResolvedValue({ data: { mobiledevices: [{ resourceId: 'dev-1' }] } }), action }, + }); + const out = await googleWipeMobileDeviceHandler({ userEmail: 'u@x.com', reason: 'phone stolen' }, auth, SESSION); + expect(action).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'dev-1', requestBody: { action: 'admin_remote_wipe' } }), + ); + expect(out).toContain('FULL factory reset'); + expect(out).toContain('entire device'); + }); + + it('reports when no devices are enrolled', async () => { + (client.getDirectoryClient as any).mockReturnValue({ + mobiledevices: { list: vi.fn().mockResolvedValue({ data: { mobiledevices: [] } }), action: vi.fn() }, + }); + const out = await googleWipeMobileDeviceHandler({ userEmail: 'u@x.com', reason: 'stolen' }, auth, SESSION); + expect(out).toContain('nothing to wipe'); + }); +}); + +describe('computeSecurityDrift', () => { + const NOW = Date.parse('2026-05-31T00:00:00Z'); + const users = [ + { primaryEmail: 'admin@x.com', isAdmin: true, suspended: false, isEnrolledIn2Sv: true, lastLoginTime: '2026-05-30T00:00:00Z' }, + { primaryEmail: 'no2sv@x.com', isAdmin: false, suspended: false, isEnrolledIn2Sv: false, lastLoginTime: '2026-05-29T00:00:00Z' }, + { primaryEmail: 'stale@x.com', isAdmin: false, suspended: false, isEnrolledIn2Sv: true, lastLoginTime: '2026-01-01T00:00:00Z' }, + { primaryEmail: 'never@x.com', isAdmin: false, suspended: false, isEnrolledIn2Sv: false, lastLoginTime: '1970-01-01T00:00:00Z' }, + { primaryEmail: 'gone@x.com', isAdmin: false, suspended: true, isEnrolledIn2Sv: false, lastLoginTime: null }, + ]; + + it('buckets users correctly', () => { + const d = computeSecurityDrift(users, 90, NOW); + expect(d.totalUsers).toBe(5); + expect(d.superAdmins.users).toEqual(['admin@x.com']); + expect(d.suspended.users).toEqual(['gone@x.com']); + // no2sv + never are active + not enrolled; gone is suspended so excluded + expect(d.noTwoStep.users.sort()).toEqual(['never@x.com', 'no2sv@x.com']); + expect(d.neverLoggedIn.users).toEqual(['never@x.com']); + expect(d.stale.users).toEqual(['stale@x.com']); + expect(d.stale.thresholdDays).toBe(90); + }); + + it('excludes suspended users from active buckets', () => { + const d = computeSecurityDrift(users, 90, NOW); + expect(d.noTwoStep.users).not.toContain('gone@x.com'); + }); +}); + +describe('security drift + email report', () => { + function armUserList(users: any[]) { + (client.getDirectoryClient as any).mockReturnValue({ + users: { list: vi.fn().mockResolvedValue({ data: { users, nextPageToken: undefined } }) }, + }); + } + + it('security_drift returns a summary with counts', async () => { + armUserList([ + { primaryEmail: 'a@x.com', isAdmin: true, suspended: false, isEnrolledIn2Sv: true, lastLoginTime: '2026-05-30T00:00:00Z' }, + { primaryEmail: 'b@x.com', isAdmin: false, suspended: false, isEnrolledIn2Sv: false, lastLoginTime: '2026-05-30T00:00:00Z' }, + ]); + const out = await googleSecurityDriftHandler({}, auth, SESSION); + expect(out).toContain('security drift for x.com'); + expect(out).toContain('"superAdmins"'); + expect(out).toContain('b@x.com'); + }); + + it('email_report sends to the admin address and reports success', async () => { + armUserList([{ primaryEmail: 'b@x.com', isAdmin: false, suspended: false, isEnrolledIn2Sv: false, lastLoginTime: '2026-05-30T00:00:00Z' }]); + const sendEmail = vi.fn().mockResolvedValue(undefined); + (emailSvc.getEmailService as any).mockReturnValue({ sendEmail }); + const out = await googleEmailReportHandler({}, auth, SESSION); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'admin@x.com', subject: expect.stringContaining('x.com') }), + ); + expect(out).toContain('Emailed the Google Workspace security-drift report'); + }); + + it('email_report errors cleanly when no email provider is configured', async () => { + (emailSvc.getEmailService as any).mockReturnValue(null); + const out = await googleEmailReportHandler({}, auth, SESSION); + expect(out).toContain('email_not_configured'); + }); +}); diff --git a/apps/api/src/services/aiToolsGoogle.ts b/apps/api/src/services/aiToolsGoogle.ts new file mode 100644 index 000000000..91fba278b --- /dev/null +++ b/apps/api/src/services/aiToolsGoogle.ts @@ -0,0 +1,1089 @@ +/** + * Google Workspace helpdesk AI tool handlers. + * + * Mirrors aiToolsM365: each handler is (input, auth, sessionId) => Promise + * and is registered inside createBreezeMcpServer (aiAgentSdkTools.ts). Flow per + * call: resolve session -> resolve the org's single Google connection (cross-org + * guard + status check) -> decrypt the SA key -> impersonate (admin for Directory, + * the target user for Gmail) -> call -> format a concise LLM-readable string. + * + * Tier 1 = read (auto). Tier 3 = mutation (per-step human approval + audit; a + * `reason` is required). All writes go through the existing guardrail/approval + * gate in aiAgentSdk.ts — these handlers do not bypass it. + * + * Note on the "disable login challenge" workflow: Google exposes NO API to turn + * a user's login challenge off for 10 minutes (admin-console only). The + * google_signout tool is the supported substitute — it ends all the user's + * sessions, which clears most lockout states. + */ + +import { randomBytes } from 'node:crypto'; +import type { AuthContext } from '../middleware/auth'; +import { + errorString, + loadSession, + loadGoogleConnection, + authorizeGoogleConnection, + decryptConnectionKey, +} from './googleHelpers'; +import { + getDirectoryClient, + getGmailClient, + getCalendarClient, + getLicensingClient, + normalizeGoogleError, + type GoogleApiError, +} from './googleClient'; +import type { GoogleWorkspaceConnectionRow } from '../db/schema/google'; +import { getEmailService } from './email'; + +export const googleToolTiers: Record = { + google_lookup_user: 1, + google_reset_password: 3, + google_suspend_user: 3, + google_restore_user: 3, + google_signout: 3, + google_set_forwarding: 3, + google_disable_forwarding: 3, + google_set_vacation: 3, + google_update_user: 3, + google_share_calendar: 3, + google_offboard_user: 3, + google_wipe_mobile_device: 3, + google_security_drift: 1, + google_email_report: 1, + google_list_user_groups: 1, + google_add_to_group: 3, + google_remove_from_group: 3, + google_move_ou: 3, + google_rename_user: 3, + google_reset_2sv: 3, + google_add_mail_delegate: 3, + google_remove_mail_delegate: 3, + google_list_licenses: 1, + google_assign_license: 3, + google_remove_license: 3, +}; + +const CALENDAR_ROLES = ['freeBusyReader', 'reader', 'writer', 'owner'] as const; +type CalendarRole = (typeof CALENDAR_ROLES)[number]; + +type DirectoryClient = ReturnType; + +/** + * Issue a mobile-device action to every device enrolled to a user. + * - admin_account_wipe: remove ONLY the managed corporate account + its data + * (mail/Drive) from the device. Safe for BYOD; the personal device is intact. + * - admin_remote_wipe: full factory reset of the entire device. STOLEN-DEVICE + * use only — never part of offboarding. + */ +async function wipeMobileDevices( + dir: DirectoryClient, + userEmail: string, + action: 'admin_account_wipe' | 'admin_remote_wipe', +): Promise { + const list = await dir.mobiledevices.list({ customerId: 'my_customer', query: `email:${userEmail}` }); + const devices = list.data.mobiledevices ?? []; + let n = 0; + for (const d of devices) { + if (!d.resourceId) continue; + await dir.mobiledevices.action({ + customerId: 'my_customer', + resourceId: d.resourceId, + requestBody: { action }, + }); + n++; + } + return n; +} + +interface StepResult { + step: string; + ok: boolean; + detail: string; +} + +async function runStep(step: string, fn: () => Promise): Promise { + try { + return { step, ok: true, detail: await fn() }; + } catch (err) { + const norm = normalizeGoogleError(err); + return { step, ok: false, detail: `${norm.code}: ${norm.message}` }; + } +} + +type ResolvedContext = + | { error: string } + | { conn: GoogleWorkspaceConnectionRow; keyJson: string }; + +async function resolveContext(_auth: AuthContext, sessionId: string): Promise { + const session = await loadSession(sessionId); + if (!session) return { error: errorString('session_not_found', 'AI session not found.') }; + const conn = await loadGoogleConnection(session.orgId); + const authz = authorizeGoogleConnection(conn, session.orgId); + if (!authz.ok) { + return { + error: errorString( + 'no_google_connection', + 'No active Google Workspace connection for this organization. Connect one in settings first.', + ), + }; + } + let keyJson: string; + try { + keyJson = decryptConnectionKey(authz.conn); + } catch (err) { + return { error: errorString('connection_key_error', (err as Error).message) }; + } + return { conn: authz.conn, keyJson }; +} + +function requireString(input: Record, key: string): string | null { + const v = input[key]; + return typeof v === 'string' && v.length > 0 ? v : null; +} + +const googleError = (err: unknown): string => { + const norm = normalizeGoogleError(err); + return errorString(norm.code, norm.message); +}; + +/** Generate a strong temporary password (mixed classes, ~20 chars). */ +function generateTempPassword(): string { + const raw = randomBytes(16).toString('base64').replace(/[+/=]/g, ''); + // Guarantee at least one of each required class. + return `Bz9!${raw.slice(0, 16)}`; +} + +// ── Tier 1: read ────────────────────────────────────────────────────────────── + +export async function googleLookupUserHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email (primary email) is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const res = await dir.users.get({ userKey: email }); + const u = res.data; + const summary = { + primaryEmail: u.primaryEmail, + name: u.name?.fullName, + suspended: u.suspended ?? false, + isAdmin: u.isAdmin ?? false, + isEnrolledIn2Sv: u.isEnrolledIn2Sv ?? false, + lastLoginTime: u.lastLoginTime, + orgUnitPath: u.orgUnitPath, + aliases: u.aliases ?? [], + }; + return `Google Workspace user profile: ${JSON.stringify(summary)}`; + } catch (err) { + return googleError(err); + } +} + +// ── Tier 3: mutations (require reason + approval) ───────────────────────────── + +export async function googleResetPasswordHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + const temp = generateTempPassword(); + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.users.update({ + userKey: email, + requestBody: { password: temp, changePasswordAtNextLogin: true }, + }); + return `Reset the password for ${email}. Temporary password: ${temp} (the user must change it at next sign-in).`; + } catch (err) { + return googleError(err); + } +} + +export async function googleSuspendUserHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.users.update({ userKey: email, requestBody: { suspended: true } }); + return `Suspended Google Workspace user ${email}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleRestoreUserHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.users.update({ userKey: email, requestBody: { suspended: false } }); + return `Restored (un-suspended) Google Workspace user ${email}.`; + } catch (err) { + return googleError(err); + } +} + +// ── Group membership (cluster 3) ────────────────────────────────────────────── + +export async function googleListUserGroupsHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const res = await dir.groups.list({ userKey: email, maxResults: 200 }); + const groups = (res.data.groups ?? []).map((g) => ({ email: g.email, name: g.name, id: g.id })); + return `Google Workspace groups for ${email} (${groups.length}): ${JSON.stringify(groups)}`; + } catch (err) { + return googleError(err); + } +} + +export async function googleAddToGroupHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const groupEmail = requireString(input, 'groupEmail'); + if (!groupEmail) return errorString('missing_group', 'A group email is required.'); + const roleRaw = requireString(input, 'role'); + const role = + roleRaw && ['MEMBER', 'MANAGER', 'OWNER'].includes(roleRaw.toUpperCase()) + ? roleRaw.toUpperCase() + : 'MEMBER'; + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.members.insert({ groupKey: groupEmail, requestBody: { email, role } }); + return `Added ${email} to group ${groupEmail} as ${role}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleRemoveFromGroupHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const groupEmail = requireString(input, 'groupEmail'); + if (!groupEmail) return errorString('missing_group', 'A group email is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.members.delete({ groupKey: groupEmail, memberKey: email }); + return `Removed ${email} from group ${groupEmail}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleMoveOuHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const orgUnitPath = requireString(input, 'orgUnitPath'); + if (!orgUnitPath) return errorString('missing_ou', 'An orgUnitPath (e.g. "/Sales") is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.users.update({ userKey: email, requestBody: { orgUnitPath } }); + return `Moved ${email} to org unit ${orgUnitPath}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleRenameUserHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const newPrimaryEmail = requireString(input, 'newPrimaryEmail'); + if (!newPrimaryEmail) return errorString('missing_new_email', 'A newPrimaryEmail is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.users.update({ userKey: email, requestBody: { primaryEmail: newPrimaryEmail } }); + return `Renamed ${email} to ${newPrimaryEmail} (Google keeps the old address as an alias).`; + } catch (err) { + return googleError(err); + } +} + +// ── License management (cluster 3) ──────────────────────────────────────────── + +export async function googleListLicensesHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const productId = requireString(input, 'productId'); + if (!productId) return errorString('missing_product', 'A productId is required (e.g. "Google-Apps").'); + + try { + const lic = getLicensingClient(ctx.keyJson, ctx.conn.adminEmail); + const res = await lic.licenseAssignments.listForProduct({ + productId, + customerId: 'my_customer', + maxResults: 100, + }); + const items = (res.data.items ?? []).map((a) => ({ user: a.userId, skuId: a.skuId, skuName: a.skuName })); + return `Google Workspace license assignments for product ${productId} (${items.length}): ${JSON.stringify(items)}`; + } catch (err) { + return googleError(err); + } +} + +export async function googleAssignLicenseHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const productId = requireString(input, 'productId'); + const skuId = requireString(input, 'skuId'); + if (!productId || !skuId) return errorString('missing_sku', 'Both productId and skuId are required.'); + + try { + const lic = getLicensingClient(ctx.keyJson, ctx.conn.adminEmail); + await lic.licenseAssignments.insert({ productId, skuId, requestBody: { userId: email } }); + return `Assigned license ${productId}/${skuId} to ${email}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleRemoveLicenseHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const productId = requireString(input, 'productId'); + const skuId = requireString(input, 'skuId'); + if (!productId || !skuId) return errorString('missing_sku', 'Both productId and skuId are required.'); + + try { + const lic = getLicensingClient(ctx.keyJson, ctx.conn.adminEmail); + await lic.licenseAssignments.delete({ productId, skuId, userId: email }); + return `Removed license ${productId}/${skuId} from ${email}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleResetTwoSvHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.twoStepVerification.turnOff({ userKey: email }); + return `Turned off 2-step verification for ${email}. They can re-enroll on next sign-in (use this when a user lost their second factor).`; + } catch (err) { + return googleError(err); + } +} + +export async function googleAddMailDelegateHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user (mailbox owner) email is required.'); + const delegateEmail = requireString(input, 'delegateEmail'); + if (!delegateEmail) return errorString('missing_delegate', 'A delegateEmail is required.'); + + try { + const gmailClient = getGmailClient(ctx.keyJson, email); + await gmailClient.users.settings.delegates.create({ userId: 'me', requestBody: { delegateEmail } }); + return `Granted ${delegateEmail} delegated access to ${email}'s mailbox (read/send/manage).`; + } catch (err) { + return googleError(err); + } +} + +export async function googleRemoveMailDelegateHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user (mailbox owner) email is required.'); + const delegateEmail = requireString(input, 'delegateEmail'); + if (!delegateEmail) return errorString('missing_delegate', 'A delegateEmail is required.'); + + try { + const gmailClient = getGmailClient(ctx.keyJson, email); + await gmailClient.users.settings.delegates.delete({ userId: 'me', delegateEmail }); + return `Removed ${delegateEmail}'s delegated access to ${email}'s mailbox.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleSignOutHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + await dir.users.signOut({ userKey: email }); + return `Signed ${email} out of all sessions. (Note: Google has no API to toggle the login challenge for 10 minutes; sign-out clears most lockout states.)`; + } catch (err) { + return googleError(err); + } +} + +type ForwardingOutcome = + | { ok: true; verificationStatus: string } + | { ok: false; error: { code: string; message: string } }; + +/** + * Ensure the forwarding address exists and turn on auto-forwarding for the + * impersonated mailbox. Returns the destination's verificationStatus ('accepted' + * once mail will actually forward; 'pending' until the owner confirms Google's + * verification email). A create failure is NOT swallowed: if the create throws, + * we probe with a read — the address may already exist from a prior run (Gmail + * returns 409, which normalizeGoogleError can't tell apart from other 4xx by + * code) — and only surface the create error when the address is genuinely + * absent, so we never report forwarding "enabled" when it can't deliver. + */ +async function enableAutoForwarding( + gmailClient: ReturnType, + forwardTo: string, + keepCopy: boolean, +): Promise { + let verificationStatus: string | null | undefined; + try { + const created = await gmailClient.users.settings.forwardingAddresses.create({ + userId: 'me', + requestBody: { forwardingEmail: forwardTo }, + }); + verificationStatus = created.data.verificationStatus; + } catch (createErr) { + try { + const existing = await gmailClient.users.settings.forwardingAddresses.get({ + userId: 'me', + forwardingEmail: forwardTo, + }); + verificationStatus = existing.data.verificationStatus; + } catch { + return { ok: false, error: normalizeGoogleError(createErr) }; + } + } + await gmailClient.users.settings.updateAutoForwarding({ + userId: 'me', + requestBody: { + enabled: true, + emailAddress: forwardTo, + disposition: keepCopy ? 'leaveInInbox' : 'archive', + }, + }); + return { ok: true, verificationStatus: verificationStatus ?? 'unknown' }; +} + +export async function googleSetForwardingHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + const forwardTo = requireString(input, 'forwardTo'); + if (!email) return errorString('missing_user', 'A user email (the mailbox to forward FROM) is required.'); + if (!forwardTo) return errorString('missing_forward_to', 'A forwarding destination address is required.'); + const keepCopy = input.keepCopy !== false; // default to keeping a copy + + try { + // Gmail per-mailbox settings impersonate the USER, not the admin. + const gmailClient = getGmailClient(ctx.keyJson, email); + const outcome = await enableAutoForwarding(gmailClient, forwardTo, keepCopy); + if (!outcome.ok) { + return errorString( + outcome.error.code, + `Could not set up forwarding from ${email} to ${forwardTo}: ${outcome.error.message}`, + ); + } + if (outcome.verificationStatus !== 'accepted') { + // Forwarding is configured but the destination is unverified — Gmail will + // NOT deliver until the owner confirms. Surface this as a structured error + // so it is not recorded as a completed forward (it isn't, yet). + return errorString( + 'forwarding_pending_verification', + `Forwarding from ${email} to ${forwardTo} is configured but the destination is not yet verified (status: ${outcome.verificationStatus}). Mail will NOT forward until the owner of ${forwardTo} confirms the verification email Google sent; it will start automatically once verified.`, + ); + } + return `Enabled forwarding from ${email} to ${forwardTo} (${keepCopy ? 'keeping' : 'not keeping'} a copy in ${email}). The destination is verified, so mail is forwarding now.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleDisableForwardingHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email (the mailbox to stop forwarding) is required.'); + // Optionally also delete the forwarding address; `forwardTo` is only needed + // for that. Disabling auto-forwarding alone is enough to stop delivery. + const removeAddress = input.removeAddress === true; + const forwardTo = requireString(input, 'forwardTo'); + + try { + // Gmail per-mailbox settings impersonate the USER, not the admin. + const gmailClient = getGmailClient(ctx.keyJson, email); + await gmailClient.users.settings.updateAutoForwarding({ + userId: 'me', + requestBody: { enabled: false }, + }); + if (removeAddress && forwardTo) { + try { + await gmailClient.users.settings.forwardingAddresses.delete({ + userId: 'me', + forwardingEmail: forwardTo, + }); + } catch (delErr) { + const norm = normalizeGoogleError(delErr); + // Already gone is fine; surface any other deletion failure. + if (norm.code !== 'google_not_found') { + return errorString( + norm.code, + `Disabled forwarding for ${email}, but could not remove the forwarding address ${forwardTo}: ${norm.message}`, + ); + } + } + } + return `Disabled mail forwarding for ${email}.${removeAddress && forwardTo ? ` Removed the forwarding address ${forwardTo}.` : ''}`; + } catch (err) { + return googleError(err); + } +} + +export async function googleSetVacationHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + const enable = input.enable !== false; // default enable + const subject = requireString(input, 'subject') ?? ''; + const message = requireString(input, 'message') ?? ''; + if (enable && !message) return errorString('missing_message', 'A response message is required to enable the vacation responder.'); + + try { + const gmailClient = getGmailClient(ctx.keyJson, email); + await gmailClient.users.settings.updateVacation({ + userId: 'me', + requestBody: { + enableAutoReply: enable, + responseSubject: subject || undefined, + responseBodyPlainText: message || undefined, + }, + }); + return enable + ? `Enabled the out-of-office responder for ${email}.` + : `Disabled the out-of-office responder for ${email}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleUpdateUserHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + const givenName = requireString(input, 'givenName'); + const familyName = requireString(input, 'familyName'); + const recoveryEmail = requireString(input, 'recoveryEmail'); + const recoveryPhone = requireString(input, 'recoveryPhone'); + const addAlias = requireString(input, 'addAlias'); + const removeAlias = requireString(input, 'removeAlias'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const changes: string[] = []; + + if (givenName || familyName || recoveryEmail || recoveryPhone) { + const requestBody: Record = {}; + if (givenName || familyName) { + requestBody.name = { + ...(givenName ? { givenName } : {}), + ...(familyName ? { familyName } : {}), + }; + } + if (recoveryEmail) requestBody.recoveryEmail = recoveryEmail; + if (recoveryPhone) requestBody.recoveryPhone = recoveryPhone; + await dir.users.update({ userKey: email, requestBody }); + changes.push('profile'); + } + if (addAlias) { + await dir.users.aliases.insert({ userKey: email, requestBody: { alias: addAlias } }); + changes.push(`added alias ${addAlias}`); + } + if (removeAlias) { + await dir.users.aliases.delete({ userKey: email, alias: removeAlias }); + changes.push(`removed alias ${removeAlias}`); + } + if (changes.length === 0) { + return errorString('no_changes', 'No fields to update were provided.'); + } + return `Updated ${email}: ${changes.join(', ')}.`; + } catch (err) { + return googleError(err); + } +} + +export async function googleShareCalendarHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const ownerEmail = requireString(input, 'ownerEmail'); + const shareWithEmail = requireString(input, 'shareWithEmail'); + if (!ownerEmail) return errorString('missing_owner', 'The calendar owner email is required.'); + if (!shareWithEmail) return errorString('missing_share_with', 'The email to share the calendar with is required.'); + // Default to a read share of the owner's primary calendar. + const calendarId = requireString(input, 'calendarId') ?? 'primary'; + const roleInput = requireString(input, 'role') ?? 'reader'; + if (!CALENDAR_ROLES.includes(roleInput as CalendarRole)) { + return errorString('invalid_role', `role must be one of: ${CALENDAR_ROLES.join(', ')}.`); + } + const role = roleInput as CalendarRole; + + try { + // Calendar ACL writes impersonate the calendar OWNER, not the admin. + const cal = getCalendarClient(ctx.keyJson, ownerEmail); + await cal.acl.insert({ + calendarId, + requestBody: { role, scope: { type: 'user', value: shareWithEmail } }, + }); + const which = calendarId === 'primary' ? `${ownerEmail}'s primary calendar` : `calendar ${calendarId}`; + return `Shared ${which} with ${shareWithEmail} as ${role}.`; + } catch (err) { + return googleError(err); + } +} + +/** + * Guided offboard: a single, best-effort sequence over one departing user. + * Mailbox steps (OOO, forwarding) run FIRST, while the account is still active — + * suspending first would block per-user Gmail impersonation. The mobile step is + * a SELECTIVE account wipe (corporate data only), never a full device wipe, + * because the fleet is BYOD. Suspend is last. Each step is independent: a failure + * is recorded and the rest still run. + */ +export async function googleOffboardUserHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'A user email is required.'); + + const forwardTo = requireString(input, 'forwardTo'); // optional manager mailbox + const oooMessage = requireString(input, 'oooMessage'); // optional auto-reply text + const accountWipeMobile = input.accountWipeMobile !== false; // default true (SELECTIVE) + const removeFromGroups = input.removeFromGroups !== false; // default true + const revokeTokens = input.revokeTokens !== false; // default true + const doSuspend = input.suspend !== false; // default true + + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const steps: StepResult[] = []; + + // 1. Mailbox settings first (account still active for impersonation). + if (oooMessage) { + steps.push(await runStep('out_of_office', async () => { + const g = getGmailClient(ctx.keyJson, email); + await g.users.settings.updateVacation({ + userId: 'me', + requestBody: { enableAutoReply: true, responseBodyPlainText: oooMessage }, + }); + return 'auto-reply enabled'; + })); + } + if (forwardTo) { + steps.push(await runStep('forwarding', async () => { + const g = getGmailClient(ctx.keyJson, email); + const outcome = await enableAutoForwarding(g, forwardTo, false); + // A genuine create/enable failure throws so this step is recorded FAILED + // (and the offboard reports incomplete), rather than being swallowed. + if (!outcome.ok) throw new Error(`${outcome.error.code}: ${outcome.error.message}`); + return outcome.verificationStatus === 'accepted' + ? `forwarding to ${forwardTo} (no copy kept)` + : `forwarding to ${forwardTo} configured but PENDING verification (status: ${outcome.verificationStatus}); will not deliver until ${forwardTo} is confirmed`; + })); + } + + // 2. Revoke third-party OAuth app grants. + if (revokeTokens) { + steps.push(await runStep('revoke_oauth_tokens', async () => { + const res = await dir.tokens.list({ userKey: email }); + let n = 0; + for (const t of res.data.items ?? []) { + if (!t.clientId) continue; + await dir.tokens.delete({ userKey: email, clientId: t.clientId }); + n++; + } + return `revoked ${n} OAuth app grant(s)`; + })); + } + + // 3. Remove from all groups. + if (removeFromGroups) { + steps.push(await runStep('remove_from_groups', async () => { + const res = await dir.groups.list({ userKey: email, maxResults: 200 }); + let n = 0; + for (const grp of res.data.groups ?? []) { + if (!grp.id) continue; + await dir.members.delete({ groupKey: grp.id, memberKey: email }); + n++; + } + return `removed from ${n} group(s)`; + })); + } + + // 4. SELECTIVE mobile account-wipe (BYOD: corporate data only). + if (accountWipeMobile) { + steps.push(await runStep('mobile_account_wipe', async () => { + const n = await wipeMobileDevices(dir, email, 'admin_account_wipe'); + return n === 0 ? 'no mobile devices enrolled' : `account-wiped ${n} device(s) (corporate data only)`; + })); + } + + // 5. End all sessions. + steps.push(await runStep('sign_out', async () => { + await dir.users.signOut({ userKey: email }); + return 'all sessions ended'; + })); + + // 6. Suspend last. + if (doSuspend) { + steps.push(await runStep('suspend', async () => { + await dir.users.update({ userKey: email, requestBody: { suspended: true } }); + return 'sign-in blocked'; + })); + } + + const okCount = steps.filter((s) => s.ok).length; + const lines = steps.map((s) => ` - ${s.step}: ${s.ok ? 'OK' : 'FAILED'} (${s.detail})`).join('\n'); + const summary = `Offboard of ${email}: ${okCount}/${steps.length} steps OK.\n${lines}\nNote: the mobile step removed only the corporate account (BYOD-safe), not the whole device.`; + // If any step failed, surface a structured error so the post-tool-use audit + // records this as a FAILED Tier-3 mutation. Returning prose makes the success + // detector (JSON-with-error-key) treat a 0/6 offboard as a success — a leaver + // who still has access showing a green check in the audit trail. + if (okCount < steps.length) { + return errorString('offboard_incomplete', summary); + } + return summary; +} + +/** + * STOLEN-DEVICE remote wipe: a full factory reset of every device enrolled to a + * user. This erases the ENTIRE device, not just corporate data — it is NOT part + * of offboarding (offboard uses a selective account wipe). Use only for lost or + * stolen hardware. + */ +export async function googleWipeMobileDeviceHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const reason = requireString(input, 'reason'); + if (!reason) return errorString('missing_reason', 'A reason is required for this action.'); + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const email = requireString(input, 'userEmail'); + if (!email) return errorString('missing_user', 'The user whose lost/stolen device should be fully wiped is required.'); + + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const n = await wipeMobileDevices(dir, email, 'admin_remote_wipe'); + if (n === 0) return `No mobile devices are enrolled for ${email}; nothing to wipe.`; + return `Issued a FULL factory reset to ${n} device(s) for ${email} (stolen-device remote wipe). This erases the entire device, not just corporate data.`; + } catch (err) { + return googleError(err); + } +} + +// ── Cluster 2: security drift (read) + reports-by-email ─────────────────────── + +interface DriftUser { + primaryEmail?: string | null; + isAdmin?: boolean | null; + suspended?: boolean | null; + isEnrolledIn2Sv?: boolean | null; + lastLoginTime?: string | null; +} + +/** Page through every user in the customer's directory (projection kept small). */ +async function listAllDomainUsers(dir: DirectoryClient): Promise { + const users: DriftUser[] = []; + let pageToken: string | undefined; + do { + const res = await dir.users.list({ + customer: 'my_customer', + maxResults: 500, + orderBy: 'email', + pageToken, + fields: 'nextPageToken,users(primaryEmail,isAdmin,suspended,isEnrolledIn2Sv,lastLoginTime)', + }); + for (const u of res.data.users ?? []) users.push(u as DriftUser); + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + return users; +} + +interface DriftBucket { + count: number; + users: string[]; +} +interface SecurityDrift { + totalUsers: number; + noTwoStep: DriftBucket; + superAdmins: DriftBucket; + suspended: DriftBucket; + neverLoggedIn: DriftBucket; + stale: DriftBucket & { thresholdDays: number }; +} + +/** Bucket users into the security-drift categories. Pure function (testable). */ +function computeSecurityDrift(users: DriftUser[], staleDays: number, nowMs: number): SecurityDrift { + const staleMs = staleDays * 86_400_000; + const emailOf = (u: DriftUser) => u.primaryEmail ?? '(unknown)'; + const active = users.filter((u) => !u.suspended); + const bucket = (list: DriftUser[]): DriftBucket => ({ count: list.length, users: list.map(emailOf).slice(0, 50) }); + + const noTwoStep = active.filter((u) => u.isEnrolledIn2Sv === false); + const superAdmins = users.filter((u) => u.isAdmin === true); + const suspended = users.filter((u) => u.suspended === true); + const neverLoggedIn = active.filter((u) => { + const t = Date.parse(u.lastLoginTime ?? ''); + return !u.lastLoginTime || Number.isNaN(t) || t <= 0; + }); + const stale = active.filter((u) => { + const t = Date.parse(u.lastLoginTime ?? ''); + return t > 0 && nowMs - t > staleMs; + }); + + return { + totalUsers: users.length, + noTwoStep: bucket(noTwoStep), + superAdmins: bucket(superAdmins), + suspended: bucket(suspended), + neverLoggedIn: bucket(neverLoggedIn), + stale: { ...bucket(stale), thresholdDays: staleDays }, + }; +} + +function resolveStaleDays(input: Record): number { + const v = input.staleDays; + return typeof v === 'number' && v > 0 && v <= 3650 ? v : 90; +} + +export async function googleSecurityDriftHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const staleDays = resolveStaleDays(input); + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const users = await listAllDomainUsers(dir); + const drift = computeSecurityDrift(users, staleDays, Date.now()); + return `Google Workspace security drift for ${ctx.conn.customerDomain}: ${JSON.stringify(drift)}`; + } catch (err) { + return googleError(err); + } +} + +/** Minimal HTML report for the drift email. */ +function renderDriftHtml(domain: string, drift: SecurityDrift): string { + const row = (label: string, b: DriftBucket) => + `${label}${b.count}`; + const list = (label: string, b: DriftBucket) => + b.users.length + ? `

${label} (${b.count}${b.count > b.users.length ? ', first ' + b.users.length : ''})

${b.users.join('
')}
` + : ''; + return `
+

Google Workspace security drift — ${domain}

+

${drift.totalUsers} users scanned. Stale threshold: ${drift.stale.thresholdDays} days.

+${row('No 2-step', drift.noTwoStep)}${row('Super-admins', drift.superAdmins)}${row('Suspended', drift.suspended)}${row('Never logged in', drift.neverLoggedIn)}${row('Stale', drift.stale)}
+${list('Users with no 2-step verification', drift.noTwoStep)} +${list('Super-admins', drift.superAdmins)} +${list('Never logged in', drift.neverLoggedIn)} +${list('Stale accounts', drift.stale)} +
`; +} + +/** + * Run the security-drift report and email it. The recipient is LOCKED to the + * connection's admin address (no arbitrary recipient) so the agent cannot use + * this to exfiltrate directory data. Tier 1: it changes no Google or device + * state, only emails a read-only summary to the org's own admin. + */ +export async function googleEmailReportHandler( + input: Record, + auth: AuthContext, + sessionId: string, +): Promise { + const ctx = await resolveContext(auth, sessionId); + if ('error' in ctx) return ctx.error; + const svc = getEmailService(); + if (!svc) { + return errorString('email_not_configured', 'No email provider is configured on this instance; cannot send the report.'); + } + const staleDays = resolveStaleDays(input); + try { + const dir = getDirectoryClient(ctx.keyJson, ctx.conn.adminEmail); + const users = await listAllDomainUsers(dir); + const drift = computeSecurityDrift(users, staleDays, Date.now()); + const to = ctx.conn.adminEmail; // locked to the connection admin + await svc.sendEmail({ + to, + subject: `Google Workspace security drift — ${ctx.conn.customerDomain}`, + html: renderDriftHtml(ctx.conn.customerDomain, drift), + }); + return `Emailed the Google Workspace security-drift report for ${ctx.conn.customerDomain} to ${to} (${users.length} users scanned, stale threshold ${staleDays}d).`; + } catch (err) { + return googleError(err); + } +} + +// Keep the GoogleApiError type referenced for downstream importers/tests. +export type { GoogleApiError, SecurityDrift }; +export { computeSecurityDrift }; diff --git a/apps/api/src/services/aiToolsM365.test.ts b/apps/api/src/services/aiToolsM365.test.ts index 92abd7b1a..34fbcc314 100644 --- a/apps/api/src/services/aiToolsM365.test.ts +++ b/apps/api/src/services/aiToolsM365.test.ts @@ -5,9 +5,15 @@ vi.mock('./m365Helpers', async (orig) => { const actual = await (orig as any)(); return { ...actual, loadSession: vi.fn(), loadConnection: vi.fn() }; }); +// Direct backend off by default so the existing tests exercise the Delegant path. +vi.mock('./m365DirectGraph', () => ({ + hasDirectM365Connection: vi.fn().mockResolvedValue(false), + invokeDirect: vi.fn(), +})); import { invokeDelegantTool } from './delegantClient'; import { loadSession, loadConnection } from './m365Helpers'; +import { hasDirectM365Connection, invokeDirect } from './m365DirectGraph'; import { m365LookupUserHandler, m365RecentSigninsHandler, m365ListGroupMembershipsHandler, m365DisableUserHandler, m365ResetPasswordHandler, m365ToolTiers, @@ -124,6 +130,28 @@ describe('m365_disable_user', () => { }); }); +describe('m365 user resolution surfaces real failures (not a phantom "user not found")', () => { + beforeEach(() => { + (loadSession as any).mockResolvedValue({ id: 'sess-1', orgId: 'org-A', delegantM365ConnectionId: 'c1' }); + (loadConnection as any).mockResolvedValue(activeConn); + }); + + it('surfaces an auth failure on get_user as itself, not as "user not found"', async () => { + (invokeDelegantTool as any).mockResolvedValue({ kind: 'error', code: 'auth_failed', message: 'token expired' }); + // UPN (with @) forces a get_user resolution, which fails on auth. + const out = await m365DisableUserHandler({ userIdentifier: 'jane@x.com', reason: 'offboarding' }, auth, 'sess-1'); + const parsed = JSON.parse(out); + expect(parsed.error).toBe('auth_failed'); + expect(parsed.error).not.toBe('user_not_found'); + }); + + it('reports a genuinely-absent user (404) as user_not_found', async () => { + (invokeDelegantTool as any).mockResolvedValue({ kind: 'error', code: 'not_found', message: 'no such user' }); + const out = await m365DisableUserHandler({ userIdentifier: 'ghost@x.com', reason: 'offboarding' }, auth, 'sess-1'); + expect(JSON.parse(out).error).toBe('user_not_found'); + }); +}); + describe('m365_list_group_memberships', () => { it('lists groups without needing a user identifier', async () => { (loadSession as any).mockResolvedValue({ id: 'sess-1', orgId: 'org-A', delegantM365ConnectionId: 'c1' }); @@ -144,3 +172,29 @@ describe('tool tiers', () => { expect(m365ToolTiers['m365_reset_password']).toBe(3); }); }); + +describe('direct Graph backend (no Delegant)', () => { + it('routes to the direct backend when the org has an m365 connection, not Delegant', async () => { + (hasDirectM365Connection as any).mockResolvedValue(true); + (loadSession as any).mockResolvedValue({ id: 'sess-1', orgId: 'org-A', delegantM365ConnectionId: null }); + (invokeDirect as any).mockResolvedValue({ kind: 'ok', data: { id: 'u1', displayName: 'Jane' } }); + const out = await m365LookupUserHandler({ userIdentifier: 'jane@x.com' }, auth, 'sess-1'); + expect(invokeDirect as any).toHaveBeenCalledWith('org-A', 'get_user', { userId: 'jane@x.com' }); + expect(invokeDelegantTool as any).not.toHaveBeenCalled(); + expect(out).toContain('Jane'); + }); + + it('reset_password via direct backend requires a reason and dispatches reset_user_password', async () => { + (hasDirectM365Connection as any).mockResolvedValue(true); + (loadSession as any).mockResolvedValue({ id: 'sess-1', orgId: 'org-A', delegantM365ConnectionId: null }); + const missing = await m365ResetPasswordHandler({ userIdentifier: 'jane@x.com' }, auth, 'sess-1'); + expect(JSON.parse(missing).error).toBe('missing_reason'); + + (invokeDirect as any).mockResolvedValue({ kind: 'ok', data: { ok: true, temporaryPassword: 'Tmp!1234' } }); + const out = await m365ResetPasswordHandler({ userIdentifier: 'jane@x.com', reason: 'lockout' }, auth, 'sess-1'); + const names = (invokeDirect as any).mock.calls.map((c: any[]) => c[1]); + expect(names).toContain('reset_user_password'); + expect(invokeDelegantTool as any).not.toHaveBeenCalled(); + expect(out).toBeTruthy(); + }); +}); diff --git a/apps/api/src/services/aiToolsM365.ts b/apps/api/src/services/aiToolsM365.ts index 64d87f06a..024d0b329 100644 --- a/apps/api/src/services/aiToolsM365.ts +++ b/apps/api/src/services/aiToolsM365.ts @@ -12,7 +12,8 @@ */ import type { AuthContext } from '../middleware/auth'; -import { invokeDelegantTool, type DelegantToolName } from './delegantClient'; +import { invokeDelegantTool, type DelegantToolName, type DelegantInvokeResult } from './delegantClient'; +import { invokeDirect, hasDirectM365Connection } from './m365DirectGraph'; import type { DelegantM365ConnectionRow } from '../db/schema/delegant'; import { loadSession, loadConnection, authorizeConnection, formatResultForLlm, errorString, @@ -48,18 +49,28 @@ function principals(auth: AuthContext) { }; } -type ResolvedContext = - | { error: string } - | { conn: DelegantM365ConnectionRow }; +// The handler layer is backend-agnostic: a session resolves to EITHER a direct +// Graph backend (self-hosted, the org has its own m365_connections row) or the +// Delegant broker connection (session.delegantM365ConnectionId). `call()` +// dispatches on this; every handler stays identical. +type Backend = + | { backend: 'direct'; orgId: string } + | { backend: 'delegant'; conn: DelegantM365ConnectionRow }; +type ResolvedContext = { error: string } | Backend; async function resolveContext(auth: AuthContext, sessionId: string): Promise { const session = await loadSession(sessionId); if (!session) return { error: errorString('session_not_found', 'AI session not found.') }; + // Prefer the direct Graph backend when this org has its own M365 connection + // (no Delegant broker required). Falls back to the Delegant session connection. + if (auth.orgId && (await hasDirectM365Connection(auth.orgId))) { + return { backend: 'direct', orgId: auth.orgId }; + } if (!session.delegantM365ConnectionId) { return { error: errorString( 'no_customer_selected', - 'No M365 customer is selected for this session. Start a new session and pick a customer.', + 'No M365 connection for this organization, and no Delegant customer is selected for this session. Connect Microsoft 365 in settings.', ), }; } @@ -68,19 +79,25 @@ async function resolveContext(auth: AuthContext, sessionId: string): Promise, -) { +): Promise { + if (ctx.backend === 'direct') { + // DirectInvokeResult is structurally a subset of DelegantInvokeResult; its + // error `code` is a plain string (formatResultForLlm reads it for display + // only), so the cast is sound. + return (await invokeDirect(ctx.orgId, toolName, parameters)) as DelegantInvokeResult; + } const p = principals(auth); return invokeDelegantTool( - { connection: conn, toolName, parameters, actingUser: p.actingUser, agent: p.agent, sessionId }, + { connection: ctx.conn, toolName, parameters, actingUser: p.actingUser, agent: p.agent, sessionId }, { env }, ); } @@ -88,22 +105,32 @@ async function call( /** * Resolve a user identifier to a Graph object id. UPNs (containing '@') are * resolved via a get_user call first; bare object ids are returned as-is. - * Returns null if resolution fails (so the caller can surface a graceful error). + * On failure returns the underlying error (code + message) so the caller can + * distinguish a genuinely-absent user (404 'not_found') from an auth/permission/ + * transport failure that must not masquerade as "user not found". */ +type ResolveUserResult = + | { ok: true; userId: string } + | { ok: false; error: { code: string; message: string } }; + async function resolveUserId( identifier: string, - conn: DelegantM365ConnectionRow, + ctx: Backend, auth: AuthContext, sessionId: string, -): Promise { - if (!identifier.includes('@')) return identifier; - const res = await call(conn, auth, sessionId, 'get_user', { userId: identifier }); - if (res.kind === 'ok') return (res.data as any)?.id ?? identifier; - return null; +): Promise { + if (!identifier.includes('@')) return { ok: true, userId: identifier }; + const res = await call(ctx, auth, sessionId, 'get_user', { userId: identifier }); + if (res.kind === 'ok') return { ok: true, userId: (res.data as { id?: string } | null)?.id ?? identifier }; + // Propagate the real failure instead of collapsing every non-ok result to + // "user not found": only a 404 (code 'not_found') means the user is genuinely + // absent. auth_failed / forbidden / 5xx are config/permission/transport + // errors that must surface as themselves, not as a phantom missing user. + return { ok: false, error: { code: res.code, message: res.message } }; } const errorTemplate = (e: { code: string; message: string }): string => - `Could not complete the M365 operation: ${e.message}`; + errorString(e.code, `Could not complete the M365 operation: ${e.message}`); const unresolvedUser = (identifier: string): string => errorString('user_not_found', `Could not find an M365 user matching "${identifier}".`); @@ -123,7 +150,7 @@ export async function m365LookupUserHandler( const identifier = requireString(input, 'userIdentifier'); if (!identifier) return errorString('missing_user', 'A user identifier (UPN or object id) is required.'); - const result = await call(ctx.conn, auth, sessionId, 'get_user', { userId: identifier }); + const result = await call(ctx, auth, sessionId, 'get_user', { userId: identifier }); return formatResultForLlm(result, { successTemplate: (data) => `M365 user profile: ${JSON.stringify(data)}`, errorTemplate, @@ -140,10 +167,15 @@ export async function m365RecentSigninsHandler( const identifier = requireString(input, 'userIdentifier'); if (!identifier) return errorString('missing_user', 'A user identifier (UPN or object id) is required.'); - const userId = await resolveUserId(identifier, ctx.conn, auth, sessionId); - if (userId === null) return unresolvedUser(identifier); + const resolved = await resolveUserId(identifier, ctx, auth, sessionId); + if (!resolved.ok) { + return resolved.error.code === 'not_found' + ? unresolvedUser(identifier) + : errorTemplate(resolved.error); + } + const userId = resolved.userId; - const result = await call(ctx.conn, auth, sessionId, 'get_user_signin_activity', { userId }); + const result = await call(ctx, auth, sessionId, 'get_user_signin_activity', { userId }); return formatResultForLlm(result, { successTemplate: (data) => `Recent sign-in activity for ${identifier}: ${JSON.stringify(data)}`, errorTemplate, @@ -158,7 +190,7 @@ export async function m365ListGroupMembershipsHandler( const ctx = await resolveContext(auth, sessionId); if ('error' in ctx) return ctx.error; - const result = await call(ctx.conn, auth, sessionId, 'list_groups', {}); + const result = await call(ctx, auth, sessionId, 'list_groups', {}); return formatResultForLlm(result, { successTemplate: (data) => `Groups in the customer tenant: ${JSON.stringify(data)}`, errorTemplate, @@ -178,10 +210,15 @@ export async function m365DisableUserHandler( const identifier = requireString(input, 'userIdentifier'); if (!identifier) return errorString('missing_user', 'A user identifier (UPN or object id) is required.'); - const userId = await resolveUserId(identifier, ctx.conn, auth, sessionId); - if (userId === null) return unresolvedUser(identifier); + const resolved = await resolveUserId(identifier, ctx, auth, sessionId); + if (!resolved.ok) { + return resolved.error.code === 'not_found' + ? unresolvedUser(identifier) + : errorTemplate(resolved.error); + } + const userId = resolved.userId; - const result = await call(ctx.conn, auth, sessionId, 'disable_user', { userId, reason }); + const result = await call(ctx, auth, sessionId, 'disable_user', { userId, reason }); return formatResultForLlm(result, { successTemplate: () => `Disabled (blocked sign-in for) M365 user ${identifier}.`, errorTemplate, @@ -201,10 +238,15 @@ export async function m365ResetPasswordHandler( const identifier = requireString(input, 'userIdentifier'); if (!identifier) return errorString('missing_user', 'A user identifier (UPN or object id) is required.'); - const userId = await resolveUserId(identifier, ctx.conn, auth, sessionId); - if (userId === null) return unresolvedUser(identifier); + const resolved = await resolveUserId(identifier, ctx, auth, sessionId); + if (!resolved.ok) { + return resolved.error.code === 'not_found' + ? unresolvedUser(identifier) + : errorTemplate(resolved.error); + } + const userId = resolved.userId; - const result = await call(ctx.conn, auth, sessionId, 'reset_user_password', { userId, reason }); + const result = await call(ctx, auth, sessionId, 'reset_user_password', { userId, reason }); return formatResultForLlm(result, { successTemplate: (data) => { const temp = (data as any)?.temporaryPassword; diff --git a/apps/api/src/services/googleClient.test.ts b/apps/api/src/services/googleClient.test.ts new file mode 100644 index 000000000..5299a532d --- /dev/null +++ b/apps/api/src/services/googleClient.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { + parseServiceAccountKey, + normalizeGoogleError, + GoogleApiError, + ALL_DWD_SCOPES_CSV, + DIRECTORY_SCOPES, + GMAIL_USER_SCOPES, + CALENDAR_SCOPES, + LICENSING_SCOPES, + getDirectoryClient, + getGmailClient, +} from './googleClient'; + +const VALID_KEY = JSON.stringify({ + client_email: 'sa@proj.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n', +}); + +describe('parseServiceAccountKey', () => { + it('parses a valid key', () => { + const k = parseServiceAccountKey(VALID_KEY); + expect(k.client_email).toBe('sa@proj.iam.gserviceaccount.com'); + expect(k.private_key).toContain('BEGIN PRIVATE KEY'); + }); + it('throws GoogleApiError on non-JSON', () => { + expect(() => parseServiceAccountKey('not json')).toThrow(GoogleApiError); + }); + it('throws when client_email/private_key missing', () => { + expect(() => parseServiceAccountKey(JSON.stringify({ client_email: 'x' }))).toThrow(GoogleApiError); + }); +}); + +describe('normalizeGoogleError', () => { + it('maps 403 to google_forbidden', () => { + expect(normalizeGoogleError({ code: 403, message: 'no' }).code).toBe('google_forbidden'); + }); + it('maps 404 to google_not_found', () => { + expect(normalizeGoogleError({ code: 404, message: 'gone' }).code).toBe('google_not_found'); + }); + it('maps 429 to google_rate_limited', () => { + expect(normalizeGoogleError({ code: 429 }).code).toBe('google_rate_limited'); + }); + it('passes through a GoogleApiError', () => { + const out = normalizeGoogleError(new GoogleApiError('invalid_service_account', 'bad key')); + expect(out).toEqual({ code: 'invalid_service_account', message: 'bad key' }); + }); + it('falls back to google_error with the api message', () => { + const out = normalizeGoogleError({ errors: [{ message: 'deep msg' }] }); + expect(out).toEqual({ code: 'google_error', message: 'deep msg' }); + }); +}); + +describe('scopes', () => { + it('CSV is the union of directory + gmail + calendar + licensing scopes', () => { + expect(ALL_DWD_SCOPES_CSV).toBe( + [...DIRECTORY_SCOPES, ...GMAIL_USER_SCOPES, ...CALENDAR_SCOPES, ...LICENSING_SCOPES].join(','), + ); + expect(ALL_DWD_SCOPES_CSV).toContain('admin.directory.user'); + expect(ALL_DWD_SCOPES_CSV).toContain('gmail.settings.sharing'); + expect(ALL_DWD_SCOPES_CSV).toContain('apps.licensing'); + expect(ALL_DWD_SCOPES_CSV).toContain('calendar.acls'); + }); +}); + +describe('client construction (smoke)', () => { + it('builds a directory client without making a network call', () => { + const client = getDirectoryClient(VALID_KEY, 'admin@example.com'); + expect(typeof client.users.get).toBe('function'); + }); + it('builds a gmail client without making a network call', () => { + const client = getGmailClient(VALID_KEY, 'user@example.com'); + expect(typeof client.users.settings.updateVacation).toBe('function'); + }); +}); diff --git a/apps/api/src/services/googleClient.ts b/apps/api/src/services/googleClient.ts new file mode 100644 index 000000000..8e0fae13e --- /dev/null +++ b/apps/api/src/services/googleClient.ts @@ -0,0 +1,193 @@ +/** + * Google Workspace API client construction for the Breeze identity tools. + * + * Auth model: per-org service account with domain-wide delegation (DWD). One + * service account, TWO impersonation subjects: + * - Admin SDK Directory calls impersonate the customer super-admin + * (`connection.adminEmail`). + * - Gmail (per-mailbox) calls impersonate the TARGET end user, because + * forwarding / vacation are per-user mailbox settings, not admin operations. + * + * The DWD grant in the customer's Admin console (Security > API controls > + * Domain-wide delegation) must authorize this service account's client id for + * exactly the scopes in DIRECTORY_SCOPES + GMAIL_USER_SCOPES. + * + * The service-account key JSON arrives DECRYPTED (caller decrypts via + * secretCrypto). It is a domain god-key: never log it, never echo it back. + */ + +import { admin, auth as adminAuth, type admin_directory_v1 } from '@googleapis/admin'; +import { gmail, auth as gmailAuth, type gmail_v1 } from '@googleapis/gmail'; +import { calendar, auth as calendarAuth, type calendar_v3 } from '@googleapis/calendar'; +import { licensing, auth as licensingAuth, type licensing_v1 } from '@googleapis/licensing'; + +// Least-privilege scope sets. Keep these minimal; the DWD grant authorizes +// exactly this union, so widening here widens the god-key. +export const DIRECTORY_SCOPES = [ + 'https://www.googleapis.com/auth/admin.directory.user', // read + update user (password, suspend, profile) + 'https://www.googleapis.com/auth/admin.directory.user.security', // signOut, 2SV state, OAuth token revoke + 'https://www.googleapis.com/auth/admin.directory.user.alias', // aliases + 'https://www.googleapis.com/auth/admin.directory.group', // list a user's groups (offboard) + 'https://www.googleapis.com/auth/admin.directory.group.member', // remove from groups (offboard) + 'https://www.googleapis.com/auth/admin.directory.device.mobile.action', // selective account-wipe / stolen-device wipe +] as const; + +export const GMAIL_USER_SCOPES = [ + 'https://www.googleapis.com/auth/gmail.settings.basic', // vacation responder + 'https://www.googleapis.com/auth/gmail.settings.sharing', // forwarding addresses + auto-forwarding +] as const; + +export const CALENDAR_SCOPES = [ + 'https://www.googleapis.com/auth/calendar.acls', // share a calendar (ACL insert), nothing more +] as const; + +export const LICENSING_SCOPES = [ + 'https://www.googleapis.com/auth/apps.licensing', // assign / list / remove Workspace license assignments +] as const; + +/** Comma-separated scope list for the operator's DWD setup instructions. */ +export const ALL_DWD_SCOPES_CSV = [ + ...DIRECTORY_SCOPES, + ...GMAIL_USER_SCOPES, + ...CALENDAR_SCOPES, + ...LICENSING_SCOPES, +].join(','); + +interface ServiceAccountKey { + client_email: string; + private_key: string; +} + +/** + * Parse + validate the decrypted service-account JSON. Throws a tagged error + * (never leaking key material) if the JSON is malformed or missing fields. + */ +export function parseServiceAccountKey(decryptedKeyJson: string): ServiceAccountKey { + let parsed: unknown; + try { + parsed = JSON.parse(decryptedKeyJson); + } catch { + throw new GoogleApiError('invalid_service_account', 'Service-account key is not valid JSON.'); + } + const obj = parsed as Record; + const clientEmail = typeof obj.client_email === 'string' ? obj.client_email : ''; + const privateKey = typeof obj.private_key === 'string' ? obj.private_key : ''; + if (!clientEmail || !privateKey) { + throw new GoogleApiError( + 'invalid_service_account', + 'Service-account key is missing client_email or private_key.', + ); + } + return { client_email: clientEmail, private_key: privateKey }; +} + +/** + * Admin SDK Directory client, impersonating the super-admin `adminEmail`. + * Used for user lookup, password reset, suspend, profile/alias edits, signOut. + * + * The JWT is built from the @googleapis/admin package's own auth namespace so + * the auth client type matches admin()'s expected type exactly (avoids the + * google-auth-library dual-version skew you get importing JWT separately). + */ +export function getDirectoryClient( + decryptedKeyJson: string, + adminEmail: string, +): admin_directory_v1.Admin { + const key = parseServiceAccountKey(decryptedKeyJson); + const auth = new adminAuth.JWT({ + email: key.client_email, + key: key.private_key, + scopes: [...DIRECTORY_SCOPES], + subject: adminEmail, // DWD: impersonate the super-admin + }); + return admin({ version: 'directory_v1', auth }); +} + +/** + * Gmail client impersonating the TARGET end user (not the admin). Used for + * per-mailbox settings: forwarding, vacation responder. + */ +export function getGmailClient( + decryptedKeyJson: string, + targetUserEmail: string, +): gmail_v1.Gmail { + const key = parseServiceAccountKey(decryptedKeyJson); + const auth = new gmailAuth.JWT({ + email: key.client_email, + key: key.private_key, + scopes: [...GMAIL_USER_SCOPES], + subject: targetUserEmail, // DWD: impersonate the end user + }); + return gmail({ version: 'v1', auth }); +} + +/** + * Calendar client impersonating the calendar OWNER (the user whose calendar is + * being shared). Sharing = inserting an ACL rule on the owner's calendar, so the + * subject is the owner, not the admin. Scope is the narrow calendar.acls only. + */ +export function getCalendarClient( + decryptedKeyJson: string, + ownerEmail: string, +): calendar_v3.Calendar { + const key = parseServiceAccountKey(decryptedKeyJson); + const auth = new calendarAuth.JWT({ + email: key.client_email, + key: key.private_key, + scopes: [...CALENDAR_SCOPES], + subject: ownerEmail, // DWD: impersonate the calendar owner + }); + return calendar({ version: 'v3', auth }); +} + +/** + * Enterprise License Manager client, impersonating the super-admin. Used to + * assign / list / remove Workspace license assignments for users. + */ +export function getLicensingClient( + decryptedKeyJson: string, + adminEmail: string, +): licensing_v1.Licensing { + const key = parseServiceAccountKey(decryptedKeyJson); + const auth = new licensingAuth.JWT({ + email: key.client_email, + key: key.private_key, + scopes: [...LICENSING_SCOPES], + subject: adminEmail, // DWD: impersonate the super-admin + }); + return licensing({ version: 'v1', auth }); +} + +/** Tagged error for Google operations; carries a stable code + safe message. */ +export class GoogleApiError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'GoogleApiError'; + } +} + +/** + * Normalize a thrown error from the Google client (Gaxios) into a stable + * { code, message }. Never includes response bodies or key material — only the + * HTTP status class and the API's top-level message, suitable for an audit + * column or an LLM-readable string. + */ +export function normalizeGoogleError(err: unknown): { code: string; message: string } { + if (err instanceof GoogleApiError) return { code: err.code, message: err.message }; + const e = err as { code?: number | string; status?: number; message?: string; errors?: Array<{ message?: string }> }; + const status = typeof e?.code === 'number' ? e.code : e?.status; + const apiMessage = e?.errors?.[0]?.message ?? e?.message ?? 'Unknown Google API error.'; + if (status === 401 || status === 403) { + return { code: 'google_forbidden', message: `Google denied the request (${status}): ${apiMessage}` }; + } + if (status === 404) { + return { code: 'google_not_found', message: `Google resource not found: ${apiMessage}` }; + } + if (status === 429) { + return { code: 'google_rate_limited', message: 'Google rate limit hit; try again shortly.' }; + } + return { code: 'google_error', message: apiMessage }; +} diff --git a/apps/api/src/services/googleHelpers.test.ts b/apps/api/src/services/googleHelpers.test.ts new file mode 100644 index 000000000..8bfa2d52b --- /dev/null +++ b/apps/api/src/services/googleHelpers.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { authorizeGoogleConnection, errorString } from './googleHelpers'; + +describe('errorString', () => { + it('produces a stable JSON envelope', () => { + expect(errorString('x', 'y')).toBe(JSON.stringify({ error: 'x', message: 'y' })); + }); +}); + +describe('authorizeGoogleConnection', () => { + it('rejects null', () => { + expect(authorizeGoogleConnection(null, 'org-A').ok).toBe(false); + }); + it('rejects a different org', () => { + const conn = { orgId: 'org-A', status: 'active' } as any; + expect(authorizeGoogleConnection(conn, 'org-B').ok).toBe(false); + }); + it('rejects an inactive connection', () => { + const conn = { orgId: 'org-A', status: 'disabled' } as any; + expect(authorizeGoogleConnection(conn, 'org-A').ok).toBe(false); + }); + it('accepts a same-org active connection', () => { + const conn = { orgId: 'org-A', status: 'active' } as any; + const out = authorizeGoogleConnection(conn, 'org-A'); + expect(out.ok).toBe(true); + }); +}); + +describe('decryptConnectionKey', () => { + it('throws when decryption yields null', async () => { + vi.resetModules(); + vi.doMock('./secretCrypto', () => ({ decryptForColumn: () => null })); + const { decryptConnectionKey } = await import('./googleHelpers'); + expect(() => decryptConnectionKey({ serviceAccountKey: 'enc' } as any)).toThrow(/could not be decrypted/); + vi.doUnmock('./secretCrypto'); + }); +}); diff --git a/apps/api/src/services/googleHelpers.ts b/apps/api/src/services/googleHelpers.ts new file mode 100644 index 000000000..ee64a0af9 --- /dev/null +++ b/apps/api/src/services/googleHelpers.ts @@ -0,0 +1,69 @@ +/** + * Shared helpers for the Google Workspace identity AI tool handlers. + * + * Mirrors m365Helpers: pure functions (errorString, authorizeGoogleConnection) + * plus minimal DB-backed loaders. The tool handler owns the DB access context + * (tool execution runs under the session's org RLS context), so these are plain + * selects; the explicit org check in authorizeGoogleConnection is defense in + * depth on top of RLS. + * + * Unlike the M365 (Delegant) model, the Google connection is one-per-org and + * stores the service-account key itself, encrypted. decryptConnectionKey returns + * the plaintext JSON for in-memory use only — it must never be logged or + * returned to a client. + */ + +import { db } from '../db'; +import { eq } from 'drizzle-orm'; +import { aiSessions } from '../db/schema/ai'; +import { googleWorkspaceConnections } from '../db/schema/google'; +import type { GoogleWorkspaceConnectionRow } from '../db/schema/google'; +import { decryptForColumn } from './secretCrypto'; + +export function errorString(code: string, message: string): string { + return JSON.stringify({ error: code, message }); +} + +export function authorizeGoogleConnection( + conn: GoogleWorkspaceConnectionRow | null, + authOrgId: string, +): { ok: true; conn: GoogleWorkspaceConnectionRow } | { ok: false } { + if (!conn) return { ok: false }; + if (conn.orgId !== authOrgId) return { ok: false }; + if (conn.status !== 'active') return { ok: false }; + return { ok: true, conn }; +} + +export async function loadSession(sessionId: string) { + const [row] = await db + .select() + .from(aiSessions) + .where(eq(aiSessions.id, sessionId)) + .limit(1); + return row ?? null; +} + +/** Resolve the single Google Workspace connection for an org (by org_id). */ +export async function loadGoogleConnection( + orgId: string, +): Promise { + const [row] = await db + .select() + .from(googleWorkspaceConnections) + .where(eq(googleWorkspaceConnections.orgId, orgId)) + .limit(1); + return row ?? null; +} + +/** Decrypt the stored service-account key JSON for in-memory use. */ +export function decryptConnectionKey(conn: GoogleWorkspaceConnectionRow): string { + const decrypted = decryptForColumn( + 'google_workspace_connections', + 'service_account_key', + conn.serviceAccountKey, + ); + if (!decrypted) { + throw new Error('Google Workspace connection key could not be decrypted.'); + } + return decrypted; +} diff --git a/apps/api/src/services/m365DirectGraph.test.ts b/apps/api/src/services/m365DirectGraph.test.ts new file mode 100644 index 000000000..9c4666827 --- /dev/null +++ b/apps/api/src/services/m365DirectGraph.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the row lookup, secret decryption, and token acquisition so the test +// focuses on invokeDirect's Graph endpoint/method/body mapping. +const mockRow = { tenantId: '11111111-1111-1111-1111-111111111111', clientId: 'client-1', clientSecret: 'enc-secret' }; +vi.mock('../db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => [mockRow]), + })), + })), + })), + }, +})); +vi.mock('./secretCrypto', () => ({ decryptForColumn: vi.fn(() => 'plaintext-secret') })); +vi.mock('./c2cM365', () => ({ + acquireClientCredentialsToken: vi.fn(async () => ({ accessToken: 'TOKEN-123', expiresIn: 3600 })), + isM365TenantId: (x: string) => /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(x), +})); + +import { invokeDirect } from './m365DirectGraph'; + +const GRAPH = 'https://graph.microsoft.com/v1.0'; + +function mockFetch(status: number, body: unknown) { + const fn = vi.fn(async (_url: string, _opts: RequestInit): Promise => ({ + status, + ok: status >= 200 && status < 300, + json: async () => body, + })); + vi.stubGlobal('fetch', fn); + return fn; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('m365DirectGraph.invokeDirect endpoint mapping', () => { + it('get_user → GET /users/{key} with the bearer token', async () => { + const f = mockFetch(200, { id: 'u1', displayName: 'Jane' }); + const res = await invokeDirect('org-1', 'get_user', { userId: 'jane@x.com' }); + expect(res.kind).toBe('ok'); + const [url, opts] = f.mock.calls[0]!; + expect(url).toBe(`${GRAPH}/users/jane%40x.com`); + expect(opts.method).toBe('GET'); + expect((opts.headers as Record).Authorization).toBe('Bearer TOKEN-123'); + }); + + it('disable_user → PATCH /users/{id} with accountEnabled:false', async () => { + const f = mockFetch(204, null); + const res = await invokeDirect('org-1', 'disable_user', { userId: 'u1', reason: 'offboard' }); + expect(res.kind).toBe('ok'); + const [url, opts] = f.mock.calls[0]!; + expect(url).toBe(`${GRAPH}/users/u1`); + expect(opts.method).toBe('PATCH'); + expect(JSON.parse(opts.body as string)).toEqual({ accountEnabled: false }); + }); + + it('reset_user_password → PATCH passwordProfile and returns a generated temp password', async () => { + const f = mockFetch(204, null); + const res = await invokeDirect('org-1', 'reset_user_password', { userId: 'u1', reason: 'lockout' }); + expect(res.kind).toBe('ok'); + expect((res as { kind: 'ok'; data: { temporaryPassword?: string } }).data.temporaryPassword).toBeTruthy(); + const body = JSON.parse(f.mock.calls[0]![1].body as string); + expect(body.passwordProfile.forceChangePasswordNextSignIn).toBe(true); + expect(typeof body.passwordProfile.password).toBe('string'); + }); + + it('list_groups → GET /groups', async () => { + const f = mockFetch(200, { value: [] }); + await invokeDirect('org-1', 'list_groups', {}); + expect((f.mock.calls[0]![0]).startsWith(`${GRAPH}/groups`)).toBe(true); + expect(f.mock.calls[0]![1].method).toBe('GET'); + }); + + it('get_user_signin_activity → GET /auditLogs/signIns filtered by userId', async () => { + const f = mockFetch(200, { value: [] }); + await invokeDirect('org-1', 'get_user_signin_activity', { userId: 'u1' }); + const url = f.mock.calls[0]![0]; + expect(url.startsWith(`${GRAPH}/auditLogs/signIns`)).toBe(true); + expect(decodeURIComponent(url)).toContain("userId eq 'u1'"); + }); + + it('maps a Graph 403 to a forbidden error result', async () => { + mockFetch(403, { error: { message: 'Insufficient privileges' } }); + const res = await invokeDirect('org-1', 'get_user', { userId: 'u1' }); + expect(res.kind).toBe('error'); + expect((res as { kind: 'error'; code: string; message: string }).code).toBe('forbidden'); + expect((res as { kind: 'error'; code: string; message: string }).message).toContain('Insufficient privileges'); + }); + + it('missing userId on get_user → bad_request without calling Graph', async () => { + const f = mockFetch(200, {}); + const res = await invokeDirect('org-1', 'get_user', {}); + expect(res.kind).toBe('error'); + expect((res as { kind: 'error'; code: string }).code).toBe('bad_request'); + expect(f).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/m365DirectGraph.ts b/apps/api/src/services/m365DirectGraph.ts new file mode 100644 index 000000000..65030641b --- /dev/null +++ b/apps/api/src/services/m365DirectGraph.ts @@ -0,0 +1,185 @@ +/** + * Direct Microsoft Graph backend for the M365 identity tools. + * + * For self-hosted deployments that DON'T use the external Delegant broker, this + * talks to Graph directly with a client-credentials token built from the per-org + * `m365_connections` row (tenant id + client id + encrypted client secret). + * + * It exposes `invokeDirect(orgId, toolName, params)` which maps the same + * `DelegantToolName` set the tool handlers already use to Graph calls, and + * returns the SAME result shape (`{kind:'ok',data}` | `{kind:'error',code,message}`) + * that `formatResultForLlm` consumes — so the existing handlers work unchanged + * once `aiToolsM365.ts` routes through here when a direct connection exists. + * + * Reuses `acquireClientCredentialsToken` from c2cM365 (fixed-host, SSRF-safe). + * Reads need only User.Read.All / Group.Read.All / AuditLog.Read.All; the + * mutations (disable, reset password) additionally require the app to hold + * User.ReadWrite.All / User-PasswordProfile.ReadWrite.All plus the User + * Administrator Entra role — surfaced as a clear error if the grant is missing. + */ + +import { randomBytes } from 'crypto'; +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { m365Connections } from '../db/schema/m365'; +import { decryptForColumn } from './secretCrypto'; +import { acquireClientCredentialsToken, isM365TenantId } from './c2cM365'; +import type { DelegantToolName } from './delegantClient'; + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; + +export type DirectInvokeResult = + | { kind: 'ok'; data: unknown } + | { kind: 'error'; code: string; message: string }; + +/** True when a direct M365 connection exists for the org (selects the direct backend). */ +export async function hasDirectM365Connection(orgId: string): Promise { + const [row] = await db + .select({ id: m365Connections.id }) + .from(m365Connections) + .where(eq(m365Connections.orgId, orgId)) + .limit(1); + return !!row; +} + +function mapStatusToCode(status: number): string { + switch (status) { + case 400: return 'bad_request'; + case 401: return 'auth_failed'; + case 403: return 'forbidden'; + case 404: return 'not_found'; + default: return status >= 500 ? 'graph_unavailable' : 'tool_error'; + } +} + +/** Strong temporary password (mixed classes), used by reset_user_password. */ +function generateTempPassword(): string { + const raw = randomBytes(18).toString('base64').replace(/[+/=]/g, ''); + return `Mz9!${raw.slice(0, 18)}`; +} + +async function getToken(orgId: string): Promise<{ token: string } | DirectInvokeResult> { + const [row] = await db + .select() + .from(m365Connections) + .where(eq(m365Connections.orgId, orgId)) + .limit(1); + if (!row) { + return { kind: 'error', code: 'no_connection', message: 'No Microsoft 365 connection for this organization.' }; + } + const secret = decryptForColumn('m365_connections', 'client_secret', row.clientSecret); + if (!secret) { + return { kind: 'error', code: 'connection_key_error', message: 'Could not decrypt the stored client secret.' }; + } + // The stored tenant id must still be a canonical Entra tenant GUID (the + // M365TenantId brand acquireClientCredentialsToken requires); fail closed if not. + const tenantId = row.tenantId; + if (!isM365TenantId(tenantId)) { + return { kind: 'error', code: 'connection_key_error', message: 'Stored Microsoft 365 tenant id is not a valid tenant GUID.' }; + } + try { + const t = await acquireClientCredentialsToken({ + tenantId, + clientId: row.clientId, + clientSecret: secret, + }); + return { token: t.accessToken }; + } catch (err) { + return { kind: 'error', code: 'auth_failed', message: err instanceof Error ? err.message : 'token acquisition failed' }; + } +} + +async function graphFetch( + token: string, + method: 'GET' | 'PATCH' | 'POST' | 'DELETE', + path: string, + body?: unknown, +): Promise { + let resp: Response; + try { + resp = await fetch(`${GRAPH_BASE}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (err) { + return { kind: 'error', code: 'graph_unreachable', message: err instanceof Error ? err.message : 'Graph request failed' }; + } + if (resp.status === 204) return { kind: 'ok', data: { ok: true } }; + let json: unknown = null; + try { + json = await resp.json(); + } catch { + json = null; + } + if (!resp.ok) { + const message = + (json as any)?.error?.message ?? `Graph returned HTTP ${resp.status}`; + return { kind: 'error', code: mapStatusToCode(resp.status), message }; + } + return { kind: 'ok', data: json }; +} + +/** + * Map a Delegant tool name + params to a direct Graph call. `userId` may be a UPN + * or an object id (Graph /users/{key} accepts either); sign-in filtering needs + * the object id, which the handler resolves first via get_user. + */ +export async function invokeDirect( + orgId: string, + toolName: DelegantToolName, + params: Record, +): Promise { + const tok = await getToken(orgId); + if ('kind' in tok) return tok; // error result + const token = tok.token; + const userId = typeof params.userId === 'string' ? params.userId : ''; + const groupId = typeof params.groupId === 'string' ? params.groupId : ''; + + switch (toolName) { + case 'get_user': + if (!userId) return { kind: 'error', code: 'bad_request', message: 'userId is required.' }; + return graphFetch(token, 'GET', `/users/${encodeURIComponent(userId)}`); + + case 'get_user_signin_activity': { + if (!userId) return { kind: 'error', code: 'bad_request', message: 'userId is required.' }; + // Escape single quotes per OData (double them) so a quote in the id can't + // break out of the literal; encodeURIComponent only handles URL transport. + const odataId = userId.replace(/'/g, "''"); + // Sign-in logs require Entra ID P1/P2 on the tenant; a clear error surfaces if not. + return graphFetch( + token, + 'GET', + `/auditLogs/signIns?$filter=${encodeURIComponent(`userId eq '${odataId}'`)}&$top=10`, + ); + } + + case 'list_groups': + return graphFetch(token, 'GET', `/groups?$top=50&$select=id,displayName,mail,description`); + + case 'get_group_members': + if (!groupId) return { kind: 'error', code: 'bad_request', message: 'groupId is required.' }; + return graphFetch(token, 'GET', `/groups/${encodeURIComponent(groupId)}/members`); + + case 'disable_user': + if (!userId) return { kind: 'error', code: 'bad_request', message: 'userId is required.' }; + return graphFetch(token, 'PATCH', `/users/${encodeURIComponent(userId)}`, { accountEnabled: false }); + + case 'reset_user_password': { + if (!userId) return { kind: 'error', code: 'bad_request', message: 'userId is required.' }; + const password = generateTempPassword(); + const res = await graphFetch(token, 'PATCH', `/users/${encodeURIComponent(userId)}`, { + passwordProfile: { forceChangePasswordNextSignIn: true, password }, + }); + // On success, return the temp password so the handler can surface it. + if (res.kind === 'ok') return { kind: 'ok', data: { ok: true, temporaryPassword: password } }; + return res; + } + + default: + return { kind: 'error', code: 'bad_request', message: `Unsupported tool: ${toolName}` }; + } +} diff --git a/apps/api/src/services/tenantCascade.ts b/apps/api/src/services/tenantCascade.ts index 998b13260..a70636c7f 100644 --- a/apps/api/src/services/tenantCascade.ts +++ b/apps/api/src/services/tenantCascade.ts @@ -145,6 +145,7 @@ export const ORG_CASCADE_DELETE_ORDER: ReadonlyArray = Object.freeze([ 'escalation_policies', 'event_bus_events', 'executive_summaries', + 'google_workspace_connections', 'group_membership_log', 'huntress_agents', 'huntress_incidents', @@ -158,6 +159,7 @@ export const ORG_CASCADE_DELETE_ORDER: ReadonlyArray = Object.freeze([ 'log_correlation_rules', 'log_correlations', 'log_search_queries', + 'm365_connections', 'maintenance_windows', 'network_baselines', 'network_change_events', diff --git a/apps/web/src/components/devices/DeviceDetails.tsx b/apps/web/src/components/devices/DeviceDetails.tsx index 7ef49518e..c554fe47b 100644 --- a/apps/web/src/components/devices/DeviceDetails.tsx +++ b/apps/web/src/components/devices/DeviceDetails.tsx @@ -21,11 +21,13 @@ import { Layers, Timer, Usb, + Sparkles, Ticket, } from 'lucide-react'; import { formatUptime } from '../../lib/utils'; import type { Device, DeviceStatus, OSType } from './DeviceList'; import DeviceActions from './DeviceActions'; +import { useAiStore } from '../../stores/aiStore'; import DeviceInfoTab from './DeviceInfoTab'; import DeviceHardwareInventory from './DeviceHardwareInventory'; import DeviceSoftwareInventory from './DeviceSoftwareInventory'; @@ -152,6 +154,7 @@ function getTabFromHash(): Tab { export default function DeviceDetails({ device, timezone, onBack, onAction }: DeviceDetailsProps) { const [activeTab, setActiveTab] = useState(getTabFromHash); + const startDeviceTask = useAiStore((s) => s.startDeviceTask); useEffect(() => { const onHashChange = () => setActiveTab(getTabFromHash()); @@ -218,7 +221,27 @@ export default function DeviceDetails({ device, timezone, onBack, onAction }: De - +
+ + +
diff --git a/apps/web/src/components/integrations/GoogleWorkspaceIntegration.tsx b/apps/web/src/components/integrations/GoogleWorkspaceIntegration.tsx new file mode 100644 index 000000000..ffa51108c --- /dev/null +++ b/apps/web/src/components/integrations/GoogleWorkspaceIntegration.tsx @@ -0,0 +1,321 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + Eye, + EyeOff, + Loader2, + Save, + Unplug, + Users +} from 'lucide-react'; +import { fetchWithAuth } from '../../stores/auth'; + +type Connection = { + connected: boolean; + customerDomain?: string; + adminEmail?: string; + serviceAccountEmail?: string; + status?: string; + lastVerifiedAt?: string | null; +}; + +type SaveState = { status: 'idle' | 'saving' | 'saved' | 'error'; message?: string }; + +// The exact domain-wide-delegation OAuth scopes the Google identity tools use. +// Keep in sync with ALL_DWD_SCOPES_CSV in apps/api/src/services/googleClient.ts. +const GOOGLE_DWD_SCOPES_CSV = [ + 'https://www.googleapis.com/auth/admin.directory.user', + 'https://www.googleapis.com/auth/admin.directory.user.security', + 'https://www.googleapis.com/auth/admin.directory.user.alias', + 'https://www.googleapis.com/auth/admin.directory.group', + 'https://www.googleapis.com/auth/admin.directory.group.member', + 'https://www.googleapis.com/auth/admin.directory.device.mobile.action', + 'https://www.googleapis.com/auth/gmail.settings.basic', + 'https://www.googleapis.com/auth/gmail.settings.sharing', + 'https://www.googleapis.com/auth/calendar.acls', + 'https://www.googleapis.com/auth/apps.licensing' +].join(','); + +export default function GoogleWorkspaceIntegration() { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [connection, setConnection] = useState(null); + + const [customerDomain, setCustomerDomain] = useState(''); + const [adminEmail, setAdminEmail] = useState(''); + const [serviceAccountKey, setServiceAccountKey] = useState(''); + const [showKey, setShowKey] = useState(false); + + const [saveState, setSaveState] = useState({ status: 'idle' }); + + const isConnected = !!connection?.connected; + // When already connected the key may be left blank to keep the stored one; + // a fresh connection requires all three fields. + const canSave = + customerDomain.trim().length > 0 && + adminEmail.trim().length > 0 && + (serviceAccountKey.trim().length > 0 || isConnected); + + const fetchConnection = useCallback(async () => { + try { + const res = await fetchWithAuth('/google/connection'); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setLoadError( + `Failed to load connection (${res.status}): ${(json as Record).error ?? res.statusText}` + ); + return; + } + const data = (await res.json()) as Connection; + setConnection(data); + if (data.connected) { + setCustomerDomain(data.customerDomain ?? ''); + setAdminEmail(data.adminEmail ?? ''); + setServiceAccountKey(''); + } + } catch (err) { + setLoadError(`Failed to load connection: ${err instanceof Error ? err.message : 'Network error'}`); + } + }, []); + + useEffect(() => { + const load = async () => { + setLoading(true); + await fetchConnection(); + setLoading(false); + }; + load(); + }, [fetchConnection]); + + const handleSave = async () => { + setSaveState({ status: 'saving' }); + try { + const res = await fetchWithAuth('/google/connection', { + method: 'POST', + body: JSON.stringify({ + customerDomain: customerDomain.trim(), + adminEmail: adminEmail.trim(), + serviceAccountKey: serviceAccountKey.trim() + }) + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + const hint = (json as Record).hint; + setSaveState({ + status: 'error', + message: `${(json as Record).error ?? 'Failed to save'}${hint ? ` — ${hint}` : ''}` + }); + return; + } + setSaveState({ status: 'saved', message: 'Connection verified and saved.' }); + setServiceAccountKey(''); + await fetchConnection(); + } catch (err) { + setSaveState({ status: 'error', message: err instanceof Error ? err.message : 'Network error' }); + } + }; + + const handleDisconnect = async () => { + setSaveState({ status: 'saving' }); + try { + const res = await fetchWithAuth('/google/connection', { method: 'DELETE' }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + const err = (json as Record).error; + setSaveState({ status: 'error', message: typeof err === 'string' ? err : 'Failed to disconnect' }); + return; + } + setSaveState({ status: 'idle' }); + setConnection({ connected: false }); + setCustomerDomain(''); + setAdminEmail(''); + setServiceAccountKey(''); + } catch (err) { + setSaveState({ status: 'error', message: err instanceof Error ? err.message : 'Network error' }); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Google Workspace

+

+ Connect a Workspace domain so the AI assistant can look up users, manage groups, reset + passwords, run guided offboarding, and more for this organization. +

+
+ {isConnected ? ( + + Connected + + ) : ( + + Not connected + + )} +
+ + {loadError && ( +
{loadError}
+ )} + + {/* Connection card */} +
+

Connection

+

+ Paste the service-account JSON key and the super-admin it impersonates. Breeze makes a live + Directory API call to verify domain-wide delegation before saving. + {!isConnected && ' Saving requires MFA verification.'} +

+ +
+ How to get the service-account JSON and authorize delegation +
    +
  1. + In the Google Cloud Console, open{' '} + IAM & Admin → Service Accounts and create one (or pick an + existing service account) in the project you want to use. +
  2. +
  3. + Open the service account, go to the Keys tab →{' '} + Add key → Create new key → JSON → Create. The JSON downloads + once. That file is what you paste below. Also note the service account's{' '} + Client ID (its numeric Unique ID). +
  4. +
  5. + Enable the APIs the tools use in that project (APIs & Services → Enable APIs):{' '} + Admin SDK, Gmail, Calendar, Enterprise License Manager. +
  6. +
  7. + In the Google Admin console, go to{' '} + Security → Access and data control → API controls → Manage Domain Wide + Delegation → Add new. Paste the service account's Client ID, and in OAuth Scopes paste the comma-separated list + below, then Authorize. +
  8. +
+

OAuth scopes to authorize:

+
{GOOGLE_DWD_SCOPES_CSV}
+
+ +
+
+ + setCustomerDomain(e.target.value)} + placeholder="example.com" + className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/30" + /> +
+ +
+ + setAdminEmail(e.target.value)} + placeholder="admin@example.com" + className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/30" + /> +
+ +
+ +
+