diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 737067534..5c1f15ca3 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -55,6 +55,7 @@ VOLUME /app/.next/cache/images COPY --chown=non-root-user:nodejs --chmod=555 frontend/scripts ./scripts COPY --from=frontend-builder /app/public ./public RUN chown non-root-user:nodejs ./public/data + COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/standalone ./ COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/static ./.next/static @@ -93,9 +94,18 @@ RUN mkdir frontend-build # Production stage FROM base AS production +RUN apk add --upgrade --no-cache ca-certificates RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 non-root-user +# Give non-root-user permission to update SSL certs +RUN chown -R non-root-user /etc/ssl/certs +RUN chown non-root-user /etc/ssl/certs/ca-certificates.crt +RUN chmod -R u+rwx /etc/ssl/certs +RUN chmod u+rw /etc/ssl/certs/ca-certificates.crt +RUN chown non-root-user /usr/sbin/update-ca-certificates +RUN chmod u+rx /usr/sbin/update-ca-certificates + ## set pre baked keys ARG POSTHOG_API_KEY ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a9651e9f7..243cacea2 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -35,6 +35,7 @@ import { TGroupProjectServiceFactory } from "@app/services/group-project/group-p import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -53,6 +54,7 @@ import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -123,6 +125,7 @@ declare module "fastify" { identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; + identityAzureAuth: TIdentityAzureAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; @@ -145,6 +148,7 @@ declare module "fastify" { dynamicSecretLease: TDynamicSecretLeaseServiceFactory; projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; + secretSharing: TSecretSharingServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index bdc1c7138..2b07ea9ce 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -80,6 +80,9 @@ import { TIdentityAwsAuths, TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate, + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate, TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, @@ -201,6 +204,9 @@ import { TSecretScanningGitRisks, TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate, + TSecretSharing, + TSecretSharingInsert, + TSecretSharingUpdate, TSecretsInsert, TSecretSnapshotFolders, TSecretSnapshotFoldersInsert, @@ -369,6 +375,7 @@ declare module "knex/types/tables" { TSecretFolderVersionsInsert, TSecretFolderVersionsUpdate >; + [TableName.SecretSharing]: Knex.CompositeTableType; [TableName.SecretTag]: Knex.CompositeTableType; [TableName.SecretImport]: Knex.CompositeTableType; [TableName.Integration]: Knex.CompositeTableType; @@ -400,6 +407,11 @@ declare module "knex/types/tables" { TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate >; + [TableName.IdentityAzureAuth]: Knex.CompositeTableType< + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts b/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts new file mode 100644 index 000000000..7b208f010 --- /dev/null +++ b/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist) t.index("projectId"); + if (doesOrgIdExist) t.index("orgId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist) t.dropIndex("projectId"); + if (doesOrgIdExist) t.dropIndex("orgId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts b/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts new file mode 100644 index 000000000..59fe14145 --- /dev/null +++ b/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "envId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesEnvIdExist) t.index("envId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "envId"); + + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesEnvIdExist) t.dropIndex("envId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts b/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts new file mode 100644 index 000000000..f01c0d3cc --- /dev/null +++ b/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SecretVersion, "envId"); + if (await knex.schema.hasTable(TableName.SecretVersion)) { + await knex.schema.alterTable(TableName.SecretVersion, (t) => { + if (doesEnvIdExist) t.index("envId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SecretVersion, "envId"); + + if (await knex.schema.hasTable(TableName.SecretVersion)) { + await knex.schema.alterTable(TableName.SecretVersion, (t) => { + if (doesEnvIdExist) t.dropIndex("envId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts b/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts new file mode 100644 index 000000000..7f200ed3e --- /dev/null +++ b/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSnapshotIdExist) t.index("snapshotId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSnapshotIdExist) t.dropIndex("snapshotId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts b/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts new file mode 100644 index 000000000..ffb7c3336 --- /dev/null +++ b/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotFolder, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotFolder)) { + await knex.schema.alterTable(TableName.SnapshotFolder, (t) => { + if (doesSnapshotIdExist) t.index("snapshotId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotFolder, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotFolder)) { + await knex.schema.alterTable(TableName.SnapshotFolder, (t) => { + if (doesSnapshotIdExist) t.dropIndex("snapshotId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts b/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts new file mode 100644 index 000000000..f1225e264 --- /dev/null +++ b/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesFolderIdExist = await knex.schema.hasColumn(TableName.Secret, "folderId"); + const doesUserIdExist = await knex.schema.hasColumn(TableName.Secret, "userId"); + if (await knex.schema.hasTable(TableName.Secret)) { + await knex.schema.alterTable(TableName.Secret, (t) => { + if (doesFolderIdExist && doesUserIdExist) t.index(["folderId", "userId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesFolderIdExist = await knex.schema.hasColumn(TableName.Secret, "folderId"); + const doesUserIdExist = await knex.schema.hasColumn(TableName.Secret, "userId"); + + if (await knex.schema.hasTable(TableName.Secret)) { + await knex.schema.alterTable(TableName.Secret, (t) => { + if (doesUserIdExist && doesFolderIdExist) t.dropIndex(["folderId", "userId"]); + }); + } +} diff --git a/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts b/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts new file mode 100644 index 000000000..b6dbf3e74 --- /dev/null +++ b/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesExpireAtExist = await knex.schema.hasColumn(TableName.AuditLog, "expiresAt"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesExpireAtExist) t.index("expiresAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesExpireAtExist = await knex.schema.hasColumn(TableName.AuditLog, "expiresAt"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesExpireAtExist) t.dropIndex("expiresAt"); + }); + } +} diff --git a/backend/src/db/migrations/20240527073740_identity-azure-auth.ts b/backend/src/db/migrations/20240527073740_identity-azure-auth.ts new file mode 100644 index 000000000..3d91b2f9c --- /dev/null +++ b/backend/src/db/migrations/20240527073740_identity-azure-auth.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAzureAuth))) { + await knex.schema.createTable(TableName.IdentityAzureAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("tenantId").notNullable(); + t.string("resource").notNullable(); + t.string("allowedServicePrincipalIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAzureAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} diff --git a/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts b/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts new file mode 100644 index 000000000..2b2ecd783 --- /dev/null +++ b/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts"); + const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked"); + const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd"); + + await knex.schema.alterTable(TableName.Users, (t) => { + if (!hasConsecutiveFailedMfaAttempts) { + t.integer("consecutiveFailedMfaAttempts").defaultTo(0); + } + + if (!hasIsLocked) { + t.boolean("isLocked").defaultTo(false); + } + + if (!hasTemporaryLockDateEnd) { + t.dateTime("temporaryLockDateEnd").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts"); + const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked"); + const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd"); + + await knex.schema.alterTable(TableName.Users, (t) => { + if (hasConsecutiveFailedMfaAttempts) { + t.dropColumn("consecutiveFailedMfaAttempts"); + } + + if (hasIsLocked) { + t.dropColumn("isLocked"); + } + + if (hasTemporaryLockDateEnd) { + t.dropColumn("temporaryLockDateEnd"); + } + }); +} diff --git a/backend/src/db/migrations/20240528190137_secret_sharing.ts b/backend/src/db/migrations/20240528190137_secret_sharing.ts new file mode 100644 index 000000000..c1eab2ea6 --- /dev/null +++ b/backend/src/db/migrations/20240528190137_secret_sharing.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretSharing))) { + await knex.schema.createTable(TableName.SecretSharing, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("encryptedValue").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.text("hashedHex").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts b/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts new file mode 100644 index 000000000..8d4322b5a --- /dev/null +++ b/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSecretVersionIdExist) t.index("secretVersionId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSecretVersionIdExist) t.dropIndex("secretVersionId"); + }); + } +} diff --git a/backend/src/db/migrations/20240529203152_secret_sharing.ts b/backend/src/db/migrations/20240529203152_secret_sharing.ts new file mode 100644 index 000000000..c1eab2ea6 --- /dev/null +++ b/backend/src/db/migrations/20240529203152_secret_sharing.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretSharing))) { + await knex.schema.createTable(TableName.SecretSharing, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("encryptedValue").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.text("hashedHex").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/schemas/identity-azure-auths.ts b/backend/src/db/schemas/identity-azure-auths.ts new file mode 100644 index 000000000..856f7b8f1 --- /dev/null +++ b/backend/src/db/schemas/identity-azure-auths.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAzureAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + tenantId: z.string(), + resource: z.string(), + allowedServicePrincipalIds: z.string() +}); + +export type TIdentityAzureAuths = z.infer; +export type TIdentityAzureAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAzureAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b18286438..3415f4049 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -24,6 +24,7 @@ export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; +export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-org-memberships"; @@ -65,6 +66,7 @@ export * from "./secret-imports"; export * from "./secret-rotation-outputs"; export * from "./secret-rotations"; export * from "./secret-scanning-git-risks"; +export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; export * from "./secret-snapshots"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 96bc4b25a..62ed0e4d5 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -35,6 +35,7 @@ export enum TableName { ProjectKeys = "project_keys", Secret = "secrets", SecretReference = "secret_references", + SecretSharing = "secret_sharing", SecretBlindIndex = "secret_blind_indexes", SecretVersion = "secret_versions", SecretFolder = "secret_folders", @@ -53,6 +54,7 @@ export enum TableName { IdentityUniversalAuth = "identity_universal_auths", IdentityKubernetesAuth = "identity_kubernetes_auths", IdentityGcpAuth = "identity_gcp_auths", + IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", @@ -155,5 +157,6 @@ export enum IdentityAuthMethod { Univeral = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts new file mode 100644 index 000000000..a412221b2 --- /dev/null +++ b/backend/src/db/schemas/secret-sharing.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretSharingSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.date(), + userId: z.string().uuid(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretSharing = z.infer; +export type TSecretSharingInsert = Omit, TImmutableDBKeys>; +export type TSecretSharingUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index d5a4d5b49..c10af4ba4 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -22,7 +22,10 @@ export const UsersSchema = z.object({ updatedAt: z.date(), isGhost: z.boolean().default(false), username: z.string(), - isEmailVerified: z.boolean().default(false).nullable().optional() + isEmailVerified: z.boolean().default(false).nullable().optional(), + consecutiveFailedMfaAttempts: z.number().optional(), + isLocked: z.boolean().optional(), + temporaryLockDateEnd: z.date().nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index 9a1a91672..58c6793d7 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -5,10 +5,15 @@ import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { ProjectPermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; +import { + ProjectPermissionSchema, + ProjectSpecificPrivilegePermissionSchema, + SanitizedIdentityPrivilegeSchema +} from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { @@ -39,7 +44,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional() }), response: { 200: z.object({ @@ -49,6 +59,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -57,7 +79,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: false, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -90,7 +112,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional(), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), @@ -111,6 +138,19 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -119,7 +159,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: true, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -156,13 +196,16 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission + ).optional(), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), temporaryRange: z .string() - .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .refine((val) => typeof val === "undefined" || ms(val) > 0, "Temporary range must be a positive number") .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), temporaryAccessStartTime: z .string() @@ -179,7 +222,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const updatedInfo = req.body.privilegeDetails; + const { permissions, privilegePermission, ...updatedInfo } = req.body.privilegeDetails; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, @@ -190,7 +244,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: req.body.projectSlug, data: { ...updatedInfo, - permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined + permissions: permission ? JSON.stringify(packRules(permission)) : undefined } }); return { privilege }; diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 380f61e23..6691032a8 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -23,7 +23,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .min(1) .trim() .refine( - (val) => !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) .refine((v) => slugify(v) === v, { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index bb4d2fa8e..69038a057 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,146 +1,232 @@ +import { packRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ProjectPermissionSchema, SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:projectId/roles", + url: "/:projectSlug/roles", config: { rateLimit: writeLimit }, schema: { + description: "Create a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z.string().trim(), - name: z.string().trim(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .min(1) + .refine( + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }) + .describe(PROJECT_ROLE.CREATE.slug), + name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), + description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.createRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.createRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "PATCH", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Update a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z.string().trim().optional(), - name: z.string().trim().optional(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .optional() + .describe(PROJECT_ROLE.UPDATE.slug) + .refine( + (val) => + typeof val === "undefined" || + !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), + name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.updateRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.updateRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "DELETE", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Delete a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.deleteRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId + }); return { role }; } }); server.route({ method: "GET", - url: "/:projectId/roles", + url: "/:projectSlug/roles", + config: { + rateLimit: readLimit + }, + schema: { + description: "List project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug) + }), + response: { + 200: z.object({ + roles: ProjectRolesSchema.omit({ permissions: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const roles = await server.services.projectRole.listRoles({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug + }); + return { roles }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/roles/slug/:slug", config: { rateLimit: readLimit }, schema: { params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug), + slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) }), response: { 200: z.object({ - data: z.object({ - roles: ProjectRolesSchema.omit({ permissions: true }) - .merge(z.object({ permissions: z.unknown() })) - .array() - }) + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles( - req.permission.type, - req.permission.id, - req.params.projectId, - req.permission.authMethod, - req.permission.orgId - ); - return { data: { roles } }; + const role = await server.services.projectRole.getRoleBySlug({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleSlug: req.params.slug + }); + return { role }; } }); 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 6c563b573..f93b391a5 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -3,7 +3,6 @@ import { RawAxiosRequestHeaders } from "axios"; import { SecretKeyEncoding } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; -import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -113,35 +112,7 @@ export const auditLogQueueServiceFactory = ({ ); }); - queueService.start(QueueName.AuditLogPrune, async () => { - logger.info(`${QueueName.AuditLogPrune}: queue task started`); - await auditLogDAL.pruneAuditLog(); - logger.info(`${QueueName.AuditLogPrune}: queue task completed`); - }); - - // we do a repeat cron job in utc timezone at 12 Midnight each day - const startAuditLogPruneJob = async () => { - // clear previous job - await queueService.stopRepeatableJob( - QueueName.AuditLogPrune, - QueueJobs.AuditLogPrune, - { pattern: "0 0 * * *", utc: true }, - QueueName.AuditLogPrune // just a job id - ); - - await queueService.queue(QueueName.AuditLogPrune, QueueJobs.AuditLogPrune, undefined, { - delay: 5000, - jobId: QueueName.AuditLogPrune, - repeat: { pattern: "0 0 * * *", utc: true } - }); - }; - - queueService.listen(QueueName.AuditLogPrune, "failed", (err) => { - logger.error(err?.failedReason, `${QueueName.AuditLogPrune}: log pruning failed`); - }); - return { - pushToLog, - startAuditLogPruneJob + pushToLog }; }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index e512389d7..415814998 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -79,6 +79,10 @@ export enum EventType { ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", + LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", + ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", + UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", + GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -572,6 +576,48 @@ interface GetIdentityAwsAuthEvent { }; } +interface LoginIdentityAzureAuthEvent { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + identityAzureAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAzureAuthEvent { + type: EventType.ADD_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId: string; + resource: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAzureAuthEvent { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId?: string; + resource?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAzureAuthEvent { + type: EventType.GET_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -839,6 +885,10 @@ export type Event = | AddIdentityAwsAuthEvent | UpdateIdentityAwsAuthEvent | GetIdentityAwsAuthEvent + | LoginIdentityAzureAuthEvent + | AddIdentityAzureAuthEvent + | UpdateIdentityAzureAuthEvent + | GetIdentityAzureAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index 4e70dfb5a..cf7048801 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -16,6 +16,8 @@ export const licenseDALFactory = (db: TDbClient) => { void bd.where({ orgId }); } }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.Users}.isGhost`, false) .count(); return doc?.[0].count; } catch (error) { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 01f0e5142..70f5ed608 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -225,7 +225,8 @@ export const PROJECT_IDENTITIES = { roles: { description: "A list of role slugs to assign to the identity project membership.", role: "The role slug to assign to the newly created identity project membership.", - isTemporary: "Whether the assigned role is temporary.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", temporaryMode: "Type of temporary expiry.", temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", temporaryAccessStartTime: "Time to which the temporary access starts" @@ -242,7 +243,8 @@ export const PROJECT_IDENTITIES = { roles: { description: "A list of role slugs to assign to the newly created identity project membership.", role: "The role slug to assign to the newly created identity project membership.", - isTemporary: "Whether the assigned role is temporary.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", temporaryMode: "Type of temporary expiry.", temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", temporaryAccessStartTime: "Time to which the temporary access starts" @@ -519,7 +521,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { projectSlug: "The slug of the project of the identity in.", identityId: "The ID of the identity to create.", slug: "The slug of the privilege to create.", - permissions: `The permission object for the privilege. + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. - Read secrets \`\`\` { "permissions": [{"action": "read", "subject": "secrets"]} @@ -533,6 +536,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { - { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } \`\`\` `, + privilegePermission: "The permission object for the privilege.", isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", @@ -544,7 +548,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { identityId: "The ID of the identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", - permissions: `The permission object for the privilege. + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. - Read secrets \`\`\` { "permissions": [{"action": "read", "subject": "secrets"]} @@ -558,6 +563,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { - { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } \`\`\` `, + privilegePermission: "The permission object for the privilege.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", @@ -662,6 +668,7 @@ export const INTEGRATION = { secretPrefix: "The prefix for the saved secret. Used by GCP.", secretSuffix: "The suffix for the saved secret. Used by GCP.", initialSyncBehavoir: "Type of syncing behavoir with the integration.", + mappingBehavior: "The mapping behavior of the integration.", shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", @@ -714,3 +721,32 @@ export const AUDIT_LOG_STREAMS = { id: "The ID of the audit log stream to get details." } }; + +export const PROJECT_ROLE = { + CREATE: { + projectSlug: "Slug of the project to create the role for.", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + UPDATE: { + projectSlug: "Slug of the project to update the role for.", + roleId: "The ID of the role to update", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + DELETE: { + projectSlug: "Slug of the project to delete this role for.", + roleId: "The ID of the role to update" + }, + GET_ROLE_BY_SLUG: { + projectSlug: "The slug of the project.", + roleSlug: "The slug of the role to get details" + }, + LIST: { + projectSlug: "The slug of the project to list the roles of." + } +}; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index d78020809..bf057cc73 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -104,24 +104,68 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Create" }); } }, - updateById: async (id: string, data: Tables[Tname]["update"], tx?: Knex) => { + updateById: async ( + id: string, + { + $incr, + $decr, + ...data + }: Tables[Tname]["update"] & { + $incr?: { [x in keyof Partial]: number }; + $decr?: { [x in keyof Partial]: number }; + }, + tx?: Knex + ) => { try { - const [res] = await (tx || db)(tableName) + const query = (tx || db)(tableName) .where({ id } as never) .update(data as never) .returning("*"); - return res; + if ($incr) { + Object.entries($incr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + if ($decr) { + Object.entries($decr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + const [docs] = await query; + return docs; } catch (error) { throw new DatabaseError({ error, name: "Update by id" }); } }, - update: async (filter: TFindFilter, data: Tables[Tname]["update"], tx?: Knex) => { + update: async ( + filter: TFindFilter, + { + $incr, + $decr, + ...data + }: Tables[Tname]["update"] & { + $incr?: { [x in keyof Partial]: number }; + $decr?: { [x in keyof Partial]: number }; + }, + tx?: Knex + ) => { try { - const res = await (tx || db)(tableName) + const query = (tx || db)(tableName) .where(buildFindFilter(filter)) .update(data as never) .returning("*"); - return res; + // increment and decrement operation in update + if ($incr) { + Object.entries($incr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + if ($decr) { + Object.entries($decr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + return await query; } catch (error) { throw new DatabaseError({ error, name: "Update" }); } diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index bc8ac88ff..9d85b6015 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -12,7 +12,9 @@ export enum QueueName { SecretRotation = "secret-rotation", SecretReminder = "secret-reminder", AuditLog = "audit-log", + // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune", + DailyResourceCleanUp = "daily-resource-cleanup", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", @@ -26,7 +28,9 @@ export enum QueueJobs { SecretReminder = "secret-reminder-job", SecretRotation = "secret-rotation-job", AuditLog = "audit-log-job", + // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune-job", + DailyResourceCleanUp = "daily-resource-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", @@ -55,6 +59,10 @@ export type TQueueJobTypes = { name: QueueJobs.AuditLog; payload: TCreateAuditLogDTO; }; + [QueueName.DailyResourceCleanUp]: { + name: QueueJobs.DailyResourceCleanUp; + payload: undefined; + }; [QueueName.AuditLogPrune]: { name: QueueJobs.AuditLogPrune; payload: undefined; @@ -172,7 +180,9 @@ export const queueServiceFactory = (redisUrl: string) => { jobId?: string ) => { const q = queueContainer[name]; - return q.removeRepeatable(job, repeatOpt, jobId); + if (q) { + return q.removeRepeatable(job, repeatOpt, jobId); + } }; const stopRepeatableJobByJobId = async (name: T, jobId: string) => { diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 6c92de62c..ea5ba3410 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -52,9 +52,25 @@ export const inviteUserRateLimit: RateLimitOptions = { keyGenerator: (req) => req.realIp }; +export const mfaRateLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + max: 20, + keyGenerator: (req) => { + return req.headers.authorization?.split(" ")[1] || req.realIp; + } +}; + export const creationLimit: RateLimitOptions = { // identity, project, org timeWindow: 60 * 1000, max: 30, keyGenerator: (req) => req.realIp }; + +// Public endpoints to avoid brute force attacks +export const publicEndpointLimit: RateLimitOptions = { + // Shared Secrets + timeWindow: 60 * 1000, + max: 30, + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 084f18198..11a94657b 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -1,5 +1,6 @@ import fp from "fastify-plugin"; +import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; // inject permission type needed based on auth extracted @@ -15,6 +16,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, // if the req.auth.authMode is AuthMode.API_KEY, the orgId will be "API_KEY" authMethod: req.auth.authMethod // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.userId}] [type=${ActorType.USER}]` + ); } else if (req.auth.actor === ActorType.IDENTITY) { req.permission = { type: ActorType.IDENTITY, @@ -22,6 +27,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, authMethod: null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.identityId}] [type=${ActorType.IDENTITY}]` + ); } else if (req.auth.actor === ActorType.SERVICE) { req.permission = { type: ActorType.SERVICE, @@ -29,6 +38,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, authMethod: null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.serviceTokenId}] [type=${ActorType.SERVICE}]` + ); } else if (req.auth.actor === ActorType.SCIM_CLIENT) { req.permission = { type: ActorType.SCIM_CLIENT, @@ -36,6 +49,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, authMethod: null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.scimTokenId}] [type=${ActorType.SCIM_CLIENT}]` + ); } }); }); diff --git a/backend/src/server/plugins/ip.ts b/backend/src/server/plugins/ip.ts index b3c8171af..7b5838d57 100644 --- a/backend/src/server/plugins/ip.ts +++ b/backend/src/server/plugins/ip.ts @@ -6,6 +6,7 @@ const headersOrder = [ "cf-connecting-ip", // Cloudflare "Cf-Pseudo-IPv4", // Cloudflare "x-client-ip", // Most common + "x-envoy-external-address", // for envoy "x-forwarded-for", // Mostly used by proxies "fastly-client-ip", "true-client-ip", // Akamai and Cloudflare @@ -23,7 +24,21 @@ export const fastifyIp = fp(async (fastify) => { const forwardedIpHeader = headersOrder.find((header) => Boolean(req.headers[header])); const forwardedIp = forwardedIpHeader ? req.headers[forwardedIpHeader] : undefined; if (forwardedIp) { - req.realIp = Array.isArray(forwardedIp) ? forwardedIp[0] : forwardedIp; + if (Array.isArray(forwardedIp)) { + // eslint-disable-next-line + req.realIp = forwardedIp[0]; + return; + } + + if (forwardedIp.includes(",")) { + // the ip header when placed with load balancers that proxy request + // will attach the internal ips to header by appending with comma + // https://github.com/go-chi/chi/blob/master/middleware/realip.go + const clientIPFromProxy = forwardedIp.slice(0, forwardedIp.indexOf(",")).trim(); + req.realIp = clientIPFromProxy; + return; + } + req.realIp = forwardedIp; } else { req.realIp = req.ip; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 48e82c9f6..446ff69fc 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -87,6 +87,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; +import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; @@ -122,6 +124,7 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; +import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretQueueFactory } from "@app/services/secret/secret-queue"; import { secretServiceFactory } from "@app/services/secret/secret-service"; @@ -134,6 +137,8 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; +import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; @@ -219,8 +224,8 @@ export const registerRoutes = async ( const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); - const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -257,6 +262,7 @@ export const registerRoutes = async ( const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); + const secretSharingDAL = secretSharingDALFactory(db); const licenseDAL = licenseDALFactory(db); const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); @@ -553,7 +559,8 @@ export const registerRoutes = async ( permissionService, projectRoleDAL, projectUserMembershipRoleDAL, - identityProjectMembershipRoleDAL + identityProjectMembershipRoleDAL, + projectDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -641,6 +648,12 @@ export const registerRoutes = async ( projectEnvDAL, projectBotService }); + + const secretSharingService = secretSharingServiceFactory({ + permissionService, + secretSharingDAL + }); + const sarService = secretApprovalRequestServiceFactory({ permissionService, projectBotService, @@ -775,6 +788,15 @@ export const registerRoutes = async ( permissionService }); + const identityAzureAuthService = identityAzureAuthServiceFactory({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -802,14 +824,20 @@ export const registerRoutes = async ( folderDAL, licenseService }); + const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ + auditLogDAL, + queueService, + identityAccessTokenDAL, + secretSharingDAL + }); await superAdminService.initServerCfg(); // // setup the communication with license key server await licenseService.init(); - await auditLogQueue.startAuditLogPruneJob(); await telemetryQueue.startTelemetryCheck(); + await dailyResourceCleanUp.startCleanUp(); // inject all services server.decorate("services", { @@ -846,6 +874,7 @@ export const registerRoutes = async ( identityKubernetesAuth: identityKubernetesAuthService, identityGcpAuth: identityGcpAuthService, identityAwsAuth: identityAwsAuthService, + identityAzureAuth: identityAzureAuthService, secretApprovalPolicy: sapService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, @@ -867,7 +896,8 @@ export const registerRoutes = async ( secretBlindIndex: secretBlindIndexService, telemetry: telemetryService, projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, - identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, + secretSharing: secretSharingService }); server.decorate("store", { diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index cf9f23851..5b0b754f3 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -4,6 +4,7 @@ import { DynamicSecretsSchema, IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, + ProjectRolesSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; @@ -88,10 +89,38 @@ export const ProjectPermissionSchema = z.object({ .optional() }); +export const ProjectSpecificPrivilegePermissionSchema = z.object({ + actions: z + .nativeEnum(ProjectPermissionActions) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read") + .array() + .min(1), + subject: z + .enum([ProjectPermissionSub.Secrets]) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("The environment slug this permission should allow."), + secretPath: z + .object({ + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") + }) + .optional() + }) + .describe("When specified, only matching conditions will be allowed to access given resource.") +}); + export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ permissions: UnpackedPermissionSchema.array() }); +export const SanitizedRoleSchema = ProjectRolesSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts new file mode 100644 index 000000000..d10cd131b --- /dev/null +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -0,0 +1,262 @@ +import { z } from "zod"; + +import { IdentityAzureAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { validateAzureAuthField } from "@app/services/identity-azure-auth/identity-azure-auth-validators"; + +export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/azure-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Azure Auth", + body: z.object({ + identityId: z.string(), + jwt: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAzureAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAzureAuthId: identityAzureAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Azure Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim(), + resource: z.string().trim(), + allowedServicePrincipalIds: validateAzureAuthField, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.attachAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim().optional(), + resource: z.string().trim().optional(), + allowedServicePrincipalIds: validateAzureAuthField.optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.updateAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "GET", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.getAzureAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId + } + } + }); + + return { identityAzureAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 58654f220..34940eb13 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -160,9 +160,9 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) }), body: z.object({ type: z.enum(["iam", "gce"]).optional(), - allowedServiceAccounts: validateGcpAuthField, - allowedProjects: validateGcpAuthField, - allowedZones: validateGcpAuthField, + allowedServiceAccounts: validateGcpAuthField.optional(), + allowedProjects: validateGcpAuthField.optional(), + allowedZones: validateGcpAuthField.optional(), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 52248ac63..eee7dac65 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -5,6 +5,7 @@ import { registerCaRouter } from "./certificate-authority-router"; import { registerCertRouter } from "./certificate-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; +import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; @@ -20,6 +21,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; +import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; @@ -36,6 +38,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAwsAuthRouter); + await authRouter.register(registerIdentityAzureAuthRouter); }, { prefix: "/auth" } ); @@ -73,4 +76,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); + await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); }; diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index d9db7404e..899c1cac8 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -330,7 +330,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) teams: z .object({ name: z.string(), - id: z.string().optional() + id: z.string() }) .array() }) diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 1fd92df3a..f23abc45b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -8,6 +8,7 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { IntegrationMappingBehavior } from "@app/services/integration-auth/integration-list"; import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { @@ -49,6 +50,10 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z + .nativeEnum(IntegrationMappingBehavior) + .optional() + .describe(INTEGRATION.CREATE.metadata.mappingBehavior), shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), secretGCPLabel: z .object({ @@ -160,6 +165,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.mappingBehavior), shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), secretGCPLabel: z .object({ diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts new file mode 100644 index 000000000..67751395b --- /dev/null +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; + +import { SecretSharingSchema } from "@app/db/schemas"; +import { publicEndpointLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretSharingRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.array(SecretSharingSchema) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return sharedSecrets; + } + }); + + server.route({ + method: "GET", + url: "/public/:id", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + hashedHex: z.string() + }), + response: { + 200: SecretSharingSchema.pick({ name: true, encryptedValue: true, iv: true, tag: true, expiresAt: true }) + } + }, + handler: async (req) => { + const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex( + req.params.id, + req.query.hashedHex + ); + if (!sharedSecret) return undefined; + return { + name: sharedSecret.name, + encryptedValue: sharedSecret.encryptedValue, + iv: sharedSecret.iv, + tag: sharedSecret.tag, + expiresAt: sharedSecret.expiresAt + }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: z.string(), + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.string().refine((date) => new Date(date) > new Date(), { + message: "Expires at should be a future date" + }) + }), + response: { + 200: z.object({ + id: z.string().uuid() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { name, encryptedValue, iv, tag, hashedHex, expiresAt } = req.body; + const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name, + encryptedValue, + iv, + tag, + hashedHex, + expiresAt: new Date(expiresAt) + }); + return { id: sharedSecret.id }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sharedSecretId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sharedSecretId: z.string().uuid() + }), + response: { + 200: SecretSharingSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { sharedSecretId } = req.params; + const deletedSharedSecret = await req.server.services.secretSharing.deleteSharedSecretById({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + sharedSecretId + }); + + return { ...deletedSharedSecret }; + } + }); +}; diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index bdede8a3a..3d9f531b9 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,11 +1,15 @@ import { z } from "zod"; import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { authRateLimit, readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerUserRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + server.route({ method: "GET", url: "/", @@ -25,4 +29,29 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { return { user }; } }); + + server.route({ + method: "GET", + url: "/:userId/unlock", + config: { + rateLimit: authRateLimit + }, + schema: { + querystring: z.object({ + token: z.string().trim() + }), + params: z.object({ + userId: z.string() + }) + }, + handler: async (req, res) => { + try { + await server.services.user.unlockUser(req.params.userId, req.query.token); + } catch (err) { + logger.error(`User unlock failed for ${req.params.userId}`); + logger.error(err); + } + return res.redirect(`${appCfg.SITE_URL}/login`); + } + }); }; diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index 973804c7c..1c685866d 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; export const registerMfaRouter = async (server: FastifyZodProvider) => { @@ -34,7 +34,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/mfa/send", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { response: { @@ -53,7 +53,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { url: "/mfa/verify", method: "POST", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { body: z.object({ diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 5d68a4e94..b1f8aa2f6 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -13,8 +13,9 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; - userDAL: Pick; + userDAL: Pick; }; + export type TAuthTokenServiceFactory = ReturnType; export const getTokenConfig = (tokenType: TokenType) => { @@ -53,6 +54,11 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_USER_UNLOCK: { + const token = crypto.randomBytes(16).toString("hex"); + const expiresAt = new Date(new Date().getTime() + 259200000); + return { token, expiresAt }; + } default: { const token = crypto.randomBytes(16).toString("hex"); const expiresAt = new Date(); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 630e36310..8917bd672 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -3,7 +3,8 @@ export enum TokenType { TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", - TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" + TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", + TOKEN_USER_UNLOCK = "userUnlock" } export type TCreateTokenForUserDTO = { diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 80fb0b325..ecbf73a48 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -44,3 +44,27 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError(); if (decodedToken.userId !== userId) throw new UnauthorizedError(); }; + +export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => { + if (isLocked) { + throw new UnauthorizedError({ + name: "User Locked", + message: + "User is locked due to multiple failed login attempts. An email has been sent to you in order to unlock your account. You can also reset your password to unlock your account." + }); + } + + if (temporaryLockDateEnd) { + const timeDiff = new Date().getTime() - temporaryLockDateEnd.getTime(); + if (timeDiff < 0) { + const secondsDiff = (-1 * timeDiff) / 1000; + const timeDisplay = + secondsDiff > 60 ? `${Math.ceil(secondsDiff / 60)} minutes` : `${Math.ceil(secondsDiff)} seconds`; + + throw new UnauthorizedError({ + name: "User Locked", + message: `User is temporary locked due to multiple failed login attempts. Try again after ${timeDisplay}. You can also reset your password now to proceed.` + }); + } + } +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 4d2a302c6..cbf43b245 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -4,7 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TTokenDALFactory } from "../auth-token/auth-token-dal"; @@ -13,7 +13,7 @@ import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; -import { validateProviderAuthToken } from "./auth-fns"; +import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns"; import { TLoginClientProofDTO, TLoginGenServerPublicKeyDTO, @@ -212,6 +212,9 @@ export const authLoginServiceFactory = ({ }); // send multi factor auth token if they it enabled if (userEnc.isMfaEnabled && userEnc.email) { + const user = await userDAL.findById(userEnc.userId); + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + const mfaToken = jwt.sign( { authMethod, @@ -300,28 +303,111 @@ export const authLoginServiceFactory = ({ const resendMfaToken = async (userId: string) => { const user = await userDAL.findById(userId); if (!user || !user.email) return; + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); await sendUserMfaCode({ userId: user.id, email: user.email }); }; + const processFailedMfaAttempt = async (userId: string) => { + try { + const updatedUser = await userDAL.transaction(async (tx) => { + const PROGRESSIVE_DELAY_INTERVAL = 3; + const user = await userDAL.updateById(userId, { $incr: { consecutiveFailedMfaAttempts: 1 } }, tx); + + if (!user) { + throw new Error("User not found"); + } + + const progressiveDelaysInMins = [5, 30, 60]; + + // lock user when failed attempt exceeds threshold + if ( + user.consecutiveFailedMfaAttempts && + user.consecutiveFailedMfaAttempts >= PROGRESSIVE_DELAY_INTERVAL * (progressiveDelaysInMins.length + 1) + ) { + return userDAL.updateById( + userId, + { + isLocked: true, + temporaryLockDateEnd: null + }, + tx + ); + } + + // delay user only when failed MFA attempts is a multiple of configured delay interval + if (user.consecutiveFailedMfaAttempts && user.consecutiveFailedMfaAttempts % PROGRESSIVE_DELAY_INTERVAL === 0) { + const delayIndex = user.consecutiveFailedMfaAttempts / PROGRESSIVE_DELAY_INTERVAL - 1; + return userDAL.updateById( + userId, + { + temporaryLockDateEnd: new Date(new Date().getTime() + progressiveDelaysInMins[delayIndex] * 60 * 1000) + }, + tx + ); + } + + return user; + }); + + return updatedUser; + } catch (error) { + throw new DatabaseError({ error, name: "Process failed MFA Attempt" }); + } + }; + /* * Multi factor authentication verification of code * Third step of login in which user completes with mfa * */ const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => { - await tokenService.validateTokenForUser({ - type: TokenType.TOKEN_EMAIL_MFA, - userId, - code: mfaToken - }); + const appCfg = getConfig(); + const user = await userDAL.findById(userId); + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + + try { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_MFA, + userId, + code: mfaToken + }); + } catch (err) { + const updatedUser = await processFailedMfaAttempt(userId); + if (updatedUser.isLocked) { + if (updatedUser.email) { + const unlockToken = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_USER_UNLOCK, + userId: updatedUser.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.UnlockAccount, + subjectLine: "Unlock your Infisical account", + recipients: [updatedUser.email], + substitutions: { + token: unlockToken, + callback_url: `${appCfg.SITE_URL}/api/v1/user/${updatedUser.id}/unlock` + } + }); + } + } + + throw err; + } const decodedToken = jwt.verify(mfaJwtToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload; const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to authenticate user"); + // reset lock states + await userDAL.updateById(userId, { + consecutiveFailedMfaAttempts: 0, + temporaryLockDateEnd: null + }); + const token = await generateUserTokens({ user: { ...userEnc, diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 4025e4903..a400c297b 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -174,6 +174,12 @@ export const authPaswordServiceFactory = ({ salt, verifier }); + + await userDAL.updateById(userId, { + isLocked: false, + temporaryLockDateEnd: null, + consecutiveFailedMfaAttempts: 0 + }); }; /* diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 92bae670c..a0f9fbc27 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -39,6 +39,12 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { `${TableName.IdentityAwsAuth}.identityId` ); }) + .leftJoin(TableName.IdentityAzureAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.AZURE_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityAzureAuth}.identityId` + ); + }) .leftJoin(TableName.IdentityKubernetesAuth, (qb) => { qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.KUBERNETES_AUTH])).andOn( `${TableName.Identity}.id`, @@ -50,6 +56,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), db.ref("name").withSchema(TableName.Identity) ) @@ -63,6 +70,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { doc.accessTokenTrustedIpsUa || doc.accessTokenTrustedIpsGcp || doc.accessTokenTrustedIpsAws || + doc.accessTokenTrustedIpsAzure || doc.accessTokenTrustedIpsK8s }; } catch (error) { @@ -70,5 +78,48 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { } }; - return { ...identityAccessTokenOrm, findOne }; + const removeExpiredTokens = async (tx?: Knex) => { + try { + const docs = (tx || db)(TableName.IdentityAccessToken) + .where({ + isAccessTokenRevoked: true + }) + .orWhere((qb) => { + void qb + .where("accessTokenNumUsesLimit", ">", 0) + .andWhere( + "accessTokenNumUses", + ">=", + db.ref("accessTokenNumUsesLimit").withSchema(TableName.IdentityAccessToken) + ); + }) + .orWhere((qb) => { + void qb.where("accessTokenTTL", ">", 0).andWhere((qb2) => { + void qb2 + .where((qb3) => { + void qb3 + .whereNotNull("accessTokenLastRenewedAt") + // accessTokenLastRenewedAt + convert_integer_to_seconds(accessTokenTTL) < present_date + .andWhereRaw( + `"${TableName.IdentityAccessToken}"."accessTokenLastRenewedAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()` + ); + }) + .orWhere((qb3) => { + void qb3 + .whereNull("accessTokenLastRenewedAt") + // created + convert_integer_to_seconds(accessTokenTTL) < present_date + .andWhereRaw( + `"${TableName.IdentityAccessToken}"."createdAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()` + ); + }); + }); + }) + .delete(); + return await docs; + } catch (error) { + throw new DatabaseError({ error, name: "IdentityAccessTokenPrune" }); + } + }; + + return { ...identityAccessTokenOrm, findOne, removeExpiredTokens }; }; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 898d0bc62..3e7fe31a6 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -21,17 +21,18 @@ export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, identityOrgMembershipDAL }: TIdentityAccessTokenServiceFactoryDep) => { - const validateAccessTokenExp = (identityAccessToken: TIdentityAccessTokens) => { + const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { + id: tokenId, accessTokenTTL, accessTokenNumUses, accessTokenNumUsesLimit, accessTokenLastRenewedAt, - accessTokenMaxTTL, createdAt: accessTokenCreatedAt } = identityAccessToken; if (accessTokenNumUsesLimit > 0 && accessTokenNumUses > 0 && accessTokenNumUses >= accessTokenNumUsesLimit) { + await identityAccessTokenDAL.deleteById(tokenId); throw new BadRequestError({ message: "Unable to renew because access token number of uses limit reached" }); @@ -46,41 +47,26 @@ export const identityAccessTokenServiceFactory = ({ const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); - if (currentDate > expirationDate) + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(tokenId); throw new UnauthorizedError({ message: "Failed to renew MI access token due to TTL expiration" }); + } } else { // access token has never been renewed const accessTokenCreated = new Date(accessTokenCreatedAt); const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); - if (currentDate > expirationDate) + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(tokenId); throw new UnauthorizedError({ message: "Failed to renew MI access token due to TTL expiration" }); + } } } - - // max ttl checks - if (Number(accessTokenMaxTTL) > 0) { - const accessTokenCreated = new Date(accessTokenCreatedAt); - const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; - const currentDate = new Date(); - const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); - - if (currentDate > expirationDate) - throw new UnauthorizedError({ - message: "Failed to renew MI access token due to Max TTL expiration" - }); - - const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL)); - if (extendToDate > expirationDate) - throw new UnauthorizedError({ - message: "Failed to renew MI access token past its Max TTL expiration" - }); - } }; const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => { @@ -97,7 +83,32 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityAccessToken) throw new UnauthorizedError(); - validateAccessTokenExp(identityAccessToken); + await validateAccessTokenExp(identityAccessToken); + + const { accessTokenMaxTTL, createdAt: accessTokenCreatedAt, accessTokenTTL } = identityAccessToken; + + // max ttl checks - will it go above max ttl + if (Number(accessTokenMaxTTL) > 0) { + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; + const currentDate = new Date(); + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(identityAccessToken.id); + throw new UnauthorizedError({ + message: "Failed to renew MI access token due to Max TTL expiration" + }); + } + + const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL * 1000)); + if (extendToDate > expirationDate) { + await identityAccessTokenDAL.deleteById(identityAccessToken.id); + throw new UnauthorizedError({ + message: "Failed to renew MI access token past its Max TTL expiration" + }); + } + } const updatedIdentityAccessToken = await identityAccessTokenDAL.updateById(identityAccessToken.id, { accessTokenLastRenewedAt: new Date() @@ -131,7 +142,7 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityAccessToken) throw new UnauthorizedError(); - if (ipAddress) { + if (ipAddress && identityAccessToken) { checkIPAgainstBlocklist({ ipAddress, trustedIps: identityAccessToken?.accessTokenTrustedIps as TIp[] @@ -146,7 +157,14 @@ export const identityAccessTokenServiceFactory = ({ throw new UnauthorizedError({ message: "Identity does not belong to any organization" }); } - validateAccessTokenExp(identityAccessToken); + await validateAccessTokenExp(identityAccessToken); + + await identityAccessTokenDAL.updateById(identityAccessToken.id, { + accessTokenLastUsedAt: new Date(), + $incr: { + accessTokenNumUses: 1 + } + }); return { ...identityAccessToken, orgId: identityOrgMembership.orgId }; }; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts new file mode 100644 index 000000000..7038e2b9c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAzureAuthDALFactory = ReturnType; + +export const identityAzureAuthDALFactory = (db: TDbClient) => { + const azureAuthOrm = ormify(db, TableName.IdentityAzureAuth); + return azureAuthOrm; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts new file mode 100644 index 000000000..ad9e6f12d --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TAzureAuthJwtPayload, TAzureJwksUriResponse, TDecodedAzureAuthJwt } from "./identity-azure-auth-types"; + +export const validateAzureIdentity = async ({ + tenantId, + resource, + jwt: azureJwt +}: { + tenantId: string; + resource: string; + jwt: string; +}) => { + const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/keys`; + + const decodedJwt = jwt.decode(azureJwt, { complete: true }) as TDecodedAzureAuthJwt; + const { kid } = decodedJwt.header; + + const { data }: { data: TAzureJwksUriResponse } = await axios.get(jwksUri); + const signingKeys = data.keys; + + const signingKey = signingKeys.find((key) => key.kid === kid); + if (!signingKey) throw new UnauthorizedError(); + + const publicKey = `-----BEGIN CERTIFICATE-----\n${signingKey.x5c[0]}\n-----END CERTIFICATE-----`; + + return jwt.verify(azureJwt, publicKey, { + audience: resource, + issuer: `https://sts.windows.net/${tenantId}/` + }) as TAzureAuthJwtPayload; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts new file mode 100644 index 000000000..fa439bdc0 --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -0,0 +1,286 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal"; +import { validateAzureIdentity } from "./identity-azure-auth-fns"; +import { + TAttachAzureAuthDTO, + TGetAzureAuthDTO, + TLoginAzureAuthDTO, + TUpdateAzureAuthDTO +} from "./identity-azure-auth-types"; + +type TIdentityAzureAuthServiceFactoryDep = { + identityAzureAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityAzureAuthServiceFactory = ReturnType; + +export const identityAzureAuthServiceFactory = ({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityAzureAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + if (!identityAzureAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAzureAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + const azureIdentity = await validateAzureIdentity({ + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + jwt: azureJwt + }); + + if (azureIdentity.tid !== identityAzureAuth.tenantId) throw new UnauthorizedError(); + + if (identityAzureAuth.allowedServicePrincipalIds) { + // validate if the service principal id is in the list of allowed service principal ids + + const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds + .split(",") + .map((servicePrincipalId) => servicePrincipalId.trim()) + .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + + if (!isServicePrincipalAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAzureAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add Azure Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { + const doc = await identityAzureAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AZURE_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "Failed to update Azure Auth" + }); + + const identityGcpAuth = await identityAzureAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAzureAuth = await identityAzureAuthDAL.updateById(identityGcpAuth.id, { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedAzureAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getAzureAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "The identity does not have Azure Auth attached" + }); + + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAzureAuth, + updateAzureAuth, + getAzureAuth + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts new file mode 100644 index 000000000..65459003c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -0,0 +1,120 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAzureAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachAzureAuthDTO = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAzureAuthDTO = { + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAzureAuthDTO = { + identityId: string; +} & Omit; + +export type TAzureJwksUriResponse = { + keys: { + kty: string; + use: string; + kid: string; + x5t: string; + n: string; + e: string; + x5c: string[]; + }[]; +}; + +type TUserPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + acr: string; + aio: string; + amr: string[]; + appid: string; + appidacr: string; + family_name: string; + given_name: string; + groups: string[]; + idtyp: string; + ipaddr: string; + name: string; + oid: string; + puid: string; + rh: string; + scp: string; + sub: string; + tid: string; + unique_name: string; + upn: string; + uti: string; + ver: string; + wids: string[]; + xms_cae: string; + xms_cc: string[]; + xms_filter_index: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +type TAppPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + aio: string; + appid: string; + appidacr: string; + idp: string; + idtyp: string; + oid: string; // service principal id + rh: string; + sub: string; + tid: string; + uti: string; + ver: string; + xms_cae: string; + xms_cc: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +export type TAzureAuthJwtPayload = TUserPayload | TAppPayload; + +export type TDecodedAzureAuthJwt = { + header: { + type: string; + alg: string; + x5t: string; + kid: string; + }; + payload: TAzureAuthJwtPayload; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts new file mode 100644 index 000000000..3f7f7d8af --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateAzureAuthField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index fb5dc6fb0..10f2b3460 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -259,7 +259,7 @@ export const identityProjectServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - const [deletedIdentity] = await identityProjectDAL.delete({ identityId }); + const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); return deletedIdentity; }; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e49cd3862..2aaf5d5f4 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -43,6 +43,11 @@ export enum IntegrationInitialSyncBehavior { PREFER_SOURCE = "prefer-source" } +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} + export enum IntegrationUrls { // integration oauth endpoints GCP_TOKEN_URL = "https://oauth2.googleapis.com/token", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 1581eaef7..587f6c6a8 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -30,7 +30,12 @@ import { BadRequestError } from "@app/lib/errors"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; -import { IntegrationInitialSyncBehavior, Integrations, IntegrationUrls } from "./integration-list"; +import { + IntegrationInitialSyncBehavior, + IntegrationMappingBehavior, + Integrations, + IntegrationUrls +} from "./integration-list"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -570,134 +575,149 @@ const syncSecretsAWSSecretManager = async ({ accessId: string | null; accessToken: string; }) => { - let secretsManager; - const secKeyVal = getSecretKeyValuePair(secrets); const metadata = z.record(z.any()).parse(integration.metadata || {}); - try { - if (!accessId) return; - secretsManager = new SecretsManagerClient({ - region: integration.region as string, - credentials: { - accessKeyId: accessId, - secretAccessKey: accessToken + if (!accessId) return; + + const secretsManager = new SecretsManagerClient({ + region: integration.region as string, + credentials: { + accessKeyId: accessId, + secretAccessKey: accessToken + } + }); + + const processAwsSecret = async ( + secretId: string, + secretValue: Record | string + ) => { + try { + const awsSecretManagerSecret = await secretsManager.send( + new GetSecretValueCommand({ + SecretId: secretId + }) + ); + + let secretToCompare; + if (awsSecretManagerSecret?.SecretString) { + if (typeof secretValue === "string") { + secretToCompare = awsSecretManagerSecret.SecretString; + } else { + secretToCompare = JSON.parse(awsSecretManagerSecret.SecretString); + } } - }); - const awsSecretManagerSecret = await secretsManager.send( - new GetSecretValueCommand({ - SecretId: integration.app as string - }) - ); + if (!isEqual(secretToCompare, secretValue)) { + await secretsManager.send( + new UpdateSecretCommand({ + SecretId: secretId, + SecretString: typeof secretValue === "string" ? secretValue : JSON.stringify(secretValue) + }) + ); + } - let awsSecretManagerSecretObj: { [key: string]: AWS.SecretsManager } = {}; + const secretAWSTag = metadata.secretAWSTag as { key: string; value: string }[] | undefined; - if (awsSecretManagerSecret?.SecretString) { - awsSecretManagerSecretObj = JSON.parse(awsSecretManagerSecret.SecretString); - } + if (secretAWSTag && secretAWSTag.length) { + const describedSecret = await secretsManager.send( + // requires secretsmanager:DescribeSecret policy + new DescribeSecretCommand({ + SecretId: secretId + }) + ); - if (!isEqual(awsSecretManagerSecretObj, secKeyVal)) { - await secretsManager.send( - new UpdateSecretCommand({ - SecretId: integration.app as string, - SecretString: JSON.stringify(secKeyVal) - }) - ); - } + if (!describedSecret.Tags) return; - const secretAWSTag = metadata.secretAWSTag as { key: string; value: string }[] | undefined; + const integrationTagObj = secretAWSTag.reduce( + (acc, item) => { + acc[item.key] = item.value; + return acc; + }, + {} as Record + ); - if (secretAWSTag && secretAWSTag.length) { - const describedSecret = await secretsManager.send( - // requires secretsmanager:DescribeSecret policy - new DescribeSecretCommand({ - SecretId: integration.app as string - }) - ); + const awsTagObj = (describedSecret.Tags || []).reduce( + (acc, item) => { + if (item.Key && item.Value) { + acc[item.Key] = item.Value; + } + return acc; + }, + {} as Record + ); - if (!describedSecret.Tags) return; + const tagsToUpdate: { Key: string; Value: string }[] = []; + const tagsToDelete: { Key: string; Value: string }[] = []; - const integrationTagObj = secretAWSTag.reduce( - (acc, item) => { - acc[item.key] = item.value; - return acc; - }, - {} as Record - ); - - const awsTagObj = (describedSecret.Tags || []).reduce( - (acc, item) => { - if (item.Key && item.Value) { - acc[item.Key] = item.Value; + describedSecret.Tags?.forEach((tag) => { + if (tag.Key && tag.Value) { + if (!(tag.Key in integrationTagObj)) { + // delete tag from AWS secret manager + tagsToDelete.push({ + Key: tag.Key, + Value: tag.Value + }); + } else if (tag.Value !== integrationTagObj[tag.Key]) { + // update tag in AWS secret manager + tagsToUpdate.push({ + Key: tag.Key, + Value: integrationTagObj[tag.Key] + }); + } } - return acc; - }, - {} as Record - ); + }); - const tagsToUpdate: { Key: string; Value: string }[] = []; - const tagsToDelete: { Key: string; Value: string }[] = []; - - describedSecret.Tags?.forEach((tag) => { - if (tag.Key && tag.Value) { - if (!(tag.Key in integrationTagObj)) { - // delete tag from AWS secret manager - tagsToDelete.push({ - Key: tag.Key, - Value: tag.Value - }); - } else if (tag.Value !== integrationTagObj[tag.Key]) { - // update tag in AWS secret manager + secretAWSTag?.forEach((tag) => { + if (!(tag.key in awsTagObj)) { + // create tag in AWS secret manager tagsToUpdate.push({ - Key: tag.Key, - Value: integrationTagObj[tag.Key] + Key: tag.key, + Value: tag.value }); } - } - }); + }); - secretAWSTag?.forEach((tag) => { - if (!(tag.key in awsTagObj)) { - // create tag in AWS secret manager - tagsToUpdate.push({ - Key: tag.key, - Value: tag.value - }); + if (tagsToUpdate.length) { + await secretsManager.send( + new TagResourceCommand({ + SecretId: secretId, + Tags: tagsToUpdate + }) + ); } - }); - if (tagsToUpdate.length) { - await secretsManager.send( - new TagResourceCommand({ - SecretId: integration.app as string, - Tags: tagsToUpdate - }) - ); + if (tagsToDelete.length) { + await secretsManager.send( + new UntagResourceCommand({ + SecretId: secretId, + TagKeys: tagsToDelete.map((tag) => tag.Key) + }) + ); + } } - - if (tagsToDelete.length) { + } catch (err) { + // case when AWS manager can't find the specified secret + if (err instanceof ResourceNotFoundException && secretsManager) { await secretsManager.send( - new UntagResourceCommand({ - SecretId: integration.app as string, - TagKeys: tagsToDelete.map((tag) => tag.Key) + new CreateSecretCommand({ + Name: secretId, + SecretString: typeof secretValue === "string" ? secretValue : JSON.stringify(secretValue), + ...(metadata.kmsKeyId && { KmsKeyId: metadata.kmsKeyId }), + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) + : [] }) ); } } - } catch (err) { - // case when AWS manager can't find the specified secret - if (err instanceof ResourceNotFoundException && secretsManager) { - await secretsManager.send( - new CreateSecretCommand({ - Name: integration.app as string, - SecretString: JSON.stringify(secKeyVal), - ...(metadata.kmsKeyId && { KmsKeyId: metadata.kmsKeyId }), - Tags: metadata.secretAWSTag - ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) - : [] - }) - ); + }; + + if (metadata.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE) { + for await (const [key, value] of Object.entries(secrets)) { + await processAwsSecret(key, value.value); } + } else { + await processAwsSecret(integration.app as string, getSecretKeyValuePair(secrets)); } }; @@ -2676,18 +2696,21 @@ const syncSecretsCloudflarePages = async ({ }) ).data.result.deployment_configs[integration.targetEnvironment as string].env_vars; - // copy the secrets object, so we can set deleted keys to null - const secretsObj = Object.fromEntries( - Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [ - key, - key in Object.keys(getSecretsRes) ? { type: "secret_text", value: val } : null - ]) - ); + let secretEntries: [string, object | null][] = Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [ + key, + { type: "secret_text", value: val } + ]); + + if (getSecretsRes) { + const toDeleteKeys = Object.keys(getSecretsRes).filter((key) => !Object.keys(secrets).includes(key)); + const toDeleteEntries: [string, null][] = toDeleteKeys.map((key) => [key, null]); + secretEntries = [...secretEntries, ...toDeleteEntries]; + } const data = { deployment_configs: { [integration.targetEnvironment as string]: { - env_vars: secretsObj + env_vars: Object.fromEntries(secretEntries) } } }; diff --git a/backend/src/services/integration-auth/integration-team.ts b/backend/src/services/integration-auth/integration-team.ts index 81ef9b70c..c39b2c44f 100644 --- a/backend/src/services/integration-auth/integration-team.ts +++ b/backend/src/services/integration-auth/integration-team.ts @@ -5,7 +5,7 @@ import { Integrations, IntegrationUrls } from "./integration-list"; type Team = { name: string; - teamId: string; + id: string; }; const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: string }) => { const gitLabApiUrl = url ? `${url}/api` : IntegrationUrls.GITLAB_API_URL; @@ -22,7 +22,7 @@ const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: teams = res.map((t) => ({ name: t.name, - teamId: t.id + id: t.id.toString() })); return teams; diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index eff73c1b6..821267dfb 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -66,6 +66,11 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath }) + ); + const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder path not found" }); @@ -123,6 +128,11 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + const folder = await folderDAL.findBySecretPath(integration.projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder path not found" }); diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts index 3f22b8704..00604b37f 100644 --- a/backend/src/services/project-bot/project-bot-fns.ts +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -3,6 +3,7 @@ import { decryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/en import { BadRequestError } from "@app/lib/errors"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TGetPrivateKeyDTO } from "./project-bot-types"; export const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => @@ -13,11 +14,17 @@ export const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => ciphertext: bot.encryptedPrivateKey }); -export const getBotKeyFnFactory = (projectBotDAL: TProjectBotDALFactory) => { +export const getBotKeyFnFactory = ( + projectBotDAL: TProjectBotDALFactory, + projectDAL: Pick +) => { const getBotKeyFn = async (projectId: string) => { - const bot = await projectBotDAL.findOne({ projectId }); + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found during bot lookup." }); - if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); + const bot = await projectBotDAL.findOne({ projectId: project.id }); + + if (!bot) throw new BadRequestError({ message: "Failed to find bot key" }); if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) throw new BadRequestError({ message: "Encryption key missing" }); diff --git a/backend/src/services/project-bot/project-bot-service.ts b/backend/src/services/project-bot/project-bot-service.ts index 23667ef67..ce7782a80 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -25,7 +25,7 @@ export const projectBotServiceFactory = ({ projectDAL, permissionService }: TProjectBotServiceFactoryDep) => { - const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); const getBotKey = async (projectId: string) => { return getBotKeyFn(projectId); diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index f8acc10d8..590c26ecc 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -104,9 +106,9 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; - const findProjectGhostUser = async (projectId: string) => { + const findProjectGhostUser = async (projectId: string, tx?: Knex) => { try { - const ghostUser = await db(TableName.ProjectMembership) + const ghostUser = await (tx || db)(TableName.ProjectMembership) .where({ projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.Users)) diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 831af3200..ffd446fad 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -1,25 +1,30 @@ -import { ForbiddenError } from "@casl/ability"; -import { packRules } from "@casl/ability/extra"; +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; -import { ProjectMembershipRole, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { ProjectMembershipRole } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectAdminPermissions, projectMemberPermissions, projectNoAccessPermissions, ProjectPermissionActions, + ProjectPermissionSet, ProjectPermissionSub, projectViewerPermission } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ActorAuthMethod } from "../auth/auth-type"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; + projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; @@ -27,20 +32,68 @@ type TProjectRoleServiceFactoryDep = { export type TProjectRoleServiceFactory = ReturnType; +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + +const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; + export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, identityProjectMembershipRoleDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + projectDAL }: TProjectRoleServiceFactoryDep) => { - const createRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const createRole = async ({ projectSlug, data, actor, actorId, actorAuthMethod, actorOrgId }: TCreateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -53,21 +106,54 @@ export const projectRoleServiceFactory = ({ if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); const role = await projectRoleDAL.create({ ...data, - projectId, - permissions: JSON.stringify(data.permissions) + projectId }); - return role; + return { ...role, permissions: unpackPermissions(role.permissions) }; }; - const updateRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const getRoleBySlug = async ({ + actor, + actorId, + projectSlug, + actorAuthMethod, + actorOrgId, + roleSlug + }: TGetRoleBySlugDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) { + const predefinedRole = getPredefinedRoles(projectId, roleSlug as ProjectMembershipRole)[0]; + return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) }; + } + + const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId }); + if (!customRole) throw new BadRequestError({ message: "Role not found" }); + return { ...customRole, permissions: unpackPermissions(customRole.permissions) }; + }; + + const updateRole = async ({ + roleId, + projectSlug, + actorOrgId, + actorAuthMethod, + actorId, + actor, + data + }: TUpdateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -81,22 +167,16 @@ export const projectRoleServiceFactory = ({ if (existingRole && existingRole.id !== roleId) throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); } - const [updatedRole] = await projectRoleDAL.update( - { id: roleId, projectId }, - { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } - ); + const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); - return updatedRole; + return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; }; - const deleteRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const deleteRole = async ({ actor, actorId, actorAuthMethod, actorOrgId, projectSlug, roleId }: TDeleteRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -125,16 +205,14 @@ export const projectRoleServiceFactory = ({ const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Delete role" }); - return deletedRole; + return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) }; }; - const listRoles = async ( - actor: ActorType, - actorId: string, - projectId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const listRoles = async ({ projectSlug, actorOrgId, actorAuthMethod, actorId, actor }: TListRolesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -144,52 +222,7 @@ export const projectRoleServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); const customRoles = await projectRoleDAL.find({ projectId }); - const roles = [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: "no-access", - description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - ...(customRoles || []).map(({ permissions, ...data }) => ({ - ...data, - permissions - })) - ]; + const roles = [...getPredefinedRoles(projectId), ...(customRoles || [])]; return roles; }; @@ -209,5 +242,5 @@ export const projectRoleServiceFactory = ({ return { permissions: packRules(permission.rules), membership }; }; - return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; + return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; }; diff --git a/backend/src/services/project-role/project-role-types.ts b/backend/src/services/project-role/project-role-types.ts index e69de29bb..62b627a79 100644 --- a/backend/src/services/project-role/project-role-types.ts +++ b/backend/src/services/project-role/project-role-types.ts @@ -0,0 +1,27 @@ +import { TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateRoleDTO = { + data: Omit; + projectSlug: string; +} & Omit; + +export type TGetRoleBySlugDTO = { + roleSlug: string; + projectSlug: string; +} & Omit; + +export type TUpdateRoleDTO = { + roleId: string; + data: Omit; + projectSlug: string; +} & Omit; + +export type TDeleteRoleDTO = { + roleId: string; + projectSlug: string; +} & Omit; + +export type TListRolesDTO = { + projectSlug: string; +} & Omit; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 753b1efba..aeff70126 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -348,7 +348,7 @@ export const projectServiceFactory = ({ const deletedProject = await projectDAL.transaction(async (tx) => { const delProject = await projectDAL.deleteById(project.id, tx); - const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(project.id).catch(() => null); + const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(project.id, tx).catch(() => null); // Delete the org membership for the ghost user if it's found. if (projectGhostUser) { diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts new file mode 100644 index 000000000..afae2677f --- /dev/null +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -0,0 +1,62 @@ +import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; + +type TDailyResourceCleanUpQueueServiceFactoryDep = { + auditLogDAL: Pick; + identityAccessTokenDAL: Pick; + secretSharingDAL: Pick; + queueService: TQueueServiceFactory; +}; + +export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; + +export const dailyResourceCleanUpQueueServiceFactory = ({ + auditLogDAL, + queueService, + identityAccessTokenDAL, + secretSharingDAL +}: TDailyResourceCleanUpQueueServiceFactoryDep) => { + queueService.start(QueueName.DailyResourceCleanUp, async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); + await auditLogDAL.pruneAuditLog(); + await identityAccessTokenDAL.removeExpiredTokens(); + await secretSharingDAL.pruneExpiredSharedSecrets(); + logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); + }); + + // we do a repeat cron job in utc timezone at 12 Midnight each day + const startCleanUp = async () => { + // TODO(akhilmhdh): remove later + await queueService.stopRepeatableJob( + QueueName.AuditLogPrune, + QueueJobs.AuditLogPrune, + { pattern: "0 0 * * *", utc: true }, + QueueName.AuditLogPrune // just a job id + ); + // clear previous job + await queueService.stopRepeatableJob( + QueueName.DailyResourceCleanUp, + QueueJobs.DailyResourceCleanUp, + { pattern: "0 0 * * *", utc: true }, + QueueName.DailyResourceCleanUp // just a job id + ); + + await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, { + delay: 5000, + jobId: QueueName.DailyResourceCleanUp, + repeat: { pattern: "0 0 * * *", utc: true } + }); + }; + + queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => { + logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); + }); + + return { + startCleanUp + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts new file mode 100644 index 000000000..6b5090d66 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSecretSharingDALFactory = ReturnType; + +export const secretSharingDALFactory = (db: TDbClient) => { + const sharedSecretOrm = ormify(db, TableName.SecretSharing); + + const pruneExpiredSharedSecrets = async (tx?: Knex) => { + try { + const today = new Date(); + const docs = await (tx || db)(TableName.SecretSharing).where("expiresAt", "<", today).del(); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "pruneExpiredSharedSecrets" }); + } + }; + + return { + ...sharedSecretOrm, + pruneExpiredSharedSecrets + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts new file mode 100644 index 000000000..85cfe97f6 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -0,0 +1,66 @@ +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { UnauthorizedError } from "@app/lib/errors"; + +import { TSecretSharingDALFactory } from "./secret-sharing-dal"; +import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types"; + +type TSecretSharingServiceFactoryDep = { + permissionService: Pick; + secretSharingDAL: TSecretSharingDALFactory; +}; + +export type TSecretSharingServiceFactory = ReturnType; + +export const secretSharingServiceFactory = ({ + permissionService, + secretSharingDAL +}: TSecretSharingServiceFactoryDep) => { + const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } = + createSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const newSharedSecret = await secretSharingDAL.create({ + name, + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + userId: actorId, + orgId + }); + return { id: newSharedSecret.id }; + }; + + const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const userSharedSecrets = await secretSharingDAL.find({ userId: actorId, orgId }, { sort: [["expiresAt", "asc"]] }); + return userSharedSecrets; + }; + + const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { + const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); + if (sharedSecret && sharedSecret.expiresAt < new Date()) { + return; + } + return sharedSecret; + }; + + const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId); + return deletedSharedSecret; + }; + + return { + createSharedSecret, + getSharedSecrets, + deleteSharedSecretById, + getActiveSharedSecretByIdAndHashedHex + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts new file mode 100644 index 000000000..2d14ed12c --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -0,0 +1,22 @@ +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export type TSharedSecretPermission = { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + orgId: string; +}; + +export type TCreateSharedSecretDTO = { + name: string; + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + expiresAt: Date; +} & TSharedSecretPermission; + +export type TDeleteSharedSecretDTO = { + sharedSecretId: string; +} & TSharedSecretPermission; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 6b2b50920..51ad7a6aa 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -608,7 +608,7 @@ export const createManySecretsRawFnFactory = ({ secretVersionTagDAL, folderDAL }: TCreateManySecretsRawFnFactory) => { - const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); const createManySecretsRawFn = async ({ projectId, environment, @@ -706,7 +706,7 @@ export const updateManySecretsRawFnFactory = ({ secretVersionTagDAL, folderDAL }: TUpdateManySecretsRawFnFactory) => { - const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); const updateManySecretsRawFn = async ({ projectId, environment, diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 677ba78fa..e434bd91f 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -7,7 +7,6 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; -import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; @@ -167,15 +166,11 @@ export const serviceTokenServiceFactory = ({ const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceToken.secretHash); if (!isMatch) throw new UnauthorizedError(); - // const updatedToken = await serviceTokenDAL.updateById(serviceToken.id, { - // lastUsed: new Date() - // }); + const updatedToken = await serviceTokenDAL.updateById(serviceToken.id, { + lastUsed: new Date() + }); - logger.info( - `fnValidateServiceToken: [serviceToken=${serviceToken.id}] [serviceTokenProjectId=${serviceToken.projectId}]` - ); - - return { ...serviceToken, lastUsed: serviceToken.lastUsed, orgId: project.orgId }; + return { ...serviceToken, lastUsed: updatedToken.lastUsed, orgId: project.orgId }; }; return { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 81680537d..7d6b98b31 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -21,6 +21,7 @@ export enum SmtpTemplates { EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", + UnlockAccount = "unlockAccount.handlebars", AccessApprovalRequest = "accessApprovalRequest.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars new file mode 100644 index 000000000..36664be87 --- /dev/null +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -0,0 +1,16 @@ + + + + + + Your Infisical account has been locked + + + +

Unlock your Infisical account

+

Your account has been temporarily locked due to multiple failed login attempts. + To unlock your account, follow the link here +

If these attempts were not made by you, reset your password immediately.

+ + + \ No newline at end of file diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 089f3b8c6..a82259db6 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -207,6 +207,19 @@ export const userServiceFactory = ({ return userAction; }; + const unlockUser = async (userId: string, token: string) => { + await tokenService.validateTokenForUser({ + userId, + code: token, + type: TokenType.TOKEN_USER_UNLOCK + }); + + await userDAL.update( + { id: userId }, + { consecutiveFailedMfaAttempts: 0, isLocked: false, temporaryLockDateEnd: null } + ); + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -216,6 +229,7 @@ export const userServiceFactory = ({ deleteMe, getMe, createUserAction, - getUserAction + getUserAction, + unlockUser }; }; diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 03bf9af4d..f2b05d1f0 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -15,7 +15,6 @@ import ( "path" "runtime" "slices" - "strings" "sync" "syscall" "text/template" @@ -257,19 +256,6 @@ func WriteBytesToFile(data *bytes.Buffer, outputPath string) error { return err } -func appendAPIEndpoint(address string) string { - // Ensure the address does not already end with "/api" - if strings.HasSuffix(address, "/api") { - return address - } - - // Check if the address ends with a slash and append accordingly - if address[len(address)-1] == '/' { - return address + "api" - } - return address + "/api" -} - func ParseAgentConfig(configFile []byte) (*Config, error) { var rawConfig struct { Infisical InfisicalConfig `yaml:"infisical"` @@ -290,7 +276,7 @@ func ParseAgentConfig(configFile []byte) (*Config, error) { rawConfig.Infisical.Address = DEFAULT_INFISICAL_CLOUD_URL } - config.INFISICAL_URL = appendAPIEndpoint(rawConfig.Infisical.Address) + config.INFISICAL_URL = util.AppendAPIEndpoint(rawConfig.Infisical.Address) log.Info().Msgf("Infisical instance address set to %s", rawConfig.Infisical.Address) diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index bbb2c3a05..61e24b12f 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -101,7 +101,7 @@ var loginCmd = &cobra.Command{ //set domainQuery to false if !overrideDomain { domainQuery = false - config.INFISICAL_URL = config.INFISICAL_URL_MANUAL_OVERRIDE + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE) } } diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 06846260f..482c6f78a 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -43,6 +43,7 @@ func init() { rootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { silent, err := cmd.Flags().GetBool("silent") + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL) if err != nil { util.HandleError(err) } diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 305a1f0fd..423fe1657 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -170,6 +170,11 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to get your local config details") } + secretType, err := cmd.Flags().GetString("type") + if err != nil || (secretType != util.SECRET_TYPE_SHARED && secretType != util.SECRET_TYPE_PERSONAL) { + util.HandleError(err, "Unable to parse secret type") + } + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() if err != nil { util.HandleError(err, "Unable to authenticate") @@ -179,6 +184,7 @@ var secretsSetCmd = &cobra.Command{ util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") } + httpClient := resty.New(). SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). SetHeader("Accept", "application/json") @@ -223,7 +229,16 @@ var secretsSetCmd = &cobra.Command{ secretsToModify := []api.Secret{} secretOperations := []SecretSetOperation{} - secretByKey := getSecretsByKeys(secrets) + sharedSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + personalSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + + for _, secret := range secrets { + if secret.Type == util.SECRET_TYPE_PERSONAL { + personalSecretMapByName[secret.Key] = secret + } else { + sharedSecretMapByName[secret.Key] = secret + } + } for _, arg := range args { splitKeyValueFromArg := strings.SplitN(arg, "=", 2) @@ -251,7 +266,16 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "unable to encrypt your secrets") } - if existingSecret, ok := secretByKey[key]; ok { + var existingSecret models.SingleEnvironmentVariable + var doesSecretExist bool + + if secretType == util.SECRET_TYPE_SHARED { + existingSecret, doesSecretExist = sharedSecretMapByName[key] + } else { + existingSecret, doesSecretExist = personalSecretMapByName[key] + } + + if doesSecretExist { // case: secret exists in project so it needs to be modified encryptedSecretDetails := api.Secret{ ID: existingSecret.ID, @@ -291,7 +315,7 @@ var secretsSetCmd = &cobra.Command{ SecretValueIV: base64.StdEncoding.EncodeToString(encryptedValue.Nonce), SecretValueTag: base64.StdEncoding.EncodeToString(encryptedValue.AuthTag), SecretValueHash: hashedValue, - Type: util.SECRET_TYPE_SHARED, + Type: secretType, PlainTextKey: key, } secretsToCreate = append(secretsToCreate, encryptedSecretDetails) @@ -781,6 +805,7 @@ func init() { secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") + secretsSetCmd.Flags().String("type", util.SECRET_TYPE_SHARED, "the type of secret to create: personal or shared") // Only supports logged in users (JWT auth) secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 96b03a0bb..844213e18 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -237,7 +237,7 @@ func NewDomainPrompt() (string, error) { return "", err } - return domain, nil + return util.AppendAPIEndpoint(domain), nil } func LoggedInUsersPrompt(profiles []string) (string, error) { diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index af63aa917..4856de35a 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -88,7 +88,7 @@ func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { //configFile.LoggedInUserDomain //if not empty set as infisical url if configFile.LoggedInUserDomain != "" { - config.INFISICAL_URL = configFile.LoggedInUserDomain + config.INFISICAL_URL = AppendAPIEndpoint(configFile.LoggedInUserDomain) } isAuthenticated := api.CallIsAuthenticated(httpClient) diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 9a4d960db..9e5052530 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -233,3 +233,16 @@ func getCurrentBranch() (string, error) { } return path.Base(strings.TrimSpace(out.String())), nil } + +func AppendAPIEndpoint(address string) string { + // Ensure the address does not already end with "/api" + if strings.HasSuffix(address, "/api") { + return address + } + + // Check if the address ends with a slash and append accordingly + if address[len(address)-1] == '/' { + return address + "api" + } + return address + "/api" +} diff --git a/company/handbook/onboarding.mdx b/company/handbook/onboarding.mdx new file mode 100644 index 000000000..e85be9f04 --- /dev/null +++ b/company/handbook/onboarding.mdx @@ -0,0 +1,28 @@ +--- +title: "Onboarding" +sidebarTitle: "Onboarding" +description: "This guide explains the onboarding process for new joiners at Infisical." +--- + +Welcome to Infisical! + +The first few days of every new joiner are going to be packed with learning lots of new information, meeting new teammates, and understanding Infisical on a deeper level. + +Plus, our team is remote-first and spread across the globe (from San Francisco to Philippines), so having a great onboarding experience is very important for the new joiner to feel part of the team and be excited about what we're doing as a company. + +## Onboarding buddy + +Every new joiner has an onboarding buddy who should ideally be in the the same timezone. The onboarding buddy should be able to help with any questions that pop up during the first few weeks. Of course, everyone is available to help, but it's good to have a dedicated person that you can go to with any questions. + +## Onboarding Checklist + +1. Join the weekly all-hands meeting. It typically happens on Monday's at 8:30am PT. +2. Ship something together on day one – even if tiny! It feels great to hit the ground running, with a development environment all ready to go. +3. Check out the [Areas of Responsibility (AoR) Table](https://docs.google.com/spreadsheets/d/1RnXlGFg83Sgu0dh7ycuydsSobmFfI3A0XkGw7vrVxEI/edit?usp=sharing). This is helpful to know who you can ask about particular areas of Infisical. Feel free to add yourself to the areas you'd be most interesting to dive into. +4. Read the [Infisical Strategy Doc](https://docs.google.com/document/d/1oy_NP1Q_Zt1oqxLpyNkLIGmhAI3N28AmZq6dDIOONSQ/edit?usp=sharing). +5. Update your LinkedIn profile with one of [Infisical's official banners](https://drive.google.com/drive/u/0/folders/1oSNWjbpRl9oNYwxM_98IqzKs9fAskrb2) (if you want to). You can also coordinate your social posts in the #marketing Slack channel, so that we can boost it from Infisical's official social media accounts. +6. Over the first few weeks, feel free to schedule 1:1s with folks on the team to get to know them a bit better. +7. Change your Slack username in the users channel to `[NAME] (Infisical)`. +8. Go through the [technical overview](https://infisical.com/docs/internals/overview) of Infisical. + + diff --git a/company/handbook/overview.mdx b/company/handbook/overview.mdx new file mode 100644 index 000000000..c7067612d --- /dev/null +++ b/company/handbook/overview.mdx @@ -0,0 +1,11 @@ +--- +title: "Infisical Company Handbook" +sidebarTitle: "Welcome" +description: "This handbook explains how we work at Infisical." +--- + +Welcome! This handbook explains how we work and what we stand for at Infisical. + +Given that Infisical's core is open source, we decided to make this handbook also availably publicly to everyone. + +You can treat it as a living document as more pages and information will be added over time. diff --git a/company/handbook/spending-money.mdx b/company/handbook/spending-money.mdx new file mode 100644 index 000000000..667ed5ef2 --- /dev/null +++ b/company/handbook/spending-money.mdx @@ -0,0 +1,27 @@ +--- +title: "Spenging Money" +sidebarTitle: "Spending Money" +description: "The guide to spending money at Infisical." +--- + +Fairly frequently, you might run into situations when you need to spend company money. + +**Please spend money in a way that you think is in the best interest of the company.** + +## Trivial expenses + +We don't want you to be slowed down because you're waiting for an approval to purchase some SaaS. For trivial expenses – **Just do it**. + +This means expenses that are: +1. Non-recurring AND less than $75/month in total. +2. Recurring AND less than $20/month. + +## Saving receipts + +Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay. + +You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually. + +## Brex + +We use Brex as our primary credit card provider. Don't have a company card yet? Reach out to Maidul. \ No newline at end of file diff --git a/company/handbook/time-off.mdx b/company/handbook/time-off.mdx new file mode 100644 index 000000000..a80721440 --- /dev/null +++ b/company/handbook/time-off.mdx @@ -0,0 +1,13 @@ +--- +title: "Time Off" +sidebarTitle: "Time Off" +description: "The guide to taking time off at Infisical." +--- + +We offer eveyone at Infisical unlimited time off. We care about your results, not how long you work. + +To request time off, just submit a request in Rippling and let Maidul know at least a week in advance. + +## National holidays + +Since Infisical's team is globally distributed, it is hard for us to keep track of all the various national holidays across many different countries. Whether you'd like to celebrate Christmas or National Brisket Day (which, by the way, is on May 28th), you are welcome to take PTO on those days – just let Maidul know at least a week ahead so that we can adjust our planning. \ No newline at end of file diff --git a/company/mint.json b/company/mint.json index d867ab7a5..0ae63107b 100644 --- a/company/mint.json +++ b/company/mint.json @@ -1,6 +1,5 @@ { "name": "Infisical", - "openapi": "https://app.infisical.com/api/docs/json", "logo": { "dark": "/logo/dark.svg", "light": "/logo/light.svg", @@ -44,33 +43,22 @@ "name": "Start for Free", "url": "https://app.infisical.com/signup" }, - "tabs": [ - { - "name": "Integrations", - "url": "integrations" - }, - { - "name": "CLI", - "url": "cli" - }, - { - "name": "API Reference", - "url": "api-reference" - }, - { - "name": "SDKs", - "url": "sdks" - }, - { - "name": "Changelog", - "url": "changelog" - } - ], + "primaryTab": { + "name": "About" + }, "navigation": [ { - "group": "Getting Started", + "group": "Handbook", "pages": [ - "documentation/getting-started/introduction" + "handbook/overview" + ] + }, + { + "group": "How we work", + "pages": [ + "handbook/onboarding", + "handbook/spending-money", + "handbook/time-off" ] } ], diff --git a/company/style.css b/company/style.css index b76d06450..ea8c60dc9 100644 --- a/company/style.css +++ b/company/style.css @@ -1,7 +1,7 @@ #navbar .max-w-8xl { max-width: 100%; border-bottom: 1px solid #ebebeb; - background-color: #fcfcfc; + background-color: #F4F3EF; } .max-w-8xl { @@ -14,7 +14,7 @@ padding-right: 30px; border-right: 1px; border-color: #cdd64b; - background-color: #fcfcfc; + background-color: #F4F3EF; border-right: 1px solid #ebebeb; } @@ -37,6 +37,13 @@ padding: 0px; } +#sidebar li > a.text-primary { + border-radius: 0; + background-color: #FBFFCC; + border-left: 4px solid #EFFF33; + padding: 5px; +} + /* #sidebar ul > div.mt-12 { padding-top: 30px; position: relative; @@ -49,10 +56,10 @@ } */ #header { - border-left: 1px solid #26272b; + border-left: 4px solid #EFFF33; padding-left: 16px; padding-right: 16px; - background-color: #f5f5f5; + background-color: #FDFFE5; padding-bottom: 10px; padding-top: 10px; } @@ -63,6 +70,13 @@ border-color: #ebebeb; } +#content-area:hover .mt-8 .block:hover{ + border-radius: 0; + border-width: 1px; + background-color: #FDFFE5; + border-color: #EFFF33; +} + #content-area .mt-8 .rounded-xl{ border-radius: 0; } diff --git a/docs/api-reference/endpoints/project-roles/create.mdx b/docs/api-reference/endpoints/project-roles/create.mdx new file mode 100644 index 000000000..2220b9309 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/delete.mdx b/docs/api-reference/endpoints/project-roles/delete.mdx new file mode 100644 index 000000000..6362c2154 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/project-roles/get-by-slug.mdx b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx new file mode 100644 index 000000000..18817bca9 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectSlug}/roles/slug/{slug}" +--- diff --git a/docs/api-reference/endpoints/project-roles/list.mdx b/docs/api-reference/endpoints/project-roles/list.mdx new file mode 100644 index 000000000..ca83d6e7d --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/update.mdx b/docs/api-reference/endpoints/project-roles/update.mdx new file mode 100644 index 000000000..5a3d9668e --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index b9adf6f20..c279b1a15 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -153,6 +153,16 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb ``` + + + Used to select the type of secret to create. This could be either personal or shared (defaults to shared) + + ```bash + # Example + infisical secrets set DOMAIN=example.com --type=personal + ``` + + diff --git a/docs/documentation/guides/node.mdx b/docs/documentation/guides/node.mdx index 8b78cde5e..d1b8fe5e8 100644 --- a/docs/documentation/guides/node.mdx +++ b/docs/documentation/guides/node.mdx @@ -36,7 +36,7 @@ Initialize a new Node.js project with a default `package.json` file. npm init -y ``` -Install `express` and [infisical-node](https://github.com/Infisical/infisical-node), the client Node SDK for Infisical. +Install `express` and [@infisical/sdk](https://www.npmjs.com/package/@infisical/sdk), the client Node SDK for Infisical. ```console npm install express @infisical/sdk @@ -46,16 +46,19 @@ Finally, create an index.js file containing the application code. ```js const express = require('express'); -const { InfisicalClient, LogLevel } = require("@infisical/sdk"); +const { InfisicalClient } = require("@infisical/sdk"); const app = express(); const PORT = 3000; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + } + } }); app.get("/", async (req, res) => { diff --git a/docs/documentation/guides/python.mdx b/docs/documentation/guides/python.mdx index 696f0a7ee..00b3d6089 100644 --- a/docs/documentation/guides/python.mdx +++ b/docs/documentation/guides/python.mdx @@ -5,7 +5,7 @@ title: "Python" This guide demonstrates how to use Infisical to manage secrets for your Python stack from local development to production. It uses: - Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets. -- The [infisical-python](https://github.com/Infisical/sdk/tree/main/crates/infisical-py) Python client SDK to fetch secrets back to your Python application on demand. +- The [infisical-python](https://pypi.org/project/infisical-python/) Python client SDK to fetch secrets back to your Python application on demand. ## Project Setup @@ -36,23 +36,27 @@ python3 -m venv env source env/bin/activate ``` -Install Flask and [infisical-python](https://github.com/Infisical/sdk/tree/main/crates/infisical-py), the client Python SDK for Infisical. +Install Flask and [infisical-python](https://pypi.org/project/infisical-python/), the client Python SDK for Infisical. ```console -pip install Flask infisical-python +pip install flask infisical-python ``` Finally, create an `app.py` file containing the application code. ```py from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions +from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod app = Flask(__name__) client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) @app.route("/") diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index 505d5a8dd..3eb094cc4 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -280,6 +280,10 @@ access the Infisical API using the AWS Auth authentication method. --data-urlencode 'iamRequestHeaders=...' ``` + + Note that you should replace `` with the ID of the identity you created in step 1. + + #### Sample response ```bash Response diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx new file mode 100644 index 000000000..3ac957752 --- /dev/null +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -0,0 +1,176 @@ +--- +title: Azure Auth +description: "Learn how to authenticate with Infisical for services on Azure" +--- + +**Azure Auth** is an Azure-native authentication method for Azure resources like Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc. to access Infisical. + +## Diagram + +The following sequence digram illustrates the Azure Auth workflow for authenticating Azure [service principals](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser) with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + participant Azure as Azure AD OpenID + + Note over Client,Azure: Step 1: Instance Identity Token Retrieval + Client->>Azure: Request managed identity access token + Azure-->>Client: Return managed identity access token + + Note over Client,Infis: Step 2: Identity Token Login Operation + Client->>Infis: Send managed identity access token to /api/v1/auth/azure-auth/login + Infis->>Azure: Request public key + Azure-->>Infis: Return public key + + Note over Infis: Step 3: Identity Token Verification + Note over Infis: Step 4: Identity Property Validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 4: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an Azure service by verifying its identity and checking that it meets specific requirements (e.g. it is bound to an allowed service principal) at the `/api/v1/auth/azure-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client running on an Azure service obtains an [access token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) that is a JWT token representing the managed identity for the Azure resource such as a Virtual Machine; the managed identity is associated with a service principal in Azure AD. +2. The client sends the access token to Infisical. +3. Infisical verifies the token against the corresponding public key at the [public Azure AD OpenID configuration endpoint](https://learn.microsoft.com/en-us/answers/questions/793793/azure-ad-validate-access-token). +4. Infisical checks if the entity behind the access token is allowed to authenticate with Infisical based on set criteria such as **Allowed Service Principal IDs**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using Azure Auth as they handle the +authentication process including generating the client access token for you. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the Azure Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your applications in Azure to +access the Infisical API using the Azure Auth authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **Azure Auth**. + + ![identities create azure auth method](/images/platform/identities/identities-org-create-azure-auth-method.png) + + Here's some more guidance on each field: + + - Tenant ID: The [tenant ID](https://learn.microsoft.com/en-us/entra/fundamentals/how-to-find-tenant) for the Azure AD organization. + - Resource / Audience: The resource URL for the application registered in Azure AD. The value is expected to match the `aud` claim of the access token JWT later used in the login operation against Infisical. See the [resource](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) parameter for how the audience is set when requesting a JWT access token from the Azure Instance Metadata Service (IMDS) endpoint. In most cases, this value should be `https://management.azure.com/` which is the default. + - Allowed Service Principal IDs: A comma-separated list of Azure AD service principal IDs that are allowed to authenticate with Infisical. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate a managed identity [access token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) that is a JWT token representing the managed identity for the Azure resource such as a Virtual Machine. The client token must be sent to the `/api/v1/auth/azure-auth/login` endpoint in exchange for a separate access token to access the Infisical API. + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + Start by making a request from your Azure client such as Virtual Machine to obtain a managed identity access token. + + For more examples of how to obtain the managed identity access token, refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http). + + #### Sample request + ```bash curl + curl 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F' -H Metadata:true -s + ``` + + #### Sample response + ```bash + { + "access_token": "eyJ0eXAi...", + "refresh_token": "", + "expires_in": "3599", + "expires_on": "1506484173", + "not_before": "1506480273", + "resource": "https://management.azure.com/", + "token_type": "Bearer" + } + ``` + + Next use send the obtained managed identity access token (i.e. the token from the `access_token` field above) to authenticate with Infisical and obtain a separate access token. + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use this access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using Azure Auth as they handle the authentication process including retrieving the client access token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index f189a3d20..9cc6c4c3d 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -7,9 +7,9 @@ description: "Learn how to use Machine Identities to programmatically interact w An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). -Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), [AWS Auth](/documentation/platform/identities/aws-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. +Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. -![organization identities](/images/platform/organization/organization-machine-identities.png) +![Organization Identities](/images/platform/organization/organization-machine-identities.png) Key Features: @@ -39,11 +39,10 @@ To interact with various resources in Infisical, Machine Identities are able to - [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. - [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth): A Kubernetes-native authentication method for applications (e.g. pods) to authenticate with Infisical. -- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. +- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for AWS services (e.g. EC2, Lambda functions, etc.) to authenticate with Infisical. +- [Azure Auth](/documentation/platform/identities/azure-auth): An Azure-native authentication method for Azure resources (e.g. Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc.) to authenticate with Infisical. - [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.) to authenticate with Infisical. -IAM service accounts and GCE instances to authenticate with Infisical. - ## FAQ diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index ee9ea5a7d..119a8cefa 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -9,14 +9,6 @@ description: "Learn the fundamentals of secret referencing and importing in Infi Infisical's secret referencing functionality makes it possible to reference the value of a "base" secret when defining the value of another secret. This means that updating the value of a base secret propagates directly to other secrets whose values depend on the base secret. - - Currently, the secret referencing feature is only supported by the - [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. - - ![secret referencing](../../images/platform/secret-references-imports/secret-reference.png) Since secret referencing works by reconstructing values back on the client side, the client, be it a user, service token, or a machine identity, fetching back secrets diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx new file mode 100644 index 000000000..4e4761924 --- /dev/null +++ b/docs/documentation/platform/secret-sharing.mdx @@ -0,0 +1,46 @@ +--- +title: "Secret Sharing" +sidebarTitle: "Secret Sharing" +description: "Learn how to share time-bound secrets securely with anyone on the internet." +--- + +Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. +Infisical offers a secure solution for sharing secrets over the internet in a time-bound manner. +With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. + +## Share a Secret + +1. Navigate to the **Projects** page. +2. Click on the **Secret Sharing** tab from the sidebar. + +![Secret Sharing](../../images/platform/secret-sharing/overview.png) + +3. Click on the **Share Secret** button. + + + Infisical does not have access to the shared secrets. This is a part of our zero + knowledge architecture. + + +4. Enter the secret you want to share and set the expiration time. Click on the **Share Secret** button. + +![Add Sharing Secret](../../images/platform/secret-sharing/new-secret.png) + + + Secret once set cannot be changed. This is to ensure that the secret is not + tampered with. + + +5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration time. Hence, it is recommended to share the link only with the intended recipient. + +![Copy URL](../../images/platform/secret-sharing/copy-url.png) + +## Access a Shared Secret + +Just click on the link you received to access the secret. The secret will be displayed on the screen & for how long it is valid. + +![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) + +## Delete a Shared Secret + +In the **Secret Sharing** tab, click on the **Delete** button next to the secret you want to delete. This will delete the secret immediately & the link will no longer be accessible. diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png index 21f2213ef..e43cfbf9e 100644 Binary files a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png and b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png differ diff --git a/docs/images/platform/identities/identities-org-create-azure-auth-method.png b/docs/images/platform/identities/identities-org-create-azure-auth-method.png new file mode 100644 index 000000000..fc0fd1665 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-azure-auth-method.png differ diff --git a/docs/images/platform/secret-sharing/copy-url.png b/docs/images/platform/secret-sharing/copy-url.png new file mode 100644 index 000000000..89d86ede4 Binary files /dev/null and b/docs/images/platform/secret-sharing/copy-url.png differ diff --git a/docs/images/platform/secret-sharing/new-secret.png b/docs/images/platform/secret-sharing/new-secret.png new file mode 100644 index 000000000..13a587ec9 Binary files /dev/null and b/docs/images/platform/secret-sharing/new-secret.png differ diff --git a/docs/images/platform/secret-sharing/overview.png b/docs/images/platform/secret-sharing/overview.png new file mode 100644 index 000000000..3bdbe8878 Binary files /dev/null and b/docs/images/platform/secret-sharing/overview.png differ diff --git a/docs/images/platform/secret-sharing/public-view.png b/docs/images/platform/secret-sharing/public-view.png new file mode 100644 index 000000000..f1bd9482f Binary files /dev/null and b/docs/images/platform/secret-sharing/public-view.png differ diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index db95c8308..9b3a8a2f8 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -72,6 +72,9 @@ Prerequisites: The region that you want to integrate with in AWS Secrets Manager. + + How you want the integration to map the secrets. The selected value could be either one to one or one to many. + The secret name/path in AWS into which you want to sync the secrets from Infisical. diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx index 0f21a6a9d..99edcd115 100644 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ b/docs/integrations/cloud/gcp-secret-manager.mdx @@ -51,6 +51,8 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" Using Infisical to sync secrets to GCP Secret Manager requires that you enable the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + Additionally, ensure that your GCP account has sufficient permission to manage secret and service resources (you can assign Secret Manager Admin and Service Usage Admin roles for testing purposes) @@ -115,6 +117,7 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" + Using the GCP Secret Manager integration (via the OAuth2 method) on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP @@ -123,27 +126,27 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" Navigate to your project API & Services > Credentials to create a new OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) - + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) + Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/integrations/gcp-secret-manager/oauth2/callback`. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) Obtain the **Client ID** and **Client Secret** for your GCP OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) - + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) + Back in your Infisical instance, add two new environment variables for the credentials of your GCP OAuth2 application: - `CLIENT_ID_GCP_SECRET_MANAGER`: The **Client ID** of your GCP OAuth2 application. - `CLIENT_SECRET_GCP_SECRET_MANAGER`: The **Client Secret** of your GCP OAuth2 application. - + Once added, restart your Infisical instance and use the GCP Secret Manager integration. + - diff --git a/docs/mint.json b/docs/mint.json index 06b92711c..5c5097d42 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -124,7 +124,9 @@ "documentation/platform/access-controls/temporary-access", "documentation/platform/access-controls/access-requests", "documentation/platform/pr-workflows", - "documentation/platform/audit-logs" + "documentation/platform/audit-logs", + "documentation/platform/audit-log-streams", + "documentation/platform/groups" ] }, { @@ -148,8 +150,7 @@ "documentation/platform/dynamic-secrets/aws-iam" ] }, - "documentation/platform/groups", - "documentation/platform/audit-log-streams" + "documentation/platform/secret-sharing" ] }, { @@ -160,6 +161,7 @@ "documentation/platform/identities/universal-auth", "documentation/platform/identities/kubernetes-auth", "documentation/platform/identities/gcp-auth", + "documentation/platform/identities/azure-auth", "documentation/platform/identities/aws-auth", "documentation/platform/mfa", { @@ -475,6 +477,16 @@ "api-reference/endpoints/project-identities/delete-identity-membership" ] }, + { + "group": "Project Roles", + "pages": [ + "api-reference/endpoints/project-roles/create", + "api-reference/endpoints/project-roles/update", + "api-reference/endpoints/project-roles/delete", + "api-reference/endpoints/project-roles/get-by-slug", + "api-reference/endpoints/project-roles/list" + ] + }, { "group": "Environments", "pages": [ diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx index b3a1d2086..90351a986 100644 --- a/docs/sdks/languages/csharp.mdx +++ b/docs/sdks/languages/csharp.mdx @@ -21,21 +21,28 @@ namespace Example static void Main(string[] args) { - var settings = new ClientSettings + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions { - ClientId = "CLIENT_ID", - ClientSecret = "CLIENT_SECRET", - // SiteUrl = "http://localhost:8080", <-- This line can be omitted if you're using Infisical Cloud. - }; - var infisical = new InfisicalClient(settings); + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; - var options = new GetSecretOptions + + var infisicalClient = new InfisicalClient(settings); + + var getSecretOptions = new GetSecretOptions { SecretName = "TEST", ProjectId = "PROJECT_ID", Environment = "dev", }; - var secret = infisical.GetSecret(options); + var secret = infisical.GetSecret(getSecretOptions); Console.WriteLine($"The value of secret '{secret.SecretKey}', is: {secret.SecretValue}"); @@ -52,8 +59,6 @@ This example demonstrates how to use the Infisical C# SDK in a C# application. T # Installation -Run `npm` to add `@infisical/sdk` to your project. - ```console $ dotnet add package Infisical.Sdk ``` @@ -70,14 +75,20 @@ namespace Example { static void Main(string[] args) { - - var settings = new ClientSettings + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions { - ClientId = "CLIENT_ID", - ClientSecret = "CLIENT_SECRET", - }; + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; - var infisical = new InfisicalClient(settings); // <-- Your SDK instance! + + var infisicalClient = new InfisicalClient(settings); // <-- Your SDK client is now ready to use } } } @@ -87,14 +98,14 @@ namespace Example - + Your machine identity client ID. - + Your machine identity client secret. - + An access token obtained from the machine identity login endpoint. @@ -103,13 +114,175 @@ namespace Example If manually set to 0, caching will be disabled, this is not recommended. - + Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + The authentication object to use for the client. This is required unless you're using environment variables. + +### Authentication + +The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. + +#### Universal Auth + +**Using environment variables** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; + + var infisicalClient = new InfisicalClient(settings); +``` + +#### GCP ID Token Auth + + Please note that this authentication method will only work if you're running your application on Google Cloud Platform. + Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + GcpIdToken = new GcpIdTokenAuthMethod + { + IdentityId = "your-machine-identity-id", + } + } + }; + + + var infisicalClient = new InfisicalClient(settings); +``` + +#### GCP IAM Auth + +**Using environment variables** +- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + GcpIam = new GcpIamAuthMethod + { + IdentityId = "your-machine-identity-id", + ServiceAccountKeyFilePath = "./path/to/your/service-account-key.json" + } + } + }; + + + var infisicalClient = new InfisicalClient(settings); +``` + +#### AWS IAM Auth + + Please note that this authentication method will only work if you're running your application on AWS. + Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + AwsIam = new AwsIamAuthMethod + { + IdentityId = "your-machine-identity-id", + } + } + }; + + + var infisicalClient = new InfisicalClient(settings); +``` + + +#### Azure Auth + + Please note that this authentication method will only work if you're running your application on Azure. + Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + Azure = new AzureAuthMethod + { + IdentityId = "YOUR_IDENTITY_ID", + } + } + }; + + var infisicalClient = new InfisicalClient(settings); +``` + +#### Kubernetes Auth + + Please note that this authentication method will only work if you're running your application on Kubernetes. + Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + Kubernetes = new KubernetesAuthMethod + { + ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token", // Optional + IdentityId = "YOUR_IDENTITY_ID", + } + } + }; + + var infisicalClient = new InfisicalClient(settings); +``` + + + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. @@ -155,6 +328,14 @@ Retrieve all secrets within the Infisical project and environment that client is Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) + + + Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. + + + + Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) + diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 5b8797b5d..879bfa624 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -19,12 +19,19 @@ import com.infisical.sdk.schema.*; public class Example { public static void main(String[] args) { - // Create a new Infisical Client + + // Create the authentication settings for the client ClientSettings settings = new ClientSettings(); - settings.setClientID("MACHINE_IDENTITY_CLIENT_ID"); - settings.setClientSecret("MACHINE_IDENTITY_CLIENT_SECRET"); - settings.setCacheTTL(Long.valueOf(300)); // 300 seconds, 5 minutes + AuthenticationOptions authOptions = new AuthenticationOptions(); + UniversalAuthMethod authMethod = new UniversalAuthMethod(); + authMethod.setClientID("YOUR_IDENTITY_ID"); + authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + + authOptions.setUniversalAuth(authMethod); + settings.setAuth(authOptions); + + // Create a new Infisical Client InfisicalClient client = new InfisicalClient(settings); // Create the options for fetching the secret @@ -68,11 +75,18 @@ import com.infisical.sdk.schema.*; public class App { public static void main(String[] args) { - + // Create the authentication settings for the client ClientSettings settings = new ClientSettings(); - settings.setClientID("MACHINE_IDENTITY_CLIENT_ID"); - settings.setClientSecret("MACHINE_IDENTITY_CLIENT_SECRET"); + AuthenticationOptions authOptions = new AuthenticationOptions(); + UniversalAuthMethod authMethod = new UniversalAuthMethod(); + authMethod.setClientID("YOUR_IDENTITY_ID"); + authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + + authOptions.setUniversalAuth(authMethod); + settings.setAuth(authOptions); + + // Create a new Infisical Client InfisicalClient client = new InfisicalClient(settings); // Your client! } } @@ -82,15 +96,21 @@ public class App { - + Your machine identity client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - + Your machine identity client secret. + + **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - + An access token obtained from the machine identity login endpoint. + + **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. @@ -101,10 +121,155 @@ public class App { Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + The authentication object to use for the client. This is required unless you're using environment variables. + +### Authentication + +The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. + +#### Universal Auth + +**Using environment variables** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + UniversalAuthMethod authMethod = new UniversalAuthMethod(); + + authMethod.setClientID("YOUR_IDENTITY_ID"); + authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + + authOptions.setUniversalAuth(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### GCP ID Token Auth + + Please note that this authentication method will only work if you're running your application on Google Cloud Platform. + Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + GCPIDTokenAuthMethod authMethod = new GCPIDTokenAuthMethod(); + + authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); + + authOptions.setGcpIDToken(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### GCP IAM Auth + +**Using environment variables** +- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + GCPIamAuthMethod authMethod = new GCPIamAuthMethod(); + + authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); + authMethod.setServiceAccountKeyFilePath("./path/to/your/service-account-key.json"); + + authOptions.setGcpIam(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### AWS IAM Auth + + Please note that this authentication method will only work if you're running your application on AWS. + Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + AWSIamAuthMethod authMethod = new AWSIamAuthMethod(); + + authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); + + authOptions.setAwsIam(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### Azure Auth + + Please note that this authentication method will only work if you're running your application on Azure. + Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + AzureAuthMethod authMethod = new AzureAuthMethod(); + + authMethod.setIdentityID("YOUR_IDENTITY_ID"); + + authOptions.setAzure(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### Kubernetes Auth + + Please note that this authentication method will only work if you're running your application on Kubernetes. + Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + KubernetesAuthMethod authMethod = new KubernetesAuthMethod(); + + authMethod.setIdentityID("YOUR_IDENTITY_ID"); + authMethod.setServiceAccountTokenPath("/var/run/secrets/kubernetes.io/serviceaccount/token"); // Optional + + authOptions.setKubernetes(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. @@ -119,6 +284,8 @@ options.setEnvironment("dev"); options.setProjectID("PROJECT_ID"); options.setPath("/foo/bar"); options.setIncludeImports(false); +options.setRecursive(false); +options.setExpandSecretReferences(true); SecretElement[] secrets = client.listSecrets(options); ``` @@ -148,6 +315,14 @@ Retrieve all secrets within the Infisical project and environment that client is Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) + + + Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. + + + + Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) + diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index 4816392ed..1546451b8 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Node.js" icon: "node" --- -If you're working with Node.js, the official [infisical-node](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. +If you're working with Node.js, the official [Infisical Node SDK](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. - [NPM Package](https://www.npmjs.com/package/@infisical/sdk) - [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/node) @@ -14,21 +14,25 @@ If you're working with Node.js, the official [infisical-node](https://github.com ```js import express from "express"; -import { InfisicalClient, LogLevel } from "@infisical/sdk"; +import { InfisicalClient } from "@infisical/sdk"; const app = express(); const PORT = 3000; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + siteUrl: "https://app.infisical.com", // Optional, defaults to https://app.infisical.com + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + } }); app.get("/", async (req, res) => { - // access value - + // Access the secret + const name = await client.getSecret({ environment: "dev", projectId: "PROJECT_ID", @@ -72,8 +76,12 @@ Import the SDK and create a client instance with your [Machine Identity](/docume import { InfisicalClient, LogLevel } from "@infisical/sdk"; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + }, logLevel: LogLevel.Error }); ``` @@ -81,31 +89,40 @@ Import the SDK and create a client instance with your [Machine Identity](/docume ```js - const { InfisicalClient, LogLevel } = require("@infisical/sdk"); + const { InfisicalClient } = require("@infisical/sdk"); const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + }, }); ``` -#### Parameters +### Parameters - + Your machine identity client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientId` field instead. - + Your machine identity client secret. + + **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientSecret` field instead. - + An access token obtained from the machine identity login endpoint. + + **This field is deprecated and will be removed in future versions.** Please use the `auth.accessToken` field instead. @@ -119,10 +136,138 @@ Import the SDK and create a client instance with your [Machine Identity](/docume The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust. + + + The authentication object to use for the client. This is required unless you're using environment variables. + + +### Authentication + +The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. + +#### Universal Auth + +**Using environment variables** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + } +}); +``` + +#### GCP ID Token Auth + + Please note that this authentication method will only work if you're running your application on Google Cloud Platform. + Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + gcpIdToken: { + identityId: "YOUR_IDENTITY_ID" + } + } +}); +``` + +#### GCP IAM Auth + +**Using environment variables** +- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + gcpIam: { + identityId: "YOUR_IDENTITY_ID", + serviceAccountKeyFilePath: "./path/to/your/service-account-key.json" + } + } +}); +``` + +#### AWS IAM Auth + + Please note that this authentication method will only work if you're running your application on AWS. + Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + awsIam: { + identityId: "YOUR_IDENTITY_ID" + } + } +}); +``` + +#### Azure Auth + + Please note that this authentication method will only work if you're running your application on Azure. + Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + azure: { + identityId: "YOUR_IDENTITY_ID" + } + } +}); +``` + + +#### Kubernetes Auth + + Please note that this authentication method will only work if you're running your application on Kubernetes. + Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + kubernetes: { + identityId: "YOUR_IDENTITY_ID", + serviceAccountTokenPathEnvName: "/var/run/secrets/kubernetes.io/serviceaccount/token" // Optional + } + } +}); +``` + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTtl" option when creating the client. @@ -161,6 +306,14 @@ Retrieve all secrets within the Infisical project and environment that client is Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. + + Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. + + + + Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 0ce221757..d9ab49688 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -6,20 +6,24 @@ icon: "python" If you're working with Python, the official [infisical-python](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) package is the easiest way to fetch and work with secrets for your application. -- [PyPi Package](https://pypi.org/project/infisical-python/) -- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) +- [PyPi Package](https://pypi.org/project/infisical-python/) +- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) ## Basic Usage ```py from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions +from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod app = Flask(__name__) client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) @app.route("/") @@ -38,7 +42,7 @@ def hello_world(): This example demonstrates how to use the Infisical Python SDK with a Flask application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. + We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. ## Installation @@ -56,11 +60,15 @@ Note: You need Python 3.7+. Import the SDK and create a client instance with your [Machine Identity](/api-reference/overview/authentication). ```py -from infisical_client import ClientSettings, InfisicalClient +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) ``` @@ -68,14 +76,20 @@ client = InfisicalClient(ClientSettings( - + Your Infisical Client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - + Your Infisical Client Secret. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - + If you want to directly pass an access token obtained from the authentication endpoints, you can do so. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. @@ -85,18 +99,155 @@ client = InfisicalClient(ClientSettings( - Your self-hosted absolute site URL including the protocol (e.g. - `https://app.infisical.com`) + Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + The authentication object to use for the client. This is required unless you're using environment variables. + +### Authentication + +The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. + +#### Universal Auth + +**Using environment variables** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```python3 +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) +)) +``` + +#### GCP ID Token Auth + + Please note that this authentication method will only work if you're running your application on Google Cloud Platform. + Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```py +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIDTokenAuthMethod + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + gcp_id_token=GCPIDTokenAuthMethod( + identity_id="MACHINE_IDENTITY_ID", + ) + ) +)) +``` + +#### GCP IAM Auth + +**Using environment variables** +- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. + +**Using the SDK directly** +```py +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIamAuthMethod + + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + gcp_iam=GCPIamAuthMethod( + identity_id="MACHINE_IDENTITY_ID", + service_account_key_file_path="./path/to/service_account_key.json" + ) + ) +)) +``` + +#### AWS IAM Auth + + Please note that this authentication method will only work if you're running your application on AWS. + Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```py +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, AWSIamAuthMethod + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + aws_iam=AWSIamAuthMethod(identity_id="MACHINE_IDENTITY_ID") + ) +)) +``` + +#### Azure Auth + + Please note that this authentication method will only work if you're running your application on Azure. + Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```python +from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, AzureAuthMethod + +kubernetes_client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + azure=AzureAuthMethod( + identity_id="YOUR_IDENTITY_ID", + ) + ) +)) +``` + + +#### Kubernetes Auth + + Please note that this authentication method will only work if you're running your application on Kubernetes. + Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. + + +**Using environment variables** +- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + +**Using the SDK directly** +```python +from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, KubernetesAuthMethod + +kubernetes_client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + kubernetes=KubernetesAuthMethod( + identity_id="YOUR_IDENTITY_ID", + service_account_token_path="/var/run/secrets/kubernetes.io/serviceaccount/token" # Optional + ) + ) +)) +``` + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cache_ttl" option when creating the client. @@ -133,6 +284,14 @@ Retrieve all secrets within the Infisical project and environment that client is Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. + + Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. + + + + Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) @@ -156,26 +315,26 @@ By default, `getSecret()` fetches and returns a shared secret. If not found, it #### Parameters - - - The key of the secret to retrieve - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - + + + The key of the secret to retrieve + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The project ID where the secret lives in. + + + The path from where secret should be fetched from. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". + + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) + + ### client.createSecret(options) @@ -194,26 +353,26 @@ Create a new secret in Infisical. #### Parameters - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - + + + The key of the secret to create. + + + The value of the secret. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be created. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + ### client.updateSecret(options) @@ -232,26 +391,26 @@ Update an existing secret in Infisical. #### Parameters - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - + + + The key of the secret to update. + + + The new value of the secret. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be updated. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + ### client.deleteSecret(options) @@ -269,23 +428,23 @@ Delete a secret in Infisical. #### Parameters - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - + + + The key of the secret to update. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be deleted. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + ## Cryptography @@ -299,9 +458,11 @@ key = client.createSymmetricKey() ``` #### Returns (string) + `key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. ### Encrypt symmetric + ```py encryptOptions = EncryptSymmetricOptions( key=key, @@ -314,22 +475,22 @@ encryptedData = client.encryptSymmetric(encryptOptions) #### Parameters - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - + + + The plaintext you want to encrypt. + + + The symmetric key to use for encryption. + + #### Returns (object) -`tag` (string): A base64-encoded, 128-bit authentication tag. -`iv` (string): A base64-encoded, 96-bit initialization vector. -`ciphertext` (string): A base64-encoded, encrypted ciphertext. + +`tag` (string): A base64-encoded, 128-bit authentication tag. `iv` (string): A base64-encoded, 96-bit initialization vector. `ciphertext` (string): A base64-encoded, encrypted ciphertext. ### Decrypt symmetric + ```py decryptOptions = DecryptSymmetricOptions( ciphertext=encryptedData.ciphertext, @@ -344,22 +505,24 @@ decryptedString = client.decryptSymmetric(decryptOptions) ``` #### Parameters + - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - + + + The ciphertext you want to decrypt. + + + The symmetric key to use for encryption. + + + The initialization vector to use for decryption. + + + The authentication tag to use for decryption. + + #### Returns (string) + `plaintext` (string): The decrypted plaintext. diff --git a/docs/style.css b/docs/style.css index b76d06450..3359151e4 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,7 +1,7 @@ #navbar .max-w-8xl { max-width: 100%; border-bottom: 1px solid #ebebeb; - background-color: #fcfcfc; + background-color: #F4F3EF; } .max-w-8xl { @@ -14,7 +14,7 @@ padding-right: 30px; border-right: 1px; border-color: #cdd64b; - background-color: #fcfcfc; + background-color: #F4F3EF; border-right: 1px solid #ebebeb; } @@ -27,6 +27,13 @@ padding: 5px; } +#sidebar li > a.text-primary { + border-radius: 0; + background-color: #FBFFCC; + border-left: 4px solid #EFFF33; + padding: 5px; +} + #sidebar li > a.mt-2 { border-radius: 0; padding: 5px; @@ -49,10 +56,10 @@ } */ #header { - border-left: 1px solid #26272b; + border-left: 4px solid #EFFF33; padding-left: 16px; padding-right: 16px; - background-color: #f5f5f5; + background-color: #FDFFE5; padding-bottom: 10px; padding-top: 10px; } @@ -60,9 +67,17 @@ #content-area .mt-8 .block{ border-radius: 0; border-width: 1px; + background-color: #FCFBFA; border-color: #ebebeb; } +/* #content-area:hover .mt-8 .block:hover{ + border-radius: 0; + border-width: 1px; + background-color: #FDFFE5; + border-color: #EFFF33; +} */ + #content-area .mt-8 .rounded-xl{ border-radius: 0; } diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 68ba1c497..4d13b4602 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -23,7 +23,8 @@ export const publicPaths = [ "/login/provider/success", // TODO: change "/login/provider/error", // TODO: change "/login/sso", - "/admin/signup" + "/admin/signup", + "/shared/secret/[id]" ]; export const languageMap = { diff --git a/frontend/src/helpers/secret.ts b/frontend/src/helpers/secret.ts new file mode 100644 index 000000000..607fa4268 --- /dev/null +++ b/frontend/src/helpers/secret.ts @@ -0,0 +1,175 @@ +import path from "path"; + +import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { fetchProjectEncryptedSecrets } from "@app/hooks/api/secrets/queries"; + +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; +export const interpolateSecrets = ({ + projectId, + secretEncKey +}: { + projectId: string; + secretEncKey: string; +}) => { + const fetchSecretsCrossEnv = () => { + const fetchCache: Record> = {}; + + return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { + const secRefPathUrl = path.join("/", ...secRefPath); + const uniqKey = `${secRefEnv}-${secRefPathUrl}`; + + if (fetchCache?.[uniqKey]) { + return fetchCache[uniqKey][secRefKey]; + } + + // get secrets by projectId, env, path + const encryptedSecrets = await fetchProjectEncryptedSecrets({ + workspaceId: projectId, + environment: secRefEnv, + secretPath: secRefPathUrl + }); + + const decryptedSec = encryptedSecrets.reduce>((prev, secret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: secretEncKey + }); + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: secretEncKey + }); + + // eslint-disable-next-line + prev[secretKey] = secretValue; + return prev; + }, {}); + + fetchCache[uniqKey] = decryptedSec; + + return fetchCache[uniqKey][secRefKey]; + }; + }; + + const recursivelyExpandSecret = async ( + expandedSec: Record, + interpolatedSec: Record, + fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise, + recursionChainBreaker: Record, + key: string + ) => { + if (expandedSec?.[key] !== undefined) { + return expandedSec[key]; + } + if (recursionChainBreaker?.[key]) { + return ""; + } + // eslint-disable-next-line + recursionChainBreaker[key] = true; + + let interpolatedValue = interpolatedSec[key]; + if (!interpolatedValue) { + // eslint-disable-next-line no-console + console.error(`Couldn't find referenced value - ${key}`); + return ""; + } + + const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); + if (refs) { + await Promise.all( + refs.map(async (interpolationSyntax) => { + const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); + const entities = interpolationKey.trim().split("."); + + if (entities.length === 1) { + const val = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + fetchCrossEnv, + recursionChainBreaker, + interpolationKey + ); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + return; + } + + if (entities.length > 1) { + const secRefEnv = entities[0]; + const secRefPath = entities.slice(1, entities.length - 1); + const secRefKey = entities[entities.length - 1]; + + const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + } + }) + ); + } + + // eslint-disable-next-line + expandedSec[key] = interpolatedValue; + return interpolatedValue; + }; + + // used to convert multi line ones to quotes ones with \n + const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; + }; + + const expandSecrets = async ( + secrets: Record + ) => { + const expandedSec: Record = {}; + const interpolatedSec: Record = {}; + + const crossSecEnvFetch = fetchSecretsCrossEnv(); + + Object.keys(secrets).forEach((key) => { + if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) { + interpolatedSec[key] = secrets[key].value; + } else { + expandedSec[key] = secrets[key].value; + } + }); + + await Promise.all( + Object.keys(secrets).map(async (key) => { + if (expandedSec?.[key]) { + // should not do multi line encoding if user has set it to skip + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedSec[key] + : formatMultiValueEnv(expandedSec[key]); + return; + } + + // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic + // so for any recursion building if there is an entity two times same key meaning it will be looped + const recursionChainBreaker: Record = {}; + const expandedVal = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + crossSecEnvFetch, + recursionChainBreaker, + key + ); + + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedVal + : formatMultiValueEnv(expandedVal); + }) + ); + + return secrets; + }; + return expandSecrets; +}; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 20209df71..cba815fae 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { setAuthToken } from "@app/reactQuery"; import { organizationKeys } from "../organization/queries"; +import { workspaceKeys } from "../workspace/queries"; import { ChangePasswordDTO, CompleteAccountDTO, @@ -78,7 +79,10 @@ export const useSelectOrganization = () => { return data; }, onSuccess: () => { - queryClient.invalidateQueries(organizationKeys.getUserOrganizations); + queryClient.invalidateQueries([ + organizationKeys.getUserOrganizations, + workspaceKeys.getAllUserWorkspace + ]); } }); }; diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 8ae22b30c..51495d4f2 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -4,5 +4,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth", [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", - [IdentityAuthMethod.AWS_AUTH]: "AWS Auth" + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", + [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index dc9d48cbd..66af91093 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -2,5 +2,6 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index be640572b..41b03669b 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -2,6 +2,7 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { useAddIdentityAwsAuth, + useAddIdentityAzureAuth, useAddIdentityGcpAuth, useAddIdentityKubernetesAuth, useAddIdentityUniversalAuth, @@ -11,11 +12,14 @@ export { useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, useUpdateIdentityAwsAuth, + useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, useUpdateIdentityKubernetesAuth, - useUpdateIdentityUniversalAuth} from "./mutations"; + useUpdateIdentityUniversalAuth +} from "./mutations"; export { useGetIdentityAwsAuth, + useGetIdentityAzureAuth, useGetIdentityGcpAuth, useGetIdentityKubernetesAuth, useGetIdentityUniversalAuth, diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index d6c93044a..cb1fe4c17 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -6,6 +6,7 @@ import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { AddIdentityAwsAuthDTO, + AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, AddIdentityKubernetesAuthDTO, AddIdentityUniversalAuthDTO, @@ -17,14 +18,17 @@ import { DeleteIdentityUniversalAuthClientSecretDTO, Identity, IdentityAwsAuth, + IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, + UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, UpdateIdentityKubernetesAuthDTO, - UpdateIdentityUniversalAuthDTO} from "./types"; + UpdateIdentityUniversalAuthDTO +} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -326,7 +330,41 @@ export const useUpdateIdentityAwsAuth = () => { }); }; -// --- K8s auth (TODO: add cert and token reviewer JWT fields) +export const useAddIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.post<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; export const useAddIdentityKubernetesAuth = () => { const queryClient = useQueryClient(); @@ -370,6 +408,42 @@ export const useAddIdentityKubernetesAuth = () => { }); }; +export const useUpdateIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.patch<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + export const useUpdateIdentityKubernetesAuth = () => { const queryClient = useQueryClient(); return useMutation({ @@ -403,6 +477,7 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenTrustedIps } ); + return identityKubernetesAuth; }, onSuccess: (_, { organizationId }) => { diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 270827b52..eb04227eb 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -5,10 +5,10 @@ import { apiRequest } from "@app/config/request"; import { ClientSecretData, IdentityAwsAuth, + IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, - IdentityUniversalAuth -} from "./types"; + IdentityUniversalAuth} from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => @@ -18,7 +18,8 @@ export const identitiesKeys = { getIdentityKubernetesAuth: (identityId: string) => [{ identityId }, "identity-kubernetes-auth"] as const, getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, - getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { @@ -81,6 +82,21 @@ export const useGetIdentityAwsAuth = (identityId: string) => { }); }; +export const useGetIdentityAzureAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityAzureAuth(identityId), + queryFn: async () => { + const { + data: { identityAzureAuth } + } = await apiRequest.get<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}` + ); + return identityAzureAuth; + } + }); +}; + export const useGetIdentityKubernetesAuth = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7e09bf280..80d066c72 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -195,6 +195,45 @@ export type UpdateIdentityAwsAuthDTO = { }[]; }; +export type IdentityAzureAuth = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type IdentityKubernetesAuth = { identityId: string; kubernetesHost: string; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index fad549e38..df04f3e8a 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -12,21 +12,30 @@ export type TIdentityProjectPrivilege = { updatedAt: Date; permissions?: TProjectPermission[]; } & ( - | { + | { isTemporary: true; temporaryMode: string; temporaryRange: string; temporaryAccessStartTime: string; temporaryAccessEndTime?: string; } - | { + | { isTemporary: false; temporaryMode?: null; temporaryRange?: null; temporaryAccessStartTime?: null; temporaryAccessEndTime?: null; } - ); +); + +export type TProjectSpecificPrivilegePermission = { + conditions: { + environment: string; + secretPath?: { $glob: string }; + }; + actions: string[]; + subject: string; +}; export type TCreateIdentityProjectPrivilegeDTO = { identityId: string; @@ -36,14 +45,16 @@ export type TCreateIdentityProjectPrivilegeDTO = { temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; temporaryRange?: string; temporaryAccessStartTime?: string; - permissions: TProjectPermission[]; + privilegePermission: TProjectSpecificPrivilegePermission; }; export type TUpdateIdentityProjectPrivlegeDTO = { projectSlug: string; identityId: string; privilegeSlug: string; - privilegeDetails: Partial>; + privilegeDetails: Partial< + Omit + >; }; export type TDeleteIdentityProjectPrivilegeDTO = { diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index b0e1dd9f5..4a4c5e281 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -30,7 +30,7 @@ export type HerokuPipelineCoupling = { export type Team = { name: string; - teamId: string; + id: string; }; export type Environment = { diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 9a1ee6fbf..7325dc4a3 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -64,6 +64,7 @@ export const useCreateIntegration = () => { secretSuffix?: string; initialSyncBehavior?: string; shouldAutoRedeploy?: boolean; + mappingBehavior?: string; secretAWSTag?: { key: string; value: string; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 345e41b1a..21e6bff26 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -36,6 +36,7 @@ export type TIntegration = { metadata?: { secretSuffix?: string; syncBehavior?: IntegrationSyncBehavior; + mappingBehavior?: IntegrationMappingBehavior; scope: string; org: string; project: string; @@ -48,3 +49,8 @@ export enum IntegrationSyncBehavior { PREFER_TARGET = "prefer-target", PREFER_SOURCE = "prefer-source" } + +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} diff --git a/frontend/src/hooks/api/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx index 50736b30d..53c05a7b6 100644 --- a/frontend/src/hooks/api/roles/index.tsx +++ b/frontend/src/hooks/api/roles/index.tsx @@ -8,6 +8,7 @@ export { } from "./mutation"; export { useGetOrgRoles, + useGetProjectRoleBySlug, useGetProjectRoles, useGetUserOrgPermissions, useGetUserProjectPermissions diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 6df6a3933..ae3e170de 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -17,13 +17,10 @@ export const useCreateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, permissions, ...dto }: TCreateProjectRoleDTO) => - apiRequest.post(`/api/v1/workspace/${projectId}/roles`, { - ...dto, - permissions: permissions.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => + apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -32,13 +29,10 @@ export const useUpdateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, projectId, permissions, ...dto }: TUpdateProjectRoleDTO) => - apiRequest.patch(`/api/v1/workspace/${projectId}/roles/${id}`, { - ...dto, - permissions: permissions?.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => + apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -47,12 +41,10 @@ export const useDeleteProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, id }: TDeleteProjectRoleDTO) => - apiRequest.delete(`/api/v1/workspace/${projectId}/roles/${id}`, { - data: { projectId } - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) => + apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3280bface..f04af697d 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -14,7 +14,6 @@ import { TGetUserProjectPermissionDTO, TOrgRole, TPermission, - TProjectPermission, TProjectRole } from "./types"; @@ -37,7 +36,9 @@ const glob: JsInterpreter> = (node, object, context) => { const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); export const roleQueryKeys = { - getProjectRoles: (projectId: string) => ["roles", { projectId }] as const, + getProjectRoles: (projectSlug: string) => ["roles", { projectSlug }] as const, + getProjectRoleBySlug: (projectSlug: string, roleSlug: string) => + ["roles", { projectSlug, roleSlug }] as const, getOrgRoles: (orgId: string) => ["org-roles", { orgId }] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => ["user-permissions", { orgId }] as const, @@ -46,20 +47,29 @@ export const roleQueryKeys = { }; const getProjectRoles = async (projectId: string) => { - const { data } = await apiRequest.get<{ - data: { roles: Array & { permissions: unknown }> }; - }>(`/api/v1/workspace/${projectId}/roles`); - return data.data.roles.map(({ permissions, ...el }) => ({ - ...el, - permissions: unpackRules(permissions as PackRule[]) - })); + const { data } = await apiRequest.get<{ roles: Array> }>( + `/api/v1/workspace/${projectId}/roles` + ); + return data.roles; }; -export const useGetProjectRoles = (projectId: string) => +export const useGetProjectRoles = (projectSlug: string) => useQuery({ - queryKey: roleQueryKeys.getProjectRoles(projectId), - queryFn: () => getProjectRoles(projectId), - enabled: Boolean(projectId) + queryKey: roleQueryKeys.getProjectRoles(projectSlug), + queryFn: () => getProjectRoles(projectSlug), + enabled: Boolean(projectSlug) + }); + +export const useGetProjectRoleBySlug = (projectSlug: string, roleSlug: string) => + useQuery({ + queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug), + queryFn: async () => { + const { data } = await apiRequest.get<{ role: TProjectRole }>( + `/api/v1/workspace/${projectSlug}/roles/slug/${roleSlug}` + ); + return data.role; + }, + enabled: Boolean(projectSlug && roleSlug) }); const getOrgRoles = async (orgId: string) => { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 97a90b421..e2d1b533a 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -71,7 +71,7 @@ export type TDeleteOrgRoleDTO = { }; export type TCreateProjectRoleDTO = { - projectId: string; + projectSlug: string; name: string; description?: string; slug: string; @@ -79,11 +79,11 @@ export type TCreateProjectRoleDTO = { }; export type TUpdateProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; } & Partial>; export type TDeleteProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; }; diff --git a/frontend/src/hooks/api/secretSharing/index.ts b/frontend/src/hooks/api/secretSharing/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts new file mode 100644 index 000000000..e21cc08f6 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types"; + +export const useCreateSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (inputData: TCreateSharedSecretRequest) => { + const { data } = await apiRequest.post("/api/v1/secret-sharing", inputData); + return data; + }, + onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + }); +}; + +export const useDeleteSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation< + TSharedSecret, + { message: string }, + { sharedSecretId: string } + >({ + mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => { + const { data } = await apiRequest.delete( + `/api/v1/secret-sharing/${sharedSecretId}` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(["sharedSecrets"]); + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts new file mode 100644 index 000000000..b4cf71531 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSharedSecret, TViewSharedSecretResponse } from "./types"; + +export const useGetSharedSecrets = () => { + return useQuery({ + queryKey: ["sharedSecrets"], + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/secret-sharing/" + ); + return data; + } + }); +}; + +export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { + return useQuery({ + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` + ); + return { + name: data.name, + encryptedValue: data.encryptedValue, + iv: data.iv, + tag: data.tag, + expiresAt: data.expiresAt + }; + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts new file mode 100644 index 000000000..2fbddfb35 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -0,0 +1,33 @@ +export type TSharedSecret = { + id: string; + name: string; + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + userId: string; + expiresAt: Date; + createdAt: Date; + updatedAt: Date; +}; + +export type TCreateSharedSecretRequest = { + name: string; + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + expiresAt: Date; +}; + +export type TViewSharedSecretResponse = { + name: string; + encryptedValue: string; + iv: string; + tag: string; + expiresAt: Date; +}; + +export type TDeleteSharedSecretRequest = { + sharedSecretId: string; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 1ba9a5251..28999389e 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -98,7 +98,7 @@ export const decryptSecrets = ( return secrets; }; -const fetchProjectEncryptedSecrets = async ({ +export const fetchProjectEncryptedSecrets = async ({ workspaceId, environment, secretPath diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index b26f1965d..f982100d5 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -642,6 +642,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Secret Sharing + + + {(window.location.origin.includes("https://app.infisical.com") || window.location.origin.includes("https://gamma.infisical.com")) && ( diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index 07daff39c..e6c2e7618 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -15,6 +15,7 @@ import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; import { useGetIntegrationAuthAwsKmsKeys } from "@app/hooks/api/integrationAuth/queries"; +import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { Button, @@ -70,6 +71,17 @@ const awsRegions = [ { name: "AWS GovCloud (US-West)", slug: "us-gov-west-1" } ]; +const mappingBehaviors = [ + { + label: "Many to One (All Infisical secrets will be mapped to a single AWS secret)", + value: IntegrationMappingBehavior.MANY_TO_ONE + }, + { + label: "One to One - (Each Infisical secret will be mapped to its own AWS secret)", + value: IntegrationMappingBehavior.ONE_TO_ONE + } +]; + export default function AWSSecretManagerCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); @@ -84,6 +96,9 @@ export default function AWSSecretManagerCreateIntegrationPage() { const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const [secretPath, setSecretPath] = useState("/"); const [selectedAWSRegion, setSelectedAWSRegion] = useState(""); + const [selectedMappingBehavior, setSelectedMappingBehavior] = useState( + IntegrationMappingBehavior.MANY_TO_ONE + ); const [targetSecretName, setTargetSecretName] = useState(""); const [targetSecretNameErrorText, setTargetSecretNameErrorText] = useState(""); const [tagKey, setTagKey] = useState(""); @@ -116,7 +131,14 @@ export default function AWSSecretManagerCreateIntegrationPage() { const handleButtonClick = async () => { try { - if (targetSecretName.trim() === "") { + if (!selectedMappingBehavior) { + return; + } + + if ( + selectedMappingBehavior === IntegrationMappingBehavior.MANY_TO_ONE && + targetSecretName.trim() === "" + ) { setTargetSecretName("Secret name cannot be blank"); return; } @@ -143,15 +165,16 @@ export default function AWSSecretManagerCreateIntegrationPage() { ] } : {}), - ...(kmsKeyId && { kmsKeyId }) + ...(kmsKeyId && { kmsKeyId }), + mappingBehavior: selectedMappingBehavior } }); - setIsLoading(false); setTargetSecretNameErrorText(""); router.push(`/integrations/${localStorage.getItem("projectData.id")}`); } catch (err) { + setIsLoading(false); console.error(err); } }; @@ -248,19 +271,40 @@ export default function AWSSecretManagerCreateIntegrationPage() { ))} - - setTargetSecretName(e.target.value)} - /> + + + {selectedMappingBehavior === IntegrationMappingBehavior.MANY_TO_ONE && ( + + setTargetSecretName(e.target.value)} + /> + + )} diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index 15daad270..529fd037e 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -121,7 +121,7 @@ export default function GitLabCreateIntegrationPage() { if (integrationAuthTeams) { if (integrationAuthTeams.length > 0) { // case: user is part of at least 1 group in GitLab - setValue("targetTeamId", String(integrationAuthTeams[0].teamId)); + setValue("targetTeamId", String(integrationAuthTeams[0].id)); } else { // case: user is not part of any groups in GitLab setValue("targetTeamId", "none"); @@ -312,8 +312,8 @@ export default function GitLabCreateIntegrationPage() { {integrationAuthTeams.length > 0 ? ( integrationAuthTeams.map((integrationAuthTeam) => ( {integrationAuthTeam.name} diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index de866a323..22006408e 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -121,6 +121,14 @@ export default function LoginPage() { } }, [router]); + // Case: User has no organizations. + // This can happen if the user was previously a member, but the organization was deleted or the user was removed. + useEffect(() => { + if (!organizations.isLoading && organizations.data?.length === 0) { + router.push("/org/none"); + } + }, [organizations.isLoading, organizations.data]); + if (userLoading || !user) { return ; } diff --git a/frontend/src/pages/org/[id]/secret-sharing/index.tsx b/frontend/src/pages/org/[id]/secret-sharing/index.tsx new file mode 100644 index 000000000..28bcb7831 --- /dev/null +++ b/frontend/src/pages/org/[id]/secret-sharing/index.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { ShareSecretPage } from "@app/views/ShareSecretPage"; + +const SecretApproval = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = true; diff --git a/frontend/src/pages/shared/secret/[id]/index.tsx b/frontend/src/pages/shared/secret/[id]/index.tsx new file mode 100644 index 000000000..7f53d962d --- /dev/null +++ b/frontend/src/pages/shared/secret/[id]/index.tsx @@ -0,0 +1,24 @@ +import Head from "next/head"; + +import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; + +const SecretApproval = () => { + return ( + <> + + Securely Share Secrets | Infisical + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = false; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index a6e3a45e7..267ff8580 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -21,6 +21,7 @@ import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; +import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; type Props = { @@ -131,30 +132,35 @@ export const IntegrationsSection = ({ )} -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (integration.integration === "aws-parameter-store" && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (integration.integration === "aws-parameter-store" && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +
-
+ )} {(integration.integration === "vercel" || integration.integration === "netlify" || integration.integration === "railway" || diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index f3b40300e..a4e5e89f5 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -105,8 +105,18 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } } - } catch (err) { + } catch (err: any) { console.error(err); + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + setIsLoading(false); + return; + } + setLoginError(true); createNotification({ text: "Login unsuccessful. Double-check your credentials and try again.", diff --git a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx index eaaac88a9..b23af1cef 100644 --- a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx +++ b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import axios from "axios"; import jwt_decode from "jwt-decode"; -import Error from "@app/components/basic/Error"; // which to notification +import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import attemptCliLoginMfa from "@app/components/utilities/attemptCliLoginMfa"; import attemptLoginMfa from "@app/components/utilities/attemptLoginMfa"; @@ -46,20 +46,7 @@ type Props = { callbackPort?: string | null; }; -interface VerifyMfaTokenError { - response: { - data: { - context: { - code: string; - triesLeft: number; - }; - }; - status: number; - }; -} - export const MFAStep = ({ email, password, providerAuthToken }: Props) => { - const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false); @@ -178,20 +165,31 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { }); } } - } catch (err) { - const error = err as VerifyMfaTokenError; + } catch (err: any) { + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + setIsLoading(false); + return; + } + createNotification({ text: "Failed to log in", type: "error" }); - if (error?.response?.status === 500) { - window.location.reload(); - } else if (error?.response?.data?.context?.triesLeft) { - setTriesLeft(error?.response?.data?.context?.triesLeft); - if (error.response.data.context.triesLeft === 0) { - window.location.reload(); - } + if (triesLeft) { + setTriesLeft((left) => { + if (triesLeft === 1) { + router.push("/"); + } + return (left as number) - 1; + }); + } else { + setTriesLeft(2); } setIsLoading(false); @@ -236,7 +234,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => { />
{typeof triesLeft === "number" && ( - + )}
diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index f9b086f6d..3e5e45985 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -31,7 +31,6 @@ export const PasswordStep = ({ setPassword, setStep }: Props) => { - const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const router = useRouter(); @@ -146,13 +145,23 @@ export const PasswordStep = ({ } } } - } catch (err) { + } catch (err: any) { setIsLoading(false); + console.error(err); + + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + return; + } + createNotification({ text: "Login unsuccessful. Double-check your master password and try again.", type: "error" }); - console.error(err); } }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx index b08877050..a1dc5d678 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -15,6 +15,7 @@ import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; +import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; @@ -32,7 +33,8 @@ const identityAuthMethods = [ { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, { label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH }, { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, - { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH } + { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, + { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH } ]; const schema = yup @@ -97,6 +99,15 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog /> ); } + case IdentityAuthMethod.AZURE_AUTH: { + return ( + + ); + } case IdentityAuthMethod.UNIVERSAL_AUTH: { return ( ; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityAzureAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityAzureAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityAzureAuth(); + + const { data } = useGetIdentityAzureAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + tenantId: "", + resource: "https://management.azure.com/", + allowedServicePrincipalIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + tenantId: data.tenantId, + resource: data.resource, + allowedServicePrincipalIds: data.allowedServicePrincipalIds, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + tenantId: "", + resource: "https://management.azure.com/", + allowedServicePrincipalIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + tenantId: tenantId || "", + resource: resource || "", + allowedServicePrincipalIds: allowedServicePrincipalIds || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 0ed9cd9a4..294544093 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -1,9 +1,22 @@ -import { faKey, faLock, faPencil, faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faCopy, + faEllipsis, + faKey, + faLock, + faPencil, + faServer, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, IconButton, Select, @@ -80,7 +93,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { Name - ID Role Auth Method @@ -95,7 +107,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { return ( {name} - {id} { {authMethod ? identityAuthToNameMap[authMethod] : "Not configured"} -
+
{authMethod === IdentityAuthMethod.UNIVERSAL_AUTH && ( { colorSchema="primary" variant="plain" ariaLabel="update" - // isDisabled={!isAllowed} > @@ -165,7 +175,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { colorSchema="primary" variant="plain" ariaLabel="update" - className="ml-4" isDisabled={!isAllowed} > @@ -173,54 +182,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { )} - - {(isAllowed) => ( - { - handlePopUpOpen("identity", { - identityId: id, - name, - role, - customRole - }); - }} - size="lg" - colorSchema="primary" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + + +
+ + + +
+
+ + - -
- )} -
- - {(isAllowed) => ( - ( + { + if (!isAllowed) return; + handlePopUpOpen("identity", { + identityId: id, + name, + role, + customRole + }); + }} + disabled={!isAllowed} + icon={} + > + Update identity + + )} + + + {(isAllowed) => ( + { + if (!isAllowed) return; + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + icon={} + > + Delete identity + + )} + + { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name + navigator.clipboard.writeText(id); + createNotification({ + text: "Copied identity internal ID to clipboard", + type: "success" }); }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + icon={} > - - - )} - + Copy Identity ID + + +
diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx index 818c6eae0..f0ed1c5b7 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -5,25 +5,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - Modal, - ModalContent, - Select, - SelectItem} from "@app/components/v2"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; -import { - useAddGroupToWorkspace, - useGetOrganizationGroups, - useGetProjectRoles, - useListWorkspaceGroups, +import { + useAddGroupToWorkspace, + useGetOrganizationGroups, + useGetProjectRoles, + useListWorkspaceGroups } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - slug: z.string(), - role: z.string() + slug: z.string(), + role: z.string() }); export type FormData = z.infer; @@ -33,150 +27,146 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; }; -export const GroupModal = ({ - popUp, - handlePopUpToggle -}: Props) => { - const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); - const orgId = currentOrg?.id || ""; - const workspaceId = currentWorkspace?.id || ""; - - const { data: groups } = useGetOrganizationGroups(orgId); - const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); - - const { data: roles } = useGetProjectRoles(workspaceId); - - const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); - - const filteredGroupMembershipOrgs = useMemo(() => { - const wsGroupIds = new Map(); + const orgId = currentOrg?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - groupMemberships?.forEach((groupMembership) => { - wsGroupIds.set(groupMembership.group.id, true); - }); + const { data: groups } = useGetOrganizationGroups(orgId); + const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); - return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); - }, [groups, groupMemberships]); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(schema) + const { data: roles } = useGetProjectRoles(projectSlug); + + const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); + + const filteredGroupMembershipOrgs = useMemo(() => { + const wsGroupIds = new Map(); + + groupMemberships?.forEach((groupMembership) => { + wsGroupIds.set(groupMembership.group.id, true); + }); + + return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); + }, [groups, groupMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ slug, role }: FormData) => { + try { + await addGroupToWorkspaceMutateAsync({ + projectSlug: currentWorkspace?.slug || "", + groupSlug: slug, + role: role || undefined }); - const onFormSubmit = async ({ slug, role }: FormData) => { - try { - await addGroupToWorkspaceMutateAsync({ - projectSlug: currentWorkspace?.slug || "", - groupSlug: slug, - role: role || undefined - }); - - reset(); - handlePopUpToggle("group", false); - - createNotification({ - text: "Successfully added group to project", - type: "success" - }); - - } catch (err) { - createNotification({ - text: "Failed to add group to project", - type: "error" - }); - } + reset(); + handlePopUpToggle("group", false); + + createNotification({ + text: "Successfully added group to project", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed to add group to project", + type: "error" + }); } - - return ( - { - handlePopUpToggle("group", isOpen); - reset(); - }} - > - - {filteredGroupMembershipOrgs.length ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ) : ( -
-
- All groups in your organization have already been added to this project. -
- - - -
- )} -
-
- ); -} \ No newline at end of file + }; + + return ( + { + handlePopUpToggle("group", isOpen); + reset(); + }} + > + + {filteredGroupMembershipOrgs.length ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx index 83ea00ede..449c0c95f 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -201,11 +201,7 @@ export type TMemberRolesProp = { const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; -export const GroupRoles = ({ - roles = [], - disableEdit = false, - groupSlug -}: TMemberRolesProp) => { +export const GroupRoles = ({ roles = [], disableEdit = false, groupSlug }: TMemberRolesProp) => { const { currentWorkspace } = useWorkspace(); const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); const [searchRoles, setSearchRoles] = useState(""); @@ -220,9 +216,9 @@ export const GroupRoles = ({ resolver: zodResolver(formSchema) }); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole(); @@ -317,7 +313,7 @@ export const GroupRoles = ({ icon={faClock} className={twMerge( new Date() > new Date(temporaryAccessEndTime as string) && - "text-red-600" + "text-red-600" )} /> @@ -390,14 +386,14 @@ export const GroupRoles = ({ defaultValue={ userProjectRoleDetails?.isTemporary ? { - isTemporary: true, - temporaryAccessStartTime: - userProjectRoleDetails.temporaryAccessStartTime as string, - temporaryRange: - userProjectRoleDetails.temporaryRange as string, - temporaryAccessEndTime: - userProjectRoleDetails.temporaryAccessEndTime - } + isTemporary: true, + temporaryAccessStartTime: + userProjectRoleDetails.temporaryAccessStartTime as string, + temporaryRange: + userProjectRoleDetails.temporaryRange as string, + temporaryAccessEndTime: + userProjectRoleDetails.temporaryAccessEndTime + } : false } render={({ field }) => ( diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx index 72d39537e..5637b2f1a 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx @@ -30,17 +30,17 @@ type Props = { }; export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { - const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; const { data: identityMembershipOrgs } = useGetIdentityMembershipOrgs(orgId); const { data: identityMemberships } = useGetWorkspaceIdentityMemberships(workspaceId); - const { data: roles } = useGetProjectRoles(workspaceId); + const { data: roles } = useGetProjectRoles(projectSlug); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx index ca7e31464..05aeb1fb0 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx @@ -65,7 +65,8 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const projectSlug = currentWorkspace?.slug || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( ProjectPermissionActions.Edit, @@ -79,14 +80,14 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal slug: customRoleSlug || role, temporaryAccess: dto.isTemporary ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } : { - isTemporary: dto.isTemporary - } + isTemporary: dto.isTemporary + } })) } }); @@ -191,9 +192,9 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal ? isExpired ? "Timed Access Expired" : `Until ${format( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}` + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}` : "Non expiry access" } > @@ -212,9 +213,9 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal ? isExpired ? "Access Expired" : formatDistance( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - new Date() - ) + new Date(temporaryAccess.temporaryAccessEndTime || ""), + new Date() + ) : "Permanent"} @@ -338,7 +339,7 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal type="submit" className={twMerge( "transition-all", - "opacity-0 cursor-default", + "cursor-default opacity-0", roleForm.formState.isDirty && "cursor-pointer opacity-100" )} isDisabled={!roleForm.formState.isDirty} diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 6c1d4654d..7f76992bb 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -131,20 +131,17 @@ const SpecificPrivilegeSecretForm = ({ { action: ProjectPermissionActions.Delete, allowed: data.delete }, { action: ProjectPermissionActions.Edit, allowed: data.edit } ]; - const conditions: Record = { environment: data.environmentSlug }; - if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; - } await updateIdentityPrivilege.mutateAsync({ privilegeDetails: { ...data.temporaryAccess, - permissions: actions - .filter(({ allowed }) => allowed) - .map(({ action }) => ({ - action, - subject: ProjectPermissionSub.Secrets, - conditions - })) + privilegePermission: { + actions: actions.filter(({ allowed }) => allowed).map(({ action }) => action), + subject: ProjectPermissionSub.Secrets, + conditions: { + environment: data.environmentSlug, + ...(data.secretPath ? { secretPath: { $glob: data.secretPath } } : {}) + } + } }, privilegeSlug: privilege.slug, identityId, @@ -474,15 +471,13 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { if (createIdentityPrivilege.isLoading) return; try { await createIdentityPrivilege.mutateAsync({ - permissions: [ - { - action: ProjectPermissionActions.Read, - subject: ProjectPermissionSub.Secrets, - conditions: { - environment: currentWorkspace?.environments?.[0].slug - } + privilegePermission: { + actions: [ProjectPermissionActions.Read], + subject: ProjectPermissionSub.Secrets, + conditions: { + environment: currentWorkspace?.environments?.[0].slug as string } - ], + }, identityId, projectSlug }); diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx index 04497c62c..5cad54801 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx @@ -65,7 +65,8 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const projectSlug = currentWorkspace?.slug || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( ProjectPermissionActions.Edit, @@ -79,14 +80,14 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) slug: customRoleSlug || role, temporaryAccess: dto.isTemporary ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } : { - isTemporary: dto.isTemporary - } + isTemporary: dto.isTemporary + } })) } }); @@ -191,9 +192,9 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) ? isExpired ? "Timed Access Expired" : `Until ${format( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}` + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}` : "Non expiry access" } > @@ -212,9 +213,9 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) ? isExpired ? "Access Expired" : formatDistance( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - new Date() - ) + new Date(temporaryAccess.temporaryAccessEndTime || ""), + new Date() + ) : "Permanent"} @@ -335,7 +336,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) type="submit" className={twMerge( "transition-all", - "opacity-0 cursor-default", + "cursor-default opacity-0", roleForm.formState.isDirty && "cursor-pointer opacity-100" )} isDisabled={!roleForm.formState.isDirty} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 6cfec25ea..5eb443fb6 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -3,7 +3,6 @@ import { motion } from "framer-motion"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; -import { TProjectRole } from "@app/hooks/api/roles/types"; import { ProjectRoleList } from "./components/ProjectRoleList"; import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection"; @@ -21,7 +20,7 @@ export const ProjectRoleListTab = withProjectPermission( exit={{ opacity: 0, translateX: 30 }} > handlePopUpClose("editRole")} /> @@ -33,7 +32,7 @@ export const ProjectRoleListTab = withProjectPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} > - handlePopUpOpen("editRole", role)} /> + handlePopUpOpen("editRole", slug)} /> ); }, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index d8a22bdb4..e19e61aec 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -24,7 +24,7 @@ import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; type Props = { - onSelectRole: (role?: TProjectRole) => void; + onSelectRole: (slug?: string) => void; }; export const ProjectRoleList = ({ onSelectRole }: Props) => { @@ -32,10 +32,9 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - console.log(roles); + const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { mutateAsync: deleteRole } = useDeleteProjectRole(); @@ -43,7 +42,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { const { id } = popUp?.deleteRole?.data as TProjectRole; try { await deleteRole({ - projectId: workspaceId, + projectSlug, id }); createNotification({ type: "success", text: "Successfully removed the role" }); @@ -109,7 +108,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { onSelectRole(role)} + onClick={() => onSelectRole(role.slug)} variant="plain" > @@ -146,9 +145,8 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
handlePopUpClose("deleteRole")} onDeleteApproved={handleRoleDelete} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index 4a57e2810..cd2eaeee0 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -19,9 +19,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FormControl, Input, Spinner } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useCreateProjectRole, useUpdateProjectRole } from "@app/hooks/api"; +import { + useCreateProjectRole, + useGetProjectRoleBySlug, + useUpdateProjectRole +} from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission"; @@ -117,17 +121,20 @@ const SINGLE_PERMISSION_LIST = [ ] as const; type Props = { - role?: TProjectRole; + roleSlug?: string; onGoBack: VoidFunction; }; -export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { - const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(role?.slug || ""); - const isNewRole = !role?.slug; +export const ProjectRoleModifySection = ({ roleSlug, onGoBack }: Props) => { + const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(roleSlug || ""); + const isNewRole = !roleSlug; - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; + const { data: roleDetails, isLoading: isRoleDetailsLoading } = useGetProjectRoleBySlug( + currentWorkspace?.slug || "", + roleSlug as string + ); const { handleSubmit, @@ -137,19 +144,21 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { getValues, control } = useForm({ - defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + values: roleDetails + ? { ...roleDetails, permissions: rolePermission2Form(roleDetails.permissions) } + : ({} as TProjectRole), resolver: zodResolver(formSchema) }); const { mutateAsync: createRole } = useCreateProjectRole(); const { mutateAsync: updateRole } = useUpdateProjectRole(); const handleRoleUpdate = async (el: TFormSchema) => { - if (!role?.id) return; + if (!roleDetails?.id) return; try { await updateRole({ - id: role?.id, - projectId: workspaceId, + id: roleDetails?.id as string, + projectSlug, ...el, permissions: formRolePermission2API(el.permissions) }); @@ -169,7 +178,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { try { await createRole({ - projectId: workspaceId, + projectSlug, ...el, permissions: formRolePermission2API(el.permissions) }); @@ -181,6 +190,14 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { } }; + if (!isNewRole && isRoleDetailsLoading) { + return ( +
+ +
+ ); + } + return (
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 5dc90cf8b..4d4d45e64 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -95,10 +95,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const formVal: Record = {}; permissions.forEach((permission) => { - const { - subject: [subject], - action - } = permission; + const { subject: caslSub, action } = permission; + const subject = typeof caslSub === "string" ? caslSub : caslSub[0]; if (!formVal?.[subject]) formVal[subject] = {}; if (subject === "secrets") { @@ -123,7 +121,7 @@ const multiEnvForm2Api = ( const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); // if any of them is set in all push it without any condition PERMISSION_ACTIONS.forEach((action) => { - if (formVal?.all?.[action]) permissions.push({ action, subject: [subject] }); + if (formVal?.all?.[action]) permissions.push({ action, subject }); }); if (!isFullAccess) { @@ -144,7 +142,7 @@ const multiEnvForm2Api = ( if (formVal[slug]?.secretPath) conditions.secretPath = { $glob: formVal?.[slug]?.secretPath }; - permissions.push({ action, subject: [subject], conditions }); + permissions.push({ action, subject, conditions }); } }); }); @@ -161,7 +159,7 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { } else { Object.entries(actions).forEach(([action, isAllowed]) => { if (isAllowed) { - permissions.push({ subject: [rule], action }); + permissions.push({ subject: rule, action }); } }); } diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 61c7cf8c0..9b235867b 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -23,6 +23,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { decryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, DeleteActionModal, @@ -43,8 +44,9 @@ import { UpgradePlanModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { interpolateSecrets } from "@app/helpers/secret"; import { usePopUp } from "@app/hooks"; -import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api"; import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types"; import { debounce } from "@app/lib/fn/debounce"; @@ -112,6 +114,7 @@ export const ActionBar = ({ const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); @@ -144,30 +147,59 @@ export const ActionBar = ({ const handleSecretDownload = async () => { const secPriority: Record = {}; const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = []; + + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const workspaceKey = decryptAssymmetric({ + ciphertext: decryptFileKey!.encryptedKey, + nonce: decryptFileKey!.nonce, + publicKey: decryptFileKey!.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const expandSecrets = interpolateSecrets({ + projectId: workspaceId, + secretEncKey: workspaceKey + }); + + const secretRecord: Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean } + > = {}; + // load up secrets in dashboard - secrets?.forEach(({ key, value, comment }) => { + secrets?.forEach(({ key, value, valueOverride, comment }) => { secPriority[key] = true; - downloadedSecrets.push({ key, value, comment }); + downloadedSecrets.push({ key, value: valueOverride || value, comment }); }); // now load imported secrets with secPriority for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { - importedSecrets[i].secrets.forEach(({ key, value, comment }) => { + importedSecrets[i].secrets.forEach(({ key, value, valueOverride, comment }) => { if (secPriority?.[key]) return; - downloadedSecrets.unshift({ key, value, comment }); + downloadedSecrets.unshift({ key, value: valueOverride || value, comment }); secPriority[key] = true; }); } + downloadedSecrets.forEach((secret) => { + secretRecord[secret.key] = { + value: secret.value, + comment: secret.comment + }; + }); + + await expandSecrets(secretRecord); + const file = downloadedSecrets .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) .reduce( - (prev, { key, value, comment }, index) => + (prev, { key, comment }, index) => prev + (comment - ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` - : `${key}=${value}\n`), + ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${secretRecord[key].value}\n` + : `${key}=${secretRecord[key].value}\n`), "" ); + const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, `${environment}.env`); }; diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx index 0155e5b31..98ffb0862 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx @@ -152,7 +152,7 @@ export const SecretDropzone = ({ e.dataTransfer.dropEffect = "copy"; setDragActive.off(); - parseFile(e.dataTransfer.files[0]); + parseFile(e.dataTransfer.files[0], e.dataTransfer.files[0].type === "application/json"); }; const handleFileUpload = (e: ChangeEvent) => { diff --git a/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx new file mode 100644 index 000000000..e1636446e --- /dev/null +++ b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ShareSecretSection } from "./components"; + +export const ShareSecretPage = () => { + return ( +
+
+
+

Secret Sharing

+

Share secrets securely using a shareable link

+
+
+ + + Documentation{" "} + + + +
+
+ +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx new file mode 100644 index 000000000..00ee27b44 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx @@ -0,0 +1,289 @@ +import crypto from "crypto"; + +import { useEffect, useState } 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 { AxiosError } from "axios"; +import * as yup from "yup"; + +import { createNotification } from "@app/components/notifications"; +import { + encryptSymmetric, +} from "@app/components/utilities/cryptography/crypto"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useCreateSharedSecret } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const expirationUnitsAndActions = [ + { + unit: "Minutes", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue) + }, + { + unit: "Hours", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setHours(expiresAt.getHours() + expiresInValue) + }, + { + unit: "Days", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setDate(expiresAt.getDate() + expiresInValue) + }, + { + unit: "Weeks", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7) + }, + { + unit: "Months", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setMonth(expiresAt.getMonth() + expiresInValue) + }, + { + unit: "Years", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setFullYear(expiresAt.getFullYear() + expiresInValue) + } +]; + +const schema = yup.object({ + name: yup.string().max(100).required().label("Shared Secret Name"), + value: yup.string().max(1000).required().label("Shared Secret Value"), + expiresInValue: yup.number().min(1).required().label("Expiration Value"), + expiresInUnit: yup.string().required().label("Expiration Unit") +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["createSharedSecret"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createSharedSecret"]>, + state?: boolean + ) => void; +}; + +export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + const createSharedSecret = useCreateSharedSecret(); + const { currentOrg } = useOrganization(); + const [newSharedSecret, setnewSharedSecret] = useState(""); + const hasSharedSecret = Boolean(newSharedSecret); + const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ + initialState: false, + }); + + const copyUrlToClipboard = () => { + navigator.clipboard.writeText(newSharedSecret); + setIsUrlCopied(true); + }; + useEffect(() => { + if (isUrlCopied) { + setTimeout(() => setIsUrlCopied(false), 2000); + } + }, [isUrlCopied]); + + const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => { + try { + if (!currentOrg?.id) return; + + const key = crypto.randomBytes(16).toString("hex"); + const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: value, + key + }); + + + const expiresAt = new Date(); + const updateExpiresAt = expirationUnitsAndActions.find( + (item) => item.unit === expiresInUnit + )?.action; + if (updateExpiresAt) { + updateExpiresAt(expiresAt, expiresInValue); + } + + const { id } = await createSharedSecret.mutateAsync({ + name, + encryptedValue: ciphertext, + iv, + tag, + hashedHex, + expiresAt, + }); + setnewSharedSecret( + `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}` + ); + + createNotification({ + text: "Successfully created a shared secret", + type: "success" + }); + } catch (err) { + console.error(err); + const axiosError = err as AxiosError; + if (axiosError?.response?.status === 401) { + createNotification({ + text: "You do not have access to create shared secrets", + type: "error" + }); + } else { + createNotification({ + text: "Failed to create a shared secret", + type: "error" + }); + } + } + }; + + return ( + { + handlePopUpToggle("createSharedSecret", open); + reset(); + setnewSharedSecret(""); + }} + > + + {!hasSharedSecret ? ( + + ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+ + + + +
+ + ) : ( +
+

{newSharedSecret}

+ + + + Click to Copy + + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx new file mode 100644 index 000000000..c71b9830f --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx @@ -0,0 +1,78 @@ +import Head from "next/head"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing"; + +import { AddShareSecretModal } from "./AddShareSecretModal"; +import { ShareSecretsTable } from "./ShareSecretsTable"; + +type DeleteModalData = { name: string; id: string }; + +export const ShareSecretSection = () => { + const deleteSharedSecret = useDeleteSharedSecret(); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createSharedSecret", + "deleteSharedSecretConfirmation" + ] as const); + + const onDeleteApproved = async () => { + try { + deleteSharedSecret.mutateAsync({ + sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id, + }); + createNotification({ + text: "Successfully deleted shared secret", + type: "success" + }); + + handlePopUpClose("deleteSharedSecretConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete shared secret", + type: "error" + }); + } + }; + + return ( + +
+ + Secret Sharing + + + +
+

Shared Secrets

+ + +
+ + + handlePopUpToggle("deleteSharedSecretConfirmation", isOpen)} + deleteKey={(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteSharedSecretConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx new file mode 100644 index 000000000..204aed59b --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Td, Tr } from "@app/components/v2"; +import { TSharedSecret } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : ""); + +const isExpired = (expiresAt: Date): boolean => new Date(expiresAt) < new Date(); + +const getValidityStatusText = (expiresAt: Date): string => + isExpired(expiresAt) ? "Expired " : "Valid for "; + +const timeAgo = (inputDate: Date, currentDate: Date): string => { + const now = new Date(currentDate).getTime(); + const date = new Date(inputDate).getTime(); + const elapsedMilliseconds = now - date; + const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000)); + const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60)); + const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60)); + const elapsedDays = Math.abs(Math.floor(elapsedHours / 24)); + const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7)); + const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30)); + const elapsedYears = Math.abs(Math.floor(elapsedDays / 365)); + + if (elapsedYears > 0) { + return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedMonths > 0) { + return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedWeeks > 0) { + return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedDays > 0) { + return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedHours > 0) { + return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedMinutes > 0) { + return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; +}; + +export const ShareSecretsRow = ({ + row, + handlePopUpOpen, + onSecretExpiration +}: { + row: TSharedSecret; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>, + { + name, + id + }: { + name: string; + id: string; + } + ) => void; + onSecretExpiration: (expiredSecretId: string) => void; +}) => { + const [currentTime, setCurrentTime] = useState(new Date()); + + useEffect(() => { + const intervalId = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + + useEffect(() => { + if (isExpired(row.expiresAt)) { + onSecretExpiration(row.id); + } + }, [isExpired(row.expiresAt)]); + + return ( + + {row.name} + +

{timeAgo(row.createdAt, currentTime)}

+

{formatDate(row.createdAt)}

+ + +

+ {getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)} +

+

{formatDate(row.expiresAt)}

+ + + + handlePopUpOpen("deleteSharedSecretConfirmation", { + name: row.name, + id: row.id + }) + } + colorSchema="danger" + ariaLabel="delete" + > + + + + + ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx new file mode 100644 index 000000000..6b438f17a --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx @@ -0,0 +1,72 @@ +import { faKey } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useGetSharedSecrets } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { ShareSecretsRow } from "./ShareSecretsRow"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>, + { + name, + id + }: { + name: string; + id: string; + } + ) => void; +}; + +export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => { + const { isLoading, data = [] } = useGetSharedSecrets(); + + let tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()) + const handleSecretExpiration = () => { + tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()); + }; + + return ( + + + + + + + + + {isLoading && } + {!isLoading && + tableData && + tableData.map((row) => ( + + ))} + {!isLoading && tableData && tableData?.length === 0 && ( + + + + )} + +
Secret Name Created Valid Until +
+ +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/index.tsx b/frontend/src/views/ShareSecretPage/components/index.tsx new file mode 100644 index 000000000..64a0c2774 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/index.tsx @@ -0,0 +1 @@ +export { ShareSecretSection } from "./ShareSecretSection"; diff --git a/frontend/src/views/ShareSecretPage/index.tsx b/frontend/src/views/ShareSecretPage/index.tsx new file mode 100644 index 000000000..fa8198494 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/index.tsx @@ -0,0 +1 @@ +export { ShareSecretPage } from "./ShareSecretPage"; diff --git a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx new file mode 100644 index 000000000..618859a80 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useState } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/router"; + +import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { useTimedReset } from "@app/hooks"; +import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing"; + +import { DragonMainImage, SecretTable } from "./components"; + +export const ShareSecretPublicPage = () => { + const router = useRouter(); + const { id, key: urlEncodedPublicKey } = router.query; + const [hashedHex, key] = urlEncodedPublicKey!.toString().split("-"); + + const publicKey = decodeURIComponent(urlEncodedPublicKey as string); + useEffect(() => { + if (!id || !publicKey) { + router.push("/404"); + } + }, [id, publicKey]); + + const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(id as string, hashedHex as string ); + const decryptedSecret = useMemo(() => { + if (data && data.encryptedValue && publicKey) { + const res = decryptSymmetric({ + ciphertext: data.encryptedValue, + iv: data.iv, + tag: data.tag, + key, + }); + return res; + } + return ""; + }, [data, publicKey]); + + const [timeLeft, setTimeLeft] = useState(""); + const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ + initialState: false, + }); + + const millisecondsPerDay = 1000 * 60 * 60 * 24; + const millisecondsPerHour = 1000 * 60 * 60; + const millisecondsPerMinute = 1000 * 60; + + useEffect(() => { + const updateTimer = () => { + if (data && data.expiresAt) { + const expirationTime = new Date(data.expiresAt).getTime(); + const currentTime = new Date().getTime(); + const timeDifference = expirationTime - currentTime; + + if (timeDifference < 0) { + setTimeLeft("Expired"); + } else { + const hoursRemaining = Math.floor((timeDifference % millisecondsPerDay) / millisecondsPerHour); + const minutesRemaining = Math.floor((timeDifference % millisecondsPerHour) / millisecondsPerMinute); + const secondsRemaining = Math.floor((timeDifference % millisecondsPerMinute) / 1000); + setTimeLeft(`${hoursRemaining}h ${minutesRemaining}m ${secondsRemaining}s`); + } + } + }; + + const timer = setInterval(updateTimer, 1000); + return () => clearInterval(timer); + }, [data?.expiresAt]); + + useEffect(() => { + if (isUrlCopied) { + setTimeout(() => setIsUrlCopied(false), 2000); + } + }, [isUrlCopied]); + + + const copyUrlToClipboard = () => { + navigator.clipboard.writeText(decryptedSecret); + setIsUrlCopied(true); + }; + + return ( +
+ + Secret Shared | Infisical + + + +
+ Infisical logo +
+

+ A secret has been shared with you securely via Infisical +

+
+ +
+

+ Secret Details +

+
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx b/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx new file mode 100644 index 000000000..49a7e17ed --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx @@ -0,0 +1,14 @@ +import Image from "next/image"; + +export const DragonMainImage = () => { + return ( +
+ Infisical Dragon - Came to send you a secret! +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx new file mode 100644 index 000000000..97d7deca0 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx @@ -0,0 +1,96 @@ +import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + EmptyState, + IconButton, + SecretInput, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing"; + +type Props = { + isLoading: boolean; + sharedSecret?: TViewSharedSecretResponse; + decryptedSecret: string; + timeLeft: string; + isUrlCopied: boolean; + copyUrlToClipboard: () => void; +}; + +export const SecretTable = ({ + isLoading, + sharedSecret, + decryptedSecret, + timeLeft, + isUrlCopied, + copyUrlToClipboard +}: Props) => { + return ( + + + + + + + + + + + {!isLoading && sharedSecret && decryptedSecret && ( + + + + + + )} + {isLoading && ( + + + + )} + {!isLoading && !sharedSecret && ( + + + + )} + {!isLoading && sharedSecret && !decryptedSecret && ( + + + + )} + +
NameValueValid Until
{sharedSecret.name} +
+
+ +
+ + + +
+
{timeLeft}
+ Loading... +
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/index.tsx b/frontend/src/views/ShareSecretPublicPage/components/index.tsx new file mode 100644 index 000000000..5a7b53a0d --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/index.tsx @@ -0,0 +1,2 @@ +export { DragonMainImage } from "./MainImage"; +export { SecretTable } from "./SecretTable"; diff --git a/frontend/src/views/ShareSecretPublicPage/index.tsx b/frontend/src/views/ShareSecretPublicPage/index.tsx new file mode 100644 index 000000000..778e8ee58 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/index.tsx @@ -0,0 +1 @@ +export { ShareSecretPublicPage } from "./ShareSecretPublicPage"; diff --git a/standalone-entrypoint.sh b/standalone-entrypoint.sh index 3f88260e9..8fcdb26e7 100755 --- a/standalone-entrypoint.sh +++ b/standalone-entrypoint.sh @@ -1,5 +1,7 @@ #!/bin/sh +update-ca-certificates + cd frontend-build scripts/initialize-standalone-build.sh