diff --git a/.env.example b/.env.example index 8a714f77d..8463fea92 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 # Required -DB_CONNECTION_URI=postgres://infisical:infisical@db:5432/infisical +DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} # JWT # Required secrets to sign JWT tokens diff --git a/.github/resources/changelog-generator.py b/.github/resources/changelog-generator.py new file mode 100644 index 000000000..7dd8140ee --- /dev/null +++ b/.github/resources/changelog-generator.py @@ -0,0 +1,190 @@ +# inspired by https://www.photoroom.com/inside-photoroom/how-we-automated-our-changelog-thanks-to-chatgpt +import os +import requests +import re +from openai import OpenAI +import subprocess +from datetime import datetime + +import uuid + +# Constants +REPO_OWNER = "infisical" +REPO_NAME = "infisical" +TOKEN = os.environ["GITHUB_TOKEN"] +SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"] +OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] +SLACK_MSG_COLOR = "#36a64f" + +headers = { + "Authorization": f"Bearer {TOKEN}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + + +def set_multiline_output(name, value): + with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: + delimiter = uuid.uuid1() + print(f'{name}<<{delimiter}', file=fh) + print(value, file=fh) + print(delimiter, file=fh) + +def post_changelog_to_slack(changelog, tag): + slack_payload = { + "text": "Hey team, it's changelog time! :wave:", + "attachments": [ + { + "color": SLACK_MSG_COLOR, + "title": f"🗓️Infisical Changelog - {tag}", + "text": changelog, + } + ], + } + + response = requests.post(SLACK_WEBHOOK_URL, json=slack_payload) + + if response.status_code != 200: + raise Exception("Failed to post changelog to Slack.") + +def find_previous_release_tag(release_tag:str): + previous_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0", f"{release_tag}^"]).decode("utf-8").strip() + while not(previous_tag.startswith("infisical/")): + previous_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0", f"{previous_tag}^"]).decode("utf-8").strip() + return previous_tag + +def get_tag_creation_date(tag_name): + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/git/refs/tags/{tag_name}" + response = requests.get(url, headers=headers) + response.raise_for_status() + commit_sha = response.json()['object']['sha'] + + commit_url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/commits/{commit_sha}" + commit_response = requests.get(commit_url, headers=headers) + commit_response.raise_for_status() + creation_date = commit_response.json()['commit']['author']['date'] + + return datetime.strptime(creation_date, '%Y-%m-%dT%H:%M:%SZ') + + +def fetch_prs_between_tags(previous_tag_date:datetime, release_tag_date:datetime): + # Use GitHub API to fetch PRs merged between the commits + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/pulls?state=closed&merged=true" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise Exception("Error fetching PRs from GitHub API!") + + prs = [] + for pr in response.json(): + # the idea is as tags happen recently we get last 100 closed PRs and then filter by tag creation date + if pr["merged_at"] and datetime.strptime(pr["merged_at"],'%Y-%m-%dT%H:%M:%SZ') < release_tag_date and datetime.strptime(pr["merged_at"],'%Y-%m-%dT%H:%M:%SZ') > previous_tag_date: + prs.append(pr) + + return prs + + +def extract_commit_details_from_prs(prs): + commit_details = [] + for pr in prs: + commit_message = pr["title"] + commit_url = pr["html_url"] + pr_number = pr["number"] + branch_name = pr["head"]["ref"] + issue_numbers = re.findall(r"(www-\d+|web-\d+)", branch_name) + + # If no issue numbers are found, add the PR details without issue numbers and URLs + if not issue_numbers: + commit_details.append( + { + "message": commit_message, + "pr_number": pr_number, + "pr_url": commit_url, + "issue_number": None, + "issue_url": None, + } + ) + continue + + for issue in issue_numbers: + commit_details.append( + { + "message": commit_message, + "pr_number": pr_number, + "pr_url": commit_url, + "issue_number": issue, + } + ) + + return commit_details + +# Function to generate changelog using OpenAI +def generate_changelog_with_openai(commit_details): + commit_messages = [] + for details in commit_details: + base_message = f"{details['pr_url']} - {details['message']}" + # Add the issue URL if available + # if details["issue_url"]: + # base_message += f" (Linear Issue: {details['issue_url']})" + commit_messages.append(base_message) + + commit_list = "\n".join(commit_messages) + prompt = """ +Generate a changelog for Infisical, opensource secretops +The changelog should: +1. Be Informative: Using the provided list of GitHub commits, break them down into categories such as Features, Fixes & Improvements, and Technical Updates. Summarize each commit concisely, ensuring the key points are highlighted. +2. Have a Professional yet Friendly tone: The tone should be balanced, not too corporate or too informal. +3. Celebratory Introduction and Conclusion: Start the changelog with a celebratory note acknowledging the team's hard work and progress. End with a shoutout to the team and wishes for a pleasant weekend. +4. Formatting: you cannot use Markdown formatting, and you can only use emojis for the introductory paragraph or the conclusion paragraph, nowhere else. +5. Links: the syntax to create links is the following: ``. +6. Linear Links: note that the Linear link is optional, include it only if provided. +7. Do not wrap your answer in a codeblock. Just output the text, nothing else +Here's a good example to follow, please try to match the formatting as closely as possible, only changing the content of the changelog and have some liberty with the introduction. Notice the importance of the formatting of a changelog item: +- : We optimize our ci to strip comments and minify production builds. ()) +And here's an example of the full changelog: + +*Features* +• : We optimize our ci to strip comments and minify production builds. () +*Fixes & Improvements* +• : We optimize our ci to strip comments and minify production builds. () +*Technical Updates* +• : We optimize our ci to strip comments and minify production builds. () + +Stay tuned for more exciting updates coming soon! +And here are the commits: +{} + """.format( + commit_list + ) + + client = OpenAI(api_key=OPENAI_API_KEY) + messages = [{"role": "user", "content": prompt}] + response = client.chat.completions.create(model="gpt-3.5-turbo", messages=messages) + + if "error" in response.choices[0].message: + raise Exception("Error generating changelog with OpenAI!") + + return response.choices[0].message.content.strip() + + +if __name__ == "__main__": + try: + # Get the latest and previous release tags + latest_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]).decode("utf-8").strip() + previous_tag = find_previous_release_tag(latest_tag) + + latest_tag_date = get_tag_creation_date(latest_tag) + previous_tag_date = get_tag_creation_date(previous_tag) + + prs = fetch_prs_between_tags(previous_tag_date,latest_tag_date) + pr_details = extract_commit_details_from_prs(prs) + + # Generate changelog + changelog = generate_changelog_with_openai(pr_details) + + post_changelog_to_slack(changelog,latest_tag) + # Print or post changelog to Slack + # set_multiline_output("changelog", changelog) + + except Exception as e: + print(str(e)) \ No newline at end of file diff --git a/.github/workflows/generate-release-changelog.yml b/.github/workflows/generate-release-changelog.yml new file mode 100644 index 000000000..e26a304cf --- /dev/null +++ b/.github/workflows/generate-release-changelog.yml @@ -0,0 +1,34 @@ +name: Generate Changelog +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - "infisical/v*.*.*-postgres" + +jobs: + generate_changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12.0" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests openai + - name: Generate Changelog and Post to Slack + id: gen-changelog + run: python .github/resources/changelog-generator.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index 9840e2d3e..d01d56198 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -23,6 +23,8 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - run: git fetch --force --tags - run: echo "Ref name ${{github.ref_name}}" - uses: actions/setup-go@v3 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1c5db8f00..8f608c40c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -190,10 +190,34 @@ dockers: - dockerfile: docker/alpine goos: linux goarch: amd64 + use: buildx ids: - all-other-builds image_templates: - - "infisical/cli:{{ .Version }}" - - "infisical/cli:{{ .Major }}.{{ .Minor }}" - - "infisical/cli:{{ .Major }}" - - "infisical/cli:latest" + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" + - "infisical/cli:latest-amd64" + build_flag_templates: + - "--pull" + - "--platform=linux/amd64" + - dockerfile: docker/alpine + goos: linux + goarch: amd64 + use: buildx + ids: + - all-other-builds + image_templates: + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64" + - "infisical/cli:latest-arm64" + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" + +docker_manifests: + - name_template: "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" + image_templates: + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64" + - name_template: "infisical/cli:latest" + image_templates: + - "infisical/cli:latest-amd64" + - "infisical/cli:latest-arm64" diff --git a/backend/package-lock.json b/backend/package-lock.json index c18378aa9..a829cf346 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -47,7 +47,6 @@ "lodash.isequal": "^4.5.0", "mysql2": "^3.9.1", "nanoid": "^5.0.4", - "node-cache": "^5.1.2", "nodemailer": "^6.9.9", "ora": "^7.0.1", "passport-github": "^1.1.0", @@ -5706,14 +5705,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -9258,17 +9249,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, - "node_modules/node-cache": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", - "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", - "dependencies": { - "clone": "2.x" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/backend/package.json b/backend/package.json index 19fb3fafa..ad44dc2da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -108,7 +108,6 @@ "lodash.isequal": "^4.5.0", "mysql2": "^3.9.1", "nanoid": "^5.0.4", - "node-cache": "^5.1.2", "nodemailer": "^6.9.9", "ora": "^7.0.1", "passport-github": "^1.1.0", diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index 8e2b00b90..5a5ff5404 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -9,7 +9,12 @@ export async function seed(knex: Knex): Promise { await knex(TableName.Users).del(); await knex(TableName.UserEncryptionKey).del(); await knex(TableName.SuperAdmin).del(); - await knex(TableName.SuperAdmin).insert([{ initialized: true, allowSignUp: true }]); + + await knex(TableName.SuperAdmin).insert([ + // eslint-disable-next-line + // @ts-ignore + { id: "00000000-0000-0000-0000-000000000000", initialized: true, allowSignUp: true } + ]); // Inserts seed entries const [user] = await knex(TableName.Users) .insert([ diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 00dd09c33..6ed079a61 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -27,6 +27,7 @@ type TSAMLConfig = { cert: string; audience: string; wantAuthnResponseSigned?: boolean; + wantAssertionsSigned?: boolean; disableRequestedAuthnContext?: boolean; }; @@ -82,6 +83,10 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { samlConfig.audience = `spn:${ssoConfig.issuer}`; } } + if (ssoConfig.authProvider === SamlProviders.GOOGLE_SAML) { + samlConfig.wantAssertionsSigned = false; + } + (req as unknown as FastifyRequest).ssoConfig = ssoConfig; done(null, samlConfig); } catch (error) { diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 6f2c93221..afffd463d 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -24,7 +24,7 @@ export const auditLogQueueServiceFactory = ({ const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { removeOnFail: { - count: 5 + count: 3 }, removeOnComplete: true }); @@ -46,6 +46,7 @@ export const auditLogQueueServiceFactory = ({ const ttl = plan.auditLogsRetentionDays * MS_IN_DAY; // skip inserting if audit log retention is 0 meaning its not supported if (ttl === 0) return; + await auditLogDAL.create({ actor: actor.type, actorMetadata: actor.metadata, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index b0bc11e31..663e9d879 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -5,8 +5,8 @@ // TODO(akhilmhdh): With tony find out the api structure and fill it here import { ForbiddenError } from "@casl/ability"; -import NodeCache from "node-cache"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -39,6 +39,7 @@ type TLicenseServiceFactoryDep = { orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; + keyStore: Pick; }; export type TLicenseServiceFactory = ReturnType; @@ -46,12 +47,18 @@ export type TLicenseServiceFactory = ReturnType; const LICENSE_SERVER_CLOUD_LOGIN = "/api/auth/v1/license-server-login"; const LICENSE_SERVER_ON_PREM_LOGIN = "/api/auth/v1/license-login"; -const FEATURE_CACHE_KEY = (orgId: string, projectId?: string) => `${orgId}-${projectId || ""}`; -export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: TLicenseServiceFactoryDep) => { +const LICENSE_SERVER_CLOUD_PLAN_TTL = 30; // 30 second +const FEATURE_CACHE_KEY = (orgId: string) => `infisical-cloud-plan-${orgId}`; + +export const licenseServiceFactory = ({ + orgDAL, + permissionService, + licenseDAL, + keyStore +}: TLicenseServiceFactoryDep) => { let isValidLicense = false; let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); - const featureStore = new NodeCache({ stdTTL: 60 }); const appCfg = getConfig(); const licenseServerCloudApi = setupLicenceRequestWithStore( @@ -75,6 +82,7 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: isValidLicense = true; return; } + if (appCfg.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicence(); if (token) { @@ -100,22 +108,21 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: logger.info(`getPlan: attempting to fetch plan for [orgId=${orgId}] [projectId=${projectId}]`); try { if (instanceType === InstanceType.Cloud) { - const cachedPlan = featureStore.get(FEATURE_CACHE_KEY(orgId, projectId)); - if (cachedPlan) return cachedPlan; + const cachedPlan = await keyStore.getItem(FEATURE_CACHE_KEY(orgId)); + if (cachedPlan) return JSON.parse(cachedPlan) as TFeatureSet; const org = await orgDAL.findOrgById(orgId); if (!org) throw new BadRequestError({ message: "Org not found" }); const { data: { currentPlan } } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( - `/api/license-server/v1/customers/${org.customerId}/cloud-plan`, - { - params: { - workspaceId: projectId - } - } + `/api/license-server/v1/customers/${org.customerId}/cloud-plan` + ); + await keyStore.setItemWithExpiry( + FEATURE_CACHE_KEY(org.id), + LICENSE_SERVER_CLOUD_PLAN_TTL, + JSON.stringify(currentPlan) ); - featureStore.set(FEATURE_CACHE_KEY(org.id, projectId), currentPlan); return currentPlan; } } catch (error) { @@ -123,15 +130,20 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: `getPlan: encountered an error when fetching pan [orgId=${orgId}] [projectId=${projectId}] [error]`, error ); + await keyStore.setItemWithExpiry( + FEATURE_CACHE_KEY(orgId), + LICENSE_SERVER_CLOUD_PLAN_TTL, + JSON.stringify(onPremFeatures) + ); return onPremFeatures; } return onPremFeatures; }; - const refreshPlan = async (orgId: string, projectId?: string) => { + const refreshPlan = async (orgId: string) => { if (instanceType === InstanceType.Cloud) { - featureStore.del(FEATURE_CACHE_KEY(orgId, projectId)); - await getPlan(orgId, projectId); + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + await getPlan(orgId); } }; @@ -166,7 +178,7 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: quantity: count }); } - featureStore.del(orgId); + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null); await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { usedSeats }); @@ -215,7 +227,7 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: `/api/license-server/v1/customers/${organization.customerId}/session/trial`, { success_url } ); - featureStore.del(FEATURE_CACHE_KEY(orgId)); + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); return { url }; }; diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index a2c2c63c0..9d13cb342 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -4,7 +4,8 @@ import { ActorType } from "@app/services/auth/auth-type"; export enum SamlProviders { OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + JUMPCLOUD_SAML = "jumpcloud-saml", + GOOGLE_SAML = "google-saml" } export type TCreateSamlCfgDTO = { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a21825633..7880f79e3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -194,7 +194,7 @@ export const registerRoutes = async ( projectRoleDAL, serviceTokenDAL }); - const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL }); + const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); const trustedIpService = trustedIpServiceFactory({ licenseService, projectDAL, @@ -263,6 +263,8 @@ export const registerRoutes = async ( incidentContactDAL, tokenService, projectDAL, + projectMembershipDAL, + projectKeyDAL, smtpService, userDAL, orgBotDAL diff --git a/backend/src/server/routes/v1/identity-ua.ts b/backend/src/server/routes/v1/identity-ua.ts index 11b8e0e8d..4499a88e7 100644 --- a/backend/src/server/routes/v1/identity-ua.ts +++ b/backend/src/server/routes/v1/identity-ua.ts @@ -39,11 +39,12 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityUa, accessToken, identityAccessToken, validClientSecretInfo } = + const { identityUa, accessToken, identityAccessToken, validClientSecretInfo, identityMembershipOrg } = await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, event: { type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH, metadata: { diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 6eff558fb..fcc1a86e0 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -87,11 +87,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ - name: z.string().trim().optional(), + name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), slug: z .string() .trim() - .regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens") + .max(64, { message: "Slug must be 64 or fewer characters" }) + .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") .optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional() diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 0a5f8d85e..026b16f1f 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -222,7 +222,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { workspaceId: z.string().trim() }), body: z.object({ - name: z.string().trim().optional(), + name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), autoCapitalization: z.boolean().optional() }), response: { diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 8a80fb728..af1bf7212 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -120,7 +120,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/:folderId", + url: "/:folderIdOrName", method: "DELETE", schema: { description: "Delete a folder", @@ -131,7 +131,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } ], params: z.object({ - folderId: z.string() + folderIdOrName: z.string() }), body: z.object({ workspaceId: z.string().trim(), @@ -155,7 +155,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId, - id: req.params.folderId, + idOrName: req.params.folderIdOrName, path }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 3b73da6d4..d375a8fa5 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -54,6 +54,8 @@ export const identityUaServiceFactory = ({ const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) throw new UnauthorizedError(); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + checkIPAgainstBlocklist({ ipAddress: ip, trustedIps: identityUa.clientSecretTrustedIps as TIp[] @@ -131,7 +133,7 @@ export const identityUaServiceFactory = ({ } ); - return { accessToken, identityUa, validClientSecretInfo, identityAccessToken }; + return { accessToken, identityUa, validClientSecretInfo, identityAccessToken, identityMembershipOrg }; }; const attachUa = async ({ diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 0b3f9b4c3..ee4528dce 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -649,33 +649,21 @@ export const integrationAuthServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); - if (appId) { + + if (appId && appId !== "") { const query = ` - query project($id: String!) { - project(id: $id) { - createdAt - deletedAt - id - description - expiredAt - isPublic - isTempProject - isUpdatable - name - prDeploys - teamId - updatedAt - upstreamUrl - services { - edges { - node { - id - name - } - } - } + query project($id: String!) { + project(id: $id) { + services { + edges { + node { + id + name + } + } + } + } } - } `; const variables = { @@ -711,6 +699,7 @@ export const integrationAuthServiceFactory = ({ ); return edges.map(({ node: { name, id: serviceId } }) => ({ name, serviceId })); } + return []; }; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index abb6a7b1e..71c7ce686 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -441,16 +441,19 @@ const syncSecretsAWSParameterStore = async ({ }) => { if (!accessId) return; - AWS.config.update({ + const config = new AWS.Config({ region: integration.region as string, - accessKeyId: accessId, - secretAccessKey: accessToken + credentials: { + accessKeyId: accessId, + secretAccessKey: accessToken + } }); const ssm = new AWS.SSM({ apiVersion: "2014-11-06", region: integration.region as string }); + ssm.config.update(config); const params = { Path: integration.path as string, @@ -514,12 +517,6 @@ const syncSecretsAWSParameterStore = async ({ } }) ); - - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); }; /** @@ -541,12 +538,6 @@ const syncSecretsAWSSecretManager = async ({ try { if (!accessId) return; - AWS.config.update({ - region: integration.region as string, - accessKeyId: accessId, - secretAccessKey: accessToken - }); - secretsManager = new SecretsManagerClient({ region: integration.region as string, credentials: { @@ -575,12 +566,6 @@ const syncSecretsAWSSecretManager = async ({ }) ); } - - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); } catch (err) { if (err instanceof ResourceNotFoundException && secretsManager) { await secretsManager.send( @@ -590,11 +575,6 @@ const syncSecretsAWSSecretManager = async ({ }) ); } - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); } }; @@ -1224,21 +1204,21 @@ const syncSecretsRailway = async ({ } `; - const input = { - projectId: integration.appId, - environmentId: integration.targetEnvironmentId, - ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), - replace: true, - variables: getSecretKeyValuePair(secrets) + const variables = { + input: { + projectId: integration.appId, + environmentId: integration.targetEnvironmentId, + ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), + replace: true, + variables: getSecretKeyValuePair(secrets) + } }; await request.post( IntegrationUrls.RAILWAY_API_URL, { query, - variables: { - input - } + variables }, { headers: { diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index f39ac5366..28cf4b382 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -22,6 +22,8 @@ import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -44,6 +46,8 @@ type TOrgServiceFactoryDep = { orgRoleDAL: TOrgRoleDALFactory; userDAL: TUserDALFactory; projectDAL: TProjectDALFactory; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; smtpService: TSmtpService; @@ -65,6 +69,8 @@ export const orgServiceFactory = ({ permissionService, smtpService, projectDAL, + projectMembershipDAL, + projectKeyDAL, tokenService, orgBotDAL, licenseService, @@ -503,10 +509,50 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const membership = await orgDAL.deleteMembershipById(membershipId, orgId); + const deletedMembership = await orgDAL.transaction(async (tx) => { + const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx); - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return membership; + if (!orgMembership.userId) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + } + + // Get all the project memberships of the user in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); + + // Delete all the project memberships of the user in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId) + }, + receiverId: orgMembership.userId + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + }); + + return deletedMembership; }; /* diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index e0002f911..77ed867c4 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -82,5 +82,25 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; - return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByEmail }; + const findProjectMembershipsByUserId = async (orgId: string, userId: string) => { + try { + const memberships = await db(TableName.ProjectMembership) + .where({ userId }) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .where({ [`${TableName.Project}.orgId` as "orgId"]: orgId }) + .select(selectAllTableCols(TableName.ProjectMembership)); + + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Find project memberships by user id" }); + } + }; + + return { + ...projectMemberOrm, + findAllProjectMembers, + findProjectGhostUser, + findMembershipsByEmail, + findProjectMembershipsByUserId + }; }; diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index efc795cae..e1d45e96d 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -102,8 +102,11 @@ export const projectQueueFactory = ({ const oldProjectKey = await projectKeyDAL.findLatestProjectKey(data.startedByUserId, data.projectId); - if (!project || !oldProjectKey) { - throw new Error("Project or project key not found"); + if (!project) { + throw new Error("Project not found"); + } + if (!oldProjectKey) { + throw new Error("Old project key not found"); } if (project.upgradeStatus !== ProjectUpgradeStatus.Failed && project.upgradeStatus !== null) { @@ -267,8 +270,19 @@ export const projectQueueFactory = ({ const user = await userDAL.findUserEncKeyByUserId(key.receiverId); const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId }); - if (!user || !orgMembership) { - throw new Error(`User with ID ${key.receiverId} was not found during upgrade, or user is not in org.`); + if (!user) { + throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`); + } + + if (!orgMembership) { + // This can happen. Since we don't remove project memberships and project keys when a user is removed from an org, this is a valid case. + logger.info("User is not in organization", { + userId: key.receiverId, + orgId: project.orgId, + projectId: project.id + }); + // eslint-disable-next-line no-continue + continue; } const [newMember] = assignWorkspaceKeysToMembers({ @@ -532,7 +546,12 @@ export const projectQueueFactory = ({ logger.error("Failed to upgrade project, because no project was found", data); } else { await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.Failed); - logger.error(err, "Failed to upgrade project"); + logger.error("Failed to upgrade project", err, { + extra: { + project, + jobData: data + } + }); } throw err; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 53d2a9fdf..26c1c1f4f 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -164,7 +164,7 @@ export const secretFolderServiceFactory = ({ actorOrgId, environment, path: secretPath, - id + idOrName }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan( @@ -179,7 +179,10 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx); if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); - const [doc] = await folderDAL.delete({ envId: env.id, id, parentId: parentFolder.id }, tx); + const [doc] = await folderDAL.delete( + { envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id }, + tx + ); if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); return doc; }); diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 7a68434f5..88b7b1017 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -16,7 +16,7 @@ export type TUpdateFolderDTO = { export type TDeleteFolderDTO = { environment: string; path: string; - id: string; + idOrName: string; } & TProjectPermission; export type TGetFolderDTO = { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 4c14edc0c..ecd13dae1 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -375,6 +375,10 @@ export const secretServiceFactory = ({ await projectDAL.checkProjectUpgradeStatus(projectId); + if (inputSecret.newSecretName === "") { + throw new BadRequestError({ message: "New secret name cannot be empty" }); + } + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index d438adb33..1b69b8f23 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -145,6 +145,25 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo return loginTwoV2Response, nil } +func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse, error) { + var orgResponse GetOrganizationsResponse + response, err := httpClient. + R(). + SetResult(&orgResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/organization", config.INFISICAL_URL)) + + if err != nil { + return GetOrganizationsResponse{}, err + } + + if response.IsError() { + return GetOrganizationsResponse{}, fmt.Errorf("CallGetAllOrganizations: Unsuccessful response: [response=%v]", response) + } + + return orgResponse, nil +} + func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesResponse, error) { var workSpacesResponse GetWorkSpacesResponse response, err := httpClient. diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 67ac97c25..310976078 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -120,14 +120,21 @@ type PullSecretsByInfisicalTokenResponse struct { type GetWorkSpacesResponse struct { Workspaces []struct { - ID string `json:"_id"` - Name string `json:"name"` - Plan string `json:"plan,omitempty"` - V int `json:"__v"` - Organization string `json:"organization,omitempty"` + ID string `json:"_id"` + Name string `json:"name"` + Plan string `json:"plan,omitempty"` + V int `json:"__v"` + OrganizationId string `json:"orgId"` } `json:"workspaces"` } +type GetOrganizationsResponse struct { + Organizations []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"organizations"` +} + type Secret struct { SecretKeyCiphertext string `json:"secretKeyCiphertext,omitempty"` SecretKeyIV string `json:"secretKeyIV,omitempty"` @@ -292,10 +299,10 @@ type GetFoldersV1Response struct { } type CreateFolderV1Request struct { - FolderName string `json:"folderName"` + FolderName string `json:"name"` WorkspaceId string `json:"workspaceId"` Environment string `json:"environment"` - Directory string `json:"directory"` + Path string `json:"path"` } type CreateFolderV1Response struct { @@ -505,5 +512,5 @@ type GetRawSecretsV3Response struct { SecretComment string `json:"secretComment"` } `json:"secrets"` Imports []any `json:"imports"` - ETag string + ETag string } diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 750727df1..db7c81225 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -228,7 +228,9 @@ func secretTemplateFunction(accessToken string, existingEtag string, currentEtag *currentEtag = res.Etag } - return res.Secrets, nil + expandedSecrets := util.ExpandSecrets(res.Secrets, models.ExpandSecretsAuthentication{UniversalAuthAccessToken: accessToken}, "") + + return expandedSecrets, nil } } @@ -622,7 +624,7 @@ var agentCmd = &cobra.Command{ } if !FileExists(configPath) && agentConfigInBase64 == "" { - log.Error().Msgf("No agent config file provided. Please provide a agent config file", configPath) + log.Error().Msgf("No agent config file provided at %v. Please provide a agent config file", configPath) return } diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 26cc45f65..9c3590e89 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -87,7 +87,9 @@ var exportCmd = &cobra.Command{ var output string if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, "") + secrets = util.ExpandSecrets(secrets, models.ExpandSecretsAuthentication{ + InfisicalToken: infisicalToken, + }, "") } secrets = util.FilterSecretsByTag(secrets, tagSlugs) output, err = formatEnvs(secrets, format) diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index 070074fa9..e47eb447e 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -5,7 +5,6 @@ package cmd import ( "encoding/json" - "fmt" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" @@ -52,25 +51,19 @@ var initCmd = &cobra.Command{ httpClient := resty.New() httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) - workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) + + organizationResponse, err := api.CallGetAllOrganizations(httpClient) if err != nil { - util.HandleError(err, "Unable to pull projects that belong to you") + util.HandleError(err, "Unable to pull organizations that belong to you") } - workspaces := workspaceResponse.Workspaces - if len(workspaces) == 0 { - message := fmt.Sprintf("You don't have any projects created in Infisical. You must first create a project at %s", util.INFISICAL_TOKEN_NAME) - util.PrintErrorMessageAndExit(message) - } + organizations := organizationResponse.Organizations - var workspaceNames []string - for _, workspace := range workspaces { - workspaceNames = append(workspaceNames, workspace.Name) - } + organizationNames := util.GetOrganizationsNameList(organizationResponse) prompt := promptui.Select{ - Label: "Which of your Infisical projects would you like to connect this project to?", - Items: workspaceNames, + Label: "Which Infisical organization would you like to select a project from?", + Items: organizationNames, Size: 7, } @@ -79,7 +72,27 @@ var initCmd = &cobra.Command{ util.HandleError(err) } - err = writeWorkspaceFile(workspaces[index]) + selectedOrganization := organizations[index] + + workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) + if err != nil { + util.HandleError(err, "Unable to pull projects that belong to you") + } + + filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrganization.ID) + + prompt = promptui.Select{ + Label: "Which of your Infisical projects would you like to connect this project to?", + Items: workspaceNames, + Size: 7, + } + + index, _, err = prompt.Run() + if err != nil { + util.HandleError(err) + } + + err = writeWorkspaceFile(filteredWorkspaces[index]) if err != nil { util.HandleError(err) } diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 2bb043c26..48eaffea9 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -110,7 +110,9 @@ var runCmd = &cobra.Command{ } if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, projectConfigDir) + secrets = util.ExpandSecrets(secrets, models.ExpandSecretsAuthentication{ + InfisicalToken: infisicalToken, + }, projectConfigDir) } secretsByKey := getSecretsByKeys(secrets) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 778ac574e..667f39b95 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "os" "regexp" "sort" "strings" @@ -39,6 +40,11 @@ var secretsCmd = &cobra.Command{ } infisicalToken, err := cmd.Flags().GetString("token") + + if infisicalToken == "" { + infisicalToken = os.Getenv(util.INFISICAL_TOKEN_NAME) + } + if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -80,7 +86,9 @@ var secretsCmd = &cobra.Command{ } if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, "") + secrets = util.ExpandSecrets(secrets, models.ExpandSecretsAuthentication{ + InfisicalToken: infisicalToken, + }, "") } visualize.PrintAllSecretDetails(secrets) @@ -406,6 +414,11 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } + showOnlyValue, err := cmd.Flags().GetBool("raw-value") + if err != nil { + util.HandleError(err, "Unable to parse path flag") + } + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}, "") if err != nil { util.HandleError(err, "To fetch all secrets") @@ -427,7 +440,15 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - visualize.PrintAllSecretDetails(requestedSecrets) + if showOnlyValue && len(requestedSecrets) > 1 { + util.PrintErrorMessageAndExit("--raw-value only works with one secret.") + } + + if showOnlyValue { + fmt.Printf(requestedSecrets[0].Value) + } else { + visualize.PrintAllSecretDetails(requestedSecrets) + } Telemetry.CaptureEvent("cli-command:secrets get", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) } @@ -661,6 +682,7 @@ func init() { secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.AddCommand(secretsGetCmd) secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") + secretsGetCmd.Flags().Bool("raw-value", false, "Returns only the value of secret, only works with one secret") secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 71033fe72..576e74909 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -21,11 +21,12 @@ type LoggedInUser struct { } type SingleEnvironmentVariable struct { - Key string `json:"key"` - Value string `json:"value"` - Type string `json:"type"` - ID string `json:"_id"` - Tags []struct { + Key string `json:"key"` + WorkspaceId string `json:"workspace"` + Value string `json:"value"` + Type string `json:"type"` + ID string `json:"_id"` + Tags []struct { ID string `json:"_id"` Name string `json:"name"` Slug string `json:"slug"` @@ -45,11 +46,11 @@ type SingleFolder struct { } type Workspace struct { - ID string `json:"_id"` - Name string `json:"name"` - Plan string `json:"plan,omitempty"` - V int `json:"__v"` - Organization string `json:"organization,omitempty"` + ID string `json:"_id"` + Name string `json:"name"` + Plan string `json:"plan,omitempty"` + V int `json:"__v"` + OrganizationId string `json:"orgId"` } type WorkspaceConfigFile struct { @@ -68,6 +69,7 @@ type GetAllSecretsParameters struct { Environment string EnvironmentPassedViaFlag bool InfisicalToken string + UniversalAuthAccessToken string TagSlugs string WorkspaceId string SecretsPath string @@ -96,3 +98,8 @@ type DeleteFolderParameters struct { FolderPath string InfisicalToken string } + +type ExpandSecretsAuthentication struct { + InfisicalToken string + UniversalAuthAccessToken string +} diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 7275653d9..0f837ee71 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -154,7 +154,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er WorkspaceId: params.WorkspaceId, Environment: params.Environment, FolderName: params.FolderName, - Directory: params.FolderPath, + Path: params.FolderPath, } apiResponse, err := api.CallCreateFolderV1(httpClient, createFolderRequest) diff --git a/cli/packages/util/init.go b/cli/packages/util/init.go new file mode 100644 index 000000000..33350f3b7 --- /dev/null +++ b/cli/packages/util/init.go @@ -0,0 +1,45 @@ +package util + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/models" +) + +func GetOrganizationsNameList(organizationResponse api.GetOrganizationsResponse) []string { + organizations := organizationResponse.Organizations + + if len(organizations) == 0 { + message := fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", INFISICAL_DEFAULT_URL) + PrintErrorMessageAndExit(message) + } + + var organizationNames []string + for _, workspace := range organizations { + organizationNames = append(organizationNames, workspace.Name) + } + + return organizationNames +} + +func GetWorkspacesInOrganization(workspaceResponse api.GetWorkSpacesResponse, orgId string) ([]models.Workspace, []string) { + workspaces := workspaceResponse.Workspaces + + var filteredWorkspaces []models.Workspace + var workspaceNames []string + + for _, workspace := range workspaces { + if workspace.OrganizationId == orgId { + filteredWorkspaces = append(filteredWorkspaces, workspace) + workspaceNames = append(workspaceNames, workspace.Name) + } + } + + if len(filteredWorkspaces) == 0 { + message := fmt.Sprintf("You don't have any projects created in Infisical organization. You must first create a project at %s", INFISICAL_DEFAULT_URL) + PrintErrorMessageAndExit(message) + } + + return filteredWorkspaces, workspaceNames +} diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index fd1fe3b44..a1764d721 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -179,7 +179,7 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin } for _, secret := range rawSecrets.Secrets { - plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue}) + plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue, WorkspaceId: secret.Workspace}) } // if includeImports { @@ -191,7 +191,7 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin return models.PlaintextSecretResult{ Secrets: plainTextSecrets, - Hash: rawSecrets.ETag, + Etag: rawSecrets.ETag, }, nil } @@ -248,11 +248,8 @@ func FilterSecretsByTag(plainTextSecrets []models.SingleEnvironmentVariable, tag } func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectConfigFilePath string) ([]models.SingleEnvironmentVariable, error) { - var infisicalToken string if params.InfisicalToken == "" { - infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) - } else { - infisicalToken = params.InfisicalToken + params.InfisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) } isConnected := CheckIsConnectedToInternet() @@ -260,7 +257,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo // var serviceTokenDetails api.GetServiceTokenDetailsResponse var errorToReturn error - if infisicalToken == "" { + if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { if isConnected { log.Debug().Msg("GetAllEnvironmentVariables: Connected to internet, checking logged in creds") @@ -306,12 +303,6 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo infisicalDotJson.WorkspaceId = params.WorkspaceId } - // // Verify environment - // err = ValidateEnvironmentName(params.Environment, workspaceFile.WorkspaceId, loggedInUserDetails.UserCredentials) - // if err != nil { - // return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) - // } - secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, infisicalDotJson.WorkspaceId, params.Environment, params.TagSlugs, params.SecretsPath, params.IncludeImport) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", errorToReturn) @@ -332,91 +323,19 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } } else { - log.Debug().Msg("Trying to fetch secrets using service token") - secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(infisicalToken, params.Environment, params.SecretsPath, params.IncludeImport) - } + if params.InfisicalToken != "" { + log.Debug().Msg("Trying to fetch secrets using service token") + secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport) + } else if params.UniversalAuthAccessToken != "" { + log.Debug().Msg("Trying to fetch secrets using universal auth") + res, err := GetPlainTextSecretsViaMachineIdentity(params.UniversalAuthAccessToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport) - return secretsToReturn, errorToReturn -} - -// func ValidateEnvironmentName(environmentName string, workspaceId string, userLoggedInDetails models.UserCredentials) error { -// httpClient := resty.New() -// httpClient.SetAuthToken(userLoggedInDetails.JTWToken). -// SetHeader("Accept", "application/json") - -// response, err := api.CallGetAccessibleEnvironments(httpClient, api.GetAccessibleEnvironmentsRequest{WorkspaceId: workspaceId}) -// if err != nil { -// return err -// } - -// listOfEnvSlugs := []string{} -// mapOfEnvSlugs := make(map[string]interface{}) - -// for _, environment := range response.AccessibleEnvironments { -// listOfEnvSlugs = append(listOfEnvSlugs, environment.Slug) -// mapOfEnvSlugs[environment.Slug] = environment -// } - -// _, exists := mapOfEnvSlugs[environmentName] -// if !exists { -// HandleError(fmt.Errorf("the environment [%s] does not exist in project with [id=%s]. Only [%s] are available", environmentName, workspaceId, strings.Join(listOfEnvSlugs, ","))) -// } - -// return nil - -// } - -func getExpandedEnvVariable(secrets []models.SingleEnvironmentVariable, variableWeAreLookingFor string, hashMapOfCompleteVariables map[string]string, hashMapOfSelfRefs map[string]string) string { - if value, found := hashMapOfCompleteVariables[variableWeAreLookingFor]; found { - return value - } - - for _, secret := range secrets { - if secret.Key == variableWeAreLookingFor { - regex := regexp.MustCompile(`\${([^\}]*)}`) - variablesToPopulate := regex.FindAllString(secret.Value, -1) - - // case: variable is a constant so return its value - if len(variablesToPopulate) == 0 { - return secret.Value - } - - valueToEdit := secret.Value - for _, variableWithSign := range variablesToPopulate { - variableWithoutSign := strings.Trim(variableWithSign, "}") - variableWithoutSign = strings.Trim(variableWithoutSign, "${") - - // case: reference to self - if variableWithoutSign == secret.Key { - hashMapOfSelfRefs[variableWithoutSign] = variableWithoutSign - continue - } else { - var expandedVariableValue string - - if preComputedVariable, found := hashMapOfCompleteVariables[variableWithoutSign]; found { - expandedVariableValue = preComputedVariable - } else { - expandedVariableValue = getExpandedEnvVariable(secrets, variableWithoutSign, hashMapOfCompleteVariables, hashMapOfSelfRefs) - hashMapOfCompleteVariables[variableWithoutSign] = expandedVariableValue - } - - // If after expanding all the vars above, is the current var a self ref? if so no replacement needed for it - if _, found := hashMapOfSelfRefs[variableWithoutSign]; found { - continue - } else { - valueToEdit = strings.ReplaceAll(valueToEdit, variableWithSign, expandedVariableValue) - } - } - } - - return valueToEdit - - } else { - continue + errorToReturn = err + secretsToReturn = res.Secrets } } - return "${" + variableWeAreLookingFor + "}" + return secretsToReturn, errorToReturn } var secRefRegex = regexp.MustCompile(`\${([^\}]*)}`) @@ -428,7 +347,7 @@ func recursivelyExpandSecret(expandedSecs map[string]string, interpolatedSecs ma interpolatedVal, ok := interpolatedSecs[key] if !ok { - HandleError(fmt.Errorf("Could not find refered secret - %s", key), "Kindly check whether its provided") + HandleError(fmt.Errorf("could not find refered secret - %s", key), "Kindly check whether its provided") } refs := secRefRegex.FindAllStringSubmatch(interpolatedVal, -1) @@ -467,7 +386,7 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod return secretMapByName } -func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken string, projectConfigPathDir string) []models.SingleEnvironmentVariable { +func ExpandSecrets(secrets []models.SingleEnvironmentVariable, auth models.ExpandSecretsAuthentication, projectConfigPathDir string) []models.SingleEnvironmentVariable { expandedSecs := make(map[string]string) interpolatedSecs := make(map[string]string) // map[env.secret-path][keyname]Secret @@ -499,8 +418,18 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken st uniqKey := fmt.Sprintf("%s.%s", env, secPathDot) if crossRefSec, ok := crossEnvRefSecs[uniqKey]; !ok { + + var refSecs []models.SingleEnvironmentVariable + var err error + // if not in cross reference cache, fetch it from server - refSecs, err := GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: infisicalToken, SecretsPath: secPath}, projectConfigPathDir) + if auth.InfisicalToken != "" { + refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: auth.InfisicalToken, SecretsPath: secPath}, projectConfigPathDir) + } else if auth.UniversalAuthAccessToken != "" { + refSecs, err = GetAllEnvironmentVariables((models.GetAllSecretsParameters{Environment: env, UniversalAuthAccessToken: auth.UniversalAuthAccessToken, SecretsPath: secPath, WorkspaceId: sec.WorkspaceId}), projectConfigPathDir) + } else { + HandleError(errors.New("no authentication provided"), "Please provide authentication to fetch secrets") + } if err != nil { HandleError(err, fmt.Sprintf("Could not fetch secrets in environment: %s secret-path: %s", env, secPath), "If you are using a service token to fetch secrets, please ensure it is valid") } @@ -508,6 +437,7 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken st // save it to avoid calling api again for same environment and folder path crossEnvRefSecs[uniqKey] = refSecsByKey return refSecsByKey[secKey].Value + } else { return crossRefSec[secKey].Value } diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 8e2183ad9..cf76f1842 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -8,24 +8,28 @@ infisical secrets ``` ## Description + This command enables you to perform CRUD (create, read, update, delete) operations on secrets within your Infisical project. With it, you can view, create, update, and delete secrets in your environment. -### Sub-commands +### Sub-commands + Use this command to print out all of the secrets in your project - ```bash - $ infisical secrets - ``` +```bash +$ infisical secrets +``` + +### Environment variables - ### Environment variables Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec ``` + @@ -34,22 +38,26 @@ This command enables you to perform CRUD (create, read, update, delete) operatio To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### Flags +### Flags + Parse shell parameter expansions in your secrets Default value: `true` + Used to select the environment name on which actions should be taken on Default value: `dev` + The `--path` flag indicates which project folder secrets will be injected from. @@ -58,6 +66,7 @@ This command enables you to perform CRUD (create, read, update, delete) operatio # Example infisical secrets --path="/" --env=dev ``` + @@ -65,38 +74,55 @@ This command enables you to perform CRUD (create, read, update, delete) operatio This command allows you selectively print the requested secrets by name - ```bash - $ infisical secrets get ... +```bash +$ infisical secrets get ... - # Example - $ infisical secrets get DOMAIN +# Example +$ infisical secrets get DOMAIN - ``` +``` + +### Flags - ### Flags Used to select the environment name on which actions should be taken on Default value: `dev` + + + + + Used to print the plain value of a single requested secret without any table style. + + Default value: `false` + + Example: `infisical secrets get DOMAIN --raw-value` + + + When running in CI/CD environments or in a script, set `INFISICAL_DISABLE_UPDATE_CHECK` env to `true`. This will help hide any CLI update messages and only show the secret value. + + -This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. +This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. If the secret key does not exist, a new secret will be created using both the key and value provided. ```bash $ infisical secrets set ... -## Example +## Example $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe ``` - ### Flags +### Flags + Used to select the environment name on which actions should be taken on Default value: `dev` + Used to select the project folder in which the secrets will be set. This is useful when creating new secrets under a particular path. @@ -105,43 +131,48 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb # Example infisical secrets set DOMAIN=example.com --path="common/backend" ``` + This command allows you to delete secrets by their name(s). - ```bash - $ infisical secrets delete ... +```bash +$ infisical secrets delete ... - ## Example - $ infisical secrets delete STRIPE_API_KEY DOMAIN HASH - ``` +## Example +$ infisical secrets delete STRIPE_API_KEY DOMAIN HASH +``` + +### Flags - ### Flags Used to select the environment name on which actions should be taken on Default value: `dev` + - The `--path` flag indicates which project folder secrets will be injected from. + The `--path` flag indicates which project folder secrets will be injected from. ```bash # Example infisical secrets delete ... --path="/" ``` + This command allows you to fetch, create and delete folders from within a path from a given project. - ```bash - $ infisical secrets folders - ``` +```bash +$ infisical secrets folders +``` + +### sub commands - ### sub commands Used to fetch all folders within a path in a given project ``` @@ -179,6 +210,7 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb Default value: `` + @@ -194,10 +226,11 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb - Name of the folder to be deleted within selected `--path` + Name of the folder to be deleted within selected `--path` Default value: `` + @@ -210,14 +243,16 @@ To place default values in your example .env file, you can simply include the sy ```bash $ infisical secrets generate-example-env -## Example +## Example $ infisical secrets generate-example-env > .example-env ``` - ### Flags +### Flags + Used to select the environment name on which actions should be taken on Default value: `dev` + diff --git a/docs/documentation/platform/project-upgrade.mdx b/docs/documentation/platform/project-upgrade.mdx index 6c7e509f5..8286342d0 100644 --- a/docs/documentation/platform/project-upgrade.mdx +++ b/docs/documentation/platform/project-upgrade.mdx @@ -2,7 +2,6 @@ title: "Enhancing Security and Usability: Project Upgrades" --- - At Infisical, we're constantly striving to elevate the security and usability standards of our platform to better serve our users. With this commitment in mind, we're excited to introduce our latest addition, non-E2EE projects, aimed at addressing two significant issues while enhancing how clients interact with Infisical programmatically. @@ -11,11 +10,11 @@ Additionally, our API lacked the capability to interact with projects without de These obstacles made API driven automation and collaboration a painful experience for a majority of our users. To overcome these limitations, our upgrade focuses on disabling end-to-end encryption (E2EE) for projects. -While this may raise eyebrows, it's important to understand that this decision is a strategic move to make Infisical easer to use and interact with. +While this may raise eyebrows, it's important to understand that this decision is a strategic move to make Infisical easier to use and interact with. -But what does this mean for our users? Essentially nothing, there are no changes required on your end. +But what does this mean for our users? Essentially nothing, there are no changes required on your end. Rest assured, all sensitive data remains encrypted at rest according to the latest industry standards. Our commitment to security remains unwavering, and this upgrade is a testament to our dedication to delivering on our promises in both security and usability when it comes to secrets management. -To increase consistency with existing and future integrations, all projects created on Infisical from now on will have end-to-end encryption (E2EE) disabled by default. +To increase consistency with existing and future integrations, all projects created on Infisical from now on will have end-to-end encryption (E2EE) disabled by default. This will not only reduce confusion for end users, but will also make the Infisical API seamless to use. diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 329bc4b9f..66961db99 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -10,7 +10,7 @@ This means that updating the value of a base secret propagates directly to other Currently, the secret referencing feature is only supported by the - [Infisical CLI](/cli/overview) and [native integrations](/integrations/overview). + [Infisical CLI](/cli/overview), [native integrations](/integrations/overview) and [Infisical Agent](/infisical-agent/overview). We intend to add support for it to the [Node SDK](https://infisical.com/docs/sdks/languages/node), [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java) this quarter. diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx new file mode 100644 index 000000000..743c4e3ff --- /dev/null +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -0,0 +1,95 @@ +--- +title: "Google SAML" +description: "Configure Google SAML for Infisical SSO" +--- + + + Google SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + + + + In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. + + Next, note the **ACS URL** and **SP Entity ID** to use when configuring the Google SAML application. + + ![Google SAML initial configuration](../../../images/sso/google-saml/init-config.png) + + + 2.1. In your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps and + create a **custom SAML app**. + + ![Google SAML app creation](../../../images/sso/google-saml/create-custom-saml-app.png) + + 2.2. In the **App details** tab, give the application a unique name like Infisical. + + ![Google SAML app naming](../../../images/sso/google-saml/name-custom-saml-app.png) + + 2.3. In the **Google Identity Provider details** tab, copy the **SSO URL**, **Entity ID** and **Certificate**. + + ![Google SAML custom app details](../../../images/sso/google-saml/custom-saml-app-config.png) + + 2.4. Back in Infisical, set **SSO URL**, **IdP Entity ID**, and **Certificate** to the corresponding items from step 2.3. + + ![Google SAML Infisical config](../../../images/sso/google-saml/infisical-config.png) + + 2.5. Back in the Google Admin console, in the **Service provider details** tab, set the **ACS URL** and **Entity ID** to the corresponding items from step 1. + + Also, check the **Signed response** checkbox. + + ![Google SAML app config 2](../../../images/sso/google-saml/custom-saml-app-config-2.png) + + 2.6. In the **Attribute mapping** tab, configure the following map: + + - **First name** -> **firstName** + - **Last name** -> **lastName** + - **Primary email** -> **email** + + ![Google SAML attribute mapping](../../../images/sso/google-saml/attribute-mapping.png) + + Click **Finish**. + + + Back in your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps > your SAML app + and press on **User access**. + + ![Google SAML user access](../../../images/sso/google-saml/user-access.png) + + To assign everyone in your organization to the application, click **On for everyone** or **Off for everyone** and then click **Save**. + + You can also assign an organizational unit or set of users to an application; you can learn more about that [here](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). + + ![Google SAML user access assignment](../../../images/sso/google-saml/user-access-assign.png) + + + Enabling SAML SSO allows members in your organization to log into Infisical via Google Workspace. + + ![Google SAML enable](../../../images/sso/google-saml/enable-saml.png) + + + Enforcing SAML SSO ensures that members in your organization can only access Infisical + by logging into the organization via Google. + + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Google user with Infisical; + Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Google + prior to enforcing SAML SSO to prevent any unintended issues. + + + + + + If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to + set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: + + - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. + - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + + +References: +- Google's guide to [set up your own custom SAML app](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). \ No newline at end of file diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index cd2f8ff31..e1fd25957 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -22,3 +22,4 @@ your IdP cannot and will not have access to the decryption key needed to decrypt - [Okta SAML](/documentation/platform/sso/okta) - [Azure SAML](/documentation/platform/sso/azure) - [JumpCloud SAML](/documentation/platform/sso/jumpcloud) +- [Google SAML](/documentation/platform/sso/google-saml) diff --git a/docs/images/docker-swarm-secrets-complete.png b/docs/images/docker-swarm-secrets-complete.png new file mode 100644 index 000000000..28b439445 Binary files /dev/null and b/docs/images/docker-swarm-secrets-complete.png differ diff --git a/docs/images/sso/google-saml/attribute-mapping.png b/docs/images/sso/google-saml/attribute-mapping.png new file mode 100644 index 000000000..b5702cd2b Binary files /dev/null and b/docs/images/sso/google-saml/attribute-mapping.png differ diff --git a/docs/images/sso/google-saml/create-custom-saml-app.png b/docs/images/sso/google-saml/create-custom-saml-app.png new file mode 100644 index 000000000..6139932f1 Binary files /dev/null and b/docs/images/sso/google-saml/create-custom-saml-app.png differ diff --git a/docs/images/sso/google-saml/custom-saml-app-config-2.png b/docs/images/sso/google-saml/custom-saml-app-config-2.png new file mode 100644 index 000000000..9839dd0c4 Binary files /dev/null and b/docs/images/sso/google-saml/custom-saml-app-config-2.png differ diff --git a/docs/images/sso/google-saml/custom-saml-app-config.png b/docs/images/sso/google-saml/custom-saml-app-config.png new file mode 100644 index 000000000..8f4ad5928 Binary files /dev/null and b/docs/images/sso/google-saml/custom-saml-app-config.png differ diff --git a/docs/images/sso/google-saml/enable-saml.png b/docs/images/sso/google-saml/enable-saml.png new file mode 100644 index 000000000..7a90eed55 Binary files /dev/null and b/docs/images/sso/google-saml/enable-saml.png differ diff --git a/docs/images/sso/google-saml/infisical-config.png b/docs/images/sso/google-saml/infisical-config.png new file mode 100644 index 000000000..250b4ed37 Binary files /dev/null and b/docs/images/sso/google-saml/infisical-config.png differ diff --git a/docs/images/sso/google-saml/init-config.png b/docs/images/sso/google-saml/init-config.png new file mode 100644 index 000000000..c4b967e54 Binary files /dev/null and b/docs/images/sso/google-saml/init-config.png differ diff --git a/docs/images/sso/google-saml/name-custom-saml-app.png b/docs/images/sso/google-saml/name-custom-saml-app.png new file mode 100644 index 000000000..580896d05 Binary files /dev/null and b/docs/images/sso/google-saml/name-custom-saml-app.png differ diff --git a/docs/images/sso/google-saml/user-access-assign.png b/docs/images/sso/google-saml/user-access-assign.png new file mode 100644 index 000000000..afa115c65 Binary files /dev/null and b/docs/images/sso/google-saml/user-access-assign.png differ diff --git a/docs/images/sso/google-saml/user-access.png b/docs/images/sso/google-saml/user-access.png new file mode 100644 index 000000000..bd69c2277 Binary files /dev/null and b/docs/images/sso/google-saml/user-access.png differ diff --git a/docs/infisical-agent/guides/docker-swarm-with-agent.mdx b/docs/infisical-agent/guides/docker-swarm-with-agent.mdx new file mode 100644 index 000000000..8ab4ca962 --- /dev/null +++ b/docs/infisical-agent/guides/docker-swarm-with-agent.mdx @@ -0,0 +1,164 @@ +--- +title: 'Docker Swarm' +description: "How to manage secrets in Docker Swarm services" +--- + +In this guide, we'll demonstrate how to use Infisical for managing secrets within Docker Swarm. +Specifically, we'll set up a sidecar container using the [Infisical Agent](/infisical-agent/overview), which authenticates with Infisical to retrieve secrets and access tokens. +These secrets are then stored in a shared volume accessible by other services in your Docker Swarm. + +## Prerequisites +- Infisical account +- Docker version 20.10.24 or newer +- Basic knowledge of Docker Swarm +- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your system +- Familiarity with the [Infisical Agent](/infisical-agent/overview) + +## Objective +Our goal is to deploy an Nginx instance in your Docker Swarm cluster, configured to display Infisical secrets on its landing page. This will provide hands-on experience in fetching and utilizing secrets from Infisical within Docker Swarm. The principles demonstrated here are also applicable to Docker Compose deployments. + + + + Start by cloning the [Infisical guide assets repository](https://github.com/Infisical/infisical-guides.git) from Github. This repository includes necessary assets for this and other Infisical guides. Focus on the `docker-swarm-with-agent` sub-directory, which we'll use as our working directory. + + + + To allow the agent to fetch your Infisical secrets, choose an authentication method for the agent. For this guide, we will use [Universal Auth](/documentation/platform/identities/universal-auth) for authentication. Follow the instructions [here](/documentation/platform/identities/universal-auth) to generate a client ID and client secret. + + + + Copy the client ID and client secret obtained in the previous step into the `client-id` and `client-secret` text files, respectively. + + + + The Infisical Agent will authenticate using Universal Auth and retrieve secrets for rendering as specified in the template(s). + Adjust the `polling-interval` to control the frequency of secret updates. + + In the example template, the secrets are rendered as an HTML page, which will be set as Nginx's home page to demonstrate successful secret retrieval and utilization. + + + Remember to add your project id, environment slug and path of corresponding Infisical project to the secret template. + + + + ```yaml infisical-agent-config + infisical: + address: "https://app.infisical.com" + auth: + type: "universal-auth" + config: + client-id: "/run/secrets/infisical-universal-auth-client-id" + client-secret: "/run/secrets/infisical-universal-auth-client-secret" + remove_client_secret_on_read: false + sinks: + - type: "file" + config: + path: "/infisical-secrets/access-token" + templates: + - source-path: /run/secrets/nginx-home-page-template + destination-path: /infisical-secrets/index.html + config: + polling-interval: 60s + ``` + + Some paths contain `/run/secrets/` because the contents of those files reside in a [Docker secret](https://docs.docker.com/engine/swarm/secrets/#how-docker-manages-secrets). + + + + ```html nginx-home-page-template + + + +

This file is rendered by Infisical agent template engine

+

Here are the secrets that have been fetched from Infisical and stored in your volume mount

+
    + {{- with secret "7df67a5f-d26a-4988-a375-7153c08149da" "dev" "/" }} + {{- range . }} +
  1. {{ .Key }}={{ .Value }}
  2. + {{- end }} + {{- end }} +
+ + + ``` +
+
+
+ + + Define the `infisical-agent` and `nginx` services in your Docker Compose file. `infisical-agent` will handle secret retrieval and storage. These secrets are stored in a volume, accessible by other services like Nginx. + + ```yaml docker-compose.yaml + version: "3.1" + + services: + infisical-agent: + container_name: infisical-agnet + image: infisical/cli:0.18.0 + command: agent --config=/run/secrets/infisical-agent-config + volumes: + - infisical-agent:/infisical-secrets + secrets: + - infisical-universal-auth-client-id + - infisical-universal-auth-client-secret + - infisical-agent-config + - nginx-home-page-template + networks: + - infisical_network + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - infisical-agent:/usr/share/nginx/html + networks: + - infisical_network + + volumes: + infisical-agent: + + secrets: + infisical-universal-auth-client-id: + file: ./client-id + infisical-universal-auth-client-secret: + file: ./client-secret + infisical-agent-config: + file: ./infisical-agent-config + nginx-home-page-template: + file: ./nginx-home-page-template + + + networks: + infisical_network: + ``` + + + + ``` + docker swarm init + ``` + + + + ``` + docker stack deploy -c docker-compose.yaml agent-demo + ``` + + + + To confirm that secrets are properly rendered and accessible, navigate to `http://localhost`. You should see the Infisical secrets displayed on the Nginx landing page. + + ![Nginx displaying Infisical secrets](/images/docker-swarm-secrets-complete.png) + + + + ``` + docker stack rm agent-demo + ``` + +
+ +## Considerations +- Secret Updates: Applications that access secrets directly from the volume mount will receive updates in real-time, in accordance with the `polling-interval` set in agent config. +- In-Memory Secrets: If your application loads secrets into memory, the new secrets will be available to the application on the next deployment. diff --git a/docs/infisical-agent/overview.mdx b/docs/infisical-agent/overview.mdx index c98090289..f62194ad8 100644 --- a/docs/infisical-agent/overview.mdx +++ b/docs/infisical-agent/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Infisical Agent" +title: "Overview" --- Infisical Agent is a client daemon that simplifies the adoption of Infisical by providing a more scalable and user-friendly approach for applications to interact with Infisical. @@ -51,6 +51,9 @@ While specifying an authentication method is mandatory to start the agent, confi | `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | | `templates[].source-path` | The path to the template file that should be used to render secrets. | | `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | +| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `60s` (optional) | +| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | +| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | ## Quick start Infisical Agent @@ -76,6 +79,11 @@ sinks: templates: - source-path: my-dot-ev-secret-template destination-path: /some/path/.env + config: + polling-interval: 60s + execute: + timeout: 30 + command: ./reload-app.sh ``` Above is an example agent configuration file that defines the token authentication method, one sink location (where to deposit access tokens after renewal) and a secret template. diff --git a/docs/internals/components.mdx b/docs/internals/components.mdx index 02c6d3692..29522b0bb 100644 --- a/docs/internals/components.mdx +++ b/docs/internals/components.mdx @@ -9,9 +9,7 @@ The Infisical API (sometimes referred to as the **backend**) contains the core p ## Storage backend -Infisical relies on a storage backend to store data including users and secrets. - -Currently, the only supported storage backend is [MongoDB](https://www.mongodb.com) but we plan to add support for other options including PostgreSQL in Q1 2024. +Infisical relies on a storage backend to store data including users and secrets. Infisical's storage backend is Postgres. ## Redis @@ -27,4 +25,4 @@ Clients are any application or infrastructure that connecting to the Infisical A - Public API: Making API requests directly to the Infisical API. - Client SDK: A platform-specific library with method abstractions for working with secrets. Currently, there are three official SDKs: [Node SDK](https://infisical.com/docs/sdks/languages/node), [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java). - CLI: A terminal-based interface for interacting with the Infisical API. -- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store \ No newline at end of file +- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store diff --git a/docs/mint.json b/docs/mint.json index 852b93853..cb6ce08ac 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -146,7 +146,8 @@ "documentation/platform/sso/gitlab", "documentation/platform/sso/okta", "documentation/platform/sso/azure", - "documentation/platform/sso/jumpcloud" + "documentation/platform/sso/jumpcloud", + "documentation/platform/sso/google-saml" ] }, { @@ -227,12 +228,27 @@ { "group": "Agent", "pages": [ - "infisical-agent/overview" + "infisical-agent/overview", + { + "group": "Use cases", + "pages": [ + "infisical-agent/guides/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + } ] }, { "group": "Infrastructure Integrations", "pages": [ + { + "group": "Container orchestrators", + "pages": [ + "integrations/platforms/kubernetes", + "infisical-agent/guides/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + }, { "group": "Docker", "pages": [ @@ -242,10 +258,8 @@ "integrations/platforms/docker-compose" ] }, - "integrations/platforms/kubernetes", "integrations/frameworks/terraform", - "integrations/platforms/ansible", - "integrations/platforms/ecs-with-agent" + "integrations/platforms/ansible" ] }, { diff --git a/docs/self-hosting/configuration/requirements.mdx b/docs/self-hosting/configuration/requirements.mdx index 45b1b92ff..262c7fb7c 100644 --- a/docs/self-hosting/configuration/requirements.mdx +++ b/docs/self-hosting/configuration/requirements.mdx @@ -58,7 +58,7 @@ Redis requirements: - Use Redis versions 6.x or 7.x. We advise upgrading to at least Redis 6.2. - Redis Cluster mode is currently not supported; use Redis Standalone, with or without High Availability (HA). -- Redis storage needs are minimal: a setup with 1 vCPU, 1 GB RAM, and 1GB SSD will be sufficient for most deployments. +- Redis storage needs are minimal: a setup with 1 vCPU, 1 GB RAM, and 1GB SSD will be sufficient for small deployments. ## Supported Web Browsers @@ -68,4 +68,4 @@ Infisical supports a range of web browsers. However, features such as browser-ba - [Google Chrome](https://www.google.com/chrome/) - [Chromium](https://www.chromium.org/getting-involved/dev-channel/) - [Apple Safari](https://www.apple.com/safari/) -- [Microsoft Edge](https://www.microsoft.com/en-us/edge?form=MA13FJ) \ No newline at end of file +- [Microsoft Edge](https://www.microsoft.com/en-us/edge?form=MA13FJ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 150d32372..710c7d8bf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,5 +1,5 @@ { - "name": "npm-proj-1709284350861-0.8379672465905885Iibrej", + "name": "frontend", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 87e278119..dc0e1a491 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -30,7 +30,7 @@ export const Checkbox = ({
-
- -
- - - - - - {userAvailableEnvs?.map(({ name, slug }, index) => { - const envSecKeyCount = getEnvSecretKeyCount(slug); - const missingKeyCount = secKeys.length - envSecKeyCount; - return ( -
-
- Name - setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))} - > - - -
-
-
-
+
+
- + handlePopUpToggle("addSecretsInAllEnvs", isOpen)} + onClose={() => handlePopUpClose("addSecretsInAllEnvs")} + decryptFileKey={latestFileKey!} + /> + handlePopUpToggle("addFolder", isOpen)} + > + + + + + ); }; diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx new file mode 100644 index 000000000..403564274 --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -0,0 +1,222 @@ +import { Controller, useForm } from "react-hook-form"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + Checkbox, + FormControl, + FormLabel, + Input, + Modal, + ModalContent, + SecretInput, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateFolder, useCreateSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; +import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types"; + +const typeSchema = z + .object({ + key: z.string().min(1, "Key is required"), + value: z.string().optional(), + environments: z.record(z.boolean().optional()) + }) + .refine((data) => data.key !== undefined, { + message: "Please enter secret name" + }); + +type TFormSchema = z.infer; + +type Props = { + secretPath?: string; + decryptFileKey: UserWsKeyPair; + getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; + // modal props + isOpen?: boolean; + onClose: () => void; + onTogglePopUp: (isOpen: boolean) => void; +}; + +export const CreateSecretForm = ({ + secretPath = "/", + decryptFileKey, + isOpen, + getSecretByKey, + onClose, + onTogglePopUp +}: Props) => { + const { + register, + handleSubmit, + control, + reset, + watch, + formState: { isSubmitting, errors } + } = useForm({ resolver: zodResolver(typeSchema) }); + const newSecretKey = watch("key"); + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const environments = currentWorkspace?.environments || []; + + const { createNotification } = useNotificationContext(); + + const { mutateAsync: createSecretV3 } = useCreateSecretV3(); + const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + const { mutateAsync: createFolder } = useCreateFolder(); + + const handleFormSubmit = async ({ key, value, environments: selectedEnv }: TFormSchema) => { + const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]); + const isEnvironmentsSelected = environmentsSelected.length; + + if (!isEnvironmentsSelected) { + createNotification({ type: "error", text: "Select at least one environment" }); + return; + } + + const promises = environmentsSelected.map(async (env) => { + const environment = env.slug; + // create folder if not existing + if (secretPath !== "/") { + // /hello/world -> [hello","world"] + const pathSegment = secretPath.split("/").filter(Boolean); + const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; + const folderName = pathSegment.at(-1); + if (folderName && parentPath) { + await createFolder({ + projectId: workspaceId, + path: parentPath, + environment, + name: folderName + }); + } + } + + const isEdit = getSecretByKey(environment, key) !== undefined; + if (isEdit) { + return updateSecretV3({ + environment, + workspaceId, + secretPath, + secretName: key, + secretValue: value || "", + type: "shared", + latestFileKey: decryptFileKey + }); + } + + return createSecretV3({ + environment, + workspaceId, + secretPath, + secretName: key, + secretValue: value || "", + secretComment: "", + type: "shared", + latestFileKey: decryptFileKey + }); + }); + + const results = await Promise.allSettled(promises); + const isSecretsAdded = results.some((result) => result.status === "fulfilled"); + + if (isSecretsAdded) { + createNotification({ + type: "success", + text: "Secrets created successfully" + }); + onClose(); + reset(); + } else { + createNotification({ + type: "error", + text: "Failed to create secrets" + }); + } + }; + return ( + + +
+ + + + ( + + + + )} + /> + +
+ {environments.map((env) => { + return ( + ( + + {env.name} + {getSecretByKey(env.slug, newSecretKey) && ( + + + + )} + + )} + /> + ); + })} +
+
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/index.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/index.tsx new file mode 100644 index 000000000..ffbe98f57 --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/index.tsx @@ -0,0 +1 @@ +export { CreateSecretForm } from "./CreateSecretForm" diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 08774535b..719b17d44 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -15,6 +15,7 @@ import { useToggle } from "@app/hooks"; import { DecryptedSecret } from "@app/hooks/api/secrets/types"; import { SecretEditRow } from "./SecretEditRow"; +import SecretRenameRow from "./SecretRenameRow"; type Props = { secretKey: string; @@ -105,6 +106,13 @@ export const SecretOverviewTableRow = ({ width: `calc(${expandableColWidth}px - 1rem)` }} > + + diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx new file mode 100644 index 000000000..a2813abee --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx @@ -0,0 +1,258 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api"; +import { DecryptedSecret } from "@app/hooks/api/types"; +import { SecretActionType } from "@app/views/SecretMainPage/components/SecretListView/SecretListView.utils"; + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; +}; + +export const formSchema = z.object({ + key: z.string().trim().min(1, { message: "Secret key is required" }) +}); + +type TFormSchema = z.infer; + +function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) { + const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); + const { createNotification } = useNotificationContext(); + + const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey)); + + const isReadOnly = environments.some((env) => { + const environment = env.slug; + const isSecretInEnvReadOnly = + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ) && + permission.cannot( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + if (isSecretInEnvReadOnly) { + return true; + } + return false; + }); + + const isOverriden = secrets.some( + (secret) => + secret?.overrideAction === SecretActionType.Created || + secret?.overrideAction === SecretActionType.Modified + ); + const workspaceId = currentWorkspace?.id || ""; + + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); + + const [isSecNameCopied, setIsSecNameCopied] = useToggle(false); + + const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + + const { + handleSubmit, + control, + reset, + trigger, + getValues, + formState: { isDirty, isSubmitting, errors } + } = useForm({ + defaultValues: { key: secretKey }, + values: { key: secretKey }, + resolver: zodResolver(formSchema) + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isSecNameCopied) { + timer = setTimeout(() => setIsSecNameCopied.off(), 2000); + } + return () => clearTimeout(timer); + }, [isSecNameCopied]); + + const handleFormSubmit = async (data: TFormSchema) => { + if (!data.key) { + createNotification({ + type: "error", + text: "Secret name cannot be empty" + }); + return; + } + + const promises = secrets + .filter((secret) => !!secret) + .map((secret) => { + if (!secret) return null; + + return updateSecretV3({ + environment: secret?.env, + workspaceId, + secretPath, + secretName: secret.key, + secretId: secret.id, + secretValue: secret.value || "", + type: "shared", + latestFileKey: decryptFileKey!, + tags: secret.tags.map((tag) => tag.id), + secretComment: secret.comment, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderNote: secret.reminderNote, + skipMultilineEncoding: secret.skipMultilineEncoding, + newSecretName: data.key + }); + }); + + await Promise.all(promises) + .then(() => { + createNotification({ + type: "success", + text: "Successfully renamed the secret" + }); + }) + .catch(() => { + createNotification({ + type: "error", + text: "Error renaming the secret" + }); + }); + }; + + const copyTokenToClipboard = () => { + const [key] = getValues(["key"]); + navigator.clipboard.writeText(key as string); + setIsSecNameCopied.on(); + }; + + return ( +
+
+ + Key + + + ( + trigger("key")} + isError={Boolean(error)} + {...field} + className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent" + /> + )} + /> +
+ + {isReadOnly || isOverriden ? ( + Read Only + ) : ( +
+ + {!isDirty ? ( + + + + + + + + ) : ( + + + + {isSubmitting ? ( + + ) : ( + + )} + + + + reset()} + isDisabled={isSubmitting} + > + + + + + )} + +
+ )} + + ); +} + +export default SecretRenameRow; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx index 23900708d..51f887abb 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx @@ -19,7 +19,8 @@ import { SSOModal } from "./SSOModal"; const ssoAuthProviderMap: { [key: string]: string } = { "okta-saml": "Okta SAML", "azure-saml": "Azure SAML", - "jumpcloud-saml": "JumpCloud SAML" + "jumpcloud-saml": "JumpCloud SAML", + "google-saml": "Google SAML" }; export const OrgSSOSection = (): JSX.Element => { diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx index 0716a5cda..11e7e51ee 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/SSOModal.tsx @@ -21,13 +21,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; enum AuthProvider { OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + JUMPCLOUD_SAML = "jumpcloud-saml", + GOOGLE_SAML = "google-saml" } const ssoAuthProviders = [ { label: "Okta SAML", value: AuthProvider.OKTA_SAML }, { label: "Azure SAML", value: AuthProvider.AZURE_SAML }, - { label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML } + { label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML }, + { label: "Google SAML", value: AuthProvider.GOOGLE_SAML } ]; const schema = yup @@ -140,7 +142,15 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) issuer: "IdP Entity ID", issuerPlaceholder: "xxx" }; - + case AuthProvider.GOOGLE_SAML: + return { + acsUrl: "ACS URL", + entityId: "SP Entity ID", + entryPoint: "SSO URL", + entryPointPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx", + issuer: "IdP Entity ID", + issuerPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx" + }; default: return { acsUrl: "ACS URL", diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/CopyButton.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/CopyButton.tsx new file mode 100644 index 000000000..8337d1c4d --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/CopyButton.tsx @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +type Props = { + value: string; + hoverText: string; + notificationText: string; + children: React.ReactNode; +}; + +export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => { + const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false); + const { createNotification } = useNotificationContext(); + + const copyToClipboard = useCallback(() => { + if (isProjectIdCopied) { + return; + } + + setIsProjectIdCopied.on(); + navigator.clipboard.writeText(value); + + createNotification({ + text: notificationText, + type: "success" + }); + + const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000); + + // eslint-disable-next-line consistent-return + return () => clearTimeout(timer); + }, [isProjectIdCopied]); + + return ( + + ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx index 719129ebf..34261f47a 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx @@ -1,7 +1,5 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; @@ -9,9 +7,10 @@ import { useNotificationContext } from "@app/components/context/Notifications/No import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks"; import { useRenameWorkspace } from "@app/hooks/api"; +import { CopyButton } from "./CopyButton"; + const formSchema = yup.object({ name: yup.string().required().label("Project Name") }); @@ -22,7 +21,6 @@ export const ProjectNameChangeSection = () => { const { createNotification } = useNotificationContext(); const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useRenameWorkspace(); - const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false); const { handleSubmit, control, reset } = useForm({ resolver: yupResolver(formSchema) }); @@ -34,16 +32,6 @@ export const ProjectNameChangeSection = () => { } }, [currentWorkspace]); - useEffect(() => { - let timer: NodeJS.Timeout; - - if (isProjectIdCopied) { - timer = setTimeout(() => setIsProjectIdCopied.off(), 2000); - } - - return () => clearTimeout(timer); -}, [setIsProjectIdCopied]); - const onFormSubmit = async ({ name }: FormData) => { try { if (!currentWorkspace?.id) return; @@ -66,35 +54,28 @@ export const ProjectNameChangeSection = () => { } }; - const copyProjectIdToClipboard = () => { - navigator.clipboard.writeText(currentWorkspace?.id || ""); - setIsProjectIdCopied.on(); - - createNotification({ - text: "Copied Project ID to clipboard", - type: "success" - }); - } - return (
-
-

Project Name

-
- +