Skip to content
Merged
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
2 changes: 2 additions & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig } from 'drizzle-kit';

export default defineConfig({
schema: [
'./src/db/schema/organizations.ts',
'./src/db/schema/credentials.ts',
'./src/db/schema/defaults.ts',
'./src/db/schema/projects.ts',
'./src/db/schema/runs.ts',
Expand Down
11 changes: 11 additions & 0 deletions src/config/configCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ConfigCache {
private projectByBoardId = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private projectByRepo = new Map<string, CacheEntry<ProjectConfig | undefined>>();
private projectSecrets = new Map<string, CacheEntry<Record<string, string>>>();
private orgIdByProject = new Map<string, CacheEntry<string>>();
private ttlMs: number;

constructor(ttlMs = DEFAULT_TTL_MS) {
Expand Down Expand Up @@ -52,6 +53,15 @@ class ConfigCache {
this.projectByRepo.set(repo, this.makeEntry(project));
}

getOrgIdForProject(projectId: string): string | null {
const entry = this.orgIdByProject.get(projectId);
return this.isValid(entry) ? entry.data : null;
}

setOrgIdForProject(projectId: string, orgId: string): void {
this.orgIdByProject.set(projectId, this.makeEntry(orgId));
}

getSecrets(projectId: string): Record<string, string> | null {
const entry = this.projectSecrets.get(projectId);
return this.isValid(entry) ? entry.data : null;
Expand All @@ -66,6 +76,7 @@ class ConfigCache {
this.projectByBoardId.clear();
this.projectByRepo.clear();
this.projectSecrets.clear();
this.orgIdByProject.clear();
}
}

Expand Down
27 changes: 21 additions & 6 deletions src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
loadConfigFromDb,
} from '../db/repositories/configRepository.js';
import {
getProjectSecret as getProjectSecretFromDb,
getProjectSecrets as getProjectSecretsFromDb,
} from '../db/repositories/secretsRepository.js';
resolveAllCredentials,
resolveCredential,
} from '../db/repositories/credentialsRepository.js';
import type { CascadeConfig, ProjectConfig } from '../types/index.js';
import { configCache } from './configCache.js';

Expand Down Expand Up @@ -43,15 +43,29 @@ export async function findProjectById(id: string): Promise<ProjectConfig | undef
return findProjectByIdFromDb(id);
}

/**
* Resolve the org ID for a project. Cached to avoid repeated DB lookups.
*/
async function getOrgIdForProject(projectId: string): Promise<string> {
const cached = configCache.getOrgIdForProject(projectId);
if (cached) return cached;

const project = await findProjectByIdFromDb(projectId);
const orgId = project?.orgId ?? 'default';
configCache.setOrgIdForProject(projectId, orgId);
return orgId;
}

export async function getProjectSecret(projectId: string, key: string): Promise<string> {
// Check cached secrets first
const cachedSecrets = configCache.getSecrets(projectId);
if (cachedSecrets && key in cachedSecrets) {
return cachedSecrets[key];
}

// DB is the sole source of truth for project secrets
const dbValue = await getProjectSecretFromDb(projectId, key);
// Resolve via credentials system (project override → org default)
const orgId = await getOrgIdForProject(projectId);
const dbValue = await resolveCredential(projectId, orgId, key);
if (dbValue) return dbValue;

throw new Error(`Secret '${key}' not found for project '${projectId}' in database`);
Expand All @@ -72,7 +86,8 @@ export async function getProjectSecrets(projectId: string): Promise<Record<strin
const cached = configCache.getSecrets(projectId);
if (cached) return cached;

const secrets = await getProjectSecretsFromDb(projectId);
const orgId = await getOrgIdForProject(projectId);
const secrets = await resolveAllCredentials(projectId, orgId);
configCache.setSecrets(projectId, secrets);
return secrets;
}
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const AgentBackendConfigSchema = z.object({

export const ProjectConfigSchema = z.object({
id: z.string().min(1),
orgId: z.string().min(1),
name: z.string().min(1),
repo: z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in format "owner/repo"'),
baseBranch: z.string().default('main'),
Expand Down
124 changes: 124 additions & 0 deletions src/db/migrations/0003_organizations_and_credentials.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
-- Organizations and Credentials Migration
-- Introduces organizations as top-level entity, org-scoped credentials,
-- and project credential overrides (replacing flat project_secrets).

BEGIN;

-- 1. Create organizations table
CREATE TABLE IF NOT EXISTS "organizations" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);

-- 2. Insert default organization
INSERT INTO "organizations" ("id", "name")
VALUES ('default', 'Default Organization')
ON CONFLICT ("id") DO NOTHING;

-- 3. Add org_id to projects (nullable first, backfill, then NOT NULL)
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "org_id" text;
UPDATE "projects" SET "org_id" = 'default' WHERE "org_id" IS NULL;
ALTER TABLE "projects" ALTER COLUMN "org_id" SET NOT NULL;
ALTER TABLE "projects" ADD CONSTRAINT "projects_org_id_organizations_id_fk"
FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action;

-- 4. Add org_id to cascade_defaults (nullable first, backfill, then NOT NULL + UNIQUE)
ALTER TABLE "cascade_defaults" ADD COLUMN IF NOT EXISTS "org_id" text;
UPDATE "cascade_defaults" SET "org_id" = 'default' WHERE "org_id" IS NULL;
ALTER TABLE "cascade_defaults" ALTER COLUMN "org_id" SET NOT NULL;
ALTER TABLE "cascade_defaults" ADD CONSTRAINT "cascade_defaults_org_id_organizations_id_fk"
FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "cascade_defaults" ADD CONSTRAINT "cascade_defaults_org_id_unique" UNIQUE ("org_id");

-- 5. Add org_id to agent_configs (nullable FK, backfill global configs)
ALTER TABLE "agent_configs" ADD COLUMN IF NOT EXISTS "org_id" text;
UPDATE "agent_configs" SET "org_id" = 'default' WHERE "project_id" IS NULL AND "org_id" IS NULL;
ALTER TABLE "agent_configs" ADD CONSTRAINT "agent_configs_org_id_organizations_id_fk"
FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action;

-- 6. Create credentials table
CREATE TABLE IF NOT EXISTS "credentials" (
"id" serial PRIMARY KEY NOT NULL,
"org_id" text NOT NULL,
"name" text NOT NULL,
"env_var_key" text NOT NULL,
"value" text NOT NULL,
"description" text,
"is_default" boolean NOT NULL DEFAULT false,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "credentials_org_id_organizations_id_fk"
FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action
);

CREATE INDEX IF NOT EXISTS "idx_credentials_org_env_var_key"
ON "credentials" ("org_id", "env_var_key");

-- Partial unique: enforce at most one default per (org_id, env_var_key)
CREATE UNIQUE INDEX IF NOT EXISTS "uq_credentials_org_env_var_key_default"
ON "credentials" ("org_id", "env_var_key") WHERE "is_default" = true;

-- 7. Create project_credential_overrides table
CREATE TABLE IF NOT EXISTS "project_credential_overrides" (
"id" serial PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"env_var_key" text NOT NULL,
"credential_id" integer NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "project_credential_overrides_project_id_projects_id_fk"
FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "project_credential_overrides_credential_id_credentials_id_fk"
FOREIGN KEY ("credential_id") REFERENCES "credentials"("id") ON DELETE cascade ON UPDATE no action
);

CREATE UNIQUE INDEX IF NOT EXISTS "uq_project_credential_overrides_project_env_var_key"
ON "project_credential_overrides" ("project_id", "env_var_key");

-- 8. Migrate data from project_secrets → credentials + overrides
-- For each unique (key, value) pair, create one credential in 'default' org.
-- Use a temp table to track the mapping.
DO $$
DECLARE
r RECORD;
cred_id integer;
first_for_key boolean;
BEGIN
-- Track which env_var_keys we've already seen (to mark first as default)
CREATE TEMP TABLE IF NOT EXISTS _seen_keys (env_var_key text PRIMARY KEY);

FOR r IN
SELECT DISTINCT ps."key" AS env_var_key, ps."value" AS secret_value
FROM "project_secrets" ps
ORDER BY ps."key", ps."value"
LOOP
-- Check if this is the first credential for this env_var_key
first_for_key := NOT EXISTS (SELECT 1 FROM _seen_keys WHERE env_var_key = r.env_var_key);

INSERT INTO "credentials" ("org_id", "name", "env_var_key", "value", "is_default")
VALUES ('default', r.env_var_key, r.env_var_key, r.secret_value, first_for_key)
RETURNING "id" INTO cred_id;

IF first_for_key THEN
INSERT INTO _seen_keys VALUES (r.env_var_key);
END IF;

-- For every project that had this exact (key, value), if it's the default,
-- no override needed. If it's not the default, create an override.
IF NOT first_for_key THEN
INSERT INTO "project_credential_overrides" ("project_id", "env_var_key", "credential_id")
SELECT ps."project_id", ps."key", cred_id
FROM "project_secrets" ps
WHERE ps."key" = r.env_var_key AND ps."value" = r.secret_value
ON CONFLICT ("project_id", "env_var_key") DO NOTHING;
END IF;
END LOOP;

DROP TABLE IF EXISTS _seen_keys;
END $$;

-- 9. project_secrets is kept alive for now (will be dropped in 0004 after verification)

COMMIT;
73 changes: 60 additions & 13 deletions src/db/repositories/configRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, isNull } from 'drizzle-orm';
import { and, eq, isNull } from 'drizzle-orm';
import { validateConfig } from '../../config/schema.js';
import type { CascadeConfig, ProjectConfig } from '../../types/index.js';
import { getDb } from '../client.js';
Expand All @@ -17,6 +17,7 @@ interface DefaultsRow {
}

interface AgentConfigRow {
orgId: string | null;
projectId: string | null;
agentType: string;
model: string | null;
Expand Down Expand Up @@ -101,6 +102,7 @@ function mapProjectRow(

const project: Record<string, unknown> = {
id: row.id,
orgId: row.orgId,
name: row.name,
repo: row.repo,
baseBranch: row.baseBranch ?? 'main',
Expand Down Expand Up @@ -136,23 +138,38 @@ async function loadAgentConfigs(): Promise<AgentConfigRow[]> {
export async function loadConfigFromDb(): Promise<CascadeConfig> {
const db = getDb();

// Load first defaults row (for the primary/default org)
const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1);
const projectRows = await db.select().from(projects);
const allAgentConfigs = await loadAgentConfigs();

// Split agent configs into global (project_id IS NULL) and per-project
const globalAgentConfigs = allAgentConfigs.filter((ac) => ac.projectId === null);
// Split agent configs: global (project_id IS NULL, org_id IS NULL) and per-project
// Also collect org-level configs (org_id set, project_id IS NULL) as fallback globals
const globalAgentConfigs = allAgentConfigs.filter(
(ac) => ac.projectId === null && ac.orgId === null,
);
const orgAgentConfigsMap = new Map<string, AgentConfigRow[]>();
const projectAgentConfigsMap = new Map<string, AgentConfigRow[]>();
for (const ac of allAgentConfigs) {
if (ac.projectId !== null) {
const existing = projectAgentConfigsMap.get(ac.projectId) ?? [];
existing.push(ac);
projectAgentConfigsMap.set(ac.projectId, existing);
} else if (ac.orgId !== null) {
const existing = orgAgentConfigsMap.get(ac.orgId) ?? [];
existing.push(ac);
orgAgentConfigsMap.set(ac.orgId, existing);
}
}

// Merge global + org-level agent configs for defaults
const mergedGlobalConfigs = [
...globalAgentConfigs,
...(orgAgentConfigsMap.get(defaultsRow?.orgId ?? 'default') ?? []),
];

const rawConfig = {
defaults: mapDefaultsRow(defaultsRow, globalAgentConfigs),
defaults: mapDefaultsRow(defaultsRow, mergedGlobalConfigs),
projects: projectRows.map((row) =>
mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? []),
),
Expand All @@ -169,11 +186,21 @@ export async function findProjectByBoardIdFromDb(
if (!row) return undefined;

const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id));
const globalAcs = await db.select().from(agentConfigs).where(isNull(agentConfigs.projectId));
const orgAcs = await db
.select()
.from(agentConfigs)
.where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId)));
const globalAcs = await db
.select()
.from(agentConfigs)
.where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId)));

const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1);
const [defaultsRow] = await db
.select()
.from(cascadeDefaults)
.where(eq(cascadeDefaults.orgId, row.orgId));
const rawConfig = {
defaults: mapDefaultsRow(defaultsRow, globalAcs),
defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]),
projects: [mapProjectRow(row, projectAcs)],
};
const validated = validateConfig(rawConfig);
Expand All @@ -186,11 +213,21 @@ export async function findProjectByRepoFromDb(repo: string): Promise<ProjectConf
if (!row) return undefined;

const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id));
const globalAcs = await db.select().from(agentConfigs).where(isNull(agentConfigs.projectId));
const orgAcs = await db
.select()
.from(agentConfigs)
.where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId)));
const globalAcs = await db
.select()
.from(agentConfigs)
.where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId)));

const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1);
const [defaultsRow] = await db
.select()
.from(cascadeDefaults)
.where(eq(cascadeDefaults.orgId, row.orgId));
const rawConfig = {
defaults: mapDefaultsRow(defaultsRow, globalAcs),
defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]),
projects: [mapProjectRow(row, projectAcs)],
};
const validated = validateConfig(rawConfig);
Expand All @@ -203,11 +240,21 @@ export async function findProjectByIdFromDb(id: string): Promise<ProjectConfig |
if (!row) return undefined;

const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id));
const globalAcs = await db.select().from(agentConfigs).where(isNull(agentConfigs.projectId));
const orgAcs = await db
.select()
.from(agentConfigs)
.where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId)));
const globalAcs = await db
.select()
.from(agentConfigs)
.where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId)));

const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1);
const [defaultsRow] = await db
.select()
.from(cascadeDefaults)
.where(eq(cascadeDefaults.orgId, row.orgId));
const rawConfig = {
defaults: mapDefaultsRow(defaultsRow, globalAcs),
defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]),
projects: [mapProjectRow(row, projectAcs)],
};
const validated = validateConfig(rawConfig);
Expand Down
Loading