mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
add concept of secrets (#180)
* add @sourcebot/schemas package * migrate things to use the schemas package * Dockerfile support * add secret table to schema * Add concept of connection manager * Rename Config->Connection * Handle job failures * Add join table between repo and connection * nits * create first version of crypto package * add crypto package as deps to others * forgot to add package changes * add server action for adding and listing secrets, create test page for it * add secrets page to nav menu * add secret to config and support fetching it in backend * reset secret form on successful submission * add toast feedback for secrets form * add instructions for adding encryption key to dev instructions * add encryption key support in docker file * add delete secret button * fix nits from pr review --------- Co-authored-by: bkellam <bshizzle1234@gmail.com>
This commit is contained in:
parent
dd8ff6edb0
commit
31114a9d95
31 changed files with 699 additions and 31 deletions
|
|
@ -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"
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -374,14 +374,20 @@ docker run <b>-v /path/to/my-repo:/repos/my-repo</b> /* 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <T>(cb : () => Promise<T>) => {
|
||||
const start = Date.now();
|
||||
|
|
@ -86,15 +88,39 @@ export const excludeReposByTopic = <T extends Repository>(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) => {
|
||||
|
|
|
|||
1
packages/crypto/.gitignore
vendored
Normal file
1
packages/crypto/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env.local
|
||||
16
packages/crypto/package.json
Normal file
16
packages/crypto/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
17
packages/crypto/src/environment.ts
Normal file
17
packages/crypto/src/environment.ts
Normal file
|
|
@ -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)!;
|
||||
35
packages/crypto/src/index.ts
Normal file
35
packages/crypto/src/index.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
23
packages/crypto/tsconfig.json
Normal file
23
packages/crypto/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ export const NavigationMenu = async () => {
|
|||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/secrets" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Secrets
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenuBase>
|
||||
</div>
|
||||
|
|
|
|||
59
packages/web/src/app/secrets/columns.tsx
Normal file
59
packages/web/src/app/secrets/columns.tsx
Normal file
|
|
@ -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<SecretColumnInfo>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "key",
|
||||
cell: ({ row }) => {
|
||||
const secret = row.original;
|
||||
return <div>{secret.key}</div>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => createSortHeader("Created At", column),
|
||||
cell: ({ row }) => {
|
||||
const secret = row.original;
|
||||
return <div>{secret.createdAt}</div>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "delete",
|
||||
cell: ({ row }) => {
|
||||
const secret = row.original;
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete(secret.key);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
const createSortHeader = (name: string, column: Column<SecretColumnInfo, unknown>) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{name}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
23
packages/web/src/app/secrets/page.tsx
Normal file
23
packages/web/src/app/secrets/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="h-screen flex flex-col items-center">
|
||||
<NavigationMenu />
|
||||
{ !isServiceError(secrets) && (
|
||||
<div className="max-w-[90%]">
|
||||
<SecretsTable initialSecrets={secrets} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
packages/web/src/app/secrets/secretsTable.tsx
Normal file
147
packages/web/src/app/secrets/secretsTable.tsx
Normal file
|
|
@ -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<z.infer<typeof formSchema>>({
|
||||
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 (
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleCreateSecret)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className="mt-5" type="submit">Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<DataTable
|
||||
columns={columns(handleDelete)}
|
||||
data={keys}
|
||||
searchKey="key"
|
||||
searchPlaceholder="Search secrets..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue