diff --git a/Dockerfile b/Dockerfile index 3720da60..4c9ff7a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,10 @@ WORKDIR /app COPY package.json yarn.lock* ./ COPY ./packages/db ./packages/db COPY ./packages/schemas ./packages/schemas +COPY ./packages/crypto ./packages/crypto RUN yarn workspace @sourcebot/db install --frozen-lockfile RUN yarn workspace @sourcebot/schemas install --frozen-lockfile +RUN yarn workspace @sourcebot/crypto install --frozen-lockfile # ------ Build Web ------ FROM node-alpine AS web-builder @@ -30,6 +32,7 @@ COPY ./packages/web ./packages/web COPY --from=shared-libs-builder /app/node_modules ./node_modules COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas +COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto # Fixes arm64 timeouts RUN yarn config set registry https://registry.npmjs.org/ @@ -60,6 +63,7 @@ COPY ./packages/backend ./packages/backend COPY --from=shared-libs-builder /app/node_modules ./node_modules COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas +COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto RUN yarn workspace @sourcebot/backend install --frozen-lockfile RUN yarn workspace @sourcebot/backend build @@ -100,7 +104,7 @@ ENV POSTHOG_PAPIK=$POSTHOG_PAPIK # ENV SOURCEBOT_TELEMETRY_DISABLED=1 # Configure dependencies -RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib +RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib openssl # Configure zoekt COPY vendor/zoekt/install-ctags-alpine.sh . @@ -129,6 +133,7 @@ COPY --from=backend-builder /app/packages/backend ./packages/backend COPY --from=shared-libs-builder /app/node_modules ./node_modules COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas +COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto # Configure the database RUN mkdir -p /run/postgresql && \ @@ -143,6 +148,8 @@ RUN chmod +x ./entrypoint.sh COPY default-config.json . +ENV SOURCEBOT_ENCRYPTION_KEY="" + EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" diff --git a/README.md b/README.md index 5084377b..c59329fb 100644 --- a/README.md +++ b/README.md @@ -374,14 +374,20 @@ docker run -v /path/to/my-repo:/repos/my-repo /* additional args */ ghcr. 5. Create a `config.json` file at the repository root. See [Configuring Sourcebot](#configuring-sourcebot) for more information. -6. Start Sourcebot with the command: +6. Create `.env.local` files in the `packages/backend` and `packages/web` directories with the following contents: + ```sh + # You can use https://acte.ltd/utils/randomkeygen to generate a key ("Encryption key 256") + SOURCEBOT_ENCRYPTION_KEY="32-byte-secret-key" + ``` + +7. Start Sourcebot with the command: ```sh yarn dev ``` A `.sourcebot` directory will be created and zoekt will begin to index the repositories found given `config.json`. -7. Start searching at `http://localhost:3000`. +8. Start searching at `http://localhost:3000`. ## Telemetry diff --git a/entrypoint.sh b/entrypoint.sh index 7d497055..b0883428 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -26,6 +26,22 @@ if [ ! -d "$DB_DATA_DIR" ]; then su postgres -c "initdb -D $DB_DATA_DIR" fi +if [ -z "$SOURCEBOT_ENCRYPTION_KEY" ]; then + echo -e "\e[31m[Error] SOURCEBOT_ENCRYPTION_KEY is not set.\e[0m" + + if [ -f "$DATA_CACHE_DIR/.secret" ]; then + echo -e "\e[34m[Info] Loading environment variables from $DATA_CACHE_DIR/.secret\e[0m" + else + echo -e "\e[34m[Info] Generating a new encryption key...\e[0m" + SOURCEBOT_ENCRYPTION_KEY=$(openssl rand -base64 24) + echo "SOURCEBOT_ENCRYPTION_KEY=\"$SOURCEBOT_ENCRYPTION_KEY\"" >> "$DATA_CACHE_DIR/.secret" + fi + + set -a + . "$DATA_CACHE_DIR/.secret" + set +a +fi + # In order to detect if this is the first run, we create a `.installed` file in # the cache directory. FIRST_RUN_FILE="$DATA_CACHE_DIR/.installedv2" diff --git a/packages/backend/package.json b/packages/backend/package.json index 3bc4cae6..02a6f772 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -31,6 +31,7 @@ "lowdb": "^7.0.1", "micromatch": "^4.0.8", "posthog-node": "^4.2.1", + "@sourcebot/crypto": "^0.1.0", "@sourcebot/db": "^0.1.0", "@sourcebot/schemas": "^0.1.0", "simple-git": "^3.27.0", diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index c4b25c72..456c5d15 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -5,7 +5,7 @@ import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { createLogger } from "./logger.js"; import os from 'os'; import { Redis } from 'ioredis'; -import { getTokenFromConfig, marshalBool } from "./utils.js"; +import { marshalBool } from "./utils.js"; import { getGitHubReposFromConfig } from "./github.js"; interface IConnectionManager { @@ -70,17 +70,13 @@ export class ConnectionManager implements IConnectionManager { const repoData: RepoData[] = await (async () => { switch (config.type) { case 'github': { - const token = config.token ? getTokenFromConfig(config.token, this.context) : undefined; - const gitHubRepos = await getGitHubReposFromConfig(config, abortController.signal, this.context); + const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal); const hostUrl = config.url ?? 'https://github.com'; const hostname = config.url ? new URL(config.url).hostname : 'github.com'; - + return gitHubRepos.map((repo) => { const repoName = `${hostname}/${repo.full_name}`; const cloneUrl = new URL(repo.clone_url!); - if (token) { - cloneUrl.username = token; - } const record: RepoData = { external_id: repo.id.toString(), diff --git a/packages/backend/src/gitea.ts b/packages/backend/src/gitea.ts index f3a6b514..8701b523 100644 --- a/packages/backend/src/gitea.ts +++ b/packages/backend/src/gitea.ts @@ -9,8 +9,9 @@ import micromatch from 'micromatch'; const logger = createLogger('Gitea'); -export const getGiteaReposFromConfig = async (config: GiteaConfig, ctx: AppContext) => { - const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined; +export const getGiteaReposFromConfig = async (config: GiteaConfig, orgId: number, ctx: AppContext) => { + // TODO: pass in DB here to fetch secret properly + const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined; const api = giteaApi(config.url ?? 'https://gitea.com', { token, diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index 6680ce15..bef5e6bc 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -4,6 +4,7 @@ import { createLogger } from "./logger.js"; import { AppContext } from "./types.js"; import { getTokenFromConfig, measure } from "./utils.js"; import micromatch from "micromatch"; +import { PrismaClient } from "@sourcebot/db"; const logger = createLogger("GitHub"); @@ -25,8 +26,8 @@ export type OctokitRepository = { size?: number, } -export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, signal: AbortSignal, ctx: AppContext) => { - const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined; +export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => { + const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; const octokit = new Octokit({ auth: token, diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index 2501fd05..73ed5e92 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -8,8 +8,9 @@ import { getTokenFromConfig, measure } from "./utils.js"; const logger = createLogger("GitLab"); export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; -export const getGitLabReposFromConfig = async (config: GitLabConfig, ctx: AppContext) => { - const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined; +export const getGitLabReposFromConfig = async (config: GitLabConfig, orgId: number, ctx: AppContext) => { + // TODO: pass in DB here to fetch secret properly + const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined; const api = new Gitlab({ ...(config.token ? { token, diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 7e9d2f80..d74e6d93 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -1,20 +1,50 @@ -import { ConnectionSyncStatus, PrismaClient, Repo, RepoIndexingStatus } from '@sourcebot/db'; +import { ConnectionSyncStatus, PrismaClient, Repo, RepoIndexingStatus, RepoToConnection, Connection } from '@sourcebot/db'; import { existsSync } from 'fs'; import { cloneRepository, fetchRepository } from "./git.js"; import { createLogger } from "./logger.js"; import { captureEvent } from "./posthog.js"; import { AppContext } from "./types.js"; -import { getRepoPath, measure } from "./utils.js"; +import { getRepoPath, getTokenFromConfig, measure } from "./utils.js"; import { indexGitRepository } from "./zoekt.js"; import { DEFAULT_SETTINGS } from './constants.js'; import { Queue, Worker, Job } from 'bullmq'; import { Redis } from 'ioredis'; import * as os from 'os'; import { ConnectionManager } from './connectionManager.js'; +import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; const logger = createLogger('main'); -const syncGitRepository = async (repo: Repo, ctx: AppContext) => { +type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection})[] }; + +// TODO: do this better? ex: try using the tokens from all the connections +// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to +// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each +// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This +// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing. +const getTokenForRepo = async (repo: RepoWithConnections, db: PrismaClient) => { + const repoConnections = repo.connections; + if (repoConnections.length === 0) { + logger.error(`Repo ${repo.id} has no connections`); + return; + } + + let token: string | undefined; + for (const repoConnection of repoConnections) { + const connection = repoConnection.connection; + const config = connection.config as unknown as ConnectionConfig; + if (config.token) { + token = await getTokenFromConfig(config.token, connection.orgId, db); + if (token) { + break; + } + } + } + + return token; +} + +const syncGitRepository = async (repo: RepoWithConnections, ctx: AppContext, db: PrismaClient) => { let fetchDuration_s: number | undefined = undefined; let cloneDuration_s: number | undefined = undefined; @@ -35,7 +65,15 @@ const syncGitRepository = async (repo: Repo, ctx: AppContext) => { } else { logger.info(`Cloning ${repo.id}...`); - const { durationMs } = await measure(() => cloneRepository(repo.cloneUrl, repoPath, metadata, ({ method, stage, progress }) => { + const token = await getTokenForRepo(repo, db); + let cloneUrl = repo.cloneUrl; + if (token) { + const url = new URL(cloneUrl); + url.username = token; + cloneUrl = url.toString(); + } + + const { durationMs } = await measure(() => cloneRepository(cloneUrl, repoPath, metadata, ({ method, stage, progress }) => { logger.info(`git.${method} ${stage} stage ${progress}% complete for ${repo.id}`) })); cloneDuration_s = durationMs / 1000; @@ -92,13 +130,13 @@ export const main = async (db: PrismaClient, context: AppContext) => { const connectionManager = new ConnectionManager(db, DEFAULT_SETTINGS, redis, context); setInterval(async () => { - const configs = await db.connection.findMany({ + const connections = await db.connection.findMany({ where: { syncStatus: ConnectionSyncStatus.SYNC_NEEDED, } }); - for (const config of configs) { - await connectionManager.scheduleConnectionSync(config); + for (const connection of connections) { + await connectionManager.scheduleConnectionSync(connection); } }, DEFAULT_SETTINGS.resyncConnectionPollingIntervalMs); @@ -111,13 +149,13 @@ export const main = async (db: PrismaClient, context: AppContext) => { const numWorkers = numCores * DEFAULT_SETTINGS.indexConcurrencyMultiple; logger.info(`Detected ${numCores} cores. Setting repo index max concurrency to ${numWorkers}`); const worker = new Worker('indexQueue', async (job: Job) => { - const repo = job.data as Repo; + const repo = job.data as RepoWithConnections; let indexDuration_s: number | undefined; let fetchDuration_s: number | undefined; let cloneDuration_s: number | undefined; - const stats = await syncGitRepository(repo, context); + const stats = await syncGitRepository(repo, context, db); indexDuration_s = stats.indexDuration_s; fetchDuration_s = stats.fetchDuration_s; cloneDuration_s = stats.cloneDuration_s; @@ -171,6 +209,13 @@ export const main = async (db: PrismaClient, context: AppContext) => { { indexedAt: { lt: thresholdDate } }, { repoIndexingStatus: RepoIndexingStatus.NEW } ] + }, + include: { + connections: { + include: { + connection: true + } + } } }); addReposToQueue(db, indexQueue, repos); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 1b2d365a..f85ea10a 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -2,7 +2,9 @@ import { Logger } from "winston"; import { AppContext, Repository } from "./types.js"; import path from 'path'; import micromatch from "micromatch"; -import { Repo } from "@sourcebot/db"; +import { PrismaClient, Repo } from "@sourcebot/db"; +import { decrypt } from "@sourcebot/crypto"; +import { Token } from "@sourcebot/schemas/v3/shared.type"; export const measure = async (cb : () => Promise) => { const start = Date.now(); @@ -86,15 +88,39 @@ export const excludeReposByTopic = (repos: T[], excludedRe }); } -export const getTokenFromConfig = (token: string | { env: string }, ctx: AppContext) => { +export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => { if (typeof token === 'string') { return token; } - const tokenValue = process.env[token.env]; - if (!tokenValue) { - throw new Error(`The environment variable '${token.env}' was referenced in ${ctx.configPath}, but was not set.`); + if ('env' in token) { + const tokenValue = process.env[token.env]; + if (!tokenValue) { + throw new Error(`The environment variable '${token.env}' was referenced in the config but was not set.`); + } + return tokenValue; + } else if ('secret' in token) { + if (!db) { + throw new Error(`Database connection required to retrieve secret`); + } + + const secretKey = token.secret; + const secret = await db.secret.findUnique({ + where: { + orgId_key: { + key: secretKey, + orgId + } + } + }); + + if (!secret) { + throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`); + } + + const decryptedSecret = decrypt(secret.iv, secret.encryptedValue); + return decryptedSecret; } - return tokenValue; + throw new Error(`Invalid token configuration in config`); } export const isRemotePath = (path: string) => { diff --git a/packages/crypto/.gitignore b/packages/crypto/.gitignore new file mode 100644 index 00000000..3a8fe5ed --- /dev/null +++ b/packages/crypto/.gitignore @@ -0,0 +1 @@ +.env.local \ No newline at end of file diff --git a/packages/crypto/package.json b/packages/crypto/package.json new file mode 100644 index 00000000..cc32c0b4 --- /dev/null +++ b/packages/crypto/package.json @@ -0,0 +1,16 @@ +{ + "name": "@sourcebot/crypto", + "main": "dist/index.js", + "version": "0.1.0", + "scripts": { + "build": "tsc", + "postinstall": "yarn build" + }, + "dependencies": { + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "typescript": "^5.7.3" + } +} diff --git a/packages/crypto/src/environment.ts b/packages/crypto/src/environment.ts new file mode 100644 index 00000000..c1f72210 --- /dev/null +++ b/packages/crypto/src/environment.ts @@ -0,0 +1,17 @@ +import dotenv from 'dotenv'; + +export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => { + if (required && !env && !defaultValue) { + throw new Error(`Missing required environment variable`); + } + + return env ?? defaultValue; +} + +dotenv.config({ + path: './.env.local', + override: true +}); + +// @note: You can use https://generate-random.org/encryption-key-generator to create a new 32 byte key +export const SOURCEBOT_ENCRYPTION_KEY = getEnv(process.env.SOURCEBOT_ENCRYPTION_KEY, undefined, true)!; \ No newline at end of file diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts new file mode 100644 index 00000000..fc63f764 --- /dev/null +++ b/packages/crypto/src/index.ts @@ -0,0 +1,35 @@ +import crypto from 'crypto'; +import { SOURCEBOT_ENCRYPTION_KEY } from './environment'; + +const algorithm = 'aes-256-cbc'; +const ivLength = 16; // 16 bytes for CBC + +const generateIV = (): Buffer => { + return crypto.randomBytes(ivLength); +}; + +export function encrypt(text: string): { iv: string; encryptedData: string } { + const encryptionKey = Buffer.from(SOURCEBOT_ENCRYPTION_KEY, 'ascii'); + + const iv = generateIV(); + const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return { iv: iv.toString('hex'), encryptedData: encrypted }; +} + +export function decrypt(iv: string, encryptedText: string): string { + const encryptionKey = Buffer.from(SOURCEBOT_ENCRYPTION_KEY, 'ascii'); + + const ivBuffer = Buffer.from(iv, 'hex'); + const encryptedBuffer = Buffer.from(encryptedText, 'hex'); + + const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer); + + let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} diff --git a/packages/crypto/tsconfig.json b/packages/crypto/tsconfig.json new file mode 100644 index 00000000..39b3533d --- /dev/null +++ b/packages/crypto/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "CommonJS", + "lib": ["ES6"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] + } + \ No newline at end of file diff --git a/packages/db/prisma/migrations/20250124190846_add_secret_table/migration.sql b/packages/db/prisma/migrations/20250124190846_add_secret_table/migration.sql new file mode 100644 index 00000000..faf5be73 --- /dev/null +++ b/packages/db/prisma/migrations/20250124190846_add_secret_table/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Secret" ( + "orgId" INTEGER NOT NULL, + "key" TEXT NOT NULL, + "encryptedValue" TEXT NOT NULL, + "iv" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Secret_pkey" PRIMARY KEY ("orgId","key") +); + +-- AddForeignKey +ALTER TABLE "Secret" ADD CONSTRAINT "Secret_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ec536d0b..4f071a1b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -88,6 +88,7 @@ model Org { members UserToOrg[] connections Connection[] repos Repo[] + secrets Secret[] } enum OrgRole { @@ -111,6 +112,19 @@ model UserToOrg { @@id([orgId, userId]) } +model Secret { + orgId Int + key String + encryptedValue String + iv String + + createdAt DateTime @default(now()) + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + + @@id([orgId, key]) +} + // @see : https://authjs.dev/concepts/database-models#user model User { id String @id @default(cuid()) diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 0a86ecf2..77a85423 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -36,6 +36,19 @@ const schema = { "env" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false } ] }, diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 30c0ff27..3d07a7c2 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -17,6 +17,12 @@ export interface GithubConnectionConfig { * The name of the environment variable that contains the token. */ env: string; + } + | { + /** + * The name of the secret that contains the token. + */ + secret: string; }; /** * The URL of the GitHub host. Defaults to https://github.com diff --git a/packages/schemas/src/v3/github.schema.ts b/packages/schemas/src/v3/github.schema.ts index 546e10a8..9d53a13a 100644 --- a/packages/schemas/src/v3/github.schema.ts +++ b/packages/schemas/src/v3/github.schema.ts @@ -32,6 +32,19 @@ const schema = { "env" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false } ] }, diff --git a/packages/schemas/src/v3/github.type.ts b/packages/schemas/src/v3/github.type.ts index d7355c5b..d9d75f4c 100644 --- a/packages/schemas/src/v3/github.type.ts +++ b/packages/schemas/src/v3/github.type.ts @@ -15,6 +15,12 @@ export interface GithubConnectionConfig { * The name of the environment variable that contains the token. */ env: string; + } + | { + /** + * The name of the secret that contains the token. + */ + secret: string; }; /** * The URL of the GitHub host. Defaults to https://github.com diff --git a/packages/schemas/src/v3/shared.schema.ts b/packages/schemas/src/v3/shared.schema.ts index ccb21856..ff9dbab1 100644 --- a/packages/schemas/src/v3/shared.schema.ts +++ b/packages/schemas/src/v3/shared.schema.ts @@ -20,6 +20,19 @@ const schema = { "env" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false } ] }, diff --git a/packages/schemas/src/v3/shared.type.ts b/packages/schemas/src/v3/shared.type.ts index 6dcd54e7..3347d6a6 100644 --- a/packages/schemas/src/v3/shared.type.ts +++ b/packages/schemas/src/v3/shared.type.ts @@ -11,6 +11,12 @@ export type Token = * The name of the environment variable that contains the token. */ env: string; + } + | { + /** + * The name of the secret that contains the token. + */ + secret: string; }; export interface Shared { diff --git a/packages/web/package.json b/packages/web/package.json index e35ae88e..d269aa94 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -58,6 +58,7 @@ "@replit/codemirror-lang-svelte": "^6.0.0", "@replit/codemirror-vim": "^6.2.1", "@shopify/lang-jsonc": "^1.0.0", + "@sourcebot/crypto": "^0.1.0", "@sourcebot/schemas": "^0.1.0", "@sourcebot/db": "^0.1.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 717a74fe..ab4c3f9f 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -8,11 +8,144 @@ import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "./lib/errorCodes"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; +import { encrypt } from "@sourcebot/crypto" const ajv = new Ajv({ validateFormats: false, }); +export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => { + const session = await auth(); + if (!session) { + return notAuthenticated(); + } + + const user = await getUser(session.user.id); + if (!user) { + return unexpectedError("User not found"); + } + const orgId = user.activeOrgId; + if (!orgId) { + return unexpectedError("User has no active org"); + } + + // @todo: refactor this into a shared function + const membership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + userId: session.user.id, + orgId, + } + }, + }); + if (!membership) { + return notFound(); + } + + try { + const encrypted = encrypt(value); + await prisma.secret.create({ + data: { + orgId, + key, + encryptedValue: encrypted.encryptedData, + iv: encrypted.iv, + } + }); + } catch (e) { + return unexpectedError(`Failed to create secret`); + } + + return { + success: true, + } +} + +export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => { + const session = await auth(); + if (!session) { + return notAuthenticated(); + } + + const user = await getUser(session.user.id); + if (!user) { + return unexpectedError("User not found"); + } + const orgId = user.activeOrgId; + if (!orgId) { + return unexpectedError("User has no active org"); + } + + const membership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + userId: session.user.id, + orgId, + } + }, + }); + if (!membership) { + return notFound(); + } + + const secrets = await prisma.secret.findMany({ + where: { + orgId, + }, + select: { + key: true, + createdAt: true + } + }); + + return secrets.map((secret) => ({ + key: secret.key, + createdAt: secret.createdAt, + })); +} + +export const deleteSecret = async (key: string): Promise<{ success: boolean } | ServiceError> => { + const session = await auth(); + if (!session) { + return notAuthenticated(); + } + + const user = await getUser(session.user.id); + if (!user) { + return unexpectedError("User not found"); + } + const orgId = user.activeOrgId; + if (!orgId) { + return unexpectedError("User has no active org"); + } + + const membership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + userId: session.user.id, + orgId, + } + }, + }); + if (!membership) { + return notFound(); + } + + await prisma.secret.delete({ + where: { + orgId_key: { + orgId, + key, + } + } + }); + + return { + success: true, + } +} + + export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => { const session = await auth(); if (!session) { diff --git a/packages/web/src/app/components/navigationMenu.tsx b/packages/web/src/app/components/navigationMenu.tsx index 871259ab..aeefd81c 100644 --- a/packages/web/src/app/components/navigationMenu.tsx +++ b/packages/web/src/app/components/navigationMenu.tsx @@ -56,6 +56,13 @@ export const NavigationMenu = async () => { + + + + Secrets + + + diff --git a/packages/web/src/app/secrets/columns.tsx b/packages/web/src/app/secrets/columns.tsx new file mode 100644 index 00000000..53e23e31 --- /dev/null +++ b/packages/web/src/app/secrets/columns.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Column, ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown } from "lucide-react" + +export type SecretColumnInfo = { + key: string; + createdAt: string; +} + +export const columns = (handleDelete: (key: string) => void): ColumnDef[] => { + return [ + { + accessorKey: "key", + cell: ({ row }) => { + const secret = row.original; + return
{secret.key}
; + } + }, + { + accessorKey: "createdAt", + header: ({ column }) => createSortHeader("Created At", column), + cell: ({ row }) => { + const secret = row.original; + return
{secret.createdAt}
; + } + }, + { + accessorKey: "delete", + cell: ({ row }) => { + const secret = row.original; + return ( + + ) + } + } + ] + +} + +const createSortHeader = (name: string, column: Column) => { + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/secrets/page.tsx b/packages/web/src/app/secrets/page.tsx new file mode 100644 index 00000000..0a594873 --- /dev/null +++ b/packages/web/src/app/secrets/page.tsx @@ -0,0 +1,23 @@ +import { NavigationMenu } from "../components/navigationMenu"; +import { SecretsTable } from "./secretsTable"; +import { getSecrets, createSecret } from "../../actions" +import { isServiceError } from "@/lib/utils"; + +export interface SecretsTableProps { + initialSecrets: { createdAt: Date; key: string; }[]; +} + +export default async function SecretsPage() { + const secrets = await getSecrets(); + + return ( +
+ + { !isServiceError(secrets) && ( +
+ +
+ )} +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/secrets/secretsTable.tsx b/packages/web/src/app/secrets/secretsTable.tsx new file mode 100644 index 00000000..94623bf5 --- /dev/null +++ b/packages/web/src/app/secrets/secretsTable.tsx @@ -0,0 +1,147 @@ +'use client'; +import { useEffect, useMemo, useState } from "react"; +import { getSecrets, createSecret } from "../../actions" +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { columns, SecretColumnInfo } from "./columns"; +import { DataTable } from "@/components/ui/data-table"; +import { isServiceError } from "@/lib/utils"; +import { useToast } from "@/components/hooks/use-toast"; +import { deleteSecret } from "../../actions" +import { SecretsTableProps } from "./page"; + +const formSchema = z.object({ + key: z.string().min(2).max(40), + value: z.string().min(2).max(40), +}); + + +export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => { + const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets); + const { toast } = useToast(); + + const fetchSecretKeys = async () => { + const keys = await getSecrets(); + if ('keys' in keys) { + setSecrets(keys); + } else { + console.error(keys); + } + }; + + useEffect(() => { + fetchSecretKeys(); + }, []); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + key: "", + value: "", + }, + }); + + const handleCreateSecret = async (values: { key: string, value: string }) => { + const res = await createSecret(values.key, values.value); + if (isServiceError(res)) { + toast({ + description: `❌ Failed to create secret` + }); + return; + } else { + toast({ + description: `✅ Secret created successfully!` + }); + } + + const keys = await getSecrets(); + if (isServiceError(keys)) { + console.error("Failed to fetch secrets"); + } else { + setSecrets(keys); + + form.reset(); + form.resetField("key"); + form.resetField("value"); + } + }; + + const handleDelete = async (key: string) => { + const res = await deleteSecret(key); + if (isServiceError(res)) { + toast({ + description: `❌ Failed to delete secret` + }); + return; + } else { + toast({ + description: `✅ Secret deleted successfully!` + }); + } + + const keys = await getSecrets(); + if ('keys' in keys) { + setSecrets(keys); + } else { + console.error(keys); + } + }; + + + const keys = useMemo(() => { + return secrets.map((secret): SecretColumnInfo => { + return { + key: secret.key, + createdAt: secret.createdAt.toISOString(), + } + }).sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }, [secrets]); + + return ( +
+
+ + ( + + Key + + + + + + )} + /> + ( + + Value + + + + + + )} + /> + + + + +
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 4c6ef988..edfe4eb1 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -98,6 +98,15 @@ export const fileSourceResponseSchema = z.object({ language: z.string(), }); +export const secretCreateRequestSchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export const secreteDeleteRequestSchema = z.object({ + key: z.string(), +}); + // @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728 const repoStatsSchema = z.object({ diff --git a/schemas/v3/shared.json b/schemas/v3/shared.json index fbb19e72..fcb1db75 100644 --- a/schemas/v3/shared.json +++ b/schemas/v3/shared.json @@ -19,6 +19,19 @@ "env" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false } ] },