Scope provider requests to workspaces
This commit is contained in:
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,
|
||||
|
||||
@@ -189,6 +189,7 @@ export interface UpdateProviderPolicyInput {
|
||||
export interface CreateProviderResponseInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
workspaceId?: string | null;
|
||||
prompt: string;
|
||||
instructions: string | null;
|
||||
executionMode?: ProviderExecutionMode;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ProviderRequestRecord {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
workspaceId: string | null;
|
||||
model: string;
|
||||
promptSha256: string;
|
||||
responseText: string | null;
|
||||
|
||||
Reference in new issue
Block a user