diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml index 695b0ea24..c5fd9034f 100644 --- a/.github/workflows/release_build.yml +++ b/.github/workflows/release_build.yml @@ -13,7 +13,7 @@ permissions: jobs: goreleaser: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 with: @@ -24,6 +24,15 @@ jobs: go-version: '>=1.19.3' cache: true cache-dependency-path: cli/go.sum + - name: libssl1.1 => libssl1.0-dev for OSXCross + run: | + echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list + sudo apt update && apt-cache policy libssl1.0-dev + sudo apt-get install libssl1.0-dev + - name: OSXCross for CGO Support + run: | + mkdir ../../osxcross + git clone https://github.com/plentico/osxcross-target.git ../../osxcross/target - uses: goreleaser/goreleaser-action@v2 with: distribution: goreleaser diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9ac38524b..5e94e0de6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,12 +7,23 @@ # # you may remove this if you don't need go generate # - cd cli && go generate ./... builds: - - env: - - CGO_ENABLED=0 + - id: darwin-build binary: infisical - id: infisical + env: + - CGO_ENABLED=1 + - CC=/home/runner/work/osxcross/target/bin/o64-clang + - CXX=/home/runner/work/osxcross/target/bin/o64-clang++ goos: - darwin + ignore: + - goos: darwin + goarch: "386" + dir: ./cli + - id: all-other-builds + env: + - CGO_ENABLED=0 + binary: infisical + goos: - freebsd - linux - netbsd @@ -27,8 +38,6 @@ builds: - 6 - 7 ignore: - - goos: darwin - goarch: "386" - goos: windows goarch: "386" - goos: freebsd @@ -71,7 +80,7 @@ nfpms: - id: infisical package_name: infisical builds: - - infisical + - all-other-builds vendor: Infisical, Inc homepage: https://infisical.com/ maintainer: Infisical, Inc diff --git a/README.md b/README.md index 3851b3c53..292f5fe94 100644 --- a/README.md +++ b/README.md @@ -321,4 +321,4 @@ Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of - + diff --git a/backend/src/app.ts b/backend/src/app.ts index 6358293ca..500aafe77 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,5 +1,6 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { patchRouterParam } = require('./utils/patchAsyncRoutes'); -import { patchRouterParam } from './utils/patchAsyncRoutes'; import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; @@ -10,6 +11,11 @@ dotenv.config(); import { PORT, NODE_ENV, SITE_URL } from './config'; import { apiLimiter } from './helpers/rateLimiter'; +import { + workspace as eeWorkspaceRouter, + secret as eeSecretRouter +} from './ee/routes'; + import { signup as signupRouter, auth as authRouter, @@ -30,12 +36,13 @@ import { integrationAuth as integrationAuthRouter, apiKey as apiKeyRouter } from './routes'; + import { getLogger } from './utils/logger'; import { RouteNotFoundError } from './utils/errors'; import { requestErrorHandler } from './middleware/requestErrorHandler'; -//* Patch Async route params to handle Promise Rejections -patchRouterParam() +// patch async route params to handle Promise Rejections +patchRouterParam(); export const app = express(); @@ -57,6 +64,10 @@ if (NODE_ENV === 'production') { app.use(helmet()); } +// /ee routers +app.use('/api/v1/secret', eeSecretRouter); +app.use('/api/v1/workspace', eeWorkspaceRouter); + // routers app.use('/api/v1/signup', signupRouter); app.use('/api/v1/auth', authRouter); diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index dfbc2111c..3fb475099 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -41,6 +41,7 @@ const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY!; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY!; const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; const TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED! !== 'false' && true; +const LICENSE_KEY = process.env.LICENSE_KEY!; export { PORT, @@ -83,5 +84,6 @@ export { STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, - TELEMETRY_ENABLED + TELEMETRY_ENABLED, + LICENSE_KEY }; diff --git a/backend/src/controllers/secretController.ts b/backend/src/controllers/secretController.ts index bfd9aee1f..6672c4b49 100644 --- a/backend/src/controllers/secretController.ts +++ b/backend/src/controllers/secretController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import { Key } from '../models'; +import { Key, Secret } from '../models'; import { pushSecrets as push, pullSecrets as pull, @@ -169,9 +169,6 @@ export const pullSecrets = async (req: Request, res: Response) => { * @returns */ export const pullSecretsServiceToken = async (req: Request, res: Response) => { - // get (encrypted) secrets from workspace with id [workspaceId] - // service token route - let secrets; let key; try { @@ -225,4 +222,4 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { secrets: reformatPullSecrets({ secrets }), key }); -}; +}; \ No newline at end of file diff --git a/backend/src/controllers/signupController.ts b/backend/src/controllers/signupController.ts index 90fad4744..42bef4973 100644 --- a/backend/src/controllers/signupController.ts +++ b/backend/src/controllers/signupController.ts @@ -10,6 +10,7 @@ import { } from '../helpers/signup'; import { issueTokens, createToken } from '../helpers/auth'; import { INVITED, ACCEPTED } from '../variables'; +import axios from 'axios'; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -179,6 +180,21 @@ export const completeAccountSignup = async (req: Request, res: Response) => { token = tokens.token; refreshToken = tokens.refreshToken; + + // sending a welcome email to new users + if (process.env.LOOPS_API_KEY) { + await axios.post("https://app.loops.so/api/v1/events/send", { + "email": email, + "eventName": "Sign Up", + "firstName": firstName, + "lastName": lastName + }, { + headers: { + "Accept": "application/json", + "Authorization": "Bearer " + process.env.LOOPS_API_KEY + }, + }); + } } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/controllers/workspaceController.ts b/backend/src/controllers/workspaceController.ts index a402834d7..6f3e4bd11 100644 --- a/backend/src/controllers/workspaceController.ts +++ b/backend/src/controllers/workspaceController.ts @@ -7,7 +7,7 @@ import { Integration, IntegrationAuth, IUser, - ServiceToken + ServiceToken, } from '../models'; import { createWorkspace as create, diff --git a/backend/src/ee/controllers/index.ts b/backend/src/ee/controllers/index.ts index e4fb89a8e..23880070d 100644 --- a/backend/src/ee/controllers/index.ts +++ b/backend/src/ee/controllers/index.ts @@ -1,5 +1,9 @@ import * as stripeController from './stripeController'; +import * as secretController from './secretController'; +import * as workspaceController from './workspaceController'; export { - stripeController + stripeController, + secretController, + workspaceController } \ No newline at end of file diff --git a/backend/src/ee/controllers/secretController.ts b/backend/src/ee/controllers/secretController.ts new file mode 100644 index 000000000..503a81c51 --- /dev/null +++ b/backend/src/ee/controllers/secretController.ts @@ -0,0 +1,35 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { SecretVersion } from '../models'; + +/** + * Return secret versions for secret with id [secretId] + * @param req + * @param res + */ + export const getSecretVersions = async (req: Request, res: Response) => { + let secretVersions; + try { + const { secretId } = req.params; + + const offset: number = parseInt(req.query.offset as string); + const limit: number = parseInt(req.query.limit as string); + + secretVersions = await SecretVersion.find({ + secret: secretId + }) + .skip(offset) + .limit(limit); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get secret versions' + }); + } + + return res.status(200).send({ + secretVersions + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/workspaceController.ts b/backend/src/ee/controllers/workspaceController.ts new file mode 100644 index 000000000..423e71793 --- /dev/null +++ b/backend/src/ee/controllers/workspaceController.ts @@ -0,0 +1,35 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { SecretSnapshot } from '../models'; + +/** + * Return secret snapshots for workspace with id [workspaceId] + * @param req + * @param res + */ + export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) => { + let secretSnapshots; + try { + const { workspaceId } = req.params; + + const offset: number = parseInt(req.query.offset as string); + const limit: number = parseInt(req.query.limit as string); + + secretSnapshots = await SecretSnapshot.find({ + workspace: workspaceId + }) + .skip(offset) + .limit(limit); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get secret snapshots' + }); + } + + return res.status(200).send({ + secretSnapshots + }); +} \ No newline at end of file diff --git a/backend/src/ee/helpers/license.ts b/backend/src/ee/helpers/license.ts deleted file mode 100644 index 256bdc23a..000000000 --- a/backend/src/ee/helpers/license.ts +++ /dev/null @@ -1,21 +0,0 @@ - -/** - * @param {Object} obj - * @param {Object} obj.licenseKey - Infisical license key - */ -const checkLicenseKey = ({ - licenseKey -}: { - licenseKey: string -}) => { - try { - // TODO - - } catch (err) { - - } -} - -export { - checkLicenseKey -} \ No newline at end of file diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts new file mode 100644 index 000000000..a688a108f --- /dev/null +++ b/backend/src/ee/helpers/secret.ts @@ -0,0 +1,74 @@ +import * as Sentry from '@sentry/node'; +import { + Secret +} from '../../models'; +import { + SecretSnapshot, + SecretVersion, + ISecretVersion +} from '../models'; + +/** + * Save a copy of the current state of secrets in workspace with id + * [workspaceId] under a new snapshot with incremented version under the + * secretsnapshots collection. + * @param {Object} obj + * @param {String} obj.workspaceId + */ + const takeSecretSnapshotHelper = async ({ + workspaceId +}: { + workspaceId: string; +}) => { + try { + const secrets = await Secret.find({ + workspace: workspaceId + }); + + const latestSecretSnapshot = await SecretSnapshot.findOne({ + workspace: workspaceId + }).sort({ version: -1 }); + + if (!latestSecretSnapshot) { + // case: no snapshots exist for workspace -> create first snapshot + await new SecretSnapshot({ + workspace: workspaceId, + version: 1, + secrets + }).save(); + + return; + } + + // case: snapshots exist for workspace + await new SecretSnapshot({ + workspace: workspaceId, + version: latestSecretSnapshot.version + 1, + secrets + }).save(); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to take a secret snapshot'); + } +} + +const addSecretVersionsHelper = async ({ + secretVersions +}: { + secretVersions: ISecretVersion[] +}) => { + try { + await SecretVersion.insertMany(secretVersions); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to add secret versions'); + } +} + +export { + takeSecretSnapshotHelper, + addSecretVersionsHelper +} \ No newline at end of file diff --git a/backend/src/ee/models/index.ts b/backend/src/ee/models/index.ts new file mode 100644 index 000000000..35d41c19a --- /dev/null +++ b/backend/src/ee/models/index.ts @@ -0,0 +1,9 @@ +import SecretSnapshot, { ISecretSnapshot } from "./secretSnapshot"; +import SecretVersion, { ISecretVersion } from "./secretVersion"; + +export { + SecretSnapshot, + ISecretSnapshot, + SecretVersion, + ISecretVersion +} \ No newline at end of file diff --git a/backend/src/ee/models/secretSnapshot.ts b/backend/src/ee/models/secretSnapshot.ts new file mode 100644 index 000000000..69633a92e --- /dev/null +++ b/backend/src/ee/models/secretSnapshot.ts @@ -0,0 +1,109 @@ +import { Schema, model, Types } from 'mongoose'; +import { + SECRET_SHARED, + SECRET_PERSONAL, + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD +} from '../../variables'; + +export interface ISecretSnapshot { + workspace: Types.ObjectId; + version: number; + secrets: { + version: number; + workspace: Types.ObjectId; + type: string; + user: Types.ObjectId; + environment: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretKeyHash: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretValueHash: string; + }[] +} + +const secretSnapshotSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + version: { + type: Number, + required: true + }, + secrets: [{ + version: { + type: Number, + default: 1, + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + type: { + type: String, + enum: [SECRET_SHARED, SECRET_PERSONAL], + required: true + }, + user: { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: 'User' + }, + environment: { + type: String, + enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], + required: true + }, + secretKeyCiphertext: { + type: String, + required: true + }, + secretKeyIV: { + type: String, // symmetric + required: true + }, + secretKeyTag: { + type: String, // symmetric + required: true + }, + secretKeyHash: { + type: String, + required: true + }, + secretValueCiphertext: { + type: String, + required: true + }, + secretValueIV: { + type: String, // symmetric + required: true + }, + secretValueTag: { + type: String, // symmetric + required: true + }, + secretValueHash: { + type: String, + required: true + } + }] + }, + { + timestamps: true + } +); + +const SecretSnapshot = model('SecretSnapshot', secretSnapshotSchema); + +export default SecretSnapshot; \ No newline at end of file diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts new file mode 100644 index 000000000..a93a037f6 --- /dev/null +++ b/backend/src/ee/models/secretVersion.ts @@ -0,0 +1,75 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface ISecretVersion { + _id?: Types.ObjectId; + secret: Types.ObjectId; + version: number; + isDeleted: boolean; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretKeyHash: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretValueHash: string; +} + +const secretVersionSchema = new Schema( + { + secret: { // could be deleted + type: Schema.Types.ObjectId, + ref: 'Secret', + required: true + }, + version: { + type: Number, + default: 1, + required: true + }, + isDeleted: { + type: Boolean, + default: false, + required: true + }, + secretKeyCiphertext: { + type: String, + required: true + }, + secretKeyIV: { + type: String, // symmetric + required: true + }, + secretKeyTag: { + type: String, // symmetric + required: true + }, + secretKeyHash: { + type: String, + required: true + }, + secretValueCiphertext: { + type: String, + required: true + }, + secretValueIV: { + type: String, // symmetric + required: true + }, + secretValueTag: { + type: String, // symmetric + required: true + }, + secretValueHash: { + type: String, + required: true + } + }, + { + timestamps: true + } +); + +const SecretVersion = model('SecretVersion', secretVersionSchema); + +export default SecretVersion; \ No newline at end of file diff --git a/backend/src/ee/routes/index.ts b/backend/src/ee/routes/index.ts new file mode 100644 index 000000000..960665f4a --- /dev/null +++ b/backend/src/ee/routes/index.ts @@ -0,0 +1,7 @@ +import secret from './secret'; +import workspace from './workspace'; + +export { + secret, + workspace +} \ No newline at end of file diff --git a/backend/src/ee/routes/secret.ts b/backend/src/ee/routes/secret.ts new file mode 100644 index 000000000..d8f1cb05b --- /dev/null +++ b/backend/src/ee/routes/secret.ts @@ -0,0 +1,26 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireWorkspaceAuth, + validateRequest +} from '../../middleware'; +import { body, query, param } from 'express-validator'; +import { secretController } from '../controllers'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../../variables'; + +router.get( + '/:secretId/secret-versions', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('secretId').exists().trim(), + query('offset').exists().isInt(), + query('limit').exists().isInt(), + validateRequest, + secretController.getSecretVersions +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/workspace.ts b/backend/src/ee/routes/workspace.ts new file mode 100644 index 000000000..e27300eb3 --- /dev/null +++ b/backend/src/ee/routes/workspace.ts @@ -0,0 +1,27 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireWorkspaceAuth, + validateRequest +} from '../../middleware'; +import { param, query } from 'express-validator'; +import { ADMIN, MEMBER, GRANTED } from '../../variables'; +import { workspaceController } from '../controllers'; + +router.get( + '/:workspaceId/secret-snapshots', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [GRANTED] + }), + param('workspaceId').exists().trim(), + query('offset').exists().isInt(), + query('limit').exists().isInt(), + validateRequest, + workspaceController.getWorkspaceSecretSnapshots +); + + +export default router; \ No newline at end of file diff --git a/backend/src/ee/services/EELicenseService.ts b/backend/src/ee/services/EELicenseService.ts new file mode 100644 index 000000000..f31482dde --- /dev/null +++ b/backend/src/ee/services/EELicenseService.ts @@ -0,0 +1,19 @@ +import { LICENSE_KEY } from '../../config'; + +/** + * Class to handle Enterprise Edition license actions + */ +class EELicenseService { + + private readonly _isLicenseValid: boolean; + + constructor(licenseKey: string) { + this._isLicenseValid = true; + } + + public get isLicenseValid(): boolean { + return this._isLicenseValid; + } +} + +export default new EELicenseService(LICENSE_KEY); \ No newline at end of file diff --git a/backend/src/ee/services/EESecretService.ts b/backend/src/ee/services/EESecretService.ts new file mode 100644 index 000000000..643f763f1 --- /dev/null +++ b/backend/src/ee/services/EESecretService.ts @@ -0,0 +1,47 @@ +import { ISecretVersion } from '../models'; +import { + takeSecretSnapshotHelper, + addSecretVersionsHelper +} from '../helpers/secret'; +import EELicenseService from './EELicenseService'; + +/** + * Class to handle Enterprise Edition secret actions + */ +class EESecretService { + + /** + * Save a copy of the current state of secrets in workspace with id + * [workspaceId] under a new snapshot with incremented version under the + * SecretSnapshot collection. + * Requires a valid license key [licenseKey] + * @param {Object} obj + * @param {String} obj.workspaceId + */ + static async takeSecretSnapshot({ + workspaceId + }: { + workspaceId: string; + }) { + if (!EELicenseService.isLicenseValid) return; + await takeSecretSnapshotHelper({ workspaceId }); + } + + /** + * Adds secret versions [secretVersions] to the SecretVersion collection. + * @param {Object} obj + * @param {SecretVersion} obj.secretVersions + */ + static async addSecretVersions({ + secretVersions + }: { + secretVersions: ISecretVersion[]; + }) { + if (!EELicenseService.isLicenseValid) return; + await addSecretVersionsHelper({ + secretVersions + }); + } +} + +export default EESecretService; \ No newline at end of file diff --git a/backend/src/ee/services/index.ts b/backend/src/ee/services/index.ts new file mode 100644 index 000000000..3cec256bb --- /dev/null +++ b/backend/src/ee/services/index.ts @@ -0,0 +1,7 @@ +import EELicenseService from "./EELicenseService"; +import EESecretService from "./EESecretService"; + +export { + EELicenseService, + EESecretService +} \ No newline at end of file diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 042aba4fa..4b3585c40 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -1,10 +1,20 @@ import * as Sentry from '@sentry/node'; import { Secret, - ISecret + ISecret, } from '../models'; +import { + EESecretService +} from '../ee/services'; +import { + SecretVersion +} from '../ee/models'; +import { + takeSecretSnapshotHelper +} from '../ee/helpers/secret'; import { decryptSymmetric } from '../utils/crypto'; import { SECRET_SHARED, SECRET_PERSONAL } from '../variables'; +import { LICENSE_KEY } from '../config'; interface PushSecret { ciphertextKey: string; @@ -19,7 +29,7 @@ interface PushSecret { } interface Update { - [index: string]: string; + [index: string]: any; } type DecryptSecretType = 'text' | 'object' | 'expanded'; @@ -46,6 +56,7 @@ const pushSecrets = async ({ environment: string; secrets: PushSecret[]; }): Promise => { + // TODO: clean up function and fix up types try { // construct useful data structures const oldSecrets = await pullSecrets({ @@ -53,74 +64,124 @@ const pushSecrets = async ({ workspaceId, environment }); - const oldSecretsObj: any = oldSecrets.reduce((accumulator, s: any) => { - return { ...accumulator, [s.secretKeyHash]: s }; - }, {}); - const newSecretsObj = secrets.reduce((accumulator, s) => { - return { ...accumulator, [s.hashKey]: s }; - }, {}); + + const oldSecretsObj: any = oldSecrets.reduce((accumulator, s: any) => + ({ ...accumulator, [`${s.type}-${s.secretKeyHash}`]: s }) + , {}); + const newSecretsObj: any = secrets.reduce((accumulator, s) => + ({ ...accumulator, [`${s.type}-${s.hashKey}`]: s }) + , {}); // handle deleting secrets - const toDelete = oldSecrets.filter( - (s: ISecret) => !(s.secretKeyHash in newSecretsObj) - ); + const toDelete = oldSecrets + .filter( + (s: ISecret) => !(`${s.type}-${s.secretKeyHash}` in newSecretsObj) + ) + .map((s) => s._id); if (toDelete.length > 0) { await Secret.deleteMany({ - _id: { $in: toDelete.map((s) => s._id) } + _id: { $in: toDelete } + }); + + await SecretVersion.updateMany({ + secret: { $in: toDelete } + }, { + isDeleted: true }); } - - // handle modifying secrets where type or value changed - const operations = secrets + + const toUpdate = oldSecrets .filter((s) => { - if (s.hashKey in oldSecretsObj) { - if (s.hashValue !== oldSecretsObj[s.hashKey].secretValueHash) { + if (`${s.type}-${s.secretKeyHash}` in newSecretsObj) { + if (s.secretValueHash !== newSecretsObj[`${s.type}-${s.secretKeyHash}`].hashValue) { // case: filter secrets where value changed return true; } - if (s.type !== oldSecretsObj[s.hashKey].type) { - // case: filter secrets where type changed + if (!s.version) { + // case: filter (legacy) secrets that were not versioned return true; } } - + return false; - }) + }); + + const operations = toUpdate .map((s) => { + const { + ciphertextValue, + ivValue, + tagValue, + hashValue + } = newSecretsObj[`${s.type}-${s.secretKeyHash}`]; + const update: Update = { - type: s.type, - secretValueCiphertext: s.ciphertextValue, - secretValueIV: s.ivValue, - secretValueTag: s.tagValue, - secretValueHash: s.hashValue - }; + secretValueCiphertext: ciphertextValue, + secretValueIV: ivValue, + secretValueTag: tagValue, + secretValueHash: hashValue + } + + if (!s.version) { + // case: (legacy) secret was not versioned + update.version = 1; + } else { + update['$inc'] = { + version: 1 + } + } if (s.type === SECRET_PERSONAL) { - // attach user assocaited with the personal secret + // attach user associated with the personal secret update['user'] = userId; } return { updateOne: { filter: { - workspace: workspaceId, - _id: oldSecretsObj[s.hashKey]._id + _id: oldSecretsObj[`${s.type}-${s.secretKeyHash}`]._id }, update } }; }); - const a = await Secret.bulkWrite(operations as any); + await Secret.bulkWrite(operations as any); + + // (EE) add secret versions for updated secrets + await EESecretService.addSecretVersions({ + secretVersions: toUpdate.map(({ + _id, + version, + type, + secretKeyHash, + }) => { + const newSecret = newSecretsObj[`${type}-${secretKeyHash}`]; + return ({ + secret: _id, + version: version ? version + 1 : 1, + isDeleted: false, + secretKeyCiphertext: newSecret.ciphertextKey, + secretKeyIV: newSecret.ivKey, + secretKeyTag: newSecret.tagKey, + secretKeyHash: newSecret.hashKey, + secretValueCiphertext: newSecret.ciphertextValue, + secretValueIV: newSecret.ivValue, + secretValueTag: newSecret.tagValue, + secretValueHash: newSecret.hashValue + }) + }) + }); // handle adding new secrets - const toAdd = secrets.filter((s) => !(s.hashKey in oldSecretsObj)); + const toAdd = secrets.filter((s) => !(`${s.type}-${s.hashKey}` in oldSecretsObj)); if (toAdd.length > 0) { // add secrets - await Secret.insertMany( + const newSecrets = await Secret.insertMany( toAdd.map((s, idx) => { - let obj: any = { + const obj: any = { + version: 1, workspace: workspaceId, type: toAdd[idx].type, environment, @@ -141,7 +202,39 @@ const pushSecrets = async ({ return obj; }) ); + + // (EE) add secret versions for new secrets + EESecretService.addSecretVersions({ + secretVersions: newSecrets.map(({ + _id, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) => ({ + secret: _id, + version: 1, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + })) + }); } + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }) } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -295,6 +388,8 @@ const decryptSecrets = ({ return content; }; + + export { pushSecrets, pullSecrets, diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index b83ef728d..d34139ecb 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -10,6 +10,7 @@ import { export interface ISecret { _id: Types.ObjectId; + version: number; workspace: Types.ObjectId; type: string; user: Types.ObjectId; @@ -26,6 +27,10 @@ export interface ISecret { const secretSchema = new Schema( { + version: { + type: Number, + required: true + }, workspace: { type: Schema.Types.ObjectId, ref: 'Workspace', diff --git a/backend/src/routes/secret.ts b/backend/src/routes/secret.ts index 98b3009de..26224fd87 100644 --- a/backend/src/routes/secret.ts +++ b/backend/src/routes/secret.ts @@ -7,8 +7,8 @@ import { validateRequest } from '../middleware'; import { body, query, param } from 'express-validator'; -import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; import { secretController } from '../controllers'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; router.post( '/:workspaceId', diff --git a/backend/src/routes/workspace.ts b/backend/src/routes/workspace.ts index 1d20c102a..acd2aaf8b 100644 --- a/backend/src/routes/workspace.ts +++ b/backend/src/routes/workspace.ts @@ -1,6 +1,6 @@ import express from 'express'; const router = express.Router(); -import { body, param } from 'express-validator'; +import { body, param, query } from 'express-validator'; import { requireAuth, requireWorkspaceAuth, diff --git a/backend/src/utils/patchAsyncRoutes.js b/backend/src/utils/patchAsyncRoutes.js index 6f6d2367f..24fe007f9 100644 --- a/backend/src/utils/patchAsyncRoutes.js +++ b/backend/src/utils/patchAsyncRoutes.js @@ -45,7 +45,7 @@ function wrap(fn) { return copyFnProps(fn, newFn); } -export function patchRouterParam() { +function patchRouterParam() { const originalParam = Router.prototype.constructor.param; Router.prototype.constructor.param = function param(name, fn) { fn = wrap(fn); @@ -62,4 +62,8 @@ Object.defineProperty(Layer.prototype, 'handle', { fn = wrap(fn); this.__handle = fn; }, -}); \ No newline at end of file +}); + +module.exports = { + patchRouterParam +}; diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 29e2c4935..9c0c22930 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -91,7 +91,9 @@ var loginCmd = &cobra.Command{ err = util.StoreUserCredsInKeyRing(userCredentialsToBeStored) if err != nil { - log.Errorln("Unable to store your credentials in system key ring") + currentVault, _ := util.GetCurrentVaultBackend() + log.Errorf("Unable to store your credentials in system vault [%s]. Rerun with flag -d to see full logs", currentVault) + log.Errorln("To trouble shoot further, read https://infisical.com/docs/cli/faq") log.Debugln(err) return } diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 17f78b5d5..3aa9c5658 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{ Short: "Infisical CLI is used to inject environment variables into any process", Long: `Infisical is a simple, end-to-end encrypted service that enables teams to sync and manage their environment variables across their development life cycle.`, CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, - Version: "0.1.12", + Version: "0.1.15", } // Execute adds all child commands to the root command and sets flags appropriately. @@ -31,8 +31,6 @@ func init() { rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") rootCmd.PersistentFlags().BoolVarP(&debugLogging, "debug", "d", false, "Enable verbose logging") rootCmd.PersistentFlags().StringVar(&util.INFISICAL_URL, "domain", "https://app.infisical.com/api", "Point the CLI to your own backend") - - rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - util.InitKeyRingInstance() - } + // rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + // } } diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 7518fe98d..6e0133c8e 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -60,6 +60,13 @@ var runCmd = &cobra.Command{ return } + secretOverriding, err := cmd.Flags().GetBool("secret-overriding") + if err != nil { + log.Errorln("Unable to parse the secret-overriding flag") + log.Debugln(err) + return + } + shouldExpandSecrets, err := cmd.Flags().GetBool("expand") if err != nil { log.Errorln("Unable to parse the substitute flag") @@ -84,6 +91,10 @@ var runCmd = &cobra.Command{ secrets = util.SubstituteSecrets(secrets) } + if secretOverriding { + secrets = util.OverrideWithPersonalSecrets(secrets) + } + if cmd.Flags().Changed("command") { command := cmd.Flag("command").Value.String() err = executeMultipleCommandWithEnvs(command, secrets) @@ -108,6 +119,7 @@ func init() { runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") runCmd.Flags().String("projectId", "", "The project ID from which your secrets should be pulled from") runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") + runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets with the same name over shared secrets") runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") } diff --git a/cli/packages/cmd/vault.go b/cli/packages/cmd/vault.go index 8bb677682..a2b03dbec 100644 --- a/cli/packages/cmd/vault.go +++ b/cli/packages/cmd/vault.go @@ -42,7 +42,7 @@ var vaultSetCmd = &cobra.Command{ err = util.WriteConfigFile(&configFile) if err != nil { - log.Errorf("Unable to set vault to [%s] because an error occurred when saving the config file [err=%s]") + log.Errorf("Unable to set vault to [%s] because an error occurred when saving the config file [err=%s]", wantedVaultTypeName, err) return } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 0d1f96a05..8ba1c4627 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -17,6 +17,7 @@ type ConfigFile struct { type SingleEnvironmentVariable struct { Key string `json:"key"` Value string `json:"value"` + Type string `json:"type"` } type WorkspaceConfigFile struct { diff --git a/cli/packages/util/common.go b/cli/packages/util/common.go index 37c881994..f3ee274b3 100644 --- a/cli/packages/util/common.go +++ b/cli/packages/util/common.go @@ -19,6 +19,7 @@ func GetHomeDir() (string, error) { return directory, err } +// write file to given path. If path does not exist throw error func WriteToFile(fileName string, dataToWrite []byte, filePerm os.FileMode) error { err := os.WriteFile(fileName, dataToWrite, filePerm) if err != nil { diff --git a/cli/packages/util/config.go b/cli/packages/util/config.go index 02e37c0a1..48bb57241 100644 --- a/cli/packages/util/config.go +++ b/cli/packages/util/config.go @@ -187,7 +187,7 @@ func GetConfigFile() (models.ConfigFile, error) { // Write a ConfigFile to disk. Raise error if unable to save the model to ask func WriteConfigFile(configFile *models.ConfigFile) error { - fullConfigFilePath, _, err := GetFullConfigFilePath() + fullConfigFilePath, fullConfigFileDirPath, err := GetFullConfigFilePath() if err != nil { return fmt.Errorf("writeConfigFile: unable to write config file because an error occurred when getting config file path [err=%s]", err) } @@ -197,8 +197,20 @@ func WriteConfigFile(configFile *models.ConfigFile) error { return fmt.Errorf("writeConfigFile: unable to write config file because an error occurred when marshalling the config file [err=%s]", err) } + // check if config folder exists and if not create it + if _, err := os.Stat(fullConfigFileDirPath); errors.Is(err, os.ErrNotExist) { + err := os.Mkdir(fullConfigFileDirPath, os.ModePerm) + if err != nil { + return err + } + } + // Create file in directory - err = WriteToFile(fullConfigFilePath, configFileMarshalled, os.ModePerm) + err = os.WriteFile(fullConfigFilePath, configFileMarshalled, os.ModePerm) + if err != nil { + return fmt.Errorf("writeConfigFile: Unable to write to file [err=%s]", err) + } + if err != nil { return fmt.Errorf("writeConfigFile: unable to write config file because an error occurred when write the config to file [err=%s]", err) diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index b83227b71..80c98fa9a 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -19,7 +19,13 @@ func StoreUserCredsInKeyRing(userCred *models.UserCredentials) error { return fmt.Errorf("StoreUserCredsInKeyRing: something went wrong when marshalling user creds [err=%s]", err) } - err = keyringInstance.Set(keyring.Item{ + // Get keyring + configuredKeyring, err := GetKeyRing() + if err != nil { + return fmt.Errorf("StoreUserCredsInKeyRing: unable to get keyring instance with [err=%s]", err) + } + + err = configuredKeyring.Set(keyring.Item{ Key: userCred.Email, Data: []byte(string(userCredMarshalled)), }) @@ -32,20 +38,26 @@ func StoreUserCredsInKeyRing(userCred *models.UserCredentials) error { } func GetUserCredsFromKeyRing(userEmail string) (credentials models.UserCredentials, err error) { - credentialsValue, err := keyringInstance.Get(userEmail) + // Get keyring + configuredKeyring, err := GetKeyRing() if err != nil { - return models.UserCredentials{}, fmt.Errorf("Unable to get key from Keyring. could not find login credentials in your Keyring. This is common if you have switched vault backend recently. If so, please login in again and retry:", err) + return models.UserCredentials{}, fmt.Errorf("GetUserCredsFromKeyRing: unable to get keyring instance with [err=%s]", err) + } + + credentialsValue, err := configuredKeyring.Get(userEmail) + if err != nil { + return models.UserCredentials{}, fmt.Errorf("GetUserCredsFromKeyRing: unable to get key from Keyring. could not find login credentials in your Keyring. This is common if you have switched vault backend recently. If so, please login in again and retry [err=%s]", err) } var userCredentials models.UserCredentials err = json.Unmarshal([]byte(credentialsValue.Data), &userCredentials) if err != nil { - return models.UserCredentials{}, fmt.Errorf("Something went wrong when unmarshalling user creds:", err) + return models.UserCredentials{}, fmt.Errorf("getUserCredsFromKeyRing: Something went wrong when unmarshalling user creds [err=%s]", err) } if err != nil { - return models.UserCredentials{}, fmt.Errorf("Unable to store user credentials", err) + return models.UserCredentials{}, fmt.Errorf("GetUserCredsFromKeyRing: Unable to store user credentials [err=%s]", err) } return userCredentials, err @@ -82,7 +94,7 @@ func IsUserLoggedIn() (hasUserLoggedIn bool, theUsersEmail string, err error) { if response.StatusCode() > 299 { log.Infoln("Login expired, please login again.") - return false, "", fmt.Errorf("Login expired, please login again.") + return false, "", fmt.Errorf("GetUserCredsFromKeyRing: Login expired, please login again.") } return true, configFile.LoggedInUserEmail, nil diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index c127111b1..f88aed96b 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -14,6 +14,9 @@ import ( "golang.org/x/crypto/nacl/box" ) +const PERSONAL_SECRET_TYPE_NAME = "personal" +const SHARED_SECRET_TYPE_NAME = "shared" + func getSecretsByWorkspaceIdAndEnvName(httpClient resty.Client, envName string, workspace models.WorkspaceConfigFile, userCreds models.UserCredentials) (listOfSecrets []models.SingleEnvironmentVariable, err error) { var pullSecretsRequestResponse models.PullSecretsResponse response, err := httpClient. @@ -78,6 +81,7 @@ func getSecretsByWorkspaceIdAndEnvName(httpClient resty.Client, envName string, env := models.SingleEnvironmentVariable{ Key: string(plainTextKey), Value: string(plainTextValue), + Type: string(secret.Type), } listOfEnv = append(listOfEnv, env) @@ -187,6 +191,7 @@ func GetSecretsFromAPIUsingInfisicalToken(infisicalToken string, envName string, env := models.SingleEnvironmentVariable{ Key: string(plainTextKey), Value: string(plainTextValue), + Type: string(secret.Type), } listOfEnv = append(listOfEnv, env) @@ -335,9 +340,48 @@ func SubstituteSecrets(secrets []models.SingleEnvironmentVariable) []models.Sing expandedSecrets = append(expandedSecrets, models.SingleEnvironmentVariable{ Key: secret.Key, Value: expandedVariable, + Type: secret.Type, }) } return expandedSecrets } + +// if two secrets with the same name are found, the one that has type `personal` will be in the returned list +func OverrideWithPersonalSecrets(secrets []models.SingleEnvironmentVariable) []models.SingleEnvironmentVariable { + personalSecret := make(map[string]models.SingleEnvironmentVariable) + sharedSecret := make(map[string]models.SingleEnvironmentVariable) + secretsToReturn := []models.SingleEnvironmentVariable{} + + for _, secret := range secrets { + if secret.Type == PERSONAL_SECRET_TYPE_NAME { + personalSecret[secret.Key] = models.SingleEnvironmentVariable{ + Key: secret.Key, + Value: secret.Value, + Type: secret.Type, + } + } + + if secret.Type == SHARED_SECRET_TYPE_NAME { + sharedSecret[secret.Key] = models.SingleEnvironmentVariable{ + Key: secret.Key, + Value: secret.Value, + Type: secret.Type, + } + } + } + + for _, secret := range secrets { + personalValue, personalExists := personalSecret[secret.Key] + sharedValue, sharedExists := sharedSecret[secret.Key] + + if personalExists && sharedExists || personalExists && !sharedExists { + secretsToReturn = append(secretsToReturn, personalValue) + } else { + secretsToReturn = append(secretsToReturn, sharedValue) + } + } + + return secretsToReturn +} diff --git a/cli/packages/util/vault.go b/cli/packages/util/vault.go index 95dba04d8..03d03b360 100644 --- a/cli/packages/util/vault.go +++ b/cli/packages/util/vault.go @@ -5,14 +5,9 @@ import ( "os" "github.com/99designs/keyring" - log "github.com/sirupsen/logrus" "golang.org/x/term" ) -// Keyring instance -var keyringInstance keyring.Keyring -var keyringInstanceConfig keyring.Config - func GetCurrentVaultBackend() (keyring.BackendType, error) { configFile, err := GetConfigFile() if err != nil { @@ -20,21 +15,19 @@ func GetCurrentVaultBackend() (keyring.BackendType, error) { } if configFile.VaultBackendType == "" { - if keyring.AvailableBackends()[0] == keyring.FileBackend { - } return keyring.AvailableBackends()[0], nil } return configFile.VaultBackendType, nil } -func InitKeyRingInstance() { +func GetKeyRing() (keyring.Keyring, error) { currentVaultBackend, err := GetCurrentVaultBackend() if err != nil { - log.Infof("InitKeyRingInstance: unable to get the current vault backend, [err=%s]", err) + return nil, fmt.Errorf("GetKeyRing: unable to get the current vault backend, [err=%s]", err) } - keyringInstanceConfig = keyring.Config{ + keyringInstanceConfig := keyring.Config{ FilePasswordFunc: fileKeyringPassphrasePrompt, ServiceName: SERVICE_NAME, LibSecretCollectionName: SERVICE_NAME, @@ -51,10 +44,12 @@ func InitKeyRingInstance() { keyringInstanceConfig.AllowedBackends = []keyring.BackendType{keyring.BackendType(currentVaultBackend)} } - keyringInstance, err = keyring.Open(keyringInstanceConfig) + keyringInstance, err := keyring.Open(keyringInstanceConfig) if err != nil { - log.Errorf("InitKeyRingInstance: Unable to create instance of Keyring because of [err=%s]", err) + return nil, fmt.Errorf("GetKeyRing: Unable to create instance of Keyring because of [err=%s]", err) } + + return keyringInstance, nil } func fileKeyringPassphrasePrompt(prompt string) (string, error) { diff --git a/docs/cli/commands/commands.mdx b/docs/cli/commands/commands.mdx index acefdfa7e..7c1deeb1b 100644 --- a/docs/cli/commands/commands.mdx +++ b/docs/cli/commands/commands.mdx @@ -9,7 +9,7 @@ title: "Commands" | `login` | Used to authenticate and set the logged in user. | | `init` | Used to link a local project to the platform. | | `run` | Used to inject envars from the platform into an application process. | - +| `vault` | Used to manage where your login credentials are stored at rest | ## Global options | Option | Description | diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index 32c1bedba..de004c97f 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -7,7 +7,5 @@ infisical login ``` ## Description - -Verify a user and save credentials to the system keyring. - -To change the logged in user, run the command again to overwrite the previous login. +The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. +If you want to change where the login credentials are stored, visit the [vaults command](./vault) \ No newline at end of file diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index 2c65ef53e..4afd7586d 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -34,3 +34,4 @@ Inject environment variables from the platform into an application process. | `--projectId` | Used to link a local project to the platform (required only if injecting via the service token method) | None | | `--expand` | Parse shell parameter expansions in your secrets (e.g., `${DOMAIN}`) | `true` | | `--command` | Pass secrets into chained commands (e.g., `"first-command && second-command; more-commands..."`) | None | +| `--secret-overriding`| Prioritizes personal secrets with the same name over shared secrets | `true` | diff --git a/docs/cli/commands/vault.mdx b/docs/cli/commands/vault.mdx new file mode 100644 index 000000000..f973cb727 --- /dev/null +++ b/docs/cli/commands/vault.mdx @@ -0,0 +1,50 @@ +--- +title: "infisical vault" +--- + + + + ```bash + infisical vault + + # Example output + The following vaults are available on your system: + - keychain + - pass + - file + + You are currently using [keychain] vault to store your login credentials + ``` + + + + ```bash + infisical vault set + + # Example + infisical vault set keychain + ``` + + + + +## Description + +To ensure secure storage of your login credentials when using the CLI, Infisical stores login credentials securely in a system vault or encrypted text file with a passphrase known only by the user. + + + By default, the most appropriate vault is chosen to store your login credentials. + For example, if you are on macOS, KeyChain will be automatically selected. + +- [macOS Keychain](https://support.apple.com/en-au/guide/keychain-access/welcome/mac) +- [Windows Credential Manager](https://support.microsoft.com/en-au/help/4026814/windows-accessing-credential-manager) +- Secret Service ([Gnome Keyring](https://wiki.gnome.org/Projects/GnomeKeyring), [KWallet](https://kde.org/applications/system/org.kde.kwalletmanager5)) +- [KWallet](https://kde.org/applications/system/org.kde.kwalletmanager5) +- [Pass](https://www.passwordstore.org/) +- [KeyCtl]() +- Encrypted file (JWT) + + +To avoid constantly entering your passphrase when using the `file` vault type, set the `INFISICAL_VAULT_FILE_PASSPHRASE` environment variable with your password in your shell + + diff --git a/docs/cli/faq.mdx b/docs/cli/faq.mdx new file mode 100644 index 000000000..600386e81 --- /dev/null +++ b/docs/cli/faq.mdx @@ -0,0 +1,15 @@ +--- +title: "FAQ" +--- + +Frequently asked questions about the CLI can be found on this page. +If you can't find the answer you're looking for, please create an issue on our GitHub repository or join our Slack channel for additional support. + + +By default, the CLI will choose the most suitable store available on your system. +If you experience issues with the default store, you can switch to a different one. +If none of the available stores work for you, you can try using the `file` store type by running `infisical vault set file`, which should work in most cases. +If you are still experiencing trouble, please seek support. + +[Learn more about vault command](./commands/vault) + \ No newline at end of file diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index 4237f8154..fe08b60b3 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -4,10 +4,27 @@ title: "Usage" Prerequisite: [Install the CLI](/cli/overview) +## Authenticate + + + To use the Infisical CLI in your development environment, you can run the command below. + This will allow you to access the features and functionality provided by the CLI. + + ```bash + infisical login + ``` + + + + To use Infisical CLI in environments where you cannot run the `infisical login` command, you can authenticate via a + Infisical Token instead. Learn more about [Infisical Token](../getting-started/dashboard/token). + + + ## Initialize Infisical for your project ```bash -# move to your project +# navigate to your project cd /path/to/project # initialize infisical @@ -21,13 +38,7 @@ infisical init infisical run -- [your application start command] ``` -Options you can specify: - -| Option | Description | Default value | -| ------------- | ----------------------------------------------------------------------------------------------------------- | ------------- | -| `--env` | Used to set the environment that secrets are pulled from. Accepted values: `dev`, `staging`, `test`, `prod` | `dev` | -| `--projectId` | Used to link a local project to the platform (required only if injecting via the service token method) | `None` | -| `--expand` | Parse shell parameter expansions in your secrets (e.g., `${DOMAIN}`) | `true` | +View all available options for `run` command [here](./commands/run) ## Examples: diff --git a/docs/getting-started/dashboard/token.mdx b/docs/getting-started/dashboard/token.mdx index 9b3ddf79f..455a57929 100644 --- a/docs/getting-started/dashboard/token.mdx +++ b/docs/getting-started/dashboard/token.mdx @@ -11,6 +11,12 @@ To generate the the token, head over to your project settings as shown below. ![token add](../../images/project-token-add.png) +## Feeding Infisical Token to the CLI + +The Infisical CLI checks for the presence of an environment variable called `INFISICAL_TOKEN`. +If it detects this variable in the terminal where it is being run, it will use it to authenticate and retrieve the environment variables that the token is authorized to access. +This allows you to use the CLI in environments where you are unable to run the `infisical login` command. + The token grants read-only access to a particular environment and project for a specified amount of time. Once the token is expired, the CLI using it will no longer be able to make diff --git a/docs/mint.json b/docs/mint.json index c94b9131a..cbac56d5a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -94,9 +94,11 @@ "cli/commands/login", "cli/commands/init", "cli/commands/run", - "cli/commands/export" + "cli/commands/export", + "cli/commands/vault" ] - } + }, + "cli/faq" ] }, { diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 0b9fd5e71..f598336b6 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -28,6 +28,7 @@ Configuring Infisical requires setting some environment variables. There is a fi | `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` | | `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` | | `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | +| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` | | `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` | | `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` | | `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` | diff --git a/frontend/components/basic/InputField.tsx b/frontend/components/basic/InputField.tsx index 46b42c15c..241410959 100644 --- a/frontend/components/basic/InputField.tsx +++ b/frontend/components/basic/InputField.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { memo,useState } from 'react'; import { faCircle, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -96,6 +96,7 @@ const InputField = ( /> {props.label?.includes('Password') && ( -
+
{textLine1}
-
{textLine2}
+
{textLine2}
{/*eslint-disable-next-line react/jsx-no-target-blank */} void; value: string; type: 'varName' | 'value'; - blurred: boolean; - duplicates: string[]; + blurred?: boolean; + isDuplicate?: boolean; + override?: boolean; } /** * This component renders the input fields on the dashboard * @param {object} obj - the order number of a keyPair - * @param {number} obj.pos - the order number of a keyPair + * @param {number} obj.position - the order number of a keyPair * @param {function} obj.onChangeHandler - what happens when the input is modified * @param {string} obj.type - whether the input field is for a Key Name or for a Key Value * @param {string} obj.value - value of the InputField * @param {boolean} obj.blurred - whether the input field should be blurred (behind the gray dots) or not; this can be turned on/off in the dashboard - * @param {string[]} obj.duplicates - list of all the duplicated key names on the dashboard + * @param {boolean} obj.isDuplicate - if the key name is duplicated + * @param {boolean} obj.override - whether a secret/row should be displalyed as overriden * @returns */ @@ -33,7 +35,8 @@ const DashboardInputField = ({ type, value, blurred, - duplicates + isDuplicate, + override }: DashboardInputFieldProps) => { const ref = useRef(null); const syncScroll = (e: SyntheticEvent) => { @@ -45,8 +48,7 @@ const DashboardInputField = ({ if (type === 'varName') { const startsWithNumber = !isNaN(Number(value.charAt(0))) && value != ''; - const hasDuplicates = duplicates?.includes(value); - const error = startsWithNumber || hasDuplicates; + const error = startsWithNumber || isDuplicate; return (
@@ -72,7 +74,7 @@ const DashboardInputField = ({ Should not start with a number

)} - {hasDuplicates && !startsWithNumber && ( + {isDuplicate && !startsWithNumber && (

Secret names should be unique

@@ -85,6 +87,7 @@ const DashboardInputField = ({
+ {override == true &&
Override enabled
} onChangeHandler(e.target.value, position)} @@ -99,10 +102,13 @@ const DashboardInputField = ({
{value.split(REGEX).map((word, id) => { if (word.match(REGEX) !== null) { @@ -153,4 +159,8 @@ const DashboardInputField = ({ return <>Something Wrong; }; -export default React.memo(DashboardInputField); +function inputPropsAreEqual(prev: DashboardInputFieldProps, next: DashboardInputFieldProps) { + return prev.value === next.value && prev.type === next.type && prev.position === next.position && prev.blurred === next.blurred && prev.override === next.override && prev.isDuplicate === next.isDuplicate; +} + +export default memo(DashboardInputField, inputPropsAreEqual); diff --git a/frontend/components/dashboard/GenerateSecretMenu.tsx b/frontend/components/dashboard/GenerateSecretMenu.tsx new file mode 100644 index 000000000..115e19374 --- /dev/null +++ b/frontend/components/dashboard/GenerateSecretMenu.tsx @@ -0,0 +1,93 @@ +import { Fragment,useState } from 'react'; +import { faShuffle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Menu, Transition } from '@headlessui/react'; + + +/** + * This is the menu that is used to (re)generate secrets (currently we only have ranom hex, in future we will have more options) + * @returns the popup-menu for randomly generating secrets + */ +const GenerateSecretMenu = ({ modifyValue, position }: { modifyValue: (value: string, position: number) => void; position: number; }) => { + const [randomStringLength, setRandomStringLength] = useState(32); + + return +
+ +
+ +
+
+
+ + +
{ + if (randomStringLength > 32) { + setRandomStringLength(32); + } else if (randomStringLength < 2) { + setRandomStringLength(2); + } else { + modifyValue( + [...Array(randomStringLength)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join(''), + position + ); + } + }} + className="relative flex flex-row justify-start items-center cursor-pointer select-none py-2 px-2 rounded-md text-gray-400 hover:bg-white/10 duration-200 hover:text-gray-200 w-full" + > + +
+

Generate Random Hex

+

digits

+
+
+
+
{ + if (randomStringLength > 1) { + setRandomStringLength(randomStringLength - 1); + } + }} + > + - +
+ + setRandomStringLength(parseInt(e.target.value)) + } + value={randomStringLength} + className="text-center z-20 peer text-sm bg-transparent w-full outline-none" + spellCheck="false" + /> +
{ + if (randomStringLength < 32) { + setRandomStringLength(randomStringLength + 1); + } + }} + > + + +
+
+
+
+
+} + +export default GenerateSecretMenu; diff --git a/frontend/components/dashboard/KeyPair.tsx b/frontend/components/dashboard/KeyPair.tsx new file mode 100644 index 000000000..d917f3cee --- /dev/null +++ b/frontend/components/dashboard/KeyPair.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { faEllipsis, faShuffle, faX } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import Button from '../basic/buttons/Button'; +import DashboardInputField from './DashboardInputField'; + +interface SecretDataProps { + type: 'personal' | 'shared'; + pos: number; + key: string; + value: string; + id: string; +} + +interface KeyPairProps { + keyPair: SecretDataProps; + deleteRow: (id: string) => void; + modifyKey: (value: string, position: number) => void; + modifyValue: (value: string, position: number) => void; + isBlurred: boolean; + isDuplicate: boolean; + toggleSidebar: (id: string) => void; + sidebarSecretId: string; +} + +/** + * This component represent a single row for an environemnt variable on the dashboard + * @param {object} obj + * @param {String[]} obj.keyPair - data related to the environment variable (id, pos, key, value, public/private) + * @param {function} obj.deleteRow - a function to delete a certain keyPair + * @param {function} obj.modifyKey - modify the key of a certain environment variable + * @param {function} obj.modifyValue - modify the value of a certain environment variable + * @param {boolean} obj.isBlurred - if the blurring setting is turned on + * @param {boolean} obj.isDuplicate - list of all the duplicates secret names on the dashboard + * @param {function} obj.toggleSidebar - open/close/switch sidebar + * @param {string} obj.sidebarSecretId - the id of a secret for the side bar is displayed + * @returns + */ +const KeyPair = ({ + keyPair, + deleteRow, + modifyKey, + modifyValue, + isBlurred, + isDuplicate, + toggleSidebar, + sidebarSecretId +}: KeyPairProps) => { + return ( +
+
+ {keyPair.type == "personal" &&
+
+ + This secret is overriden + +
} +
+
+ +
+
+
+
+ +
+
+
toggleSidebar(keyPair.id)} className="cursor-pointer w-9 h-9 bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200"> + +
+
+
+
+
+
+ ); +}; + +export default React.memo(KeyPair); \ No newline at end of file diff --git a/frontend/components/dashboard/SideBar.tsx b/frontend/components/dashboard/SideBar.tsx new file mode 100644 index 000000000..60a4641fe --- /dev/null +++ b/frontend/components/dashboard/SideBar.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { faX } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import SecretVersionList from 'ee/components/SecretVersionList'; + +import Button from '../basic/buttons/Button'; +import Toggle from '../basic/Toggle'; +import DashboardInputField from './DashboardInputField'; +import GenerateSecretMenu from './GenerateSecretMenu'; + + +interface SecretProps { + key: string; + value: string; + pos: number; + type: string; + id: string; +} + +interface OverrideProps { + id: string; + keyName: string; + value: string; + pos: number; +} + +interface SideBarProps { + toggleSidebar: (value: string) => void; + data: SecretProps[]; + modifyKey: (value: string, position: number) => void; + modifyValue: (value: string, position: number) => void; + addOverride: (value: OverrideProps) => void; + deleteOverride: (id: string) => void; + buttonReady: boolean; + savePush: () => void; + sharedToHide: string[]; + setSharedToHide: (values: string[]) => void; +} + +/** + * @param {object} obj + * @param {function} obj.toggleSidebar - function that opens or closes the sidebar + * @param {SecretProps[]} obj.data - data of a certain key valeu pair + * @param {function} obj.modifyKey - function that modifies the secret key + * @param {function} obj.modifyValue - function that modifies the secret value + * @param {function} obj.addOverride - override a certain secret + * @param {function} obj.deleteOverride - delete the personal override for a certain secret + * @param {boolean} obj.buttonReady - is the button for saving chagnes active + * @param {function} obj.savePush - save changes andp ush secrets + * @param {string[]} obj.sharedToHide - an array of shared secrets that we want to hide visually because they are overriden. + * @param {function} obj.setSharedToHide - a function that updates the array of secrets that we want to hide visually + * @returns the sidebar with 'secret's settings' + */ +const SideBar = ({ + toggleSidebar, + data, + modifyKey, + modifyValue, + addOverride, + deleteOverride, + buttonReady, + savePush, + sharedToHide, + setSharedToHide +}: SideBarProps) => { + const [overrideEnabled, setOverrideEnabled] = useState(data.map(secret => secret.type).includes("personal")); + + return
+
+
+

Secret

+
toggleSidebar("None")}> + +
+
+
+

Key

+ +
+ {data.filter(secret => secret.type == "shared")[0]?.value + ?
+

Value

+ secret.type == "shared")[0]?.pos} + value={data.filter(secret => secret.type == "shared")[0]?.value} + isDuplicate={false} + blurred={true} + /> +
+ secret.type == "shared")[0]?.pos} /> +
+
+ :
+ Note: + This secret is personal. It is not shared with any of your teammates. +
} +
+ {data.filter(secret => secret.type == "shared")[0]?.value && +
+

Override value with a personal value

+ +
} +
+ secret.type == "personal")[0].pos : data[0].pos} + value={overrideEnabled ? data.filter(secret => secret.type == "personal")[0].value : data[0].value} + isDuplicate={false} + blurred={true} + /> +
+ secret.type == "personal")[0].pos : data[0].pos} /> +
+
+
+ {/*
+

Group

+ {}} + data={["Group1"]} + isFull={true} + /> +
*/} +
+
+

Comments & notes

+
+

Coming soon!

+
+
+
+ Leave your comment here... +
+
+
+
+
+
+}; + +export default SideBar; diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 3bba41534..ae9a1a613 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/router"; import { faArrowRight, diff --git a/frontend/components/navigation/NavBarDashboard.tsx b/frontend/components/navigation/NavBarDashboard.tsx index 1834d8117..23f7c0677 100644 --- a/frontend/components/navigation/NavBarDashboard.tsx +++ b/frontend/components/navigation/NavBarDashboard.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react/jsx-key */ -import React, { Fragment, useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/router'; import { faGithub, faSlack } from '@fortawesome/free-brands-svg-icons'; diff --git a/frontend/components/navigation/NavHeader.tsx b/frontend/components/navigation/NavHeader.tsx index 3d64c2eda..6e9fabe39 100644 --- a/frontend/components/navigation/NavHeader.tsx +++ b/frontend/components/navigation/NavHeader.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/router"; import { faAngleRight, diff --git a/frontend/components/utilities/secrets/getSecretsForProject.ts b/frontend/components/utilities/secrets/getSecretsForProject.ts index 3b63f9177..311c0c59a 100644 --- a/frontend/components/utilities/secrets/getSecretsForProject.ts +++ b/frontend/components/utilities/secrets/getSecretsForProject.ts @@ -39,7 +39,7 @@ const getSecretsForProject = async ({ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); - const tempFileState: { key: string; value: string; type: string }[] = []; + const tempFileState: { key: string; value: string; type: 'personal' | 'shared'; }[] = []; if (file.key) { // assymmetrically decrypt symmetric key with local private key const key = decryptAssymmetric({ @@ -97,7 +97,7 @@ const getSecretsForProject = async ({ } catch (error) { console.log('Something went wrong during accessing or decripting secrets.'); } - return true; + return []; }; export default getSecretsForProject; diff --git a/frontend/components/utilities/secrets/pushKeys.ts b/frontend/components/utilities/secrets/pushKeys.ts index 0d570f273..7b91c0b31 100644 --- a/frontend/components/utilities/secrets/pushKeys.ts +++ b/frontend/components/utilities/secrets/pushKeys.ts @@ -51,7 +51,7 @@ const pushKeys = async({ obj, workspaceId, env }: { obj: object; workspaceId: st iv: ivKey, tag: tagKey, } = encryptSymmetric({ - plaintext: key, + plaintext: key.slice(1), key: randomBytes, }); @@ -65,13 +65,13 @@ const pushKeys = async({ obj, workspaceId, env }: { obj: object; workspaceId: st key: randomBytes, }); - const visibility = obj[key as keyof typeof obj][1] != null ? obj[key as keyof typeof obj][1] : "personal"; + const visibility = key.charAt(0) == "p" ? "personal" : "shared"; return { ciphertextKey, ivKey, tagKey, - hashKey: crypto.createHash("sha256").update(key).digest("hex"), + hashKey: crypto.createHash("sha256").update(key.slice(1)).digest("hex"), ciphertextValue, ivValue, tagValue, diff --git a/frontend/ee/components/SecretVersionList.tsx b/frontend/ee/components/SecretVersionList.tsx new file mode 100644 index 000000000..a2147c2af --- /dev/null +++ b/frontend/ee/components/SecretVersionList.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { faCircle, faDotCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface SecretVersionListProps {} + +const versionData = [{ + value: "Value1", + date: "Date1", + user: "vlad@infisical.com" +}, { + value: "Value2", + date: "Date2", + user: "tony@infisical.com" +}] + +/** + * @returns a list of the versions for a specific secret + */ +const SecretVersionList = () => { + return
+

Version History

+
+
+ {versionData.map((version, index) => +
+
+
+
+
+
+
{version.date}
+

Value:{version.value}

+

Updated by:{version.user}

+
+
+ )} +
+
+
+}; + +export default SecretVersionList; diff --git a/frontend/pages/dashboard.js b/frontend/pages/dashboard.js index 5954a9db2..e183347aa 100644 --- a/frontend/pages/dashboard.js +++ b/frontend/pages/dashboard.js @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import Head from "next/head"; import { useRouter } from "next/router"; diff --git a/frontend/pages/dashboard/[id].js b/frontend/pages/dashboard/[id].tsx similarity index 50% rename from frontend/pages/dashboard/[id].js rename to frontend/pages/dashboard/[id].tsx index 36f5eb3f2..2535a47ab 100644 --- a/frontend/pages/dashboard/[id].js +++ b/frontend/pages/dashboard/[id].tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; import { useRouter } from 'next/router'; @@ -6,208 +6,68 @@ import { faArrowDownAZ, faArrowDownZA, faCheck, - faCircleInfo, faCopy, faDownload, - faEllipsis, faEye, faEyeSlash, faFolderOpen, faMagnifyingGlass, - faPeopleGroup, - faPerson, faPlus, - faShuffle, - faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Menu, Transition } from '@headlessui/react'; import Button from '~/components/basic/buttons/Button'; import ListBox from '~/components/basic/Listbox'; import BottonRightPopup from '~/components/basic/popups/BottomRightPopup'; import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider'; -import DashboardInputField from '~/components/dashboard/DashboardInputField'; import DropZone from '~/components/dashboard/DropZone'; +import KeyPair from '~/components/dashboard/KeyPair'; +import SideBar from '~/components/dashboard/SideBar'; import NavHeader from '~/components/navigation/NavHeader'; import getSecretsForProject from '~/components/utilities/secrets/getSecretsForProject'; import pushKeys from '~/components/utilities/secrets/pushKeys'; -import pushKeysIntegration from '~/components/utilities/secrets/pushKeysIntegration'; import guidGenerator from '~/utilities/randomId'; import { envMapping } from '../../public/data/frequentConstants'; -import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations'; import getUser from '../api/user/getUser'; import checkUserAction from '../api/userActions/checkUserAction'; import registerUserAction from '../api/userActions/registerUserAction'; import getWorkspaces from '../api/workspace/getWorkspaces'; -/** - * This component represent a single row for an environemnt variable on the dashboard - * @param {object} obj - * @param {String[]} obj.keyPair - data related to the environment variable (id, pos, key, value, public/private) - * @param {function} obj.deleteRow - a function to delete a certain keyPair - * @param {function} obj.modifyKey - modify the key of a certain environment variable - * @param {function} obj.modifyValue - modify the value of a certain environment variable - * @param {function} obj.modifyVisibility - switch between public/private visibility - * @param {boolean} obj.isBlurred - if the blurring setting is turned on - * @param {string[]} obj.duplicates - list of all the duplicates secret names on the dashboard - * @returns - */ -const KeyPair = ({ - keyPair, - deleteRow, - modifyKey, - modifyValue, - modifyVisibility, - isBlurred, - duplicates -}) => { - const [randomStringLength, setRandomStringLength] = useState(32); - return ( -
-
-
-
- -
-
-
-
- -
-
- -
- -
- -
-
-
- - -
- modifyVisibility( - keyPair.type == 'personal' ? 'shared' : 'personal', - keyPair.pos - ) - } - className="relative flex justify-start items-center cursor-pointer select-none py-2 px-2 rounded-md text-gray-400 hover:bg-white/10 duration-200 hover:text-gray-200 w-full" - > - -
- {keyPair.type == 'personal' ? 'Make Shared' : 'Make Personal'} -
-
-
{ - if (randomStringLength > 32) { - setRandomStringLength(32); - } else if (randomStringLength < 2) { - setRandomStringLength(2); - } else { - modifyValue( - [...Array(randomStringLength)] - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join(''), - keyPair.pos - ); - } - }} - className="relative flex flex-row justify-start items-center cursor-pointer select-none py-2 px-2 rounded-md text-gray-400 hover:bg-white/10 duration-200 hover:text-gray-200 w-full" - > - -
-

Generate Random Hex

-

digits

-
-
-
-
{ - if (randomStringLength > 1) { - setRandomStringLength(randomStringLength - 1); - } - }} - > - - -
- - setRandomStringLength(parseInt(e.target.value)) - } - value={randomStringLength} - className="text-center z-20 peer text-sm bg-transparent w-full outline-none" - spellCheck="false" - /> -
{ - if (randomStringLength < 32) { - setRandomStringLength(randomStringLength + 1); - } - }} - > - + -
-
-
-
-
-
-
-
-
-
- ); -}; +interface SecretDataProps { + type: 'personal' | 'shared'; + pos: number; + key: string; + value: string; + id: string; +} + +/** + * this function finds the teh duplicates in an array + * @param arr - array of anything (e.g., with secret keys and types (personal/shared)) + * @returns - a list with duplicates + */ +function findDuplicates(arr: any[]) { + const map = new Map(); + return arr.filter((item) => { + if (map.has(item)) { + map.set(item, false); + return true; + } else { + map.set(item, true); + return false; + } + }); +} /** * This is the main component for the dashboard (aka the screen with all the encironemnt variable & secrets) * @returns */ export default function Dashboard() { - const [data, setData] = useState(); - const [fileState, setFileState] = useState([]); + const [data, setData] = useState(); + const [fileState, setFileState] = useState([]); const [buttonReady, setButtonReady] = useState(false); const router = useRouter(); const [workspaceId, setWorkspaceId] = useState(''); @@ -227,6 +87,8 @@ export default function Dashboard() { const [sortMethod, setSortMethod] = useState('alphabetical'); const [checkDocsPopUpVisible, setCheckDocsPopUpVisible] = useState(false); const [hasUserEverPushed, setHasUserEverPushed] = useState(false); + const [sidebarSecretId, toggleSidebar] = useState("None"); + const [sharedToHide, setSharedToHide] = useState([]); const { createNotification } = useNotificationContext(); @@ -249,7 +111,7 @@ export default function Dashboard() { useEffect(() => { const warningText = 'Do you want to save your results before leaving this page?'; - const handleWindowClose = (e) => { + const handleWindowClose = (e: any) => { if (!buttonReady) return; e.preventDefault(); return (e.returnValue = warningText); @@ -265,18 +127,18 @@ export default function Dashboard() { /** * Reorder rows alphabetically or in the opprosite order */ - const reorderRows = (dataToReorder) => { + const reorderRows = (dataToReorder: SecretDataProps[] | 1) => { setSortMethod((prevSort) => prevSort == 'alphabetical' ? '-alphabetical' : 'alphabetical' ); - sortValuesHandler(dataToReorder); + sortValuesHandler(dataToReorder, undefined); }; useEffect(() => { (async () => { try { - let userWorkspaces = await getWorkspaces(); + const userWorkspaces = await getWorkspaces(); const listWorkspaces = userWorkspaces.map((workspace) => workspace._id); if ( !listWorkspaces.includes(router.asPath.split('/')[2].split('?')[0]) @@ -288,31 +150,41 @@ export default function Dashboard() { router.push(router.asPath.split('?')[0] + '?' + env); } setBlurred(true); - setWorkspaceId(router.query.id); + setWorkspaceId(String(router.query.id)); const dataToSort = await getSecretsForProject({ env, setFileState, setIsKeyAvailable, setData, - workspaceId: router.query.id + workspaceId: String(router.query.id) }); reorderRows(dataToSort); + setSharedToHide( + dataToSort?.filter(row => (dataToSort + ?.map((item) => item.key) + .filter( + (item, index) => + index !== + dataToSort?.map((item) => item.key).indexOf(item) + ).includes(row.key) && row.type == 'shared'))?.map((item) => item.id) + ) + const user = await getUser(); setIsNew( - (Date.parse(new Date()) - Date.parse(user.createdAt)) / 60000 < 3 + (Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3 ? true : false ); - let userAction = await checkUserAction({ + const userAction = await checkUserAction({ action: 'first_time_secrets_pushed' }); setHasUserEverPushed(userAction ? true : false); } catch (error) { console.log('Error', error); - setData([]); + setData(undefined); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -321,10 +193,10 @@ export default function Dashboard() { const addRow = () => { setIsNew(false); setData([ - ...data, + ...data!, { id: guidGenerator(), - pos: data.length, + pos: data!.length, key: '', value: '', type: 'shared' @@ -332,45 +204,85 @@ export default function Dashboard() { ]); }; - const deleteRow = (id) => { - setButtonReady(true); - setData(data.filter((row) => row.id !== id)); + interface overrideProps { + id: string; + keyName: string; + value: string; + pos: number; + } + + /** + * This function add an ovverrided version of a certain secret to the current user + * @param {object} obj + * @param {string} obj.id - if of this secret that is about to be overriden + * @param {string} obj.keyName - key name of this secret + * @param {string} obj.value - value of this secret + * @param {string} obj.pos - position of this secret on the dashboard + */ + const addOverride = ({ id, keyName, value, pos }: overrideProps) => { + setIsNew(false); + const tempdata: SecretDataProps[] | 1 = [ + ...data!, + { + id: id, + pos: pos, + key: keyName, + value: value, + type: 'personal' + } + ]; + sortValuesHandler(tempdata, sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical"); }; - const modifyValue = (value, pos) => { + const deleteRow = (id: string) => { + setButtonReady(true); + setData(data!.filter((row: SecretDataProps) => row.id !== id)); + }; + + /** + * This function deleted the override of a certain secrer + * @param {string} id - id of a secret to be deleted + */ + const deleteOverride = (id: string) => { + setButtonReady(true); + const tempData = data!.filter((row: SecretDataProps) => !(row.id == id && row.type == 'personal')) + sortValuesHandler(tempData, sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical") + }; + + const modifyValue = (value: string, pos: number) => { setData((oldData) => { - oldData[pos].value = value; - return [...oldData]; + oldData![pos].value = value; + return [...oldData!]; }); setButtonReady(true); }; - const modifyKey = (value, pos) => { + const modifyKey = (value: string, pos: number) => { setData((oldData) => { - oldData[pos].key = value; - return [...oldData]; + oldData![pos].key = value; + return [...oldData!]; }); setButtonReady(true); }; - const modifyVisibility = (value, pos) => { + const modifyVisibility = (value: "shared" | "personal", pos: number) => { setData((oldData) => { - oldData[pos].type = value; - return [...oldData]; + oldData![pos].type = value; + return [...oldData!]; }); setButtonReady(true); }; // For speed purposes and better perforamance, we are using useCallback - const listenChangeValue = useCallback((value, pos) => { + const listenChangeValue = useCallback((value: string, pos: number) => { modifyValue(value, pos); }, []); - const listenChangeKey = useCallback((value, pos) => { + const listenChangeKey = useCallback((value: string, pos: number) => { modifyKey(value, pos); }, []); - const listenChangeVisibility = useCallback((value, pos) => { + const listenChangeVisibility = useCallback((value: "shared" | "personal", pos: number) => { modifyVisibility(value, pos); }, []); @@ -379,21 +291,16 @@ export default function Dashboard() { */ const savePush = async () => { // Format the new object with environment variables - let obj = Object.assign( + const obj = Object.assign( {}, - ...data.map((row) => ({ [row.key]: [row.value, row.type] })) + ...data!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.type] })) ); // Checking if any of the secret keys start with a number - if so, don't do anything const nameErrors = !Object.keys(obj) - .map((key) => !isNaN(key.charAt(0))) + .map((key) => !isNaN(Number(key[0].charAt(0)))) .every((v) => v === false); - const duplicatesExist = - data - ?.map((item) => item.key) - .filter( - (item, index) => index !== data?.map((item) => item.key).indexOf(item) - ).length > 0; + const duplicatesExist = findDuplicates(data!.map((item: SecretDataProps) => item.key + item.type)).length > 0; if (nameErrors) { return createNotification({ @@ -409,9 +316,11 @@ export default function Dashboard() { }); } + console.log('pushing', obj) + // Once "Save changed is clicked", disable that button setButtonReady(false); - pushKeys({ obj, workspaceId: router.query.id, env }); + pushKeys({ obj, workspaceId: String(router.query.id), env }); // If this user has never saved environment variables before, show them a prompt to read docs if (!hasUserEverPushed) { @@ -420,8 +329,8 @@ export default function Dashboard() { } }; - const addData = (newData) => { - setData(data.concat(newData)); + const addData = (newData: SecretDataProps[]) => { + setData(data!.concat(newData)); setButtonReady(true); }; @@ -429,37 +338,39 @@ export default function Dashboard() { setBlurred(!blurred); }; - const sortValuesHandler = (dataToSort) => { - const sortedData = (dataToSort != 1 ? dataToSort : data) - .sort((a, b) => - sortMethod == 'alphabetical' - ? a.key.localeCompare(b.key) - : b.key.localeCompare(a.key) - ) - .map((item, index) => { - return { - ...item, - pos: index - }; - }); + const sortValuesHandler = (dataToSort: SecretDataProps[] | 1, specificSortMethod?: 'alphabetical' | '-alphabetical') => { + const howToSort = specificSortMethod == undefined ? sortMethod : specificSortMethod; + const sortedData = (dataToSort != 1 ? dataToSort : data)! + .sort((a, b) => + howToSort == 'alphabetical' + ? a.key.localeCompare(b.key) + : b.key.localeCompare(a.key) + ) + .map((item: SecretDataProps, index: number) => { + return { + ...item, + pos: index + }; + }); + console.log('override', sortedData) setData(sortedData); }; // This function downloads the secrets as a .env file const download = () => { - const file = data - .map((item) => [item.key, item.value].join('=')) + const file = data! + .map((item: SecretDataProps) => [item.key, item.value].join('=')) .join('\n'); const blob = new Blob([file]); const fileDownloadUrl = URL.createObjectURL(blob); - let alink = document.createElement('a'); + const alink = document.createElement('a'); alink.href = fileDownloadUrl; alink.download = envMapping[env] + '.env'; alink.click(); }; - const deleteCertainRow = (id) => { + const deleteCertainRow = (id: string) => { deleteRow(id); }; @@ -467,15 +378,17 @@ export default function Dashboard() { * This function copies the project id to the clipboard */ function copyToClipboard() { - var copyText = document.getElementById('myInput'); + const copyText = document.getElementById('myInput') as HTMLInputElement; + + if (copyText) { + copyText.select(); + copyText.setSelectionRange(0, 99999); // For mobile devices - copyText.select(); - copyText.setSelectionRange(0, 99999); // For mobile devices - - navigator.clipboard.writeText(copyText.value); - - setProjectIdCopied(true); - setTimeout(() => setProjectIdCopied(false), 2000); + navigator.clipboard.writeText(copyText.value); + + setProjectIdCopied(true); + setTimeout(() => setProjectIdCopied(false), 2000); + } } return data ? ( @@ -491,6 +404,18 @@ export default function Dashboard() { />
+ {sidebarSecretId != "None" && row.id == sidebarSecretId)} + modifyKey={listenChangeKey} + modifyValue={listenChangeValue} + addOverride={addOverride} + deleteOverride={deleteOverride} + buttonReady={buttonReady} + savePush={savePush} + sharedToHide={sharedToHide} + setSharedToHide={setSharedToHide} + />}
{checkDocsPopUpVisible && ( @@ -513,7 +438,6 @@ export default function Dashboard() { data={['Development', 'Staging', 'Production', 'Testing']} // ref={useRef(123)} onChange={setEnv} - className="z-40" /> )}
@@ -568,7 +492,6 @@ export default function Dashboard() { data={['Development', 'Staging', 'Production', 'Testing']} // ref={useRef(123)} onChange={setEnv} - className="z-40" />
{data?.length !== 0 ? ( -
-
-
- {/* */} -
-

Personal

-
- - - Personal keys are only visible to you - -
-
-
-
- {data - .filter( - (keyPair) => - keyPair.key - .toLowerCase() - .includes(searchKeys.toLowerCase()) && - keyPair.type == 'personal' - ) - ?.map((keyPair) => ( - +
+
+
+ {data?.filter(row => !(sharedToHide.includes(row.id) && row.type == 'shared')).map((keyPair) => ( + item.key) - .filter( - (item, index) => - index !== - data?.map((item) => item.key).indexOf(item) - )} + isDuplicate={findDuplicates(data?.map((item) => item.key + item.type))?.includes(keyPair.key + keyPair.type)} + toggleSidebar={toggleSidebar} + sidebarSecretId={sidebarSecretId} /> ))} -
-
-
8 ? 'h-3/4' : 'h-min' - }`} - > -
- {/* */} -
-

Shared

-
- - - Shared keys are visible to your whole team - -
+
+
+
-
- {data - .filter( - (keyPair) => - keyPair.key - .toLowerCase() - .includes(searchKeys.toLowerCase()) && - keyPair.type == 'shared' - ) - ?.map((keyPair) => ( - item.key) - .filter( - (item, index) => - index !== - data?.map((item) => item.key).indexOf(item) - )} - /> - ))} -
-
-
-
) : (
- {fileState.message != "There's nothing to pull" && - fileState.message != undefined && ( - - )} - {(fileState.message == "There's nothing to pull" || - fileState.message == undefined) && - isKeyAvailable && ( - - )} - {fileState.message == - 'Failed membership validation for workspace' && ( -

You are not authorized to view this project.

+ {isKeyAvailable && ( + )} - {fileState.message == 'Access needed to pull the latest file' || + { + // fileState.message == 'Access needed to pull the latest file' || (!isKeyAvailable && ( <> { + if (!email || !password) { + return; + } + setIsLoading(true); await attemptLogin( email, @@ -45,7 +49,7 @@ export default function Login() { setErrorLogin, router, false, - true + true, ).then(() => { setTimeout(function () { setIsLoading(false); @@ -75,68 +79,73 @@ export default function Login() { />
-
-

- Log in to your account -

-
- -
-
- -
- Forgot password? -
-
- {errorLogin && } -
-
-
+
+ {/*

I may have forgotten my password.

*/} -
- {false && ( -
- - We are experiencing minor technical difficulties. We are working on - solving it right now. Please come back in a few minutes.
- )} -
-

- Need an Infisical account? -

- - - -
+ {false && ( +
+ + We are experiencing minor technical difficulties. We are working on + solving it right now. Please come back in a few minutes. +
+ )} +
+

+ Need an Infisical account? +

+ + + +
+
); } diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js index 6907d6db4..fc58042d5 100644 --- a/frontend/pages/netlify.js +++ b/frontend/pages/netlify.js @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import Head from "next/head"; import { useRouter } from "next/router"; const queryString = require("query-string"); diff --git a/frontend/pages/password-reset.tsx b/frontend/pages/password-reset.tsx index a09b85ab0..45d17d194 100644 --- a/frontend/pages/password-reset.tsx +++ b/frontend/pages/password-reset.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/router'; import { faCheck, faX } from '@fortawesome/free-solid-svg-icons'; diff --git a/frontend/pages/settings/billing/[id].js b/frontend/pages/settings/billing/[id].js index 00d1db23f..485dfab8d 100644 --- a/frontend/pages/settings/billing/[id].js +++ b/frontend/pages/settings/billing/[id].js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import Head from "next/head"; import Plan from "~/components/billing/Plan"; diff --git a/frontend/pages/settings/org/[id].js b/frontend/pages/settings/org/[id].js index 80f6efc5d..c54e8e193 100644 --- a/frontend/pages/settings/org/[id].js +++ b/frontend/pages/settings/org/[id].js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { diff --git a/frontend/pages/settings/personal/[id].js b/frontend/pages/settings/personal/[id].js index aa497bbfd..60dcba1be 100644 --- a/frontend/pages/settings/personal/[id].js +++ b/frontend/pages/settings/personal/[id].js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import Head from "next/head"; import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; diff --git a/frontend/pages/settings/project/[id].js b/frontend/pages/settings/project/[id].js index ec4864919..64ea96093 100644 --- a/frontend/pages/settings/project/[id].js +++ b/frontend/pages/settings/project/[id].js @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Head from "next/head"; import { useRouter } from "next/router"; import { faCheck, faPlus } from "@fortawesome/free-solid-svg-icons"; diff --git a/frontend/pages/signup.tsx b/frontend/pages/signup.tsx index a5e17a8c5..e1a50406c 100644 --- a/frontend/pages/signup.tsx +++ b/frontend/pages/signup.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import ReactCodeInput from 'react-code-input'; import Head from 'next/head'; import Image from 'next/image'; @@ -260,46 +260,49 @@ export default function SignUp() { // Step 1 of the sign up process (enter the email or choose google authentication) const step1 = ( -
-

- {'Let\''}s get started -

-
+
+
+

+ {'Let\''}s get started +

+
+ +
+ {/*
+ +

I do not want to receive emails about Infisical and its products.

+
*/} +
+

+ By creating an account, you agree to our Terms and have read and + acknowledged the Privacy Policy. +

+
+
+
+
+
-
-
- -
- {/*
- -

I do not want to receive emails about Infisical and its products.

-
*/} -
-

- By creating an account, you agree to our Terms and have read and - acknowledged the Privacy Policy. -

-
-
-
+ ); // Step 2 of the signup process (enter the email verification code) @@ -340,11 +343,11 @@ export default function SignUp() {
-
+
Not seeing an email? - + @@ -512,7 +515,7 @@ export default function SignUp() { It contains your Secret Key which we cannot access or recover for you if you lose it.
-
+
- {step == 1 ? step1 : step == 2 ? step2 : step == 3 ? step3 : step4} +
e.preventDefault()}> + {step == 1 ? step1 : step == 2 ? step2 : step == 3 ? step3 : step4} +
); diff --git a/frontend/pages/signupinvite.js b/frontend/pages/signupinvite.js index a41010aea..7686161a2 100644 --- a/frontend/pages/signupinvite.js +++ b/frontend/pages/signupinvite.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; import Link from 'next/link'; diff --git a/frontend/pages/users/[id].js b/frontend/pages/users/[id].js index fe96ded3a..c1739b0a9 100644 --- a/frontend/pages/users/[id].js +++ b/frontend/pages/users/[id].js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; import { useRouter } from 'next/router'; diff --git a/frontend/pages/vercel.js b/frontend/pages/vercel.js index adfffe77e..7b15769b1 100644 --- a/frontend/pages/vercel.js +++ b/frontend/pages/vercel.js @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import Head from "next/head"; import { useRouter } from "next/router"; const queryString = require("query-string"); diff --git a/frontend/pages/verify-email.tsx b/frontend/pages/verify-email.tsx index 7dfc1427e..ae2c9fff1 100644 --- a/frontend/pages/verify-email.tsx +++ b/frontend/pages/verify-email.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; import Link from 'next/link';