Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
144 changes: 144 additions & 0 deletions backend/migrations/20260717_01_dms_connectors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
-- Migration date: 2026-07-17
--
-- Native DMS connectors (iManage / NetDocuments).
--
-- Per-user connector rows with encrypted OAuth credentials, mirroring the MCP
-- connector tables (20260613_04_user_mcp_connectors.sql +
-- 20260615_01_mcp_connector_oauth.sql) column-for-column so the exact
-- AES-256-GCM crypto and the auth-code + refresh OAuth flow apply unchanged.
--
-- Security posture (identical to the MCP tables):
-- * RLS ENABLED on every table with NO browser policies, so only the
-- service-role backend can read connector rows and token material.
-- * REVOKE ALL ... FROM anon, authenticated below removes table-level grants
-- too (BYPASSRLS on service_role skips policies, not GRANTs), following
-- 20260508_01_revoke_client_grants_backend_tables.sql.
--
-- Safe to re-run: every statement is guarded.

-- ---------------------------------------------------------------------------
-- dms_connectors
-- ---------------------------------------------------------------------------

create table if not exists public.dms_connectors (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
kind text not null
check (kind in ('imanage', 'netdocuments', 'fake')),
name text not null,
base_url text not null,
auth_type text not null default 'oauth'
check (auth_type in ('oauth')),
enabled boolean not null default true,
encrypted_auth_config text,
auth_config_iv text,
auth_config_tag text,
config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create index if not exists idx_dms_connectors_user
on public.dms_connectors(user_id);

alter table public.dms_connectors enable row level security;

-- ---------------------------------------------------------------------------
-- dms_connector_oauth_tokens (mirrors user_mcp_oauth_tokens)
-- ---------------------------------------------------------------------------

create table if not exists public.dms_connector_oauth_tokens (
id uuid primary key default gen_random_uuid(),
connector_id uuid not null references public.dms_connectors(id) on delete cascade,
encrypted_access_token text,
access_token_iv text,
access_token_tag text,
encrypted_refresh_token text,
refresh_token_iv text,
refresh_token_tag text,
token_type text,
scope text,
expires_at timestamptz,
authorization_server text,
token_endpoint text,
client_id text,
encrypted_client_secret text,
client_secret_iv text,
client_secret_tag text,
resource text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(connector_id)
);

alter table public.dms_connector_oauth_tokens enable row level security;

-- ---------------------------------------------------------------------------
-- dms_connector_oauth_states (mirrors user_mcp_oauth_states)
-- ---------------------------------------------------------------------------

create table if not exists public.dms_connector_oauth_states (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
connector_id uuid not null references public.dms_connectors(id) on delete cascade,
state_hash text not null unique,
encrypted_state_config text not null,
state_config_iv text not null,
state_config_tag text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);

create index if not exists idx_dms_connector_oauth_states_expires
on public.dms_connector_oauth_states(expires_at);

alter table public.dms_connector_oauth_states enable row level security;

-- ---------------------------------------------------------------------------
-- dms_document_links — round-trip mapping (document_id <-> external doc/version)
-- so exportDocument can push a Mike document back to the right DMS document.
-- ---------------------------------------------------------------------------

create table if not exists public.dms_document_links (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
connector_id uuid not null references public.dms_connectors(id) on delete cascade,
dms_doc_id text not null,
dms_version text,
created_at timestamptz not null default now(),
unique(document_id)
);

create index if not exists idx_dms_document_links_connector
on public.dms_document_links(connector_id);

alter table public.dms_document_links enable row level security;

-- ---------------------------------------------------------------------------
-- Allow 'dms_import' as a document_versions.source (imported docs are
-- distinguishable from interactive uploads). Re-add the check with the extra
-- value; the constraint is additive, no existing rows are affected.
-- ---------------------------------------------------------------------------

alter table public.document_versions
drop constraint if exists document_versions_source_check;
alter table public.document_versions
add constraint document_versions_source_check
check (source = any (array[
'upload'::text,
'user_upload'::text,
'assistant_edit'::text,
'user_accept'::text,
'user_reject'::text,
'generated'::text,
'dms_import'::text
]));

-- ---------------------------------------------------------------------------
-- Direct client grant hardening for the new tables
-- ---------------------------------------------------------------------------

revoke all on public.dms_connectors from anon, authenticated;
revoke all on public.dms_connector_oauth_tokens from anon, authenticated;
revoke all on public.dms_connector_oauth_states from anon, authenticated;
revoke all on public.dms_document_links from anon, authenticated;
93 changes: 92 additions & 1 deletion backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ create table if not exists public.document_versions (
'assistant_edit'::text,
'user_accept'::text,
'user_reject'::text,
'generated'::text
'generated'::text,
'dms_import'::text
]))
);

Expand Down Expand Up @@ -851,6 +852,92 @@ create table if not exists public.courtlistener_opinion_cluster_index (

alter table public.courtlistener_opinion_cluster_index enable row level security;

-- ---------------------------------------------------------------------------
-- DMS connectors (iManage / NetDocuments) — see
-- 20260717_01_dms_connectors.sql. Mirrors the MCP connector tables.
-- ---------------------------------------------------------------------------

create table if not exists public.dms_connectors (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
kind text not null
check (kind in ('imanage', 'netdocuments', 'fake')),
name text not null,
base_url text not null,
auth_type text not null default 'oauth'
check (auth_type in ('oauth')),
enabled boolean not null default true,
encrypted_auth_config text,
auth_config_iv text,
auth_config_tag text,
config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create index if not exists idx_dms_connectors_user
on public.dms_connectors(user_id);

alter table public.dms_connectors enable row level security;

create table if not exists public.dms_connector_oauth_tokens (
id uuid primary key default gen_random_uuid(),
connector_id uuid not null references public.dms_connectors(id) on delete cascade,
encrypted_access_token text,
access_token_iv text,
access_token_tag text,
encrypted_refresh_token text,
refresh_token_iv text,
refresh_token_tag text,
token_type text,
scope text,
expires_at timestamptz,
authorization_server text,
token_endpoint text,
client_id text,
encrypted_client_secret text,
client_secret_iv text,
client_secret_tag text,
resource text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(connector_id)
);

alter table public.dms_connector_oauth_tokens enable row level security;

create table if not exists public.dms_connector_oauth_states (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
connector_id uuid not null references public.dms_connectors(id) on delete cascade,
state_hash text not null unique,
encrypted_state_config text not null,
state_config_iv text not null,
state_config_tag text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);

create index if not exists idx_dms_connector_oauth_states_expires
on public.dms_connector_oauth_states(expires_at);

alter table public.dms_connector_oauth_states enable row level security;

create table if not exists public.dms_document_links (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
connector_id uuid not null references public.dms_connectors(id) on delete cascade,
dms_doc_id text not null,
dms_version text,
created_at timestamptz not null default now(),
unique(document_id)
);

create index if not exists idx_dms_document_links_connector
on public.dms_document_links(connector_id);

alter table public.dms_document_links enable row level security;

-- ---------------------------------------------------------------------------
-- Direct client grant hardening
-- ---------------------------------------------------------------------------
Expand Down Expand Up @@ -884,3 +971,7 @@ revoke all on public.user_mcp_connector_tools from anon, authenticated;
revoke all on public.user_mcp_tool_audit_logs from anon, authenticated;
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;
revoke all on public.dms_connectors from anon, authenticated;
revoke all on public.dms_connector_oauth_tokens from anon, authenticated;
revoke all on public.dms_connector_oauth_states from anon, authenticated;
revoke all on public.dms_document_links from anon, authenticated;
15 changes: 15 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import "dotenv/config";

// DMS secret handling reuses the MCP crypto (lib/mcp/client.ts), whose master
// secret resolves MCP_CONNECTORS_ENCRYPTION_SECRET → USER_API_KEYS_ENCRYPTION_SECRET.
// To let operators name the variable after the DMS feature without a second
// crypto implementation, alias DMS_CONNECTORS_ENCRYPTION_SECRET onto the MCP
// slot when the MCP one is not itself set. Runs at env load, before any
// encrypt/decrypt call (those read process.env lazily).
if (
process.env.DMS_CONNECTORS_ENCRYPTION_SECRET &&
!process.env.MCP_CONNECTORS_ENCRYPTION_SECRET
) {
process.env.MCP_CONNECTORS_ENCRYPTION_SECRET =
process.env.DMS_CONNECTORS_ENCRYPTION_SECRET;
}

import express from "express";
import cors from "cors";
import helmet from "helmet";
Expand Down
8 changes: 8 additions & 0 deletions backend/src/lib/airgap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Central air-gap policy. AIRGAPPED must fence off EVERY external egress channel
// in code (not just the LLM), so the "no content leaves" guarantee holds even if
// network isolation is imperfect.

/** True when the deployment is running in air-gapped mode. */
export function isAirgapped(env: NodeJS.ProcessEnv = process.env): boolean {
return env.AIRGAPPED === "true";
}
95 changes: 95 additions & 0 deletions backend/src/lib/dms/__tests__/airgap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { createDmsConnector, resolveDmsAdapter } from "../servers";
import { getDmsAdapter } from "../index";
import { sharedFakeDms } from "../fake";
import type { DmsConnectorRow } from "../types";

// The airgap guard reads process.env.AIRGAPPED at call time (lib/airgap.ts).
const priorAirgap = process.env.AIRGAPPED;

function row(kind: DmsConnectorRow["kind"]): DmsConnectorRow {
return {
id: "c1",
user_id: "u1",
kind,
name: "Test",
base_url: "https://tenant.example.com",
auth_type: "oauth",
enabled: true,
encrypted_auth_config: null,
auth_config_iv: null,
auth_config_tag: null,
config: { customer_id: "1", library: "ACTIVE", repository: "CAB" },
created_at: "now",
updated_at: "now",
};
}

// A db stub is never reached: the airgap guard throws before any query.
const db = {} as never;

beforeEach(() => {
process.env.AIRGAPPED = "true";
sharedFakeDms.reset();
});

afterEach(() => {
if (priorAirgap === undefined) delete process.env.AIRGAPPED;
else process.env.AIRGAPPED = priorAirgap;
});

describe("DMS air-gap gating", () => {
it("refuses to create an iManage connector when air-gapped", async () => {
await expect(
createDmsConnector(
"u1",
{ kind: "imanage", name: "x", baseUrl: "https://t.imanage.com" },
db,
),
).rejects.toThrow(/air-gapped/);
});

it("refuses to create a NetDocuments connector when air-gapped", async () => {
await expect(
createDmsConnector(
"u1",
{
kind: "netdocuments",
name: "x",
baseUrl: "https://t.netdocuments.com",
},
db,
),
).rejects.toThrow(/air-gapped/);
});

it("refuses to resolve a cloud adapter when air-gapped", () => {
expect(() => resolveDmsAdapter(row("imanage"), db)).toThrow(
/air-gapped/,
);
expect(() => resolveDmsAdapter(row("netdocuments"), db)).toThrow(
/air-gapped/,
);
});

it("keeps the in-memory Fake connector fully usable air-gapped", async () => {
// The Fake has no egress, so it is allowed even when AIRGAPPED=true.
const adapter = resolveDmsAdapter(row("fake"), db);
expect(adapter.kind).toBe("fake");
sharedFakeDms.seedDocument({
id: "d1",
name: "Local.pdf",
content: "offline",
});
const doc = await adapter.fetchDocument("d1");
expect(doc).not.toBeNull();
await expect(adapter.authenticate()).resolves.toEqual({ ok: true });
});

it("still allows creating a Fake connector air-gapped (guard is per-kind)", () => {
// getDmsAdapter for the fake kind is not gated.
const adapter = getDmsAdapter("fake", { baseUrl: "https://fake.invalid" });
expect(adapter.kind).toBe("fake");
});
});
Loading