Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
17878df
feat(google-identity): Phase 1 foundation — connection schema + migra…
bdunncompany May 31, 2026
fb37dbd
feat(google-identity): Phase 1b+2 — Google client, connect route, age…
bdunncompany May 31, 2026
ff67dee
feat(google-identity): Phase 3 — device-scoped task dispatch + Fix-wi…
bdunncompany May 31, 2026
c0d71aa
feat(google-identity): Phase 4 — google_share_calendar tool (closes t…
bdunncompany May 31, 2026
4961054
feat(google-identity): Phase 5 — guided offboard + stolen-device wipe…
bdunncompany May 31, 2026
1b644b6
feat(google-identity): Phase 6 — security drift dashboard + reports-b…
bdunncompany Jun 1, 2026
bd206d3
feat(identity): Google cluster-3 tools + connection UI; M365 direct-G…
bdunncompany Jun 1, 2026
c447364
test(api): unit-test m365DirectGraph endpoint mapping
bdunncompany Jun 1, 2026
cb6a0a6
fix(api): getToolTier must resolve Google tools (suite was dead at ru…
bdunncompany Jun 1, 2026
f4d4537
fix(api): escape OData single-quotes in M365 sign-in $filter (defense…
bdunncompany Jun 1, 2026
3d6c4b8
test(api): route tests for /m365/connection (get/post/delete)
bdunncompany Jun 1, 2026
b190909
test(api): route tests for /google/connection (get/post/delete)
bdunncompany Jun 1, 2026
777b2d6
fix(api): google/m365 connection routes must self-apply authMiddleware
bdunncompany Jun 1, 2026
25e2a7d
feat(web): in-form 'how to get these credentials' help on the identit…
bdunncompany Jun 1, 2026
4697ee5
fix(api): conform identity branch to merged #1047/#1049 security cont…
bdunncompany Jun 1, 2026
a163049
fix(web): plain placeholder on the service-account key field
bdunncompany Jun 1, 2026
14d2581
fix(identity): address #1053 review — surface failures, reachable M36…
bdunncompany Jun 2, 2026
fe49cff
Merge remote-tracking branch 'origin/main' into feat/google-identity-…
bdunncompany Jun 2, 2026
ccec6c5
Merge remote-tracking branch 'origin/main' into feat/google-identity-…
bdunncompany Jun 2, 2026
ede353c
fix(ai): bind device-scoped sessions to the device's org (Fix-with-AI…
ToddHebebrand Jun 2, 2026
31d0e8b
Merge remote-tracking branch 'origin/main' into pr-1053
ToddHebebrand Jun 12, 2026
c1e2e39
fix(deps): override @grpc/grpc-js to >=1.14.4 (CVE-2026-48068/48069)
ToddHebebrand Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/api/migrations/2026-06-01-google-workspace-connections.sql
Original file line number Diff line number Diff line change
@@ -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));
65 changes: 65 additions & 0 deletions apps/api/migrations/2026-06-01-m365-connections.sql
Original file line number Diff line number Diff line change
@@ -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));
6 changes: 6 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
47 changes: 47 additions & 0 deletions apps/api/src/db/schema/google.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions apps/api/src/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/db/schema/m365.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 47 additions & 0 deletions apps/api/src/db/schema/m365.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/routes/ai.m365session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/routes/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});

/**
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading