Scope provider requests to workspaces

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 22:16:37 -04:00
1 parent ee5eff7725
commit 335d57b77f
15 files changed
+144 -16

No files matched your search

@@ -0,0 +1,10 @@
export const providerRequestWorkspaceSchema = `
alter table provider_requests add column workspace_id text references workspaces(id) on delete set null;
create index if not exists idx_provider_requests_workspace
on provider_requests(org_id, workspace_id, created_at)
where workspace_id is not null;
insert into schema_migrations(version, name, applied_at)
values (31, 'provider_request_workspace', datetime('now'));
`;
@@ -29,6 +29,7 @@ import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records"
import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal";
import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records";
import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs";
import { providerRequestWorkspaceSchema } from "./migrations/031-provider-request-workspace";
interface Migration {
version: number;
@@ -67,6 +68,7 @@ const MIGRATIONS: Migration[] = [
{ version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema },
{ version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema },
{ version: 30, name: "approval_source_refs", sql: approvalSourceRefsSchema },
{ version: 31, name: "provider_request_workspace", sql: providerRequestWorkspaceSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -194,6 +194,7 @@ const RELATION_CHECKS: RelationCheck[] = [
{ table: "providers", column: "credential_id", referencedTable: "credentials", referencedColumn: "id", reason: "dangling_credential_id" },
{ table: "provider_requests", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "provider_requests", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
{ table: "provider_requests", column: "workspace_id", referencedTable: "workspaces", referencedColumn: "id", reason: "dangling_workspace_id" },
{ table: "provider_usage_windows", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "provider_usage_windows", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
{ table: "provider_rate_limit_observations", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
@@ -57,6 +57,7 @@ export class ProviderBackgroundService {
orgId: input.orgId,
provider: execution.provider,
model: execution.provider.model,
workspaceId: execution.workspaceId,
promptSha256: hashPrompt(execution.prompt),
inputCharCount: execution.prompt.length,
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
@@ -201,12 +202,14 @@ export class ProviderBackgroundService {
const prompt = input.prompt.trim();
if (!prompt) throw new Error("Prompt is required.");
const provider = this.loadConfiguredProvider(input.orgId, input.providerId);
this.enforcePolicy(input.orgId, provider, input.executionMode ?? PROVIDER_EXECUTION_MODE.BACKGROUND);
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
this.enforcePolicy(input.orgId, provider, input.executionMode ?? PROVIDER_EXECUTION_MODE.BACKGROUND, workspaceId);
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
throw new Error("Provider background execution requires an API key adapter.");
}
if (!provider.model) throw new Error("Provider model is required.");
return {
workspaceId,
prompt,
instructions: input.instructions?.trim() || null,
provider: { ...provider, model: provider.model },
@@ -215,8 +218,12 @@ export class ProviderBackgroundService {
};
}
private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode): void {
private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode, workspaceId: string | null): void {
if (executionMode !== PROVIDER_EXECUTION_MODE.BACKGROUND) throw new Error("Provider background execution mode is required.");
if (provider.workspace_scope !== "organization" && provider.workspace_scope !== "workspace") throw new Error("Provider workspace scope is invalid.");
if (provider.workspace_scope === "workspace" && !workspaceId) {
throw new Error("Provider workspace scope requires a workspace context.");
}
if (provider.data_policy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) {
throw new Error("Background execution conflicts with zero data retention policy.");
}
@@ -234,6 +241,15 @@ export class ProviderBackgroundService {
if (requestCount >= provider.monthly_request_limit) throw new Error("Provider monthly request limit has been reached.");
}
private normalizeWorkspaceId(orgId: string, workspaceId: string | null): string | null {
const normalized = workspaceId?.trim() || null;
if (!normalized) return null;
const workspace = this.db.prepare("select id from workspaces where id = ? and org_id = ?")
.get(normalized, orgId) as { id: string } | undefined;
if (!workspace) throw new Error("Provider workspace context was not found.");
return normalized;
}
private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow {
const provider = this.db.prepare(`
select id, provider_key, auth_type, model, credential_id, enabled, auth_state,
@@ -292,6 +308,7 @@ export class ProviderBackgroundService {
}
interface PreparedBackgroundExecution {
workspaceId: string | null;
prompt: string;
instructions: string | null;
provider: ProviderRow & { model: string };
@@ -7,12 +7,14 @@ export interface ProviderRequestOwner {
id: string;
provider_key: string;
data_policy: string;
workspace_scope: string;
}
export interface CreateProviderRequestInput {
orgId: string;
provider: ProviderRequestOwner;
model: string;
workspaceId: string | null;
promptSha256: string;
inputCharCount: number;
executionMode: ProviderExecutionMode;
@@ -99,23 +101,24 @@ export class ProviderRequestStore {
const requestId = createId("preq");
this.db.prepare(`
insert into provider_requests(
id, org_id, provider_id, provider_key, model, prompt_sha256,
id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256,
response_text, raw_response_json, state, error_message, input_char_count,
output_char_count, execution_mode, policy_decision, external_response_id,
stream_event_count, cancelled_at, created_at, completed_at
)
values (?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, null, 0, null, ?, null)
values (?, ?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, null, 0, null, ?, null)
`).run(
requestId,
input.orgId,
input.provider.id,
input.provider.provider_key,
input.workspaceId,
input.model,
input.promptSha256,
PROVIDER_REQUEST_STATE.PENDING,
input.inputCharCount,
input.executionMode,
`allowed:${input.provider.data_policy}`,
policyDecision(input.provider, input.workspaceId),
input.createdAt,
);
return requestId;
@@ -254,3 +257,8 @@ export class ProviderRequestStore {
`).run(createId("puw"), orgId, providerId, window.start, window.end, inputCharCount, outputCharCount, now, now);
}
}
function policyDecision(provider: ProviderRequestOwner, workspaceId: string | null): string {
if (provider.workspace_scope === "workspace") return `allowed:${provider.data_policy}:workspace:${workspaceId}`;
return `allowed:${provider.data_policy}:organization`;
}
@@ -361,14 +361,16 @@ export class ProviderService {
const prompt = input.prompt.trim();
if (!prompt) throw new Error("Prompt is required.");
const provider = this.loadConfiguredProvider(input.orgId, input.providerId);
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
const executionMode = input.executionMode ?? PROVIDER_EXECUTION_MODE.FOREGROUND;
this.enforcePolicy(input.orgId, provider, executionMode);
this.enforcePolicy(input.orgId, provider, executionMode, workspaceId);
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
throw new Error("Provider response execution requires an API key adapter.");
}
if (!provider.model) throw new Error("Provider model is required.");
return {
orgId: input.orgId,
workspaceId,
prompt,
instructions: input.instructions?.trim() || null,
executionMode,
@@ -383,6 +385,7 @@ export class ProviderService {
orgId: execution.orgId,
provider: execution.provider,
model: execution.provider.model,
workspaceId: execution.workspaceId,
promptSha256: hashPrompt(execution.prompt),
inputCharCount: execution.prompt.length,
executionMode: execution.executionMode,
@@ -390,8 +393,12 @@ export class ProviderService {
});
}
private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode): void {
private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode, workspaceId: string | null): void {
if (!Object.values(PROVIDER_EXECUTION_MODE).includes(executionMode)) throw new Error("Provider execution mode is invalid.");
if (!WORKSPACE_SCOPES.has(provider.workspace_scope)) throw new Error("Provider workspace scope is invalid.");
if (provider.workspace_scope === "workspace" && !workspaceId) {
throw new Error("Provider workspace scope requires a workspace context.");
}
if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && provider.data_policy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) {
throw new Error("Background execution conflicts with zero data retention policy.");
}
@@ -411,6 +418,15 @@ export class ProviderService {
if (requestCount >= provider.monthly_request_limit) throw new Error("Provider monthly request limit has been reached.");
}
private normalizeWorkspaceId(orgId: string, workspaceId: string | null): string | null {
const normalized = workspaceId?.trim() || null;
if (!normalized) return null;
const workspace = this.db.prepare("select id from workspaces where id = ? and org_id = ?")
.get(normalized, orgId) as { id: string } | undefined;
if (!workspace) throw new Error("Provider workspace context was not found.");
return normalized;
}
private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow {
const provider = this.db.prepare(`
select id, provider_key, auth_type, model, credential_id, enabled, auth_state,
@@ -476,6 +492,7 @@ export class ProviderService {
interface PreparedProviderExecution {
orgId: string;
workspaceId: string | null;
prompt: string;
instructions: string | null;
executionMode: ProviderExecutionMode;
@@ -262,7 +262,7 @@ export class SdlcService {
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
{ key: "schema_version", passed: schemaVersion >= 30, value: schemaVersion },
{ key: "schema_version", passed: schemaVersion >= 31, value: schemaVersion },
];
}
@@ -88,6 +88,7 @@ export class TaskExecutionService {
const requestId = await this.providers.createResponse({
orgId: input.orgId,
providerId: provider.id,
workspaceId: task.workspace_id,
prompt: buildTaskPrompt({
task,
agent,