diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index adf9489d4..c25d8d4d1 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -148,6 +148,7 @@ declare module "fastify" { interface Session { callbackPort: string; isAdminLogin: boolean; + orgSlug?: string; } interface FastifyRequest { diff --git a/backend/src/db/manual-migrations/partition-audit-logs.ts b/backend/src/db/manual-migrations/partition-audit-logs.ts index fbead3a24..e08f811ae 100644 --- a/backend/src/db/manual-migrations/partition-audit-logs.ts +++ b/backend/src/db/manual-migrations/partition-audit-logs.ts @@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise => { t.index("expiresAt"); t.index("orgId"); t.index("projectId"); + t.index("eventType"); + t.index("userAgentType"); + t.index("actor"); }); console.log("Adding GIN indices..."); @@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise => { console.log("Creating audit log partitions ahead of time... next date:", nextDateStr); await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1)); - // create partitions 4 years ahead - const partitionMonths = 4 * 12; + // create partitions 20 years ahead + const partitionMonths = 20 * 12; const partitionPromises: Promise[] = []; for (let x = 1; x <= partitionMonths; x += 1) { partitionPromises.push( diff --git a/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts new file mode 100644 index 000000000..03a45b1fd --- /dev/null +++ b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 1000; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified"))) { + // Add the column + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + + const aliasesToUpdate: { aliasId: string; isEmailVerified: boolean }[] = await knex(TableName.UserAliases) + .join(TableName.Users, `${TableName.UserAliases}.userId`, `${TableName.Users}.id`) + .select([`${TableName.UserAliases}.id as aliasId`, `${TableName.Users}.isEmailVerified`]); + + for (let i = 0; i < aliasesToUpdate.length; i += BATCH_SIZE) { + const batch = aliasesToUpdate.slice(i, i + BATCH_SIZE); + + const trueIds = batch.filter((row) => row.isEmailVerified).map((row) => row.aliasId); + + if (trueIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.UserAliases).whereIn("id", trueIds).update({ isEmailVerified: true }); + } + } + } + + if (!(await knex.schema.hasColumn(TableName.AuthTokens, "aliasId"))) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.string("aliasId").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified")) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.AuthTokens, "aliasId")) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("aliasId"); + }); + } +} diff --git a/backend/src/db/migrations/20250813214709_enforce-google-sso.ts b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts new file mode 100644 index 000000000..2346c91a4 --- /dev/null +++ b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced"; +const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed"; +export async function up(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (!hasGoogleSsoAuthEnforcedColumn) + table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable(); + if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable(); + }); +} + +export async function down(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME); + if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME); + }); +} diff --git a/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts b/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts new file mode 100644 index 000000000..7398920af --- /dev/null +++ b/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission"))) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.boolean("shouldCheckSecretPermission").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("shouldCheckSecretPermission"); + }); + } +} diff --git a/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts new file mode 100644 index 000000000..7a629ca52 --- /dev/null +++ b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { selectAllTableCols } from "@app/lib/knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 100; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + // find all existing SecretApprovalPolicy rows to backfill shouldCheckSecretPermission flag + const rows = await knex(TableName.SecretApprovalPolicy).select(selectAllTableCols(TableName.SecretApprovalPolicy)); + + if (rows.length > 0) { + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + // eslint-disable-next-line no-await-in-loop + await knex(TableName.SecretApprovalPolicy) + .whereIn( + "id", + batch.map((row) => row.id) + ) + .update({ shouldCheckSecretPermission: true }); + } + } + } +} + +export async function down(): Promise {} diff --git a/backend/src/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index dd8563b85..0d3e93219 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid().nullable().optional() + orgId: z.string().uuid().nullable().optional(), + aliasId: z.string().nullable().optional() }); export type TAuthTokens = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index fb0728707..afc9e2b73 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({ scannerProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), - maxSharedSecretViewLimit: z.number().nullable().optional() + maxSharedSecretViewLimit: z.number().nullable().optional(), + googleSsoAuthEnforced: z.boolean().default(false), + googleSsoAuthLastUsed: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 0273e617c..dbb881db3 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -17,7 +17,8 @@ export const SecretApprovalPoliciesSchema = z.object({ updatedAt: z.date(), enforcementLevel: z.string().default("hard"), deletedAt: z.date().nullable().optional(), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + shouldCheckSecretPermission: z.boolean().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index 14147abf8..428fa62ca 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({ emails: z.string().array().nullable().optional(), orgId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUserAliases = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 83378c16e..2cfc90564 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -133,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string().nullable().optional(), sequence: z.number().nullable().optional(), approvalsRequired: z.number().nullable().optional(), @@ -150,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv }), reviewers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string(), status: z.string() }) diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 3d07af562..bdc9c2dcd 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -294,22 +294,30 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv 200: z.object({ approval: SecretApprovalRequestsSchema.merge( z.object({ - // secretPath: z.string(), policy: z.object({ id: z.string(), name: z.string(), approvals: z.number(), - approvers: approvalRequestUser.array(), + approvers: approvalRequestUser + .extend({ isOrgMembershipActive: z.boolean().nullable().optional() }) + .array(), bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), - allowedSelfApprovals: z.boolean() + allowedSelfApprovals: z.boolean(), + shouldCheckSecretPermission: z.boolean().nullable().optional() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), committerUser: approvalRequestUser.nullish(), - reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), + reviewers: approvalRequestUser + .extend({ + status: z.string(), + comment: z.string().optional(), + isOrgMembershipActive: z.boolean().nullable().optional() + }) + .array(), secretPath: z.string(), commits: secretRawSchema .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 68f9240a6..f3972fc1c 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -5,6 +5,7 @@ import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, + TOrgMemberships, TUserGroupMembership, TUsers } from "@app/db/schemas"; @@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `${TableName.Users}.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) @@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), + + db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), + db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR { key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) + mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) => + userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined }, { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ + mapper: ({ + approverUserId, + approverSequence, + approvalsRequired, + approverUsername, + approverEmail, + approverIsOrgMembershipActive + }) => ({ userId: approverUserId, sequence: approverSequence, approvalsRequired, email: approverEmail, - username: approverUsername + username: approverUsername, + isOrgMembershipActive: approverIsOrgMembershipActive }) }, { @@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR approverSequence, approvalsRequired, approverGroupEmail, - approverGroupUsername + approverGroupUsername, + approverGroupIsOrgMembershipActive }) => ({ userId: approverGroupUserId, sequence: approverSequence, approvalsRequired, email: approverGroupEmail, - username: approverGroupUsername + username: approverGroupUsername, + isOrgMembershipActive: approverGroupIsOrgMembershipActive }) }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index ed027835a..f1d3102d6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -87,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -94,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; @@ -145,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory { reviewers: { userId: string; status: string; + isOrgMembershipActive: boolean; }[]; approvers: ( | { @@ -153,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -160,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 18a6ba48a..7a986a2d3 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type"; import { EventType, filterableSecretEvents } from "./audit-log-types"; export interface TAuditLogDALFactory extends Omit, "find"> { - pruneAuditLog: (tx?: knex.Knex) => Promise; + pruneAuditLog: () => Promise; find: ( arg: Omit & { actorId?: string | undefined; @@ -41,6 +41,10 @@ type TFindQuery = { offset?: number; }; +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + export const auditLogDALFactory = (db: TDbClient) => { const auditLogOrm = ormify(db, TableName.AuditLog); @@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => { }; // delete all audit log that have expired - const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { - const runPrune = async (dbClient: knex.Knex) => { - const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; - const MAX_RETRY_ON_FAILURE = 3; + const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => { + const today = new Date(); + let deletedAuditLogIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + let isRetrying = false; - const today = new Date(); - let deletedAuditLogIds: { id: string }[] = []; - let numberOfRetryOnFailure = 0; - let isRetrying = false; + logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedAuditLogIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); - do { - try { - const findExpiredLogSubQuery = dbClient(TableName.AuditLog) + const findExpiredLogSubQuery = trx(TableName.AuditLog) .where("expiresAt", "<", today) .where("createdAt", "<", today) // to use audit log partition .orderBy(`${TableName.AuditLog}.createdAt`, "desc") @@ -172,35 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => { .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); // eslint-disable-next-line no-await-in-loop - deletedAuditLogIds = await dbClient(TableName.AuditLog) - .whereIn("id", findExpiredLogSubQuery) - .del() - .returning("id"); - numberOfRetryOnFailure = 0; // reset - } catch (error) { - numberOfRetryOnFailure += 1; - deletedAuditLogIds = []; - logger.error(error, "Failed to delete audit log on pruning"); - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 10); // time to breathe for db - }); - } - isRetrying = numberOfRetryOnFailure > 0; - } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); - }; + const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id"); - if (tx) { - await runPrune(tx); - } else { - const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes - await db.transaction(async (trx) => { - await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - await runPrune(trx); - }); - } + return results; + }); + + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + deletedAuditLogIds = []; + logger.error(error, "Failed to delete audit log on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); }; const create: TAuditLogDALFactory["create"] = async (tx) => { diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts index dc7d15c79..13e18374f 100644 --- a/backend/src/ee/services/event/event-sse-stream.ts +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -123,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien await redis.set(key, "1", "EX", 60); - stream.push("1"); + send({ type: "ping" }); }; const close = () => { diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 338099da9..a72d50760 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({ userAlias = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustLdapEmails) { - newUser = await userDAL.findOne( - { - email: email.toLowerCase(), - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email: email.toLowerCase(), + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(username, userDAL); @@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({ aliasType: UserAliasType.LDAP, externalId, emails: [email], - orgId + orgId, + isEmailVerified: serverCfg.trustLdapEmails }, tx ); @@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({ return newUser; }); - const isUserCompleted = Boolean(user.isAccepted); - + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, hasExchangedPrivateKey: true, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, authType: UserAliasType.LDAP, + aliasId: userAlias.id, isUserCompleted, ...(relayState ? { @@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({ } ); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index bd3949a7e..8d2d6fdbe 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + enforceGoogleSSO: false, hsm: false, oidcSSO: false, scim: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 098d00feb..345e26638 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -47,6 +47,7 @@ export type TFeatureSet = { auditLogStreamLimit: 3; githubOrgSync: false; samlSSO: false; + enforceGoogleSSO: false; hsm: false; oidcSSO: false; secretAccessInsights: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index b9cf011d9..8f479b12c 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({ } const appCfg = getConfig(); - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.OIDC @@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + // we prioritize getting the most complete user to create the new alias under + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); - if (serverCfg.trustOidcEmails) { - // we prioritize getting the most complete user to create the new alias under + if (!newUser) { + // this fetches user entries created via invites newUser = await userDAL.findOne( { - email, - isEmailVerified: true + username: email }, tx ); - if (!newUser) { - // this fetches user entries created via invites - newUser = await userDAL.findOne( - { - username: email - }, - tx - ); - - if (newUser && !newUser.isEmailVerified) { - // we automatically mark it as email-verified because we've configured trust for OIDC emails - newUser = await userDAL.updateById(newUser.id, { - isEmailVerified: true - }); - } + if (newUser && !newUser.isEmailVerified) { + // we automatically mark it as email-verified because we've configured trust for OIDC emails + newUser = await userDAL.updateById(newUser.id, { + isEmailVerified: serverCfg.trustOidcEmails + }); } } @@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.OIDC, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustOidcEmails }, tx ); @@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({ await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted); + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, organizationSlug: organization.slug, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, isUserCompleted, @@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({ await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index cca4efaf2..349130d8e 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -13,6 +13,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -252,6 +253,16 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretScanningConfigs ); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -455,6 +466,16 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -505,6 +526,16 @@ const buildViewerPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9677c69b1..cdf55127f 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -35,6 +35,7 @@ export interface TPermissionDALFactory { projectFavorites?: string[] | null | undefined; customRoleSlug?: string | null | undefined; orgAuthEnforced?: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; } & { groups: { id: string; @@ -87,6 +88,7 @@ export interface TPermissionDALFactory { }[]; orgId: string; orgAuthEnforced: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; orgRole: OrgMembershipRole; userId: string; projectId: string; @@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), @@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() @@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), @@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId, username, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole, membershipId, groupMembershipId, @@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { }) => ({ orgId, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole: orgRole as OrgMembershipRole, userId, projectId, diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index d645e2bec..7d61d7499 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { function validateOrgSSO( actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], orgRole: OrgMembershipRole ) { @@ -128,10 +129,16 @@ function validateOrgSSO( throw new UnauthorizedError({ name: "No auth method defined" }); } - if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { return; } + // case: google sso is enforced, but the actor is not using google sso + if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) { + throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" }); + } + + // case: SAML SSO is enforced, but the actor is not using SAML SSO if ( isOrgSsoEnforced && actorAuthMethod !== null && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 85ee82cca..461ceef46 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -146,6 +146,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, membership.orgAuthEnforced, + membership.orgGoogleSsoAuthEnforced, membership.bypassOrgAuthEnabled, membership.role as OrgMembershipRole ); @@ -238,6 +239,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, userProjectPermission.orgAuthEnforced, + userProjectPermission.orgGoogleSsoAuthEnforced, userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.orgRole ); diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index cbb99f7eb..6b8bbe304 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({ }); } - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.SAML @@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({ user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustSamlEmails) { - newUser = await userDAL.findOne( - { - email, - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); @@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.SAML, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustSamlEmails }, tx ); @@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({ } await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); + const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified); const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -424,6 +423,7 @@ export const samlConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: authProvider, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authType: UserAliasType.SAML, isUserCompleted, ...(relayState @@ -440,10 +440,11 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 49f31bdf6..fe4ca94e1 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { SecretApprovalRequestsSchema, TableName, + TOrgMemberships, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, TUserGroupMembership, @@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `secretApprovalReviewerUser.id` ) + + .leftJoin( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.SecretApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `secretApprovalPolicyGroupApproverUser.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), + tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"), @@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"), + tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("projectId").withSchema(TableName.Environment), @@ -157,7 +180,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), - tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt"), + tx + .ref("shouldCheckSecretPermission") + .withSchema(TableName.SecretApprovalPolicy) + .as("policySecretReadAccessCompat") ); const findById = async (id: string, tx?: Knex) => { @@ -197,7 +224,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { enforcementLevel: el.policyEnforcementLevel, envId: el.policyEnvId, deletedAt: el.policyDeletedAt, - allowedSelfApprovals: el.policyAllowedSelfApprovals + allowedSelfApprovals: el.policyAllowedSelfApprovals, + shouldCheckSecretPermission: el.policySecretReadAccessCompat } }), childrenMapper: [ @@ -211,9 +239,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { reviewerLastName: lastName, reviewerUsername: username, reviewerFirstName: firstName, - reviewerComment: comment + reviewerComment: comment, + reviewerIsOrgMembershipActive: isOrgMembershipActive }) => - userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined + userId + ? { + userId, + status, + email, + firstName, + lastName, + username, + comment: comment ?? "", + isOrgMembershipActive + } + : undefined }, { key: "approverUserId", @@ -223,13 +263,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverEmail: email, approverUsername: username, approverLastName: lastName, - approverFirstName: firstName + approverFirstName: firstName, + approverIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { @@ -240,13 +282,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverGroupEmail: email, approverGroupUsername: username, approverGroupLastName: lastName, - approverGroupFirstName: firstName + approverGroupFirstName: firstName, + approverGroupIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { @@ -653,14 +697,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("lastName").withSchema("committerUser").as("committerUserLastName") ) - .distinctOn(`${TableName.SecretApprovalRequest}.id`) .as("inner"); - const query = (tx || db) - .select("*") + const countQuery = (await (tx || db) .select(db.raw("count(*) OVER() as total_count")) - .from(innerQuery) - .orderBy("createdAt", "desc") as typeof innerQuery; + .from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{ + total_count: number; + }>; + + const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery; if (search) { void query.where((qb) => { @@ -686,8 +731,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where("w.rank", ">=", rankOffset) .andWhere("w.rank", "<", rankOffset + limit); - // @ts-expect-error knex does not infer - const totalCount = Number(docs[0]?.total_count || 0); + const totalCount = Number(countQuery[0]?.total_count || 0); const formattedDoc = sqlNestRelationships({ data: docs, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 90a7b0e1f..d485f7ea0 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({ if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); + if (!secretApprovalRequest) throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` }); @@ -280,13 +281,22 @@ export const secretApprovalRequestServiceFactory = ({ ) { throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } - const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => { - const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath: secretPath || "/", - secretTags: tags.map((i) => i.slug) - }); - return canRead; + const getHasSecretReadAccess = ( + shouldCheckSecretPermission: boolean | null | undefined, + environment: string, + tags: { slug: string }[], + secretPath?: string + ) => { + if (shouldCheckSecretPermission) { + const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: secretPath || "/", + secretTags: tags.map((i) => i.slug) + }); + return canRead; + } + + return true; }; let secrets; @@ -308,8 +318,18 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), - secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret && el.secret.isRotatedSecret ? undefined @@ -325,11 +345,17 @@ export const secretApprovalRequestServiceFactory = ({ id: el.secret.id, version: el.secret.version, secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path ), - secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() @@ -345,11 +371,17 @@ export const secretApprovalRequestServiceFactory = ({ id: el.secretVersion.id, version: el.secretVersion.version, secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path ), - secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() @@ -367,7 +399,12 @@ export const secretApprovalRequestServiceFactory = ({ const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); secrets = encryptedSecrets.map((el) => ({ ...el, - secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), ...decryptSecretWithBot(el, botKey), secret: el.secret ? { @@ -1447,6 +1484,7 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; + const existingTagIds: Record = {}; const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1512,6 +1550,11 @@ export const secretApprovalRequestServiceFactory = ({ type: SecretType.Shared })) ); + + secretsToUpdateStoredInDB.forEach((el) => { + if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id); + }); + if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length) throw new NotFoundError({ message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}` @@ -1555,7 +1598,10 @@ export const secretApprovalRequestServiceFactory = ({ secretMetadata }) => { const secretId = updatingSecretsGroupByKey[secretKey][0].id; - if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds; + if (tagIds?.length || existingTagIds[secretKey]?.length) { + commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey]; + } + return { ...latestSecretVersions[secretId], secretMetadata, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b101c168f..47f5ca5cd 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2500,6 +2500,7 @@ export const SecretSyncs = { }, RENDER: { serviceId: "The ID of the Render service to sync secrets to.", + environmentGroupId: "The ID of the Render environment group to sync secrets to.", scope: "The Render scope that secrets should be synced to.", type: "The Render resource type to sync secrets to." }, diff --git a/backend/src/lib/template/dot-access.ts b/backend/src/lib/template/dot-access.ts index ec3208feb..898252acc 100644 --- a/backend/src/lib/template/dot-access.ts +++ b/backend/src/lib/template/dot-access.ts @@ -1,11 +1,11 @@ /** * Safely retrieves a value from a nested object using dot notation path */ -export const getStringValueByDot = ( +export const getValueByDot = ( obj: Record | null | undefined, path: string, - defaultValue?: string -): string | undefined => { + defaultValue?: string | number | boolean +): string | number | boolean | undefined => { // Handle null or undefined input if (!obj) { return defaultValue; @@ -26,7 +26,7 @@ export const getStringValueByDot = ( current = (current as Record)[part]; } - if (typeof current !== "string") { + if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") { return defaultValue; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6dd7d190d..099c04084 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -726,7 +726,8 @@ export const registerRoutes = async ( permissionService, groupProjectDAL, smtpService, - projectMembershipDAL + projectMembershipDAL, + userAliasDAL }); const totpService = totpServiceFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts index bbe3fbbfb..668bea065 100644 --- a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts @@ -53,4 +53,36 @@ export const registerChecklyConnectionRouter = async (server: FastifyZodProvider return { accounts }; } }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + groups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const groups = await server.services.appConnection.checkly.listGroups(connectionId, accountId, req.permission); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts index 97cff2cd3..bd0202371 100644 --- a/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts @@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider) return services; } }); + + server.route({ + method: "GET", + url: `/:connectionId/environment-groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission); + + return groups; + } + }); }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 323354bc1..b8eb3ad6b 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), + googleSsoAuthEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7a5a9341f..5e7dce76e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { firstName: true, lastName: true, id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + }) + .merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + .extend({ + isOrgMembershipActive: z.boolean() + }), project: SanitizedProjectSchema.pick({ name: true, id: true }), roles: z.array( z.object({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 0aec39f3e..366fa331d 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { try { // @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); const email = profile?.emails?.[0]?.value; if (!email) @@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort + callbackPort, + orgSlug }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { schema: { querystring: z.object({ callback_port: z.string().optional(), + org_slug: z.string().optional(), is_admin_login: z .string() .optional() @@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (orgSlug) { + req.session.set("orgSlug", orgSlug); + } if (isAdminLogin) { req.session.set("isAdminLogin", isAdminLogin); } diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 92b4138f2..f416fe8bb 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - username: z.string().trim() + token: z.string().trim() }), response: { 200: z.object({}) } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.body.username); + await server.services.user.sendEmailVerificationCode(req.body.token); return {}; } }); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts index 4e5db231f..35279d007 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts @@ -4,6 +4,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxi import { createRequestClient } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { ChecklyConnectionMethod } from "./checkly-connection-constants"; import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types"; @@ -181,6 +182,122 @@ class ChecklyPublicClient { return res; } + + async getCheckGroups(connection: TChecklyConnectionConfig, accountId: string, limit = 50, page = 1) { + const res = await this.send<{ id: number; name: string }[]>(connection, { + accountId, + method: "GET", + url: `/v1/check-groups`, + params: { limit, page } + }); + + return res?.map((group) => ({ + id: group.id.toString(), + name: group.name + })); + } + + async getCheckGroup(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + try { + type ChecklyGroupResponse = { + id: number; + name: string; + environmentVariables: Array<{ + key: string; + value: string; + locked: boolean; + }>; + }; + + const res = await this.send(connection, { + accountId, + method: "GET", + url: `/v1/check-groups/${groupId}` + }); + + if (!res) return null; + + return { + id: res.id.toString(), + name: res.name, + environmentVariables: res.environmentVariables + }; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + throw error; + } + } + + async updateCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + environmentVariables: Array<{ key: string; value: string; locked?: boolean }> + ) { + if (environmentVariables.length > 50) { + throw new SecretSyncError({ + message: "Checkly does not support syncing more than 50 variables to Check Group", + shouldRetry: false + }); + } + + const apiVariables = environmentVariables.map((v) => ({ + key: v.key, + value: v.value, + locked: v.locked ?? false, + secret: true + })); + + const group = await this.getCheckGroup(connection, accountId, groupId); + + await this.send(connection, { + accountId, + method: "PUT", + url: `/v2/check-groups/${groupId}`, + data: { name: group?.name, environmentVariables: apiVariables } + }); + + return this.getCheckGroup(connection, accountId, groupId); + } + + async getCheckGroupEnvironmentVariables(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + const group = await this.getCheckGroup(connection, accountId, groupId); + return group?.environmentVariables || []; + } + + async upsertCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variables: Array<{ key: string; value: string; locked?: boolean }> + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const varMap = new Map(existingVars.map((v) => [v.key, v])); + + for (const newVar of variables) { + varMap.set(newVar.key, { + key: newVar.key, + value: newVar.value, + locked: newVar.locked ?? false + }); + } + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, Array.from(varMap.values())); + } + + async deleteCheckGroupEnvironmentVariable( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variableKey: string + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const filteredVars = existingVars.filter((v) => v.key !== variableKey); + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, filteredVars); + } } export const ChecklyPublicAPI = new ChecklyPublicClient(); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-service.ts b/backend/src/services/app-connection/checkly/checkly-connection-service.ts index c3598320f..1312b4590 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-service.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-service.ts @@ -24,7 +24,19 @@ export const checklyConnectionService = (getAppConnection: TGetAppConnectionFunc } }; + const listGroups = async (connectionId: string, accountId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Checkly, connectionId, actor); + try { + const groups = await ChecklyPublicAPI.getCheckGroups(appConnection, accountId); + return groups!; + } catch (error) { + logger.error(error, "Failed to list accounts on Checkly"); + return []; + } + }; + return { - listAccounts + listAccounts, + listGroups }; }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-types.ts b/backend/src/services/app-connection/checkly/checkly-connection-types.ts index e8bb242ba..195d319d6 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-types.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-types.ts @@ -33,3 +33,15 @@ export type TChecklyAccount = { name: string; runtimeId: string; }; + +export type TChecklyGroupEnvironmentVariable = { + key: string; + value: string; + locked: boolean; +}; + +export type TChecklyGroup = { + id: string; + name: string; + environmentVariables?: TChecklyGroupEnvironmentVariable[]; +}; diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index a71036d82..164aa31d9 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,5 +1,3 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { request } from "@octokit/request"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getConfig } from "@app/lib/config/env"; import { request as httpRequest } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; @@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async ( ); }; -export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => { +export const getGitHubAppAuthToken = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { const appCfg = getConfig(); const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; - const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; + let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; if (!appId || !appPrivateKey) { throw new InternalServerError({ @@ -125,21 +127,42 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => }); } + appPrivateKey = appPrivateKey + .split("\n") + .map((line) => line.trim()) + .join("\n"); + if (appConnection.method !== GitHubConnectionMethod.App) { throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" }); } - const appAuth = createAppAuth({ - appId, - privateKey: appPrivateKey, - installationId: appConnection.credentials.installationId, - request: request.defaults({ - baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` - }) - }); + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now, + exp: now + 5 * 60, + iss: appId + }; - const { token } = await appAuth({ type: "installation" }); - return token; + const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" }); + + const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection); + const { installationId } = appConnection.credentials; + + const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>( + appConnection, + gatewayService, + { + url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`, + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${appJwt}`, + "X-GitHub-Api-Version": "2022-11-28" + } + } + ); + + return response.data.token; }; const parseGitHubLinkHeader = (linkHeader: string | undefined): Record => { @@ -174,7 +197,9 @@ export const makePaginatedGitHubRequest = async ( const { credentials, method } = appConnection; const token = - method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); + method === GitHubConnectionMethod.OAuth + ? credentials.accessToken + : await getGitHubAppAuthToken(appConnection, gatewayService); const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; const initialUrlObj = new URL(baseUrl); diff --git a/backend/src/services/app-connection/render/render-connection-fns.ts b/backend/src/services/app-connection/render/render-connection-fns.ts index bf85b9b71..c41c46c50 100644 --- a/backend/src/services/app-connection/render/render-connection-fns.ts +++ b/backend/src/services/app-connection/render/render-connection-fns.ts @@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list import { AppConnection } from "../app-connection-enums"; import { RenderConnectionMethod } from "./render-connection-enums"; import { + TRawRenderEnvironmentGroup, TRawRenderService, TRenderConnection, TRenderConnectionConfig, + TRenderEnvironmentGroup, TRenderService } from "./render-connection-types"; @@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom const perPage = 100; let cursor; + let maxIterations = 10; + while (hasMorePages) { + if (maxIterations <= 0) break; + const res: TRawRenderService[] = ( await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { params: new URLSearchParams({ @@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom } else { cursor = res[res.length - 1].cursor; } + + maxIterations -= 1; } return services; @@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect return inputCredentials; }; + +export const listRenderEnvironmentGroups = async ( + appConnection: TRenderConnection +): Promise => { + const { + credentials: { apiKey } + } = appConnection; + + const groups: TRenderEnvironmentGroup[] = []; + let hasMorePages = true; + const perPage = 100; + let cursor; + let maxIterations = 10; + + while (hasMorePages) { + if (maxIterations <= 0) break; + + const res: TRawRenderEnvironmentGroup[] = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, { + params: new URLSearchParams({ + ...(cursor ? { cursor: String(cursor) } : {}), + limit: String(perPage) + }), + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + res.forEach((item) => { + groups.push({ + name: item.envGroup.name, + id: item.envGroup.id + }); + }); + + if (res.length < perPage) { + hasMorePages = false; + } else { + cursor = res[res.length - 1].cursor; + } + + maxIterations -= 1; + } + + return groups; +}; diff --git a/backend/src/services/app-connection/render/render-connection-service.ts b/backend/src/services/app-connection/render/render-connection-service.ts index 371790bcb..7cf805e9c 100644 --- a/backend/src/services/app-connection/render/render-connection-service.ts +++ b/backend/src/services/app-connection/render/render-connection-service.ts @@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listRenderServices } from "./render-connection-fns"; +import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns"; import { TRenderConnection } from "./render-connection-types"; type TGetAppConnectionFunc = ( @@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc) } }; + const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor); + try { + const groups = await listRenderEnvironmentGroups(appConnection); + + return groups; + } catch (error) { + logger.error(error, "Failed to list environment groups for Render connection"); + return []; + } + }; + return { - listServices + listServices, + listEnvironmentGroups }; }; diff --git a/backend/src/services/app-connection/render/render-connection-types.ts b/backend/src/services/app-connection/render/render-connection-types.ts index 0902472e5..c3922a071 100644 --- a/backend/src/services/app-connection/render/render-connection-types.ts +++ b/backend/src/services/app-connection/render/render-connection-types.ts @@ -33,3 +33,16 @@ export type TRawRenderService = { name: string; }; }; + +export type TRenderEnvironmentGroup = { + name: string; + id: string; +}; + +export type TRawRenderEnvironmentGroup = { + cursor: string; + envGroup: { + id: string; + name: string; + }; +}; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 1a2f290ec..c309b3998 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); @@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu type, userId, orgId, - triesLeft: tkCfg?.triesLeft + triesLeft: tkCfg?.triesLeft, + aliasId }, tx ); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 5f5843bc6..7deb719a9 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = { type: TokenType; userId: string; orgId?: string; + aliasId?: string; }; export type TCreateOrgInviteTokenDTO = { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6a3456508..e3afb754a 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -448,15 +448,41 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); + + const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); + const selectedOrg = await orgDAL.findById(organizationId); - if (!hasOrganizationMembership) { + // Check if authEnforced is true, if that's the case, throw an error + if (selectedOrg.authEnforced) { + throw new BadRequestError({ + message: "Authentication is required by your organization before you can log in." + }); + } + + if (!selectedOrgMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` }); } + if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; + + if (!canBypass) { + throw new ForbiddenRequestError({ + message: "Google SSO is enforced for this organization. Please use Google SSO to login.", + error: "GoogleSsoEnforced" + }); + } + } + + if (decodedToken.authMethod === AuthMethod.GOOGLE) { + await orgDAL.updateById(selectedOrg.id, { + googleSsoAuthLastUsed: new Date() + }); + } + const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; @@ -502,7 +528,8 @@ export const authLoginServiceFactory = ({ selectedOrg.authEnforced && selectedOrg.bypassOrgAuthEnabled && !isAuthMethodSaml(decodedToken.authMethod) && - decodedToken.authMethod !== AuthMethod.OIDC + decodedToken.authMethod !== AuthMethod.OIDC && + decodedToken.authMethod !== AuthMethod.GOOGLE ) { await auditLogService.createAuditLog({ orgId: organizationId, @@ -705,7 +732,7 @@ export const authLoginServiceFactory = ({ /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => { // akhilmhdh: case sensitive email resolution const usersByUsername = await userDAL.findUserByUsername(email); let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; @@ -759,6 +786,8 @@ export const authLoginServiceFactory = ({ const appCfg = getConfig(); + let orgId = ""; + let orgName: undefined | string; if (!user) { // Create a new user based on oAuth if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); @@ -784,7 +813,6 @@ export const authLoginServiceFactory = ({ }); if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { - let orgId = ""; const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); if (!defaultOrg) { throw new BadRequestError({ @@ -824,11 +852,39 @@ export const authLoginServiceFactory = ({ } } + if (!orgId && orgSlug) { + const org = await orgDAL.findOrgBySlug(orgSlug); + + if (org) { + // checks for the membership and only sets the orgId / orgName if the user is a member of the specified org + const orgMembership = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "orgId"]: org.id, + [`${TableName.OrgMembership}.isActive` as "isActive"]: true, + [`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted + }); + + if (orgMembership) { + orgId = org.id; + orgName = org.name; + } + } + } + const isUserCompleted = user.isAccepted; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, + + ...(orgId && orgSlug && orgName !== undefined + ? { + organizationId: orgId, + organizationName: orgName, + organizationSlug: orgSlug + } + : {}), + username: user.username, email: user.email, isEmailVerified: user.isEmailVerified, diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index d9d9520a8..09d81033f 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -32,6 +32,7 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; + orgSlug?: string; }; export type TOauthTokenExchangeDTO = { diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index a776f7245..263838a9d 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { `${TableName.GroupProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership), @@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }); @@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ isGroupMember: true, id, @@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { id: projectId, name: projectName }, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive }, createdAt }), key: "id", diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 7b0a19414..9cc851437 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -21,7 +21,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({ if (identityJwtAuth.boundClaims) { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({ } // handle both single and multi-valued claims - if ( - !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) - ) { + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { throw new UnauthorizedError({ message: `Access denied: claim mismatch for field ${claimKey}` }); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts index 7d386afcb..8ed134cba 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts @@ -1,7 +1,16 @@ import picomatch from "picomatch"; -export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) => - policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => { + if (typeof fieldValue === "boolean") { + return fieldValue === (policyValue === "true"); + } + + if (typeof fieldValue === "number") { + return fieldValue === parseInt(policyValue, 10); + } + + return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +}; export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => { if (Array.isArray(fieldValue)) { diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 617b21a1f..6585e61f3 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -22,7 +22,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` }); } - filteredClaims[permissionKey] = value; + filteredClaims[permissionKey] = value.toString(); }); } diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 8f2ca01f0..7cef4a6cc 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -124,12 +124,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => { void qb .whereNull(`${TableName.OrgMembership}.lastInvitedAt`) .whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); - }) - .orWhere((qb) => { // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago - void qb - .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) - .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + void qb.orWhere((qbInner) => { + void qbInner + .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) + .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + }); }); return memberships; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ae82cd1bc..4a3bdb06e 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ createdAt: true, updatedAt: true, authEnforced: true, + googleSsoAuthEnforced: true, scimEnabled: true, kmsDefaultKeyId: true, defaultMembershipRole: true, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index ba76d0a93..356a8451c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -364,6 +364,7 @@ export const orgServiceFactory = ({ name, slug, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, @@ -430,6 +431,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced !== undefined) { + if (!plan.enforceGoogleSSO) { + throw new BadRequestError({ + message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO." + }); + } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + } + + if (authEnforced && googleSsoAuthEnforced) { + throw new BadRequestError({ + message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time." + }); + } + if (authEnforced) { const samlCfg = await samlConfigDAL.findOne({ orgId, @@ -460,6 +476,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced) { + if (googleSsoAuthEnforced && currentOrg.authEnforced) { + throw new BadRequestError({ + message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled." + }); + } + + if (!currentOrg.googleSsoAuthLastUsed) { + throw new BadRequestError({ + message: + "Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization." + }); + } + } + let defaultMembershipRole: string | undefined; if (defaultMembershipRoleSlug) { defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ @@ -474,6 +505,7 @@ export const orgServiceFactory = ({ name, slug: slug ? slugify(slug) : undefined, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRole, enforceMfa, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 645692145..1a27d131f 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { name: string; slug: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 7dfd1f65c..dd503c2a3 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => { .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join(TableName.OrgMembership, (qb) => { + qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn( + `${TableName.OrgMembership}.orgId`, + "=", + `${TableName.Project}.orgId` + ); + }) + .where((qb) => { if (filter.usernames) { void qb.whereIn("username", filter.usernames); @@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }) .orderBy(`${TableName.Users}.username` as "username"); @@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { + email, + username, + firstName, + lastName, + id: userId, + publicKey, + isGhost, + isOrgMembershipActive: isActive + }, project: { id: projectId, name: projectName diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index b9e502922..7cf665141 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({ const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); - // projectMembers[0].project if (includeGroupMembers) { const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); const allMembers = [ diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts index eded130bb..822773948 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts @@ -23,56 +23,120 @@ export const ChecklySyncFns = { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(secretMap)) { - try { + // Prepare all variables to update at once + const updatedVariables = { ...checklyGroupSecrets }; + + for (const key of Object.keys(secretMap)) { const entry = secretMap[key]; - // If value is empty, we skip the upsert - checkly does not allow empty values + // If value is empty, we skip adding it - checkly does not allow empty values if (entry.value.trim() === "") { - // Delete the secret from Checkly if its empty + // Delete the secret from the group if it's empty if (!disableSecretDeletion) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key - }); + delete updatedVariables[key]; } continue; // Skip empty values } - await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + // Add or update the variable + updatedVariables[key] = { key, value: entry.value, - secret: true, locked: true - }); + }; + } + + // Remove secrets that are not in the secretMap if deletion is enabled + if (!disableSecretDeletion) { + for (const key of Object.keys(checklyGroupSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + delete updatedVariables[key]; + } + } + } + + // Update all group environment variables at once + try { + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + Object.values(updatedVariables) + ); } catch (error) { + if (error instanceof SecretSyncError) throw error; + throw new SecretSyncError({ error, - secretKey: key + secretKey: "group_update" }); } - } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); - if (disableSecretDeletion) return; + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(checklySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; - if (!secretMap[key]) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key + // If value is empty, we skip the upsert - checkly does not allow empty values + if (entry.value.trim() === "") { + // Delete the secret from Checkly if its empty + if (!disableSecretDeletion) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + continue; // Skip empty values + } + + await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + key, + value: entry.value, + secret: true, + locked: true + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(checklySecrets)) { + try { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key }); } - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); } } }, @@ -80,23 +144,54 @@ export const ChecklySyncFns = { async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); + + // Filter out the secrets to remove + const remainingVariables = Object.keys(checklyGroupSecrets) + .filter((key) => !(key in secretMap)) + .map((key) => checklyGroupSecrets[key]); - for await (const secret of Object.keys(checklySecrets)) { try { - if (secret in secretMap) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key: secret - }); - } + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + remainingVariables + ); } catch (error) { throw new SecretSyncError({ error, - secretKey: secret + secretKey: "group_remove" }); } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(checklySecrets)) { + try { + if (secret in secretMap) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } } } }; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts index 04f444357..fc511b2d7 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts @@ -11,7 +11,17 @@ import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types" const ChecklySyncDestinationConfigSchema = z.object({ accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), - accountName: z.string().min(1, "Account Name is required").max(255, "Account ID must be less than 255 characters") + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account ID must be less than 255 characters") + .optional(), + groupId: z.string().min(1, "Group ID is required").max(255, "Group ID must be less than 255 characters").optional(), + groupName: z + .string() + .min(1, "Group Name is required") + .max(255, "Group Name must be less than 255 characters") + .optional() }); const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index e2cf8f6e8..2cae048aa 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -207,7 +207,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const publicKey = await getPublicKey(secretSync, gatewayService, token); @@ -264,7 +264,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); diff --git a/backend/src/services/secret-sync/render/render-sync-enums.ts b/backend/src/services/secret-sync/render/render-sync-enums.ts index dc0af3b91..098f90de0 100644 --- a/backend/src/services/secret-sync/render/render-sync-enums.ts +++ b/backend/src/services/secret-sync/render/render-sync-enums.ts @@ -1,5 +1,6 @@ export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 71347f998..4ab67bdd9 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ -import { isAxiosError } from "axios"; +import { AxiosRequestConfig, isAxiosError } from "axios"; import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { RenderSyncScope } from "./render-sync-enums"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; const MAX_RETRIES = 5; @@ -27,6 +29,80 @@ const makeRequestWithRetry = async (requestFn: () => Promise, attempt = 0) } }; +async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) { + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "GET", + headers: { + Authorization: `Bearer ${input.token}`, + Accept: "application/json" + } + }; + + switch (input.destination.scope) { + case RenderSyncScope.Service: { + req.url = `/services/${input.destination.serviceId}/env-vars`; + + const allSecrets: TRenderSecret[] = []; + let cursor: string | undefined; + + do { + // eslint-disable-next-line @typescript-eslint/no-loop-func + const { data } = await makeRequestWithRetry(() => + request.request< + { + envVar: { + key: string; + value: string; + }; + cursor: string; + }[] + >({ + ...req, + params: { + cursor + } + }) + ); + + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); + + allSecrets.push(...secrets); + + if (data.length > 0 && data[data.length - 1]?.cursor) { + cursor = data[data.length - 1].cursor; + } else { + cursor = undefined; + } + } while (cursor); + + return allSecrets; + } + case RenderSyncScope.EnvironmentGroup: { + req.url = `/env-groups/${input.destination.environmentGroupId}`; + + const res = await makeRequestWithRetry(() => + request.request<{ + envVars: { + key: string; + value: string; + }[]; + }>(req) + ); + + return res.data.envVars.map((item) => ({ + key: item.key, + value: item.value + })); + } + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +} + const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise => { const { destinationConfig, @@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential } } = secretSync; - const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; - const allSecrets: TRenderSecret[] = []; - let cursor: string | undefined; + const secrets = await getSecrets({ + destination: destinationConfig, + token: apiKey + }); - do { - const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; - - const { data } = await makeRequestWithRetry(() => - request.get< - { - envVar: { - key: string; - value: string; - }; - cursor: string; - }[] - >(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } - }) - ); - - const secrets = data.map((item) => ({ - key: item.envVar.key, - value: item.envVar.value - })); - - allSecrets.push(...secrets); - - if (data.length > 0 && data[data.length - 1]?.cursor) { - cursor = data[data.length - 1].cursor; - } else { - cursor = undefined; - } - } while (cursor); - - return allSecrets; + return secrets; }; const batchUpdateEnvironmentSecrets = async ( @@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async ( } } = secretSync; - await makeRequestWithRetry(() => - request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "PUT", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars`, + data: envVars + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + for await (const variable of envVars) { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`, + data: { + value: variable.value + } + }) + ); } - }) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +}; + +const deleteEnvironmentSecret = async ( + secretSync: TRenderSyncWithCredentials, + envVar: { key: string; value: string } +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "DELETE", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}` + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}` + }) + ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; const redeployService = async (secretSync: TRenderSyncWithCredentials) => { @@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => { } } = secretSync; - await makeRequestWithRetry(() => - request.post( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, - {}, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + method: "POST", + url: `/services/${destinationConfig.serviceId}/deploys`, + data: {} + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + const { data } = await request.request<{ serviceLinks: { id: string }[] }>({ + ...req, + method: "GET", + url: `/env-groups/${destinationConfig.environmentGroupId}` + }); + + for await (const link of data.serviceLinks) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${link.id}/deploys`, + data: {} + }) + ); } - ) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; export const RenderSyncFns = { @@ -169,14 +321,15 @@ export const RenderSyncFns = { const finalEnvVars: Array<{ key: string; value: string }> = []; for (const renderSecret of renderSecrets) { - if (!(renderSecret.key in secretMap)) { + if (renderSecret.key in secretMap) { finalEnvVars.push({ key: renderSecret.key, value: renderSecret.value }); } } - await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); + + await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el))); if (secretSync.syncOptions.autoRedeployServices) { await redeployService(secretSync); diff --git a/backend/src/services/secret-sync/render/render-sync-schemas.ts b/backend/src/services/secret-sync/render/render-sync-schemas.ts index 0d6e93987..0bc116255 100644 --- a/backend/src/services/secret-sync/render/render-sync-schemas.ts +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId), type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), + environmentGroupId: z + .string() + .min(1, "Environment Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId), + type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) }) ]); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index c2a72f2e6..8d9c6958a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { throw new BadRequestError({ message: "Missing personal user id" }); } void bd.orWhere({ - key: el.key, - type: el.type, - userId: el.type === SecretType.Personal ? el.userId : null + [`${TableName.SecretV2}.key` as "key"]: el.key, + [`${TableName.SecretV2}.type` as "type"]: el.type, + [`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null }); }); }) @@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select( + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); - return secrets.map((secret) => ({ - ...secret, - isRotatedSecret: Boolean(secret.rotationId) - })); + + const docs = sqlNestRelationships({ + data: secrets, + key: "id", + parentMapper: (secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + }, + { + key: "metadataId", + label: "secretMetadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return docs; } catch (error) { throw new DatabaseError({ error, name: "find by secret keys" }); } diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 101fd9b17..b23cd0c56 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -1074,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({ currentPath: path }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f776b4527..6bafa3ba6 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -637,7 +637,12 @@ export const secretServiceFactory = ({ } }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { @@ -647,7 +652,12 @@ export const secretServiceFactory = ({ }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 30bf750c3..b0d7be0fe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -9,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { AuthMethod } from "../auth/auth-type"; +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; @@ -37,6 +39,7 @@ type TUserServiceFactoryDep = { projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; @@ -48,22 +51,38 @@ export const userServiceFactory = ({ groupProjectDAL, tokenService, smtpService, - permissionService + permissionService, + userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (username: string) => { + const sendEmailVerificationCode = async (token: string) => { + const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as { + authType: string; + aliasId?: string; + username: string; + authTokenType: AuthTokenType; + }; + if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" }); + // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + let { isEmailVerified } = user; + if (aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` }); + isEmailVerified = userAlias.isEmailVerified; + } if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); - if (user.isEmailVerified) + if (isEmailVerified) throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); - const token = await tokenService.createTokenForUser({ + const userToken = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId }); await smtpService.sendMail({ @@ -71,7 +90,7 @@ export const userServiceFactory = ({ subjectLine: "Infisical confirmation code", recipients: [user.email], substitutions: { - code: token + code: userToken } }); }; @@ -95,15 +114,21 @@ export const userServiceFactory = ({ if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); - if (user.isEmailVerified) - throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); - await tokenService.validateTokenForUser({ + const token = await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, userId: user.id, code }); + if (token?.aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` }); + if (userAlias?.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true }); + } const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; await userDAL.updateById(user.id, { diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index cc0add8d8..cf2643db8 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -25,6 +25,11 @@ This functionality works in the following way: {/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) + + Optionally, approvers can edit the duration of an access request to reduce how long access will be granted by clicking the **Edit** icon next to the duration. + ![Edit Access Request](/images/platform/access-controls/edit-access-request.png) + + If the access request matches with a policy that allows break-glass approval bypasses, the requester may bypass the policy and get access to the resource diff --git a/docs/images/platform/access-controls/edit-access-request.png b/docs/images/platform/access-controls/edit-access-request.png new file mode 100644 index 000000000..c5db2f5f9 Binary files /dev/null and b/docs/images/platform/access-controls/edit-access-request.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-destination.png b/docs/images/secret-syncs/checkly/checkly-sync-destination.png index bb3df8d94..522418a3e 100644 Binary files a/docs/images/secret-syncs/checkly/checkly-sync-destination.png and b/docs/images/secret-syncs/checkly/checkly-sync-destination.png differ diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 321dbec6e..85f63079f 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -27,22 +27,73 @@ $ ansible-galaxy collection install infisical.vault The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: ```bash -$ pip install infisical-python +$ pip install infisicalsdk ``` ## Using this collection You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword: +### Authentication -```bash +The Infisical Ansible Collection supports [Universal Auth](/documentation/platform/identities/universal-auth) and [OIDC](/documentation/platform/identities/oidc-auth/general) for authenticating against Infisical. + + + + + Using Universal Auth for authentication is the most straight-forward way to get started with using the Ansible collection. + + To use Universal Auth, you need to provide the Client ID and Client Secret of your Infisical Machine Identity. + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="universal-auth", universal_auth_client_id='', universal_auth_client_secret='' ...rest) + ``` + + You can also provide the `auth_method`, `universal_auth_client_id`, and `universal_auth_client_secret` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | ------------------------------ | ---------------------------------------- | + | `auth_method` | `INFISICAL_AUTH_METHOD` | + | `universal_auth_client_id` | `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` | + | `universal_auth_client_secret` | `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` | + + + + To use OIDC Auth, you'll need to provide the ID of your machine identity, and the OIDC JWT to be used for authentication. + + + Please note that in order to use OIDC Auth, you must have `1.0.10` or newer of the `infisicalsdk` package installed. + + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="oidc-auth", identity_id='', jwt='' ...rest) + ``` + You can also provide the `auth_method`, `identity_id`, and `jwt` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | --------------- | ------------------------- | + | auth_method | `INFISICAL_AUTH_METHOD` | + | identity_id | `INFISICAL_IDENTITY_ID` | + | jwt | `INFISICAL_JWT` | + + + + +### Examples + +```yaml --- vars: read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] + + read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + # { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1 + + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" - # [{ "key": "HOST", "value": "google.com" }] + # { "key": "HOST", "value": "google.com" } ``` diff --git a/docs/integrations/platforms/docker.mdx b/docs/integrations/platforms/docker.mdx index 8e429461a..7b46e8495 100644 --- a/docs/integrations/platforms/docker.mdx +++ b/docs/integrations/platforms/docker.mdx @@ -6,32 +6,10 @@ description: "Learn how to use Infisical to inject environment variables into a This approach allows you to inject secrets from Infisical directly into your application. This is achieved by installing the Infisical CLI into your docker image and modifying your start command to execute with Infisical. -## Add the Infisical CLI to your Dockerfile +## Install the Infisical CLI to your Dockerfile - - - ```dockerfile - RUN apk add --no-cache bash curl && curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \ - && apk add infisical - ``` +To install the CLI, follow the instructions for your chosen distribution [here](/cli/overview). - - - ```dockerfile - RUN curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sh \ - && yum install -y infisical - ``` - - - ```dockerfile - RUN apt-get update && apt-get install -y bash curl && curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical - ``` - - #### We recommend you to set the version of the CLI to a specific version. This will help keep your CLI version consistent across reinstalls. [View versions](https://cloudsmith.io/~infisical/repos/infisical-cli/packages/) diff --git a/docs/integrations/secret-syncs/checkly.mdx b/docs/integrations/secret-syncs/checkly.mdx index 4599fe634..07382fb35 100644 --- a/docs/integrations/secret-syncs/checkly.mdx +++ b/docs/integrations/secret-syncs/checkly.mdx @@ -37,6 +37,7 @@ description: "Learn how to configure a Checkly Sync for Infisical." - **Checkly Connection**: The Checkly Connection to authenticate with. - **Account**: The Checkly account to sync secrets to. + - **Group**: The Checkly check group to sync secrets to (Optional). Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. diff --git a/docs/integrations/secret-syncs/render.mdx b/docs/integrations/secret-syncs/render.mdx index 341d84ad5..c81048668 100644 --- a/docs/integrations/secret-syncs/render.mdx +++ b/docs/integrations/secret-syncs/render.mdx @@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical." ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) - **Render Connection**: The Render Connection to authenticate with. - - **Scope**: Select **Service**. - - **Service**: Choose the Render service you want to sync secrets to. + - **Scope**: Select **Service** or **Environment Group**. + - **Service**: Choose the Render service you want to sync secrets to. + - **Environment Group**: Choose the Render environment group you want to sync secrets to. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. ![Configure Options](/images/secret-syncs/render/render-sync-options.png) diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx index 50c645ee2..c55313898 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx @@ -5,14 +5,15 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl } from "@app/components/v2"; import { TChecklyAccount, - useChecklyConnectionListAccounts + useChecklyConnectionListAccounts, + useChecklyConnectionListGroups } from "@app/hooks/api/appConnections/checkly"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; export const ChecklySyncFields = () => { - const { control, setValue } = useFormContext< + const { control, setValue, watch } = useFormContext< TSecretSyncForm & { destination: SecretSync.Checkly } >(); @@ -25,12 +26,24 @@ export const ChecklySyncFields = () => { } ); + const accountId = watch("destinationConfig.accountId"); + + const { data: groups = [], isPending: isGroupsLoading } = useChecklyConnectionListGroups( + connectionId, + accountId, + { + enabled: Boolean(connectionId && accountId) + } + ); + return ( <> { setValue("destinationConfig.accountId", ""); setValue("destinationConfig.accountName", ""); + setValue("destinationConfig.groupId", undefined); + setValue("destinationConfig.groupName", undefined); }} /> { )} /> + + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.groupName", v?.name ?? undefined); + }} + options={groups} + placeholder="Select a group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx index 3d3f8df93..b88e0d6e6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx @@ -5,7 +5,9 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs"; import { + TRenderEnvironmentGroup, TRenderService, + useRenderConnectionListEnvironmentGroups, useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; import { SecretSync } from "@app/hooks/api/secretSyncs"; @@ -19,6 +21,7 @@ export const RenderSyncFields = () => { >(); const connectionId = useWatch({ name: "connection.id", control }); + const selectedScope = useWatch({ name: "destinationConfig.scope", control }); const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices( connectionId, @@ -27,11 +30,17 @@ export const RenderSyncFields = () => { } ); + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(connectionId, { + enabled: Boolean(connectionId) && selectedScope === RenderSyncScope.EnvironmentGroup + }); + return ( <> { setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.environmentGroupId", ""); setValue("destinationConfig.type", RenderSyncType.Env); setValue("destinationConfig.scope", RenderSyncScope.Service); }} @@ -83,30 +92,67 @@ export const RenderSyncFields = () => { )} /> - ( - - service.id === value) ?? []) : []} - onChange={(option) => { - onChange((option as SingleValue)?.id ?? null); - setValue( - "destinationConfig.serviceName", - (option as SingleValue)?.name ?? "" - ); - }} - options={services} - placeholder="Select a service..." - getOptionLabel={(option) => option.name} - getOptionValue={(option) => option.id.toString()} - /> - - )} - /> + {selectedScope === RenderSyncScope.Service && ( + ( + + service.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.serviceName", + (option as SingleValue)?.name ?? "" + ); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + + {selectedScope === RenderSyncScope.EnvironmentGroup && ( + ( + + g.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.environmentGroupName", + (option as SingleValue)?.name ?? "" + ); + }} + options={groups} + placeholder="Select an environment group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx index d520699ce..0f86a0c06 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx @@ -6,7 +6,16 @@ import { SecretSync } from "@app/hooks/api/secretSyncs"; export const ChecklySyncReviewFields = () => { const { watch } = useFormContext(); - const accountName = watch("destinationConfig.accountName"); + const config = watch("destinationConfig"); - return {accountName}; + return ( + <> + + {config.accountName ?? config.accountId} + + {config.groupId && ( + {config.groupName ?? config.groupId} + )} + + ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx index 15f5b6625..f195f0e72 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -4,6 +4,7 @@ import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { Badge } from "@app/components/v2"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; export const RenderSyncOptionsReviewFields = () => { const { watch } = useFormContext(); @@ -27,13 +28,20 @@ export const RenderSyncOptionsReviewFields = () => { export const RenderSyncReviewFields = () => { const { watch } = useFormContext(); - const serviceName = watch("destinationConfig.serviceName"); - const scope = watch("destinationConfig.scope"); + const config = watch("destinationConfig"); return ( <> - {scope} - {serviceName} + {config.scope} + {config.scope === RenderSyncScope.Service ? ( + + {config.serviceName ?? config.serviceId} + + ) : ( + + {config.environmentGroupName ?? config.environmentGroupId} + + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts index 9ada6e276..7684df981 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts @@ -8,7 +8,15 @@ export const ChecklySyncDestinationSchema = BaseSecretSyncSchema().merge( destination: z.literal(SecretSync.Checkly), destinationConfig: z.object({ accountId: z.string(), - accountName: z.string() + accountName: z.string(), + groupId: z + .string() + .nullish() + .transform((val) => val || undefined), + groupName: z + .string() + .nullish() + .transform((val) => val || undefined) }) }) ); diff --git a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts index 83e5347eb..b65705c14 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts @@ -17,6 +17,12 @@ export const RenderSyncDestinationSchema = BaseSecretSyncSchema( serviceId: z.string().trim().min(1, "Service is required"), serviceName: z.string().trim().optional(), type: z.nativeEnum(RenderSyncType) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup), + environmentGroupId: z.string().trim().min(1, "Environment Group ID is required"), + environmentGroupName: z.string().trim().optional(), + type: z.nativeEnum(RenderSyncType) }) ]) }) diff --git a/frontend/src/components/v2/DatePicker/DatePicker.tsx b/frontend/src/components/v2/DatePicker/DatePicker.tsx index 115b0ad3e..9611e957f 100644 --- a/frontend/src/components/v2/DatePicker/DatePicker.tsx +++ b/frontend/src/components/v2/DatePicker/DatePicker.tsx @@ -98,7 +98,7 @@ export const DatePicker = ({ > {value ? formatDateTime({ timestamp: value, timezone, dateFormat }) - : "Pick a date and time"} + : `Select Date${hideTime ? "" : " and Time"}`} diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 8da61583e..6ce82cfb2 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -212,5 +212,9 @@ export const RENDER_SYNC_SCOPES: Record[]; bypassers?: Bypasser[]; approvals?: number; secretPath: string; @@ -190,7 +193,7 @@ export type TCreateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; environments?: string[]; diff --git a/frontend/src/hooks/api/appConnections/checkly/queries.tsx b/frontend/src/hooks/api/appConnections/checkly/queries.tsx index 452c21245..95156aaa4 100644 --- a/frontend/src/hooks/api/appConnections/checkly/queries.tsx +++ b/frontend/src/hooks/api/appConnections/checkly/queries.tsx @@ -8,7 +8,9 @@ import { TChecklyAccount } from "./types"; const checklyConnectionKeys = { all: [...appConnectionKeys.all, "checkly"] as const, listAccounts: (connectionId: string) => - [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const + [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const, + listGroups: (connectionId: string, accountId: string) => + [...checklyConnectionKeys.all, "groups", connectionId, accountId] as const }; export const useChecklyConnectionListAccounts = ( @@ -35,3 +37,29 @@ export const useChecklyConnectionListAccounts = ( ...options }); }; + +export const useChecklyConnectionListGroups = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TChecklyAccount[], + unknown, + TChecklyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: checklyConnectionKeys.listGroups(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ groups: TChecklyAccount[] }>( + `/api/v1/app-connections/checkly/${connectionId}/accounts/${accountId}/groups` + ); + + return data.groups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/render/queries.tsx b/frontend/src/hooks/api/appConnections/render/queries.tsx index 728c46bd9..93ec24738 100644 --- a/frontend/src/hooks/api/appConnections/render/queries.tsx +++ b/frontend/src/hooks/api/appConnections/render/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TRenderService } from "./types"; +import { TRenderEnvironmentGroup, TRenderService } from "./types"; const renderConnectionKeys = { all: [...appConnectionKeys.all, "render"] as const, listServices: (connectionId: string) => - [...renderConnectionKeys.all, "services", connectionId] as const + [...renderConnectionKeys.all, "services", connectionId] as const, + listEnvironmentGroups: (connectionId: string) => + [...renderConnectionKeys.all, "environment-groups", connectionId] as const }; export const useRenderConnectionListServices = ( @@ -35,3 +37,28 @@ export const useRenderConnectionListServices = ( ...options }); }; + +export const useRenderConnectionListEnvironmentGroups = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRenderEnvironmentGroup[], + unknown, + TRenderEnvironmentGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: renderConnectionKeys.listEnvironmentGroups(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/render/${connectionId}/environment-groups` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/render/types.ts b/frontend/src/hooks/api/appConnections/render/types.ts index ec51adb78..d422a7ae5 100644 --- a/frontend/src/hooks/api/appConnections/render/types.ts +++ b/frontend/src/hooks/api/appConnections/render/types.ts @@ -2,3 +2,8 @@ export type TRenderService = { id: string; name: string; }; + +export type TRenderEnvironmentGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index cd620bb64..15e1b861c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -104,6 +104,7 @@ export const useUpdateOrg = () => { mutationFn: ({ name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, orgId, @@ -125,6 +126,7 @@ export const useUpdateOrg = () => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, defaultMembershipRoleSlug, diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 73267f0f6..71f2a8b90 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,6 +9,7 @@ export type Organization = { createAt: string; updatedAt: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; bypassOrgAuthEnabled: boolean; orgAuthMethod: string; scimEnabled: boolean; @@ -34,6 +35,7 @@ export type UpdateOrgDTO = { orgId: string; name?: string; authEnforced?: boolean; + googleSsoAuthEnforced?: boolean; scimEnabled?: boolean; slug?: string; defaultMembershipRoleSlug?: string; diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 8fd86624d..1785ffa8a 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -20,6 +20,7 @@ export enum ApproverType { } export type Approver = { + isOrgMembershipActive: boolean; id: string; type: ApproverType; }; @@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = { name?: string; environments: string[]; secretPath: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; approvals?: number; enforcementLevel: EnforcementLevel; @@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; approvals?: number; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index caf1df320..0eef3fa3d 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -53,6 +53,7 @@ export type TSecretApprovalRequest = { firstName: string; lastName: string; username: string; + isOrgMembershipActive: boolean; }[]; workspace: string; environment: string; @@ -62,6 +63,7 @@ export type TSecretApprovalRequest = { status: "open" | "close"; policy: Omit & { approvers: { + isOrgMembershipActive: boolean; userId: string; email: string; firstName: string; diff --git a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts index 40718d261..10a8601de 100644 --- a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts @@ -3,11 +3,17 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; +export enum ChecklySyncScope { + Global = "global", + Group = "group" +} export type TChecklySync = TRootSecretSync & { destination: SecretSync.Checkly; destinationConfig: { accountId: string; accountName: string; + groupId?: string; + groupName?: string; }; connection: { app: AppConnection.Checkly; diff --git a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts index 3d66de623..4d217e840 100644 --- a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts @@ -4,12 +4,19 @@ import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/typ export type TRenderSync = TRootSecretSync & { destination: SecretSync.Render; - destinationConfig: { - scope: RenderSyncScope.Service; - type: RenderSyncType; - serviceId: string; - serviceName?: string; - }; + destinationConfig: + | { + type: RenderSyncType; + scope: RenderSyncScope.Service; + serviceId: string; + serviceName?: string | undefined; + } + | { + type: RenderSyncType; + scope: RenderSyncScope.EnvironmentGroup; + environmentGroupId: string; + environmentGroupName?: string | undefined; + }; connection: { app: AppConnection.Render; @@ -23,7 +30,8 @@ export type TRenderSync = TRootSecretSync & { }; export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 4ded71cfe..338599fe0 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -48,6 +48,7 @@ export type SubscriptionPlan = { externalKms: boolean; pkiEst: boolean; enforceMfa: boolean; + enforceGoogleSSO: boolean; projectTemplates: boolean; kmip: boolean; secretScanning: boolean; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 84bd05e07..b108a98c4 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -26,16 +26,16 @@ export const useAddUserToWsNonE2EE = () => { }); }; -export const sendEmailVerificationCode = async (username: string) => { +export const sendEmailVerificationCode = async (token: string) => { return apiRequest.post("/api/v2/users/me/emails/code", { - username + token }); }; export const useSendEmailVerificationCode = () => { return useMutation({ - mutationFn: async (username: string) => { - await sendEmailVerificationCode(username); + mutationFn: async (token: string) => { + await sendEmailVerificationCode(token); return {}; } }); diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 3af283b7e..0a43e850e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -83,6 +83,7 @@ export type TProjectMembership = { export type TWorkspaceUser = { id: string; user: { + isOrgMembershipActive: boolean; email: string; username: string; firstName: string; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..5c37d4404 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -240,6 +240,13 @@ export const Navbar = () => { return; } + if (org.googleSsoAuthEnforced) { + await logout.mutateAsync(); + window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); + window.close(); + return; + } + handleOrgChange(org?.id); }} variant="plain" diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index f331e4bda..9e6850f82 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -82,25 +82,40 @@ export const SelectOrganizationSection = () => { } } - if (organization.authEnforced && !canBypassOrgAuth) { + if ((organization.authEnforced || organization.googleSsoAuthEnforced) && !canBypassOrgAuth) { + const authToken = jwtDecode(getAuthToken()) as { authMethod: AuthMethod }; + // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO - await logout.mutateAsync(); let url = ""; if (organization.orgAuthMethod === AuthMethod.OIDC) { url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ callbackPort ? `&callbackPort=${callbackPort}` : "" }`; - } else { + } else if (organization.orgAuthMethod === AuthMethod.SAML) { url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; if (callbackPort) { url += `?callback_port=${callbackPort}`; } + } else if ( + organization.googleSsoAuthEnforced && + authToken.authMethod !== AuthMethod.GOOGLE + ) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } } - window.location.href = url; - return; + // we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO + // see line 103-106 + if (url) { + await logout.mutateAsync(); + window.location.href = url; + return; + } } const { token, isMfaEnabled, mfaMethod } = await selectOrg diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx index a200d3f7c..983b1ec61 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({ const resendCode = async () => { try { - await sendEmailVerificationCode(username); + const queryParams = new URLSearchParams(window.location.search); + const token = queryParams.get("token"); + if (!token) { + createNotification({ + text: "Failed to resend code, no token found", + type: "error" + }); + return; + } + await sendEmailVerificationCode(token); createNotification({ text: "Successfully resent code", type: "success" diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index f1f667fa4..2e4b43cbe 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -11,7 +11,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { AllProjectView } from "./components/AllProjectView"; import { MyProjectView } from "./components/MyProjectView"; -import { ProjectListToggle, ProjectListView } from "./components/ProjectListToggle"; +import { ProjectListView } from "./components/ProjectListToggle"; // const formatDescription = (type: ProjectType) => { // if (type === ProjectType.SecretManager) @@ -28,7 +28,23 @@ import { ProjectListToggle, ProjectListView } from "./components/ProjectListTogg export const ProjectsPage = () => { const { t } = useTranslation(); - const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects); + const [projectListView, setProjectListView] = useState(() => { + const storedView = localStorage.getItem("projectListView"); + + if ( + storedView && + (storedView === ProjectListView.AllProjects || storedView === ProjectListView.MyProjects) + ) { + return storedView; + } + + return ProjectListView.MyProjects; + }); + + const handleSetProjectListView = (value: ProjectListView) => { + localStorage.setItem("projectListView", value); + setProjectListView(value); + }; const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "addNewWs", @@ -49,11 +65,7 @@ export const ProjectsPage = () => {
- -
- } + title="Projects" description="Your team's complete security toolkit - organized and ready when you need them." /> @@ -62,12 +74,16 @@ export const ProjectsPage = () => { onAddNewProject={() => handlePopUpOpen("addNewWs")} onUpgradePlan={() => handlePopUpOpen("upgradePlan")} isAddingProjectsAllowed={isAddingProjectsAllowed} + projectListView={projectListView} + onProjectListViewChange={handleSetProjectListView} /> ) : ( handlePopUpOpen("addNewWs")} onUpgradePlan={() => handlePopUpOpen("upgradePlan")} isAddingProjectsAllowed={isAddingProjectsAllowed} + projectListView={projectListView} + onProjectListViewChange={handleSetProjectListView} /> )} void; onUpgradePlan: () => void; isAddingProjectsAllowed: boolean; + projectListView: ProjectListView; + onProjectListViewChange: (value: ProjectListView) => void; }; type RequestAccessModalProps = { @@ -106,7 +112,9 @@ const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProp export const AllProjectView = ({ onAddNewProject, onUpgradePlan, - isAddingProjectsAllowed + isAddingProjectsAllowed, + projectListView, + onProjectListViewChange }: Props) => { const navigate = useNavigate(); const [searchFilter, setSearchFilter] = useState(""); @@ -176,10 +184,10 @@ export const AllProjectView = ({ return (
-
+ setSearchFilter(e.target.value)} @@ -242,7 +250,7 @@ export const AllProjectView = ({ ))} -
+
void; onUpgradePlan: () => void; isAddingProjectsAllowed: boolean; + projectListView: ProjectListView; + onProjectListViewChange: (value: ProjectListView) => void; }; enum ProjectOrderBy { @@ -63,7 +69,9 @@ enum ProjectsViewMode { export const MyProjectView = ({ onAddNewProject, onUpgradePlan, - isAddingProjectsAllowed + isAddingProjectsAllowed, + projectListView, + onProjectListViewChange }: Props) => { const navigate = useNavigate(); const { currentOrg } = useOrganization(); @@ -371,10 +379,10 @@ export const MyProjectView = ({ return (
-
+ setSearchFilter(e.target.value)} @@ -441,7 +449,7 @@ export const MyProjectView = ({ ))} -
+
{ diff --git a/frontend/src/pages/organization/ProjectsPage/components/ProjectListToggle.tsx b/frontend/src/pages/organization/ProjectsPage/components/ProjectListToggle.tsx index 55da8e0ba..9d17891d3 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/ProjectListToggle.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/ProjectListToggle.tsx @@ -1,7 +1,4 @@ -import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Select, SelectItem } from "@app/components/v2"; +import { Button } from "@app/components/v2"; export enum ProjectListView { MyProjects = "my-projects", @@ -14,28 +11,32 @@ type Props = { }; export const ProjectListToggle = ({ value, onChange }: Props) => { - const getDisplayText = (listView: ProjectListView) => { - return listView === ProjectListView.MyProjects ? "My Projects" : "All Projects"; - }; - return ( -
-

- {getDisplayText(value)} -

- - + My Projects + +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index ac8685192..53df91974 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -1,5 +1,6 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -14,7 +15,21 @@ import { import { useLogoutUser, useUpdateOrg } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -export const OrgGeneralAuthSection = () => { +enum EnforceAuthType { + SAML = "saml", + GOOGLE = "google", + OIDC = "oidc" +} + +export const OrgGeneralAuthSection = ({ + isSamlConfigured, + isOidcConfigured, + isGoogleConfigured +}: { + isSamlConfigured: boolean; + isOidcConfigured: boolean; + isGoogleConfigured: boolean; +}) => { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); @@ -23,27 +38,61 @@ export const OrgGeneralAuthSection = () => { const logout = useLogoutUser(); - const handleEnforceOrgAuthToggle = async (value: boolean) => { + const handleEnforceOrgAuthToggle = async (value: boolean, type: EnforceAuthType) => { try { if (!currentOrg?.id) return; - if (!subscription?.samlSSO) { - handlePopUpOpen("upgradePlan"); - return; + + if (type === EnforceAuthType.SAML) { + if (!subscription?.samlSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else if (type === EnforceAuthType.GOOGLE) { + if (!subscription?.enforceGoogleSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + googleSsoAuthEnforced: value + }); + } else if (type === EnforceAuthType.OIDC) { + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else { + createNotification({ + text: `Invalid auth enforcement type ${type}`, + type: "error" + }); } - await mutateAsync({ - orgId: currentOrg?.id, - authEnforced: value - }); - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, + text: `Successfully ${value ? "enabled" : "disabled"} org-level auth`, type: "success" }); if (value) { await logout.mutateAsync(); - window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + + if (type === EnforceAuthType.SAML) { + window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + } else if (type === EnforceAuthType.GOOGLE) { + window.open(`/api/v1/sso/redirect/google?org_slug=${currentOrg.slug}`); + } + window.close(); } } catch (err) { @@ -78,45 +127,91 @@ export const OrgGeneralAuthSection = () => { }; return ( - <> - {/*
-
-

Allow users to send invites

- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

Allow members to invite new users to this organization

-
*/} -
-
-
- Enforce SAML SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via SAML to access this organization +

+
+

SSO Enforcement

+

+ Manage strict enforcement of specific authentication methods for your organization.

- {currentOrg?.authEnforced && ( -
+
+
+
+
+ Enforce SAML SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.SAML) + } + isChecked={currentOrg?.authEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.googleSsoAuthEnforced} + /> + )} + +
+

+ Enforce users to authenticate via SAML to access this organization. +
+ When this is enabled your organization members will only be able to login with SAML. +

+
+ +
+
+
+ Enforce OIDC SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.OIDC) + } + isDisabled={!isAllowed} + /> + )} + +
+

+ Enforce users to authenticate via OIDC to access this organization. +
+ When this is enabled your organization members will only be able to login with OIDC. +

+
+ +
+
+
+ Enforce Google SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE) + } + isChecked={currentOrg?.googleSsoAuthEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.authEnforced} + /> + )} + +
+

+ Enforce users to authenticate via Google to access this organization. +
+ When this is enabled your organization members will only be able to login with Google. +

+
+
+ {(currentOrg?.authEnforced || currentOrg?.googleSsoAuthEnforced) && ( +
Enable Admin SSO Bypass @@ -125,8 +220,8 @@ export const OrgGeneralAuthSection = () => { content={
- When this is enabled, we strongly recommend enforcing MFA at the organization - level. + When enabling admin SSO bypass, we highly recommend enabling MFA enforcement + at the organization-level for security reasons.

In case of a lockout, admins can use the{" "} @@ -182,6 +277,6 @@ export const OrgGeneralAuthSection = () => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can enforce SAML SSO if you switch to Infisical's Pro plan." /> - +

); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index 9c7293210..e66987bae 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -95,43 +95,25 @@ export const OrgLDAPSection = (): JSX.Element => { }; return ( -
+
-
-

LDAP

-
- - {(isAllowed) => ( - - )} - +
+
+

LDAP

+

Manage LDAP authentication configuration

-
-

Manage LDAP authentication configuration

-
-
-
-

LDAP Group Mappings

{(isAllowed) => ( - )}
-

- Manage how LDAP groups are mapped to internal groups in Infisical -

+ {data && ( -
+

Enable LDAP

@@ -152,6 +134,27 @@ export const OrgLDAPSection = (): JSX.Element => {

)} + +
+
+

LDAP Group Mappings

+ + {(isAllowed) => ( + + )} + +
+

+ Manage how LDAP groups are mapped to internal groups in Infisical +

+
+ { const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); - const { mutateAsync: updateOrg } = useUpdateOrg(); - const logout = useLogoutUser(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addOIDC", "upgradePlan" @@ -54,56 +52,6 @@ export const OrgOIDCSection = (): JSX.Element => { } }; - const handleEnforceOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - authEnforced: value - }); - - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, - type: "success" - }); - - if (value) { - await logout.mutateAsync(); - window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`); - window.close(); - } - } catch (err) { - console.error(err); - } - }; - - const handleEnableBypassOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - bypassOrgAuthEnabled: value - }); - - createNotification({ - text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, - type: "success" - }); - } catch (err) { - console.error(err); - } - }; - const handleOIDCGroupManagement = async (value: boolean) => { try { if (!currentOrg?.id) return; @@ -136,25 +84,22 @@ export const OrgOIDCSection = (): JSX.Element => { }; return ( -
-
-
-

OIDC

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

OIDC

+

Manage OIDC authentication configuration

-

Manage OIDC authentication configuration

+ + {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
{data && (
@@ -178,88 +123,6 @@ export const OrgOIDCSection = (): JSX.Element => {

)} -
-
-
- Enforce OIDC SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via OIDC to access this organization. -

-
- {currentOrg?.authEnforced && ( -
-
-
- Enable Admin SSO Bypass - - - When this is enabled, we strongly recommend enforcing MFA at the organization - level. - -

- In case of a lockout, admins can use the{" "} - - Admin Login Portal - {" "} - at{" "} - - {window.location.origin}/login/admin - -

-
- } - > - - -
- - {(isAllowed) => ( - handleEnableBypassOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- - Allow organization admins to bypass OIDC enforcement when SSO is unavailable, - misconfigured, or inaccessible. - -

-
- )}
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 3ffd8b3c2..33843f50f 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -79,25 +79,24 @@ export const OrgSSOSection = (): JSX.Element => { }; return ( - <> -
-
-
-

SAML

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

SAML

+

Manage SAML authentication configuration

-

Manage SAML authentication configuration

+ {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
-
-
+
+

Enable SAML

{!isPending && ( @@ -126,6 +125,6 @@ export const OrgSSOSection = (): JSX.Element => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can use SAML SSO if you switch to Infisical's Pro plan." /> - +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx index 9b11c1302..9964fdf4d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -49,13 +49,19 @@ export const OrgSsoTab = withPermission( ); const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig; - const shouldDisplaySection = (method: LoginMethod) => - !enabledLoginMethods || enabledLoginMethods.includes(method); + const shouldDisplaySection = (method: LoginMethod[] | LoginMethod) => { + if (Array.isArray(method)) { + return method.some((m) => !enabledLoginMethods || enabledLoginMethods.includes(m)); + } - const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer); + return !enabledLoginMethods || enabledLoginMethods.includes(method); + }; + + const isOidcConfigured = Boolean(oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer)); const isSamlConfigured = samlConfig && (samlConfig.entryPoint || samlConfig.issuer || samlConfig.cert); const isLdapConfigured = ldapConfig && ldapConfig.url; + const isGoogleConfigured = shouldDisplaySection(LoginMethod.GOOGLE); const shouldShowCreateIdentityProviderView = !isOidcConfigured && !isSamlConfigured && !isLdapConfigured; @@ -65,11 +71,14 @@ export const OrgSsoTab = withPermission( shouldDisplaySection(LoginMethod.OIDC) || shouldDisplaySection(LoginMethod.LDAP) ? ( <> -
-

Connect an Identity Provider

-

- Connect your identity provider to simplify user management -

+
+
+

Connect an Identity Provider

+

+ Connect your identity provider to simplify user management with options like SAML, + OIDC, and LDAP. +

+
{shouldDisplaySection(LoginMethod.SAML) && (
- {shouldShowCreateIdentityProviderView ? ( - createIdentityProviderView - ) : ( - <> - {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && ( -
- - +
+ {shouldDisplaySection([LoginMethod.SAML, LoginMethod.GOOGLE]) && ( + + )} + + {shouldShowCreateIdentityProviderView ? ( + createIdentityProviderView + ) : ( +
+
+ {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && } + {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } + {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && }
- )} - {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } - {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && } - - )} +
+ )} +
handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx index 00fcfe264..ab717f9b0 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx @@ -1,5 +1,8 @@ -import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; -import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; +import { + useRenderConnectionListEnvironmentGroups, + useRenderConnectionListServices +} from "@app/hooks/api/appConnections/render"; +import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; import { getSecretSyncDestinationColValues } from "../helpers"; import { SecretSyncTableCell } from "../SecretSyncTableCell"; @@ -9,21 +12,59 @@ type Props = { }; export const RenderSyncDestinationCol = ({ secretSync }: Props) => { + const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service; + const { data: services = [], isPending } = useRenderConnectionListServices( - secretSync.connectionId + secretSync.connectionId, + { + enabled: isServiceScope + } ); - const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ - ...secretSync, - destinationConfig: { - ...secretSync.destinationConfig, - serviceName: services.find((s) => s.id === secretSync.destinationConfig.serviceId)?.name + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope }); + + switch (secretSync.destinationConfig.scope) { + case RenderSyncScope.Service: { + const id = secretSync.destinationConfig.serviceId; + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + serviceName: services.find((s) => s.id === id)?.name + } + }); + + if (isPending) { + return ( + + ); + } + + return ; } - }); + case RenderSyncScope.EnvironmentGroup: { + const id = secretSync.destinationConfig.environmentGroupId; + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + environmentGroupName: groups.find((s) => s.id === id)?.name + } + }); - if (isPending) { - return ; + if (isGroupsPending) { + return ( + + ); + } + + return ; + } + default: + throw new Error("Unknown render sync destination scope"); } - - return ; }; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 9b88ba447..bb0246236 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -8,6 +8,7 @@ import { } from "@app/hooks/api/secretSyncs/types/github-sync"; import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; // This functional ensures parity across what is displayed in the destination column // and the values used when search filtering @@ -125,8 +126,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { secondaryText = destinationConfig.app; break; case SecretSync.Render: - primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; - secondaryText = "Service"; + if (destinationConfig.scope === RenderSyncScope.Service) { + primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; + secondaryText = "Service"; + } else { + primaryText = + destinationConfig.environmentGroupName ?? destinationConfig.environmentGroupId; + secondaryText = "Environment Group"; + } + break; case SecretSync.Flyio: primaryText = destinationConfig.appId; @@ -167,8 +175,8 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { secondaryText = "Railway Project"; break; case SecretSync.Checkly: - primaryText = destinationConfig.accountName; - secondaryText = "Checkly Account"; + primaryText = destinationConfig.accountName || destinationConfig.accountId; + secondaryText = destinationConfig.groupName || destinationConfig.groupId || "Checkly Account"; break; case SecretSync.Supabase: primaryText = destinationConfig.projectName; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index e87054f93..b1cd906bb 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -4,7 +4,9 @@ import { faCheck, faEdit, faHourglass, - faTriangleExclamation + faTriangleExclamation, + faUser, + faUserSlash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import ms from "ms"; @@ -37,7 +39,7 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; import { groupBy } from "@app/lib/fn/array"; import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal"; -const getReviewedStatusSymbol = (status?: ApprovalStatus) => { +const getReviewedStatusSymbol = (status?: ApprovalStatus, isOrgMembershipActive?: boolean) => { if (status === ApprovalStatus.APPROVED) return ( @@ -50,6 +52,17 @@ const getReviewedStatusSymbol = (status?: ApprovalStatus) => { ); + + if (!isOrgMembershipActive) { + return ( + // Can't do a tooltip here because nested tooltips doesn't work properly as of yet. + // TODO(daniel): Fix nested tooltips in the future. + + + + + ); + } return ( @@ -87,6 +100,7 @@ export const ReviewAccessRequestModal = ({ }) => { const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); const [bypassApproval, setBypassApproval] = useState(false); + const [bypassReason, setBypassReason] = useState(""); const { currentWorkspace } = useWorkspace(); const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || ""); @@ -192,6 +206,7 @@ export const ReviewAccessRequestModal = ({ (acc, curr) => { if (acc.length && acc[acc.length - 1].sequence === curr.sequence) { acc[acc.length - 1][curr.type]?.push(curr); + return acc; } @@ -203,6 +218,7 @@ export const ReviewAccessRequestModal = ({ ? { user: [curr], group: [], sequence, approvals } : { group: [curr], user: [], sequence, approvals } ); + return acc; }, [] as { @@ -216,7 +232,10 @@ export const ReviewAccessRequestModal = ({ const approvers = approversBySequence?.map((approverChain) => { const reviewers = request.policy.approvers .filter((el) => (el.sequence || 1) === approverChain.sequence) - .map((el) => ({ ...el, status: reviewesGroupById?.[el.userId]?.[0]?.status })); + .map((el) => ({ + ...el, + status: reviewesGroupById?.[el.userId]?.[0]?.status + })); const hasApproved = reviewers.filter((el) => el.status === "approved").length >= (approverChain?.approvals || 1); @@ -410,12 +429,39 @@ export const ReviewAccessRequestModal = ({
)}
- - {approver?.user - ?.map( - (el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username - ) - .join(", ")} + + {Boolean(approver.user.length) && ( +
+ {approver?.user?.map((el, idx) => { + const member = approverSequence?.membersGroupById?.[el.id]?.[0]; + if (!member) return null; + + return member.user.isOrgMembershipActive ? ( +
+ {member.user.username} + {idx < approver.user.length - 1 && ","} +
+ ) : ( +
+ + {member.user.username} + + +
+ + + Inactive + +
+
+
+
+ {idx < approver.user.length - 1 && ","} +
+ ); + })} +
+ )}
{approver?.group @@ -440,8 +486,18 @@ export const ReviewAccessRequestModal = ({ key={`reviewer-${idx + 1}`} className="flex items-center gap-2 px-2 py-2 text-sm" > -
{el.username}
- {getReviewedStatusSymbol(el?.status as ApprovalStatus)} +
+ {el.username} +
+ {getReviewedStatusSymbol( + el?.status as ApprovalStatus, + el.isOrgMembershipActive + )}
))}
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 30f77d2e1..856c16ee1 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -43,6 +43,8 @@ import { import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { TWorkspaceUser } from "@app/hooks/api/users/types"; +import { PolicyMemberOption } from "./PolicyMemberOption"; + type Props = { isOpen?: boolean; onToggle: (isOpen: boolean) => void; @@ -59,7 +61,11 @@ const formSchema = z secretPath: z.string().trim().min(1), approvals: z.number().min(1).default(1), userApprovers: z - .object({ type: z.literal(ApproverType.User), id: z.string() }) + .object({ + type: z.literal(ApproverType.User), + id: z.string(), + isOrgMembershipActive: z.boolean().optional() + }) .array() .default([]), groupApprovers: z @@ -67,7 +73,11 @@ const formSchema = z .array() .default([]), userBypassers: z - .object({ type: z.literal(BypasserType.User), id: z.string() }) + .object({ + type: z.literal(BypasserType.User), + id: z.string(), + isOrgMembershipActive: z.boolean().optional() + }) .array() .default([]), groupBypassers: z @@ -80,7 +90,11 @@ const formSchema = z sequenceApprovers: z .object({ user: z - .object({ type: z.literal(ApproverType.User), id: z.string() }) + .object({ + type: z.literal(ApproverType.User), + id: z.string(), + isOrgMembershipActive: z.boolean().optional() + }) .array() .default([]), group: z @@ -139,7 +153,11 @@ const Form = ({ userApprovers: editValues?.approvers ?.filter((approver) => approver.type === ApproverType.User) - .map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [], + .map(({ id, type, isOrgMembershipActive }) => ({ + id, + type: type as ApproverType.User, + isOrgMembershipActive + })) || [], groupApprovers: editValues?.approvers ?.filter((approver) => approver.type === ApproverType.Group) @@ -235,7 +253,9 @@ const Form = ({ ...data, approvers: sequenceApprovers?.flatMap((approvers, index) => approvers.user - .map((el) => ({ ...el, sequence: index + 1 }) as Approver) + .map( + (el) => ({ ...el, sequence: index + 1 }) as Omit + ) .concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 }))) ), approvalsRequired: sequenceApprovers?.map((el, index) => ({ @@ -291,7 +311,9 @@ const Form = ({ ...data, approvers: sequenceApprovers?.flatMap((approvers, index) => approvers.user - .map((el) => ({ ...el, sequence: index + 1 }) as Approver) + .map( + (el) => ({ ...el, sequence: index + 1 }) as Omit + ) .concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 }))) ), approvalsRequired: sequenceApprovers?.map((el, index) => ({ @@ -329,7 +351,8 @@ const Form = ({ () => members.map((member) => ({ id: member.user.id, - type: ApproverType.User + type: ApproverType.User, + isOrgMembershipActive: member.user.isOrgMembershipActive })), [members] ); @@ -347,7 +370,8 @@ const Form = ({ () => members.map((member) => ({ id: member.user.id, - type: BypasserType.User + type: BypasserType.User, + isOrgMembershipActive: member.user.isOrgMembershipActive })), [members] ); @@ -608,6 +632,7 @@ const Form = ({ isMulti placeholder="Select members..." options={memberOptions} + components={{ Option: PolicyMemberOption }} getOptionValue={(option) => option.id} getOptionLabel={(option) => { const member = members?.find((m) => m.user.id === option.id); @@ -685,6 +710,7 @@ const Form = ({ menuPlacement="top" isMulti placeholder="Select members..." + components={{ Option: PolicyMemberOption }} options={memberOptions} getOptionValue={(option) => option.id} getOptionLabel={(option) => { @@ -783,6 +809,7 @@ const Form = ({ menuPlacement="top" isMulti placeholder="Select members..." + components={{ Option: PolicyMemberOption }} options={bypasserMemberOptions} getOptionValue={(option) => option.id} getOptionLabel={(option) => { diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index b19ace7ea..89a97003e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { + faBan, faClipboardCheck, faEdit, faEllipsisV, @@ -19,6 +20,7 @@ import { GenericFieldLabel, IconButton, Td, + Tooltip, Tr } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; @@ -86,10 +88,9 @@ export const ApprovalPolicyRow = ({ return entityInSameSequence?.map((el) => { return { sequence: el.sequence || policy.approvals, - userLabels: members - ?.filter((member) => el.user.find((i) => i.id === member.user.id)) - .map((member) => getMemberLabel(member)) - .join(", "), + + users: members.filter((member) => el.user.find((i) => i.id === member.user.id)), + groupLabels: groups ?.filter(({ group }) => el.group.find((i) => i.id === group.id)) .map(({ group }) => group.name) @@ -212,7 +213,35 @@ export const ApprovalPolicyRow = ({ )}
- {el.userLabels} + {Boolean(el.users.length) && ( +
+ {el.users.map((u, idx) => { + return u.user.isOrgMembershipActive ? ( +
+ {getMemberLabel(u)} + {idx < el.users.length - 1 && ","} +
+ ) : ( +
+ + {getMemberLabel(u)} + + +
+ + + Inactive + +
+
+
+
+ {idx < el.users.length - 1 && ","} +
+ ); + })} +
+ )}
{el.groupLabels} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/PolicyMemberOption.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/PolicyMemberOption.tsx new file mode 100644 index 000000000..50376521c --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/PolicyMemberOption.tsx @@ -0,0 +1,40 @@ +import { components, OptionProps } from "react-select"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { faBan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Badge } from "@app/components/v2"; +import { BypasserType } from "@app/hooks/api/accessApproval/types"; +import { ApproverType } from "@app/hooks/api/secretApproval/types"; + +export const PolicyMemberOption = ({ + isSelected, + children, + ...props +}: OptionProps<{ + id: string; + type: BypasserType | ApproverType; + isOrgMembershipActive?: boolean; +}>) => { + return ( + +
+

+ {children} +

+ {!props.data.isOrgMembershipActive && ( + + + Inactive + + )} + {isSelected && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 0f2b00299..636952c33 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { ReactNode } from "react"; import { Controller, useForm } from "react-hook-form"; import { @@ -8,7 +9,8 @@ import { faCodeBranch, faComment, faFolder, - faHourglass + faHourglass, + faUserSlash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -85,6 +87,7 @@ const getReviewedStatusSymbol = (status?: ApprovalStatus) => { return ; if (status === ApprovalStatus.REJECTED) return ; + return ; }; @@ -162,11 +165,15 @@ export const SecretApprovalRequestChanges = ({ secretApprovalRequestDetails.policy.bypassers.some(({ userId }) => userId === userSession.id); const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce< - Record + Record >( (prev, curr) => ({ ...prev, - [curr.userId]: { status: curr.status, comment: curr.comment } + [curr.userId]: { + status: curr.status, + comment: curr.comment, + isOrgMembershipActive: curr.isOrgMembershipActive + } }), {} ); @@ -226,7 +233,7 @@ export const SecretApprovalRequestChanges = ({ return (
-
+
@@ -533,26 +540,44 @@ export const SecretApprovalRequestChanges = ({ ) .map((requiredApprover) => { const reviewer = reviewedUsers?.[requiredApprover.userId]; + const { isOrgMembershipActive } = requiredApprover; + return (
- -
-
{requiredApprover?.email}
- * -
-
-
+ +
+
{requiredApprover?.email}
+ * + {!isOrgMembershipActive && ( + + )} +
+
+
+
{reviewer?.comment && ( )} - - {getReviewedStatusSymbol(reviewer?.status)} - +
+ + Status:{" "} + + {reviewer?.status || ApprovalStatus.PENDING} + + + } + > + {getReviewedStatusSymbol(reviewer?.status)} + +
); @@ -578,20 +615,42 @@ export const SecretApprovalRequestChanges = ({ ) .map((reviewer) => { const status = reviewedUsers?.[reviewer.userId].status; + const { isOrgMembershipActive } = reviewer; return (
-
- - {reviewer?.email} +
+ +
+ {reviewer?.email} + {!isOrgMembershipActive && ( + + )} +
- *
+
{reviewer.comment && ( - + )} - + + Status:{" "} + {status || ApprovalStatus.PENDING} + + } + > {getReviewedStatusSymbol(status)}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx index 9bf9e3a15..c3f5e74ee 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -8,6 +8,7 @@ import { faSave } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2"; import { PendingAction } from "@app/hooks/api/secretFolders/types"; @@ -302,47 +303,61 @@ export const CommitForm: React.FC = ({ <> {/* Floating Panel */} {!isModalOpen && ( -
-
- {/* Left Content */} -
- {/* Header */} -
-
- Pending Changes - - {totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""} - +
+ + +
+
+ {/* Left Content */} +
+ {/* Header */} +
+
+ Pending Changes + + {totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""} + +
+ + {/* Description */} +

+ Review pending changes and commit them to apply the updates. +

+
+ + {/* Right Buttons */} +
+ + +
+
- - {/* Description */} -

- Review pending changes and commit them to apply the updates. -

-
- - {/* Right Buttons */} -
- - -
-
+ +
)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx index ea23e3de7..78e79d373 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx @@ -396,9 +396,11 @@ export const CreateReminderForm = ({ open: isDatePickerOpen, onOpenChange: setIsDatePickerOpen }} - popUpContentProps={{}} + popUpContentProps={{ + align: "end" + }} hideTime - hidden={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }} + disabled={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }} /> )} @@ -426,9 +428,11 @@ export const CreateReminderForm = ({ open: isDatePickerOpen, onOpenChange: setIsDatePickerOpen }} - popUpContentProps={{}} + popUpContentProps={{ + align: "start" + }} hideTime - hidden={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }} + disabled={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }} />
@@ -479,15 +483,15 @@ export const CreateReminderForm = ({ /> {/* Action Buttons */} -
+
{isEditMode && ( @@ -499,7 +503,7 @@ export const CreateReminderForm = ({ type="button" isDisabled={isSubmitting} > - Delete reminder + Delete Reminder )} diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx index 4920bb16b..517398e73 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx @@ -8,5 +8,12 @@ type Props = { export const ChecklySyncDestinationSection = ({ secretSync }: Props) => { const { destinationConfig } = secretSync; - return {destinationConfig.accountName}; + return ( + <> + {destinationConfig.accountName} + {destinationConfig.groupId && ( + {destinationConfig.groupName} + )} + + ); }; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx index eda37a9cf..a9dcab11c 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx @@ -1,23 +1,50 @@ import { GenericFieldLabel } from "@app/components/secret-syncs"; -import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; -import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; +import { + useRenderConnectionListEnvironmentGroups, + useRenderConnectionListServices +} from "@app/hooks/api/appConnections/render"; +import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; type Props = { secretSync: TRenderSync; }; export const RenderSyncDestinationSection = ({ secretSync }: Props) => { + const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service; + const { data: services = [], isPending } = useRenderConnectionListServices( - secretSync.connectionId + secretSync.connectionId, + { + enabled: isServiceScope + } ); - const { - destinationConfig: { serviceId } - } = secretSync; - if (isPending) { - return Loading...; + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope }); + + switch (secretSync.destinationConfig.scope) { + case RenderSyncScope.Service: { + const id = secretSync.destinationConfig.serviceId; + + if (isPending) { + return Loading...; + } + + const serviceName = services.find((service) => service.id === id)?.name; + return {serviceName ?? id}; + } + + case RenderSyncScope.EnvironmentGroup: { + const id = secretSync.destinationConfig.environmentGroupId; + + if (isGroupsPending) { + return Loading...; + } + + const envName = groups.find((g) => g.id === id)?.name; + return {envName ?? id}; + } + default: + throw new Error("Unknown render sync destination scope"); } - - const serviceName = services.find((service) => service.id === serviceId)?.name; - return {serviceName ?? serviceId}; }; diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index c5cd7d294..c0f190104 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.10.1 +version: v0.10.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.10.1" +appVersion: "v0.10.3" diff --git a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-admin-rbac.yaml b/helm-charts/secrets-operator/templates/infisicaldynamicsecret-admin-rbac.yaml deleted file mode 100644 index 1e8e0fb22..000000000 --- a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-admin-rbac.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-admin-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicaldynamicsecrets - verbs: - - '*' -- apiGroups: - - secrets.infisical.com - resources: - - infisicaldynamicsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-admin-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-admin-role' diff --git a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-editor-rbac.yaml b/helm-charts/secrets-operator/templates/infisicaldynamicsecret-editor-rbac.yaml deleted file mode 100644 index 117f9aa1a..000000000 --- a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-editor-rbac.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-editor-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicaldynamicsecrets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - secrets.infisical.com - resources: - - infisicaldynamicsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-editor-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-editor-role' diff --git a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-viewer-rbac.yaml b/helm-charts/secrets-operator/templates/infisicaldynamicsecret-viewer-rbac.yaml deleted file mode 100644 index 3df918d21..000000000 --- a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-viewer-rbac.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-viewer-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicaldynamicsecrets - verbs: - - get - - list - - watch -- apiGroups: - - secrets.infisical.com - resources: - - infisicaldynamicsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-viewer-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicaldynamicsecret-viewer-role' diff --git a/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-admin-rbac.yaml b/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-admin-rbac.yaml deleted file mode 100644 index 6bc381e02..000000000 --- a/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-admin-rbac.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-admin-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicalpushsecretsecrets - verbs: - - '*' -- apiGroups: - - secrets.infisical.com - resources: - - infisicalpushsecretsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-admin-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-admin-role' diff --git a/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-editor-rbac.yaml b/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-editor-rbac.yaml deleted file mode 100644 index b279cf179..000000000 --- a/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-editor-rbac.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-editor-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicalpushsecretsecrets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - secrets.infisical.com - resources: - - infisicalpushsecretsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-editor-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-editor-role' diff --git a/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-viewer-rbac.yaml b/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-viewer-rbac.yaml deleted file mode 100644 index 12fea8635..000000000 --- a/helm-charts/secrets-operator/templates/infisicalpushsecretsecret-viewer-rbac.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-viewer-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicalpushsecretsecrets - verbs: - - get - - list - - watch -- apiGroups: - - secrets.infisical.com - resources: - - infisicalpushsecretsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-viewer-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicalpushsecretsecret-viewer-role' diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-admin-rbac.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-admin-rbac.yaml deleted file mode 100644 index 1016dbe56..000000000 --- a/helm-charts/secrets-operator/templates/infisicalsecret-admin-rbac.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalsecret-admin-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicalsecrets - verbs: - - '*' -- apiGroups: - - secrets.infisical.com - resources: - - infisicalsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalsecret-admin-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicalsecret-admin-role' diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index 117197686..41c8f3a45 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -316,6 +316,8 @@ spec: hostAPI: description: Infisical host to pull secrets from type: string + instantUpdates: + type: boolean managedKubeConfigMapReferences: items: properties: diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-editor-rbac.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-editor-rbac.yaml deleted file mode 100644 index 6f74acba1..000000000 --- a/helm-charts/secrets-operator/templates/infisicalsecret-editor-rbac.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalsecret-editor-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicalsecrets - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - secrets.infisical.com - resources: - - infisicalsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalsecret-editor-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicalsecret-editor-role' diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-viewer-rbac.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-viewer-rbac.yaml deleted file mode 100644 index 2f63b44ef..000000000 --- a/helm-charts/secrets-operator/templates/infisicalsecret-viewer-rbac.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: Role -{{- else }} -kind: ClusterRole -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalsecret-viewer-role - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - {{- include "secrets-operator.labels" . | nindent 4 }} -rules: -- apiGroups: - - secrets.infisical.com - resources: - - infisicalsecrets - verbs: - - get - - list - - watch -- apiGroups: - - secrets.infisical.com - resources: - - infisicalsecrets/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -{{- if and .Values.scopedNamespace .Values.scopedRBAC }} -kind: RoleBinding -{{- else }} -kind: ClusterRoleBinding -{{- end }} -metadata: - name: {{ include "secrets-operator.fullname" . }}-infisicalsecret-viewer-rolebinding - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - namespace: {{ .Values.scopedNamespace | quote }} - {{- end }} - labels: - - {{- include "secrets-operator.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - {{- if and .Values.scopedNamespace .Values.scopedRBAC }} - kind: Role - {{- else }} - kind: ClusterRole - {{- end }} - name: '{{ include "secrets-operator.fullname" . }}-infisicalsecret-viewer-role' diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index bcd111382..98e3648de 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -12,7 +12,7 @@ controllerManager: readOnlyRootFilesystem: true image: repository: infisical/kubernetes-operator - tag: v0.10.1 + tag: v0.10.3 resources: limits: cpu: 500m diff --git a/k8-operator/PROJECT b/k8-operator/PROJECT index dc9260f24..5a28e2212 100644 --- a/k8-operator/PROJECT +++ b/k8-operator/PROJECT @@ -24,7 +24,7 @@ resources: controller: true domain: infisical.com group: secrets - kind: InfisicalPushSecretSecret + kind: InfisicalPushSecret path: github.com/Infisical/infisical/k8-operator/api/v1alpha1 version: v1alpha1 - api: diff --git a/k8-operator/api/v1alpha1/infisicalpushsecret_types.go b/k8-operator/api/v1alpha1/infisicalpushsecret_types.go index 8958c714d..f1905c130 100644 --- a/k8-operator/api/v1alpha1/infisicalpushsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalpushsecret_types.go @@ -16,7 +16,7 @@ type InfisicalPushSecretDestination struct { ProjectID string `json:"projectId"` } -type InfisicalPushSecretSecretSource struct { +type InfisicalPushSecretSource struct { // The name of the Kubernetes Secret // +kubebuilder:validation:Required SecretName string `json:"secretName"` @@ -48,7 +48,7 @@ type SecretPushGenerator struct { type SecretPush struct { // +kubebuilder:validation:Optional - Secret *InfisicalPushSecretSecretSource `json:"secret,omitempty"` + Secret *InfisicalPushSecretSource `json:"secret,omitempty"` // +kubebuilder:validation:Optional Generators []SecretPushGenerator `json:"generators,omitempty"` } diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index e4da2911b..796c97265 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -160,6 +160,9 @@ type InfisicalSecretSpec struct { // +kubebuilder:validation:Optional TLS TLSConfig `json:"tls"` + + // +kubebuilder:validation:Optional + InstantUpdates bool `json:"instantUpdates"` } // InfisicalSecretStatus defines the observed state of InfisicalSecret diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index f0ed9e216..02bb65b5b 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -589,7 +589,7 @@ func (in *InfisicalPushSecretList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InfisicalPushSecretSecretSource) DeepCopyInto(out *InfisicalPushSecretSecretSource) { +func (in *InfisicalPushSecretSource) DeepCopyInto(out *InfisicalPushSecretSource) { *out = *in if in.Template != nil { in, out := &in.Template, &out.Template @@ -598,12 +598,12 @@ func (in *InfisicalPushSecretSecretSource) DeepCopyInto(out *InfisicalPushSecret } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalPushSecretSecretSource. -func (in *InfisicalPushSecretSecretSource) DeepCopy() *InfisicalPushSecretSecretSource { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalPushSecretSource. +func (in *InfisicalPushSecretSource) DeepCopy() *InfisicalPushSecretSource { if in == nil { return nil } - out := new(InfisicalPushSecretSecretSource) + out := new(InfisicalPushSecretSource) in.DeepCopyInto(out) return out } @@ -992,7 +992,7 @@ func (in *SecretPush) DeepCopyInto(out *SecretPush) { *out = *in if in.Secret != nil { in, out := &in.Secret, &out.Secret - *out = new(InfisicalPushSecretSecretSource) + *out = new(InfisicalPushSecretSource) (*in).DeepCopyInto(*out) } if in.Generators != nil { diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index 3cf97dbaf..176c44374 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -314,6 +314,8 @@ spec: hostAPI: description: Infisical host to pull secrets from type: string + instantUpdates: + type: boolean managedKubeConfigMapReferences: items: properties: diff --git a/k8-operator/config/rbac/kustomization.yaml b/k8-operator/config/rbac/kustomization.yaml index d879dffa4..386e87171 100644 --- a/k8-operator/config/rbac/kustomization.yaml +++ b/k8-operator/config/rbac/kustomization.yaml @@ -22,13 +22,13 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the k8-operator itself. You can comment the following lines # if you do not want those helpers be installed with your Project. -- infisicaldynamicsecret_admin_role.yaml -- infisicaldynamicsecret_editor_role.yaml -- infisicaldynamicsecret_viewer_role.yaml -- infisicalpushsecretsecret_admin_role.yaml -- infisicalpushsecretsecret_editor_role.yaml -- infisicalpushsecretsecret_viewer_role.yaml -- infisicalsecret_admin_role.yaml -- infisicalsecret_editor_role.yaml -- infisicalsecret_viewer_role.yaml +# - infisicaldynamicsecret_admin_role.yaml +# - infisicaldynamicsecret_editor_role.yaml +# - infisicaldynamicsecret_viewer_role.yaml +# - infisicalpushsecret_admin_role.yaml +# - infisicalpushsecret_editor_role.yaml +# - infisicalpushsecret_viewer_role.yaml +# - infisicalsecret_admin_role.yaml +# - infisicalsecret_editor_role.yaml +# - infisicalsecret_viewer_role.yaml diff --git a/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml b/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml index b18f5df82..6ea6dc561 100644 --- a/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml +++ b/k8-operator/config/samples/crd/infisicalsecret/infisicalSecretCrd.yaml @@ -9,6 +9,7 @@ metadata: spec: hostAPI: http://localhost:8080/api resyncInterval: 10 + instantUpdates: false # tls: # caRef: # secretName: custom-ca-certificate diff --git a/k8-operator/config/samples/k8s-auth/sample.yaml b/k8-operator/config/samples/k8s-auth/sample.yaml index 6dcbae13a..91f910aff 100644 --- a/k8-operator/config/samples/k8s-auth/sample.yaml +++ b/k8-operator/config/samples/k8s-auth/sample.yaml @@ -29,4 +29,4 @@ spec: secretName: managed-secret-k8s secretNamespace: default creationPolicy: "Orphan" ## Owner | Orphan - # secretType: kubernetes.io/dockerconfigjson + # secretType: kubernetes.io/dockerconfigjson \ No newline at end of file diff --git a/k8-operator/config/samples/serviceTokenSecret.yaml b/k8-operator/config/samples/serviceTokenSecret.yaml index 3fd2e1b8a..dc03abcc2 100644 --- a/k8-operator/config/samples/serviceTokenSecret.yaml +++ b/k8-operator/config/samples/serviceTokenSecret.yaml @@ -1,7 +1,7 @@ -apiVersion: v1 -kind: Secret -metadata: - name: service-token -type: Opaque -data: - infisicalToken: \ No newline at end of file +# apiVersion: v1 +# kind: Secret +# metadata: +# name: service-token +# type: Opaque +# data: +# infisicalToken: \ No newline at end of file diff --git a/k8-operator/config/samples/universalAuthIdentitySecret.yaml b/k8-operator/config/samples/universalAuthIdentitySecret.yaml index 88d60e6ab..741de34ee 100644 --- a/k8-operator/config/samples/universalAuthIdentitySecret.yaml +++ b/k8-operator/config/samples/universalAuthIdentitySecret.yaml @@ -4,5 +4,5 @@ metadata: name: universal-auth-credentials type: Opaque stringData: - clientId: da81e27e-1885-47d9-9ea3-ec7d4d807bb6 - clientSecret: 2772414d440fe04d8b975f5fe25acd0fbfe71b2a4a420409eb9ac6f5ae6c1e98 + clientId: your-client-id-here + clientSecret: your-client-secret-here \ No newline at end of file diff --git a/k8-operator/internal/api/api.go b/k8-operator/internal/api/api.go index 36edfa5c1..2e57d5930 100644 --- a/k8-operator/internal/api/api.go +++ b/k8-operator/internal/api/api.go @@ -1,8 +1,11 @@ package api import ( + "encoding/json" "fmt" + "net/http" + "github.com/Infisical/infisical/k8-operator/internal/model" "github.com/go-resty/resty/v2" ) @@ -146,3 +149,85 @@ func CallGetProjectByID(httpClient *resty.Client, request GetProjectByIDRequest) return projectResponse, nil } + +func CallGetProjectByIDv2(httpClient *resty.Client, request GetProjectByIDRequest) (model.Project, error) { + var projectResponse model.Project + + response, err := httpClient. + R().SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT_NAME). + Get(fmt.Sprintf("%s/v2/workspace/%s", API_HOST_URL, request.ProjectID)) + + if err != nil { + return model.Project{}, fmt.Errorf("CallGetProject: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return model.Project{}, fmt.Errorf("CallGetProject: Unsuccessful response: [response=%s]", response) + } + + return projectResponse, nil + +} + +func CallSubscribeProjectEvents(httpClient *resty.Client, projectId, secretsPath, envSlug, token string) (*http.Response, error) { + conditions := &SubscribeProjectEventsRequestCondition{ + SecretPath: secretsPath, + EnvironmentSlug: envSlug, + } + + body, err := json.Marshal(&SubscribeProjectEventsRequest{ + ProjectID: projectId, + Register: []SubscribeProjectEventsRequestRegister{ + { + Event: "secret:create", + Conditions: conditions, + }, + { + Event: "secret:update", + Conditions: conditions, + }, + { + Event: "secret:delete", + Conditions: conditions, + }, + { + Event: "secret:import-mutation", + Conditions: conditions, + }, + }, + }) + + if err != nil { + return nil, fmt.Errorf("CallSubscribeProjectEvents: Unable to marshal body [err=%s]", err) + } + + response, err := httpClient. + R(). + SetDoNotParseResponse(true). + SetHeader("User-Agent", USER_AGENT_NAME). + SetHeader("Content-Type", "application/json"). + SetHeader("Accept", "text/event-stream"). + SetHeader("Connection", "keep-alive"). + SetHeader("Authorization", fmt.Sprint("Bearer ", token)). + SetBody(body). + Post(fmt.Sprintf("%s/v1/events/subscribe/project-events", API_HOST_URL)) + + if err != nil { + return nil, fmt.Errorf("CallSubscribeProjectEvents: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + data := struct { + Message string `json:"message"` + }{} + + if err := json.NewDecoder(response.RawBody()).Decode(&data); err != nil { + return nil, err + } + + return nil, fmt.Errorf("CallSubscribeProjectEvents: Unsuccessful response: [message=%s]", data.Message) + } + + return response.RawResponse, nil +} diff --git a/k8-operator/internal/api/models.go b/k8-operator/internal/api/models.go index 2128aac2a..49e1a8c8c 100644 --- a/k8-operator/internal/api/models.go +++ b/k8-operator/internal/api/models.go @@ -206,3 +206,20 @@ type GetProjectByIDRequest struct { type GetProjectByIDResponse struct { Project model.Project `json:"workspace"` } + +type SubscribeProjectEventsRequestRegister struct { + Event string `json:"event"` + Conditions *SubscribeProjectEventsRequestCondition `json:"conditions"` +} + +type SubscribeProjectEventsRequestCondition struct { + EnvironmentSlug string `json:"environmentSlug"` + SecretPath string `json:"secretPath"` +} + +type SubscribeProjectEventsRequest struct { + ProjectID string `json:"projectId"` + Register []SubscribeProjectEventsRequestRegister `json:"register"` +} + +type SubscribeProjectEventsResponse struct{} diff --git a/k8-operator/internal/controller/infisicalpushsecret_controller.go b/k8-operator/internal/controller/infisicalpushsecret_controller.go index e8665b5d9..73f7b3b75 100644 --- a/k8-operator/internal/controller/infisicalpushsecret_controller.go +++ b/k8-operator/internal/controller/infisicalpushsecret_controller.go @@ -42,7 +42,7 @@ import ( "github.com/go-logr/logr" ) -// InfisicalPushSecretReconciler reconciles a InfisicalPushSecretSecret object +// InfisicalPushSecretReconciler reconciles a InfisicalPushSecret object type InfisicalPushSecretReconciler struct { client.Client BaseLogger logr.Logger @@ -231,7 +231,6 @@ func (r *InfisicalPushSecretReconciler) Reconcile(ctx context.Context, req ctrl. } func (r *InfisicalPushSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Custom predicate that allows both spec changes and deletions specChangeOrDelete := predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { diff --git a/k8-operator/internal/controller/infisicalpushsecret_controller_test.go b/k8-operator/internal/controller/infisicalpushsecret_controller_test.go index 160ef7e43..92d00f32c 100644 --- a/k8-operator/internal/controller/infisicalpushsecret_controller_test.go +++ b/k8-operator/internal/controller/infisicalpushsecret_controller_test.go @@ -30,7 +30,7 @@ import ( secretsv1alpha1 "github.com/Infisical/infisical/k8-operator/api/v1alpha1" ) -var _ = Describe("InfisicalPushSecretSecret Controller", func() { +var _ = Describe("InfisicalPushSecret Controller", func() { Context("When reconciling a resource", func() { const resourceName = "test-resource" @@ -40,11 +40,11 @@ var _ = Describe("InfisicalPushSecretSecret Controller", func() { Name: resourceName, Namespace: "default", // TODO(user):Modify as needed } - infisicalpushsecretsecret := &secretsv1alpha1.InfisicalPushSecret{} + infisicalpushsecret := &secretsv1alpha1.InfisicalPushSecret{} BeforeEach(func() { - By("creating the custom resource for the Kind InfisicalPushSecretSecret") - err := k8sClient.Get(ctx, typeNamespacedName, infisicalpushsecretsecret) + By("creating the custom resource for the Kind InfisicalPushSecret") + err := k8sClient.Get(ctx, typeNamespacedName, infisicalpushsecret) if err != nil && errors.IsNotFound(err) { resource := &secretsv1alpha1.InfisicalPushSecret{ ObjectMeta: metav1.ObjectMeta{ @@ -63,7 +63,7 @@ var _ = Describe("InfisicalPushSecretSecret Controller", func() { err := k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) - By("Cleanup the specific resource instance InfisicalPushSecretSecret") + By("Cleanup the specific resource instance InfisicalPushSecret") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) It("should successfully reconcile the resource", func() { diff --git a/k8-operator/internal/controller/infisicalsecret_controller.go b/k8-operator/internal/controller/infisicalsecret_controller.go index d5d9c5d39..07552f9cf 100644 --- a/k8-operator/internal/controller/infisicalsecret_controller.go +++ b/k8-operator/internal/controller/infisicalsecret_controller.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" secretsv1alpha1 "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/internal/controllerhelpers" @@ -41,8 +42,10 @@ import ( // InfisicalSecretReconciler reconciles a InfisicalSecret object type InfisicalSecretReconciler struct { client.Client - BaseLogger logr.Logger - Scheme *runtime.Scheme + BaseLogger logr.Logger + Scheme *runtime.Scheme + + SourceCh chan event.TypedGenericEvent[client.Object] Namespace string IsNamespaceScoped bool } @@ -74,7 +77,6 @@ func (r *InfisicalSecretReconciler) GetLogger(req ctrl.Request) logr.Logger { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := r.GetLogger(req) var infisicalSecretCRD secretsv1alpha1.InfisicalSecret @@ -196,6 +198,20 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ }, nil } + if infisicalSecretCRD.Spec.InstantUpdates { + if err := handler.OpenInstantUpdatesStream(ctx, logger, &infisicalSecretCRD, infisicalSecretResourceVariablesMap, r.SourceCh); err != nil { + requeueTime = time.Second * 10 + logger.Info(fmt.Sprintf("event stream failed. Will requeue after [requeueTime=%v] [error=%s]", requeueTime, err.Error())) + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil + } + + logger.Info("Instant updates are enabled") + } else { + handler.CloseInstantUpdatesStream(ctx, logger, &infisicalSecretCRD, infisicalSecretResourceVariablesMap) + } + // Sync again after the specified time logger.Info(fmt.Sprintf("Successfully synced %d secrets. Operator will requeue after [%v]", secretsCount, requeueTime)) return ctrl.Result{ @@ -204,7 +220,12 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ } func (r *InfisicalSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.SourceCh = make(chan event.TypedGenericEvent[client.Object]) + return ctrl.NewControllerManagedBy(mgr). + WatchesRawSource( + source.Channel[client.Object](r.SourceCh, &util.EnqueueDelayedEventHandler{Delay: time.Second * 10}), + ). For(&secretsv1alpha1.InfisicalSecret{}, builder.WithPredicates(predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { if e.ObjectOld.GetGeneration() == e.ObjectNew.GetGeneration() { @@ -230,4 +251,5 @@ func (r *InfisicalSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { }, })). Complete(r) + } diff --git a/k8-operator/internal/services/infisicalsecret/handler.go b/k8-operator/internal/services/infisicalsecret/handler.go index 5657d701d..7fa75581a 100644 --- a/k8-operator/internal/services/infisicalsecret/handler.go +++ b/k8-operator/internal/services/infisicalsecret/handler.go @@ -7,6 +7,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/internal/api" @@ -100,3 +101,22 @@ func (h *InfisicalSecretHandler) SetInfisicalAutoRedeploymentReady(ctx context.C } reconciler.SetInfisicalAutoRedeploymentReady(ctx, logger, infisicalSecret, numDeployments, errorToConditionOn) } + +func (h *InfisicalSecretHandler) CloseInstantUpdatesStream(ctx context.Context, logger logr.Logger, infisicalSecret *v1alpha1.InfisicalSecret, resourceVariablesMap map[string]util.ResourceVariables) error { + reconciler := &InfisicalSecretReconciler{ + Client: h.Client, + Scheme: h.Scheme, + IsNamespaceScoped: h.IsNamespaceScoped, + } + return reconciler.CloseInstantUpdatesStream(ctx, logger, infisicalSecret, resourceVariablesMap) +} + +// Ensures that SSE stream is open, incase if the stream is already opened - this is a noop +func (h *InfisicalSecretHandler) OpenInstantUpdatesStream(ctx context.Context, logger logr.Logger, infisicalSecret *v1alpha1.InfisicalSecret, resourceVariablesMap map[string]util.ResourceVariables, eventCh chan<- event.TypedGenericEvent[client.Object]) error { + reconciler := &InfisicalSecretReconciler{ + Client: h.Client, + Scheme: h.Scheme, + IsNamespaceScoped: h.IsNamespaceScoped, + } + return reconciler.OpenInstantUpdatesStream(ctx, logger, infisicalSecret, resourceVariablesMap, eventCh) +} diff --git a/k8-operator/internal/services/infisicalsecret/reconciler.go b/k8-operator/internal/services/infisicalsecret/reconciler.go index 94596fb8c..3bd9de34a 100644 --- a/k8-operator/internal/services/infisicalsecret/reconciler.go +++ b/k8-operator/internal/services/infisicalsecret/reconciler.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" tpl "text/template" @@ -15,11 +16,14 @@ import ( "github.com/Infisical/infisical/k8-operator/internal/model" "github.com/Infisical/infisical/k8-operator/internal/template" "github.com/Infisical/infisical/k8-operator/internal/util" + "github.com/Infisical/infisical/k8-operator/internal/util/sse" "github.com/go-logr/logr" + "github.com/go-resty/resty/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" infisicalSdk "github.com/infisical/go-sdk" corev1 "k8s.io/api/core/v1" @@ -409,9 +413,10 @@ func (r *InfisicalSecretReconciler) getResourceVariables(infisicalSecret v1alpha }) resourceVariablesMap[string(infisicalSecret.UID)] = util.ResourceVariables{ - InfisicalClient: client, - CancelCtx: cancel, - AuthDetails: util.AuthenticationDetails{}, + InfisicalClient: client, + CancelCtx: cancel, + AuthDetails: util.AuthenticationDetails{}, + ServerSentEvents: sse.NewConnectionRegistry(ctx), } resourceVariables = resourceVariablesMap[string(infisicalSecret.UID)] @@ -421,7 +426,6 @@ func (r *InfisicalSecretReconciler) getResourceVariables(infisicalSecret v1alpha } return resourceVariables - } func (r *InfisicalSecretReconciler) updateResourceVariables(infisicalSecret v1alpha1.InfisicalSecret, resourceVariables util.ResourceVariables, resourceVariablesMap map[string]util.ResourceVariables) { @@ -454,9 +458,10 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context } r.updateResourceVariables(*infisicalSecret, util.ResourceVariables{ - InfisicalClient: infisicalClient, - CancelCtx: cancelCtx, - AuthDetails: authDetails, + InfisicalClient: infisicalClient, + CancelCtx: cancelCtx, + AuthDetails: authDetails, + ServerSentEvents: sse.NewConnectionRegistry(ctx), }, resourceVariablesMap) } @@ -525,3 +530,94 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context return secretsCount, nil } + +func (r *InfisicalSecretReconciler) CloseInstantUpdatesStream(ctx context.Context, logger logr.Logger, infisicalSecret *v1alpha1.InfisicalSecret, resourceVariablesMap map[string]util.ResourceVariables) error { + if infisicalSecret == nil { + return fmt.Errorf("infisicalSecret is nil") + } + + variables := r.getResourceVariables(*infisicalSecret, resourceVariablesMap) + + if !variables.AuthDetails.IsMachineIdentityAuth { + return fmt.Errorf("only machine identity is supported for subscriptions") + } + + conn := variables.ServerSentEvents + + if _, ok := conn.Get(); ok { + conn.Close() + } + + return nil +} + +func (r *InfisicalSecretReconciler) OpenInstantUpdatesStream(ctx context.Context, logger logr.Logger, infisicalSecret *v1alpha1.InfisicalSecret, resourceVariablesMap map[string]util.ResourceVariables, eventCh chan<- event.TypedGenericEvent[client.Object]) error { + if infisicalSecret == nil { + return fmt.Errorf("infisicalSecret is nil") + } + + variables := r.getResourceVariables(*infisicalSecret, resourceVariablesMap) + + if !variables.AuthDetails.IsMachineIdentityAuth { + return fmt.Errorf("only machine identity is supported for subscriptions") + } + + projectSlug := variables.AuthDetails.MachineIdentityScope.ProjectSlug + secretsPath := variables.AuthDetails.MachineIdentityScope.SecretsPath + envSlug := variables.AuthDetails.MachineIdentityScope.EnvSlug + + infiscalClient := variables.InfisicalClient + sseRegistry := variables.ServerSentEvents + + token := infiscalClient.Auth().GetAccessToken() + + project, err := util.GetProjectBySlug(token, projectSlug) + + if err != nil { + return fmt.Errorf("failed to get project [err=%s]", err) + } + + if variables.AuthDetails.MachineIdentityScope.Recursive { + secretsPath = fmt.Sprint(secretsPath, "**") + } + + if err != nil { + return fmt.Errorf("CallSubscribeProjectEvents: unable to marshal body [err=%s]", err) + } + + events, errors, err := sseRegistry.Subscribe(func() (*http.Response, error) { + httpClient := resty.New() + + req, err := api.CallSubscribeProjectEvents(httpClient, project.ID, secretsPath, envSlug, token) + + if err != nil { + return nil, err + } + + return req, nil + }) + + if err != nil { + return fmt.Errorf("unable to connect sse [err=%s]", err) + } + + go func() { + outer: + for { + select { + case ev := <-events: + logger.Info("Received SSE Event", "event", ev) + eventCh <- event.TypedGenericEvent[client.Object]{ + Object: infisicalSecret, + } + case err := <-errors: + logger.Error(err, "Error occurred") + break outer + case <-ctx.Done(): + break outer + } + } + }() + + return nil +} diff --git a/k8-operator/internal/util/handler.go b/k8-operator/internal/util/handler.go new file mode 100644 index 000000000..d1cc86562 --- /dev/null +++ b/k8-operator/internal/util/handler.go @@ -0,0 +1,59 @@ +package util + +import ( + "context" + "math/rand" + "time" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// computeMaxJitterDuration returns a random duration between 0 and max. +// This is useful for introducing jitter to event processing. +func computeMaxJitterDuration(max time.Duration) (time.Duration, time.Duration) { + if max <= 0 { + return 0, 0 + } + jitter := time.Duration(rand.Int63n(int64(max))) + return max, jitter +} + +// EnqueueDelayedEventHandler enqueues reconcile requests with a random delay (jitter) +// to spread the load and avoid thundering herd issues. +type EnqueueDelayedEventHandler struct { + Delay time.Duration +} + +func (e *EnqueueDelayedEventHandler) Create(_ context.Context, _ event.TypedCreateEvent[client.Object], _ workqueue.TypedRateLimitingInterface[reconcile.Request]) { +} + +func (e *EnqueueDelayedEventHandler) Update(_ context.Context, _ event.TypedUpdateEvent[client.Object], _ workqueue.TypedRateLimitingInterface[reconcile.Request]) { +} + +func (e *EnqueueDelayedEventHandler) Delete(_ context.Context, _ event.TypedDeleteEvent[client.Object], _ workqueue.TypedRateLimitingInterface[reconcile.Request]) { +} + +func (e *EnqueueDelayedEventHandler) Generic(_ context.Context, evt event.TypedGenericEvent[client.Object], q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + if evt.Object == nil { + return + } + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: evt.Object.GetNamespace(), + Name: evt.Object.GetName(), + }, + } + + _, delay := computeMaxJitterDuration(e.Delay) + + if delay > 0 { + q.AddAfter(req, delay) + } else { + q.Add(req) + } +} diff --git a/k8-operator/internal/util/models.go b/k8-operator/internal/util/models.go index 8030731c2..e583858fa 100644 --- a/k8-operator/internal/util/models.go +++ b/k8-operator/internal/util/models.go @@ -3,11 +3,13 @@ package util import ( "context" + "github.com/Infisical/infisical/k8-operator/internal/util/sse" infisicalSdk "github.com/infisical/go-sdk" ) type ResourceVariables struct { - InfisicalClient infisicalSdk.InfisicalClientInterface - CancelCtx context.CancelFunc - AuthDetails AuthenticationDetails + InfisicalClient infisicalSdk.InfisicalClientInterface + CancelCtx context.CancelFunc + AuthDetails AuthenticationDetails + ServerSentEvents *sse.ConnectionRegistry } diff --git a/k8-operator/internal/util/sse/sse.go b/k8-operator/internal/util/sse/sse.go new file mode 100644 index 000000000..7bfbcef88 --- /dev/null +++ b/k8-operator/internal/util/sse/sse.go @@ -0,0 +1,331 @@ +package sse + +import ( + "bufio" + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Event represents a Server-Sent Event +type Event struct { + ID string + Event string + Data string + Retry int +} + +// ConnectionMeta holds metadata about an SSE connection +type ConnectionMeta struct { + EventChan <-chan Event + ErrorChan <-chan error + lastPingAt atomic.Value // stores time.Time + cancel context.CancelFunc +} + +// LastPing returns the last ping time +func (c *ConnectionMeta) LastPing() time.Time { + if t, ok := c.lastPingAt.Load().(time.Time); ok { + return t + } + return time.Time{} +} + +// UpdateLastPing atomically updates the last ping time +func (c *ConnectionMeta) UpdateLastPing() { + c.lastPingAt.Store(time.Now()) +} + +// Cancel terminates the connection +func (c *ConnectionMeta) Cancel() { + if c.cancel != nil { + c.cancel() + } +} + +// ConnectionRegistry manages SSE connections with high performance +type ConnectionRegistry struct { + mu sync.RWMutex + conn *ConnectionMeta + + monitorOnce sync.Once + monitorStop chan struct{} + + onPing func() // Callback for ping events +} + +// NewConnectionRegistry creates a new high-performance connection registry +func NewConnectionRegistry(ctx context.Context) *ConnectionRegistry { + r := &ConnectionRegistry{ + monitorStop: make(chan struct{}), + } + + // Configure ping handler + r.onPing = func() { + r.UpdateLastPing() + } + + return r +} + +// Subscribe provides SSE events, creating a connection if needed +func (r *ConnectionRegistry) Subscribe(request func() (*http.Response, error)) (<-chan Event, <-chan error, error) { + // Fast path: check if connection exists + if conn := r.getConnection(); conn != nil { + return conn.EventChan, conn.ErrorChan, nil + } + + // Slow path: create new connection under lock + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check after acquiring lock + if r.conn != nil { + return r.conn.EventChan, r.conn.ErrorChan, nil + } + + res, err := request() + if err != nil { + return nil, nil, err + } + + conn, err := r.createStream(res) + if err != nil { + return nil, nil, err + } + + r.conn = conn + + // Start monitor once + r.monitorOnce.Do(func() { + go r.monitorConnections() + }) + + return conn.EventChan, conn.ErrorChan, nil +} + +// Get retrieves the current connection +func (r *ConnectionRegistry) Get() (*ConnectionMeta, bool) { + conn := r.getConnection() + return conn, conn != nil +} + +// IsConnected checks if there's an active connection +func (r *ConnectionRegistry) IsConnected() bool { + return r.getConnection() != nil +} + +// UpdateLastPing updates the last ping time for the current connection +func (r *ConnectionRegistry) UpdateLastPing() { + if conn := r.getConnection(); conn != nil { + conn.UpdateLastPing() + } +} + +// Close gracefully shuts down the registry +func (r *ConnectionRegistry) Close() { + // Stop monitor first + select { + case <-r.monitorStop: + // Already closed + default: + close(r.monitorStop) + } + + // Close connection + r.mu.Lock() + if r.conn != nil { + r.conn.Cancel() + r.conn = nil + } + r.mu.Unlock() +} + +// getConnection returns the current connection without locking +func (r *ConnectionRegistry) getConnection() *ConnectionMeta { + r.mu.RLock() + conn := r.conn + r.mu.RUnlock() + return conn +} + +func (r *ConnectionRegistry) createStream(res *http.Response) (*ConnectionMeta, error) { + ctx, cancel := context.WithCancel(context.Background()) + + eventChan, errorChan, err := r.stream(ctx, res) + if err != nil { + cancel() + return nil, err + } + + meta := &ConnectionMeta{ + EventChan: eventChan, + ErrorChan: errorChan, + cancel: cancel, + } + meta.UpdateLastPing() + + return meta, nil +} + +// stream processes SSE data from an HTTP response +func (r *ConnectionRegistry) stream(ctx context.Context, res *http.Response) (<-chan Event, <-chan error, error) { + eventChan := make(chan Event, 10) + errorChan := make(chan error, 1) + + go r.processStream(ctx, res.Body, eventChan, errorChan) + + return eventChan, errorChan, nil +} + +// processStream reads and parses SSE events from the response body +func (r *ConnectionRegistry) processStream(ctx context.Context, body io.ReadCloser, eventChan chan<- Event, errorChan chan<- error) { + defer body.Close() + defer close(eventChan) + defer close(errorChan) + + scanner := bufio.NewScanner(body) + + var currentEvent Event + var dataBuilder strings.Builder + + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + + line := scanner.Text() + + // Empty line indicates end of event + if len(line) == 0 { + if currentEvent.Data != "" || currentEvent.Event != "" { + // Finalize data + if dataBuilder.Len() > 0 { + currentEvent.Data = dataBuilder.String() + dataBuilder.Reset() + } + + // Handle ping events + if r.isPingEvent(currentEvent) { + if r.onPing != nil { + r.onPing() + } + } else { + // Send non-ping events + select { + case eventChan <- currentEvent: + case <-ctx.Done(): + return + } + } + + // Reset for next event + currentEvent = Event{} + } + continue + } + + // Parse line efficiently + r.parseLine(line, ¤tEvent, &dataBuilder) + } + + if err := scanner.Err(); err != nil { + select { + case errorChan <- err: + case <-ctx.Done(): + } + } +} + +// parseLine efficiently parses SSE protocol lines +func (r *ConnectionRegistry) parseLine(line string, event *Event, dataBuilder *strings.Builder) { + colonIndex := strings.IndexByte(line, ':') + if colonIndex == -1 { + return // Invalid line format + } + + field := line[:colonIndex] + value := line[colonIndex+1:] + + // Trim leading space from value (SSE spec) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + + switch field { + case "data": + if dataBuilder.Len() > 0 { + dataBuilder.WriteByte('\n') + } + dataBuilder.WriteString(value) + case "event": + event.Event = value + case "id": + event.ID = value + case "retry": + // Parse retry value if needed + // This could be used to configure reconnection delay + case "": + // Comment line, ignore + } +} + +// isPingEvent checks if an event is a ping/keepalive +func (r *ConnectionRegistry) isPingEvent(event Event) bool { + // Check for common ping patterns + if event.Event == "ping" { + return true + } + + // Check for heartbeat data (common pattern is "1" or similar) + if event.Event == "" && strings.TrimSpace(event.Data) == "1" { + return true + } + + return false +} + +// monitorConnections checks connection health periodically +func (r *ConnectionRegistry) monitorConnections() { + const ( + checkInterval = 30 * time.Second + pingTimeout = 2 * time.Minute + ) + + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + for { + select { + case <-r.monitorStop: + return + case <-ticker.C: + r.checkConnectionHealth(pingTimeout) + } + } +} + +// checkConnectionHealth verifies connection is still alive +func (r *ConnectionRegistry) checkConnectionHealth(timeout time.Duration) { + conn := r.getConnection() + if conn == nil { + return + } + + if time.Since(conn.LastPing()) > timeout { + // Connection is stale, close it + r.mu.Lock() + if r.conn == conn { // Verify it's still the same connection + r.conn.Cancel() + r.monitorStop <- struct{}{} + r.conn = nil + } + r.mu.Unlock() + } +} diff --git a/k8-operator/internal/util/workspace.go b/k8-operator/internal/util/workspace.go index d62c0288a..d014a4c4e 100644 --- a/k8-operator/internal/util/workspace.go +++ b/k8-operator/internal/util/workspace.go @@ -9,7 +9,6 @@ import ( ) func GetProjectByID(accessToken string, projectId string) (model.Project, error) { - httpClient := resty.New() httpClient. SetAuthScheme("Bearer"). @@ -25,3 +24,21 @@ func GetProjectByID(accessToken string, projectId string) (model.Project, error) return projectDetails.Project, nil } + +func GetProjectBySlug(accessToken string, projectSlug string) (model.Project, error) { + httpClient := resty.New() + httpClient. + SetAuthScheme("Bearer"). + SetAuthToken(accessToken). + SetHeader("Accept", "application/json") + + project, err := api.CallGetProjectByIDv2(httpClient, api.GetProjectByIDRequest{ + ProjectID: projectSlug, + }) + + if err != nil { + return model.Project{}, fmt.Errorf("unable to get project by slug. [err=%v]", err) + } + + return project, nil +}