Merge remote-tracking branch 'origin/main' into feat/azurePkiConnector

This commit is contained in:
Carlos Monastyrski
2025-08-28 16:50:18 -03:00
151 changed files with 3300 additions and 1363 deletions
+1
View File
@@ -148,6 +148,7 @@ declare module "fastify" {
interface Session { interface Session {
callbackPort: string; callbackPort: string;
isAdminLogin: boolean; isAdminLogin: boolean;
orgSlug?: string;
} }
interface FastifyRequest { interface FastifyRequest {
@@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise<void> => {
t.index("expiresAt"); t.index("expiresAt");
t.index("orgId"); t.index("orgId");
t.index("projectId"); t.index("projectId");
t.index("eventType");
t.index("userAgentType");
t.index("actor");
}); });
console.log("Adding GIN indices..."); console.log("Adding GIN indices...");
@@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise<void> => {
console.log("Creating audit log partitions ahead of time... next date:", nextDateStr); console.log("Creating audit log partitions ahead of time... next date:", nextDateStr);
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1)); await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
// create partitions 4 years ahead // create partitions 20 years ahead
const partitionMonths = 4 * 12; const partitionMonths = 20 * 12;
const partitionPromises: Promise<void>[] = []; const partitionPromises: Promise<void>[] = [];
for (let x = 1; x <= partitionMonths; x += 1) { for (let x = 1; x <= partitionMonths; x += 1) {
partitionPromises.push( partitionPromises.push(
@@ -0,0 +1,49 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
const BATCH_SIZE = 1000;
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
});
}
}
@@ -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<void> {
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<void> {
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);
});
}
@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.dropColumn("shouldCheckSecretPermission");
});
}
}
@@ -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<void> {
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<void> {}
+2 -1
View File
@@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
userId: z.string().uuid().nullable().optional(), 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<typeof AuthTokensSchema>; export type TAuthTokens = z.infer<typeof AuthTokensSchema>;
+3 -1
View File
@@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({
scannerProductEnabled: z.boolean().default(true).nullable().optional(), scannerProductEnabled: z.boolean().default(true).nullable().optional(),
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
maxSharedSecretLifetime: z.number().default(2592000).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<typeof OrganizationsSchema>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;
@@ -17,7 +17,8 @@ export const SecretApprovalPoliciesSchema = z.object({
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard"), enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional(), 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<typeof SecretApprovalPoliciesSchema>; export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;
+2 -1
View File
@@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({
emails: z.string().array().nullable().optional(), emails: z.string().array().nullable().optional(),
orgId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
isEmailVerified: z.boolean().default(false).nullable().optional()
}); });
export type TUserAliases = z.infer<typeof UserAliasesSchema>; export type TUserAliases = z.infer<typeof UserAliasesSchema>;
@@ -133,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
approvals: z.number(), approvals: z.number(),
approvers: z approvers: z
.object({ .object({
isOrgMembershipActive: z.boolean().nullable().optional(),
userId: z.string().nullable().optional(), userId: z.string().nullable().optional(),
sequence: z.number().nullable().optional(), sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional(), approvalsRequired: z.number().nullable().optional(),
@@ -150,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
}), }),
reviewers: z reviewers: z
.object({ .object({
isOrgMembershipActive: z.boolean().nullable().optional(),
userId: z.string(), userId: z.string(),
status: z.string() status: z.string()
}) })
@@ -294,22 +294,30 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
200: z.object({ 200: z.object({
approval: SecretApprovalRequestsSchema.merge( approval: SecretApprovalRequestsSchema.merge(
z.object({ z.object({
// secretPath: z.string(),
policy: z.object({ policy: z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
approvals: z.number(), approvals: z.number(),
approvers: approvalRequestUser.array(), approvers: approvalRequestUser
.extend({ isOrgMembershipActive: z.boolean().nullable().optional() })
.array(),
bypassers: approvalRequestUser.array(), bypassers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish(), deletedAt: z.date().nullish(),
allowedSelfApprovals: z.boolean() allowedSelfApprovals: z.boolean(),
shouldCheckSecretPermission: z.boolean().nullable().optional()
}), }),
environment: z.string(), environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(), statusChangedByUser: approvalRequestUser.optional(),
committerUser: approvalRequestUser.nullish(), 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(), secretPath: z.string(),
commits: secretRawSchema commits: secretRawSchema
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
@@ -5,6 +5,7 @@ import {
AccessApprovalRequestsSchema, AccessApprovalRequestsSchema,
TableName, TableName,
TAccessApprovalRequests, TAccessApprovalRequests,
TOrgMemberships,
TUserGroupMembership, TUserGroupMembership,
TUsers TUsers
} from "@app/db/schemas"; } from "@app/db/schemas";
@@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -151,6 +153,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -202,6 +205,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
reviewers: { reviewers: {
userId: string; userId: string;
status: string; status: string;
isOrgMembershipActive: boolean;
}[]; }[];
approvers: ( approvers: (
| { | {
@@ -210,6 +214,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -217,6 +222,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -288,6 +294,24 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
`requestedByUser.id` `requestedByUser.id`
) )
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
`approverOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
`${TableName.Users}.id`,
`approverGroupOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("reviewerOrgMembership"),
`${TableName.AccessApprovalRequestReviewer}.reviewerUserId`,
`reviewerOrgMembership.userId`
)
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
.select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(selectAllTableCols(TableName.AccessApprovalRequest))
@@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), 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") db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
) )
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
@@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
{ {
key: "reviewerUserId", key: "reviewerUserId",
label: "reviewers" as const, 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", key: "approverUserId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ mapper: ({
approverUserId,
approverSequence,
approvalsRequired,
approverUsername,
approverEmail,
approverIsOrgMembershipActive
}) => ({
userId: approverUserId, userId: approverUserId,
sequence: approverSequence, sequence: approverSequence,
approvalsRequired, approvalsRequired,
email: approverEmail, email: approverEmail,
username: approverUsername username: approverUsername,
isOrgMembershipActive: approverIsOrgMembershipActive
}) })
}, },
{ {
@@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
approverSequence, approverSequence,
approvalsRequired, approvalsRequired,
approverGroupEmail, approverGroupEmail,
approverGroupUsername approverGroupUsername,
approverGroupIsOrgMembershipActive
}) => ({ }) => ({
userId: approverGroupUserId, userId: approverGroupUserId,
sequence: approverSequence, sequence: approverSequence,
approvalsRequired, approvalsRequired,
email: approverGroupEmail, email: approverGroupEmail,
username: approverGroupUsername username: approverGroupUsername,
isOrgMembershipActive: approverGroupIsOrgMembershipActive
}) })
}, },
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
@@ -87,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -94,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -145,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory {
reviewers: { reviewers: {
userId: string; userId: string;
status: string; status: string;
isOrgMembershipActive: boolean;
}[]; }[];
approvers: ( approvers: (
| { | {
@@ -153,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
| { | {
userId: string; userId: string;
@@ -160,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory {
approvalsRequired: number | null | undefined; approvalsRequired: number | null | undefined;
email: string | null | undefined; email: string | null | undefined;
username: string; username: string;
isOrgMembershipActive: boolean;
} }
)[]; )[];
bypassers: string[]; bypassers: string[];
@@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type";
import { EventType, filterableSecretEvents } from "./audit-log-types"; import { EventType, filterableSecretEvents } from "./audit-log-types";
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> { export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
pruneAuditLog: (tx?: knex.Knex) => Promise<void>; pruneAuditLog: () => Promise<void>;
find: ( find: (
arg: Omit<TFindQuery, "actor" | "eventType"> & { arg: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string | undefined; actorId?: string | undefined;
@@ -41,6 +41,10 @@ type TFindQuery = {
offset?: number; 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) => { export const auditLogDALFactory = (db: TDbClient) => {
const auditLogOrm = ormify(db, TableName.AuditLog); const auditLogOrm = ormify(db, TableName.AuditLog);
@@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => {
}; };
// delete all audit log that have expired // delete all audit log that have expired
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => {
const runPrune = async (dbClient: knex.Knex) => { const today = new Date();
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; let deletedAuditLogIds: { id: string }[] = [];
const MAX_RETRY_ON_FAILURE = 3; let numberOfRetryOnFailure = 0;
let isRetrying = false;
const today = new Date(); logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
let deletedAuditLogIds: { id: string }[] = []; do {
let numberOfRetryOnFailure = 0; try {
let isRetrying = false; // 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`); const findExpiredLogSubQuery = trx(TableName.AuditLog)
do {
try {
const findExpiredLogSubQuery = dbClient(TableName.AuditLog)
.where("expiresAt", "<", today) .where("expiresAt", "<", today)
.where("createdAt", "<", today) // to use audit log partition .where("createdAt", "<", today) // to use audit log partition
.orderBy(`${TableName.AuditLog}.createdAt`, "desc") .orderBy(`${TableName.AuditLog}.createdAt`, "desc")
@@ -172,35 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => {
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE); .limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await dbClient(TableName.AuditLog) const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id");
.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`);
};
if (tx) { return results;
await runPrune(tx); });
} else {
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes numberOfRetryOnFailure = 0; // reset
await db.transaction(async (trx) => { } catch (error) {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); numberOfRetryOnFailure += 1;
await runPrune(trx); 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) => { const create: TAuditLogDALFactory["create"] = async (tx) => {
@@ -123,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
await redis.set(key, "1", "EX", 60); await redis.set(key, "1", "EX", 60);
stream.push("1"); send({ type: "ping" });
}; };
const close = () => { const close = () => {
@@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({
userAlias = await userDAL.transaction(async (tx) => { userAlias = await userDAL.transaction(async (tx) => {
let newUser: TUsers | undefined; let newUser: TUsers | undefined;
if (serverCfg.trustLdapEmails) { newUser = await userDAL.findOne(
newUser = await userDAL.findOne( {
{ email: email.toLowerCase(),
email: email.toLowerCase(), isEmailVerified: true
isEmailVerified: true },
}, tx
tx );
);
}
if (!newUser) { if (!newUser) {
const uniqueUsername = await normalizeUsername(username, userDAL); const uniqueUsername = await normalizeUsername(username, userDAL);
@@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({
aliasType: UserAliasType.LDAP, aliasType: UserAliasType.LDAP,
externalId, externalId,
emails: [email], emails: [email],
orgId orgId,
isEmailVerified: serverCfg.trustLdapEmails
}, },
tx tx
); );
@@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({
return newUser; return newUser;
}); });
const isUserCompleted = Boolean(user.isAccepted); const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified;
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
hasExchangedPrivateKey: true, hasExchangedPrivateKey: true,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
@@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({
organizationSlug: organization.slug, organizationSlug: organization.slug,
authMethod: AuthMethod.LDAP, authMethod: AuthMethod.LDAP,
authType: UserAliasType.LDAP, authType: UserAliasType.LDAP,
aliasId: userAlias.id,
isUserCompleted, isUserCompleted,
...(relayState ...(relayState
? { ? {
@@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({
} }
); );
if (user.email && !user.isEmailVerified) { if (user.email && !userAlias.isEmailVerified) {
const token = await tokenService.createTokenForUser({ const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId: userAlias.id
}); });
await smtpService.sendMail({ await smtpService.sendMail({
@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
auditLogStreams: false, auditLogStreams: false,
auditLogStreamLimit: 3, auditLogStreamLimit: 3,
samlSSO: false, samlSSO: false,
enforceGoogleSSO: false,
hsm: false, hsm: false,
oidcSSO: false, oidcSSO: false,
scim: false, scim: false,
@@ -47,6 +47,7 @@ export type TFeatureSet = {
auditLogStreamLimit: 3; auditLogStreamLimit: 3;
githubOrgSync: false; githubOrgSync: false;
samlSSO: false; samlSSO: false;
enforceGoogleSSO: false;
hsm: false; hsm: false;
oidcSSO: false; oidcSSO: false;
secretAccessInsights: false; secretAccessInsights: false;
@@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({
} }
const appCfg = getConfig(); const appCfg = getConfig();
const userAlias = await userAliasDAL.findOne({ let userAlias = await userAliasDAL.findOne({
externalId, externalId,
orgId, orgId,
aliasType: UserAliasType.OIDC aliasType: UserAliasType.OIDC
@@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({
} else { } else {
user = await userDAL.transaction(async (tx) => { user = await userDAL.transaction(async (tx) => {
let newUser: TUsers | undefined; 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) { if (!newUser) {
// we prioritize getting the most complete user to create the new alias under // this fetches user entries created via invites
newUser = await userDAL.findOne( newUser = await userDAL.findOne(
{ {
email, username: email
isEmailVerified: true
}, },
tx tx
); );
if (!newUser) { if (newUser && !newUser.isEmailVerified) {
// this fetches user entries created via invites // we automatically mark it as email-verified because we've configured trust for OIDC emails
newUser = await userDAL.findOne( newUser = await userDAL.updateById(newUser.id, {
{ isEmailVerified: serverCfg.trustOidcEmails
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
});
}
} }
} }
@@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({
); );
} }
await userAliasDAL.create( userAlias = await userAliasDAL.create(
{ {
userId: newUser.id, userId: newUser.id,
aliasType: UserAliasType.OIDC, aliasType: UserAliasType.OIDC,
externalId, externalId,
emails: email ? [email] : [], emails: email ? [email] : [],
orgId orgId,
isEmailVerified: serverCfg.trustOidcEmails
}, },
tx tx
); );
@@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({
await licenseService.updateSubscriptionOrgMemberCount(organization.id); await licenseService.updateSubscriptionOrgMemberCount(organization.id);
const isUserCompleted = Boolean(user.isAccepted); const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified;
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
organizationId: organization.id, organizationId: organization.id,
organizationSlug: organization.slug, organizationSlug: organization.slug,
hasExchangedPrivateKey: true, hasExchangedPrivateKey: true,
aliasId: userAlias.id,
authMethod: AuthMethod.OIDC, authMethod: AuthMethod.OIDC,
authType: UserAliasType.OIDC, authType: UserAliasType.OIDC,
isUserCompleted, isUserCompleted,
@@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({
await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() });
if (user.email && !user.isEmailVerified) { if (user.email && !userAlias.isEmailVerified) {
const token = await tokenService.createTokenForUser({ const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId: userAlias.id
}); });
await smtpService await smtpService
@@ -13,6 +13,7 @@ import {
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
ProjectPermissionSecretEventActions,
ProjectPermissionSecretRotationActions, ProjectPermissionSecretRotationActions,
ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningConfigActions,
ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSecretScanningDataSourceActions,
@@ -252,6 +253,16 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretScanningConfigs ProjectPermissionSub.SecretScanningConfigs
); );
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules; return rules;
}; };
@@ -455,6 +466,16 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules; return rules;
}; };
@@ -505,6 +526,16 @@ const buildViewerPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules; return rules;
}; };
@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
projectFavorites?: string[] | null | undefined; projectFavorites?: string[] | null | undefined;
customRoleSlug?: string | null | undefined; customRoleSlug?: string | null | undefined;
orgAuthEnforced?: boolean | null | undefined; orgAuthEnforced?: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
} & { } & {
groups: { groups: {
id: string; id: string;
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
}[]; }[];
orgId: string; orgId: string;
orgAuthEnforced: boolean | null | undefined; orgAuthEnforced: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
orgRole: OrgMembershipRole; orgRole: OrgMembershipRole;
userId: string; userId: string;
projectId: 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("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("permissions").withSchema(TableName.OrgRoles),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), 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("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("groupId").withSchema("userGroups"), db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"),
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
OrgMembershipsSchema.extend({ OrgMembershipsSchema.extend({
permissions: z.unknown(), permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(), orgAuthEnforced: z.boolean().optional().nullable(),
orgGoogleSsoAuthEnforced: z.boolean(),
bypassOrgAuthEnabled: z.boolean(), bypassOrgAuthEnabled: z.boolean(),
customRoleSlug: z.string().optional().nullable(), customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean() shouldUseNewPrivilegeSystem: z.boolean()
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), 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("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project), db.ref("orgId").withSchema(TableName.Project),
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
orgId, orgId,
username, username,
orgAuthEnforced, orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole, orgRole,
membershipId, membershipId,
groupMembershipId, groupMembershipId,
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
}) => ({ }) => ({
orgId, orgId,
orgAuthEnforced, orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole: orgRole as OrgMembershipRole, orgRole: orgRole as OrgMembershipRole,
userId, userId,
projectId, projectId,
@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
function validateOrgSSO( function validateOrgSSO(
actorAuthMethod: ActorAuthMethod, actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"], isOrgSsoEnforced: TOrganizations["authEnforced"],
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
orgRole: OrgMembershipRole orgRole: OrgMembershipRole
) { ) {
@@ -128,10 +129,16 @@ function validateOrgSSO(
throw new UnauthorizedError({ name: "No auth method defined" }); throw new UnauthorizedError({ name: "No auth method defined" });
} }
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
return; 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 ( if (
isOrgSsoEnforced && isOrgSsoEnforced &&
actorAuthMethod !== null && actorAuthMethod !== null &&
@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
validateOrgSSO( validateOrgSSO(
authMethod, authMethod,
membership.orgAuthEnforced, membership.orgAuthEnforced,
membership.orgGoogleSsoAuthEnforced,
membership.bypassOrgAuthEnabled, membership.bypassOrgAuthEnabled,
membership.role as OrgMembershipRole membership.role as OrgMembershipRole
); );
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
validateOrgSSO( validateOrgSSO(
authMethod, authMethod,
userProjectPermission.orgAuthEnforced, userProjectPermission.orgAuthEnforced,
userProjectPermission.orgGoogleSsoAuthEnforced,
userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.bypassOrgAuthEnabled,
userProjectPermission.orgRole userProjectPermission.orgRole
); );
@@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({
}); });
} }
const userAlias = await userAliasDAL.findOne({ let userAlias = await userAliasDAL.findOne({
externalId, externalId,
orgId, orgId,
aliasType: UserAliasType.SAML aliasType: UserAliasType.SAML
@@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({
user = await userDAL.transaction(async (tx) => { user = await userDAL.transaction(async (tx) => {
let newUser: TUsers | undefined; let newUser: TUsers | undefined;
if (serverCfg.trustSamlEmails) { newUser = await userDAL.findOne(
newUser = await userDAL.findOne( {
{ email,
email, isEmailVerified: true
isEmailVerified: true },
}, tx
tx );
);
}
if (!newUser) { if (!newUser) {
const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL);
@@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({
); );
} }
await userAliasDAL.create( userAlias = await userAliasDAL.create(
{ {
userId: newUser.id, userId: newUser.id,
aliasType: UserAliasType.SAML, aliasType: UserAliasType.SAML,
externalId, externalId,
emails: email ? [email] : [], emails: email ? [email] : [],
orgId orgId,
isEmailVerified: serverCfg.trustSamlEmails
}, },
tx tx
); );
@@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({
} }
await licenseService.updateSubscriptionOrgMemberCount(organization.id); 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( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
username: user.username, username: user.username,
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
firstName, firstName,
lastName, lastName,
organizationName: organization.name, organizationName: organization.name,
@@ -424,6 +423,7 @@ export const samlConfigServiceFactory = ({
organizationSlug: organization.slug, organizationSlug: organization.slug,
authMethod: authProvider, authMethod: authProvider,
hasExchangedPrivateKey: true, hasExchangedPrivateKey: true,
aliasId: userAlias.id,
authType: UserAliasType.SAML, authType: UserAliasType.SAML,
isUserCompleted, isUserCompleted,
...(relayState ...(relayState
@@ -440,10 +440,11 @@ export const samlConfigServiceFactory = ({
await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); await samlConfigDAL.update({ orgId }, { lastUsed: new Date() });
if (user.email && !user.isEmailVerified) { if (user.email && !userAlias.isEmailVerified) {
const token = await tokenService.createTokenForUser({ const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId: userAlias.id
}); });
await smtpService.sendMail({ await smtpService.sendMail({
@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
import { import {
SecretApprovalRequestsSchema, SecretApprovalRequestsSchema,
TableName, TableName,
TOrgMemberships,
TSecretApprovalRequests, TSecretApprovalRequests,
TSecretApprovalRequestsSecrets, TSecretApprovalRequestsSecrets,
TUserGroupMembership, TUserGroupMembership,
@@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`secretApprovalReviewerUser.id` `secretApprovalReviewerUser.id`
) )
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverOrgMembership"),
`${TableName.SecretApprovalPolicyApprover}.approverUserId`,
`approverOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
`secretApprovalPolicyGroupApproverUser.id`,
`approverGroupOrgMembership.userId`
)
.leftJoin<TOrgMemberships>(
db(TableName.OrgMembership).as("reviewerOrgMembership"),
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`reviewerOrgMembership.userId`
)
.select(selectAllTableCols(TableName.SecretApprovalRequest)) .select(selectAllTableCols(TableName.SecretApprovalRequest))
.select( .select(
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"),
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), 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("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"), 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("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"), tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
tx.ref("projectId").withSchema(TableName.Environment), 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("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"),
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), 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) => { const findById = async (id: string, tx?: Knex) => {
@@ -197,7 +224,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId, envId: el.policyEnvId,
deletedAt: el.policyDeletedAt, deletedAt: el.policyDeletedAt,
allowedSelfApprovals: el.policyAllowedSelfApprovals allowedSelfApprovals: el.policyAllowedSelfApprovals,
shouldCheckSecretPermission: el.policySecretReadAccessCompat
} }
}), }),
childrenMapper: [ childrenMapper: [
@@ -211,9 +239,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerLastName: lastName, reviewerLastName: lastName,
reviewerUsername: username, reviewerUsername: username,
reviewerFirstName: firstName, 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", key: "approverUserId",
@@ -223,13 +263,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approverEmail: email, approverEmail: email,
approverUsername: username, approverUsername: username,
approverLastName: lastName, approverLastName: lastName,
approverFirstName: firstName approverFirstName: firstName,
approverIsOrgMembershipActive: isOrgMembershipActive
}) => ({ }) => ({
userId, userId,
email, email,
firstName, firstName,
lastName, lastName,
username username,
isOrgMembershipActive
}) })
}, },
{ {
@@ -240,13 +282,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approverGroupEmail: email, approverGroupEmail: email,
approverGroupUsername: username, approverGroupUsername: username,
approverGroupLastName: lastName, approverGroupLastName: lastName,
approverGroupFirstName: firstName approverGroupFirstName: firstName,
approverGroupIsOrgMembershipActive: isOrgMembershipActive
}) => ({ }) => ({
userId, userId,
email, email,
firstName, firstName,
lastName, lastName,
username username,
isOrgMembershipActive
}) })
}, },
{ {
@@ -653,14 +697,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName") db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
) )
.distinctOn(`${TableName.SecretApprovalRequest}.id`)
.as("inner"); .as("inner");
const query = (tx || db) const countQuery = (await (tx || db)
.select("*")
.select(db.raw("count(*) OVER() as total_count")) .select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery) .from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{
.orderBy("createdAt", "desc") as typeof innerQuery; total_count: number;
}>;
const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery;
if (search) { if (search) {
void query.where((qb) => { void query.where((qb) => {
@@ -686,8 +731,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.where("w.rank", ">=", rankOffset) .where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit); .andWhere("w.rank", "<", rankOffset + limit);
// @ts-expect-error knex does not infer const totalCount = Number(countQuery[0]?.total_count || 0);
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({ const formattedDoc = sqlNestRelationships({
data: docs, data: docs,
@@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id);
if (!secretApprovalRequest) if (!secretApprovalRequest)
throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` }); 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" }); throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
} }
const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => { const getHasSecretReadAccess = (
const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { shouldCheckSecretPermission: boolean | null | undefined,
environment, environment: string,
secretPath: secretPath || "/", tags: { slug: string }[],
secretTags: tags.map((i) => i.slug) secretPath?: string
}); ) => {
return canRead; if (shouldCheckSecretPermission) {
const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
environment,
secretPath: secretPath || "/",
secretTags: tags.map((i) => i.slug)
});
return canRead;
}
return true;
}; };
let secrets; let secrets;
@@ -308,8 +318,18 @@ export const secretApprovalRequestServiceFactory = ({
version: el.version, version: el.version,
secretMetadata: el.secretMetadata as ResourceMetadataDTO, secretMetadata: el.secretMetadata as ResourceMetadataDTO,
isRotatedSecret: el.secret?.isRotatedSecret ?? false, isRotatedSecret: el.secret?.isRotatedSecret ?? false,
secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), secretValueHidden: !getHasSecretReadAccess(
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) 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 ? INFISICAL_SECRET_VALUE_HIDDEN_MASK
: el.secret && el.secret.isRotatedSecret : el.secret && el.secret.isRotatedSecret
? undefined ? undefined
@@ -325,11 +345,17 @@ export const secretApprovalRequestServiceFactory = ({
id: el.secret.id, id: el.secret.id,
version: el.secret.version, version: el.secret.version,
secretValueHidden: !getHasSecretReadAccess( secretValueHidden: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment, secretApprovalRequest.environment,
el.tags, el.tags,
secretPath?.[0]?.path 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 ? INFISICAL_SECRET_VALUE_HIDDEN_MASK
: el.secret.encryptedValue : el.secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
@@ -345,11 +371,17 @@ export const secretApprovalRequestServiceFactory = ({
id: el.secretVersion.id, id: el.secretVersion.id,
version: el.secretVersion.version, version: el.secretVersion.version,
secretValueHidden: !getHasSecretReadAccess( secretValueHidden: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment, secretApprovalRequest.environment,
el.tags, el.tags,
secretPath?.[0]?.path 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 ? INFISICAL_SECRET_VALUE_HIDDEN_MASK
: el.secretVersion.encryptedValue : el.secretVersion.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
@@ -367,7 +399,12 @@ export const secretApprovalRequestServiceFactory = ({
const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
secrets = encryptedSecrets.map((el) => ({ secrets = encryptedSecrets.map((el) => ({
...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), ...decryptSecretWithBot(el, botKey),
secret: el.secret secret: el.secret
? { ? {
@@ -1447,6 +1484,7 @@ export const secretApprovalRequestServiceFactory = ({
const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = []; const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = [];
const commitTagIds: Record<string, string[]> = {}; const commitTagIds: Record<string, string[]> = {};
const existingTagIds: Record<string, string[]> = {};
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
@@ -1512,6 +1550,11 @@ export const secretApprovalRequestServiceFactory = ({
type: SecretType.Shared type: SecretType.Shared
})) }))
); );
secretsToUpdateStoredInDB.forEach((el) => {
if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id);
});
if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length) if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length)
throw new NotFoundError({ throw new NotFoundError({
message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}` message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}`
@@ -1555,7 +1598,10 @@ export const secretApprovalRequestServiceFactory = ({
secretMetadata secretMetadata
}) => { }) => {
const secretId = updatingSecretsGroupByKey[secretKey][0].id; 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 { return {
...latestSecretVersions[secretId], ...latestSecretVersions[secretId],
secretMetadata, secretMetadata,
+1
View File
@@ -2500,6 +2500,7 @@ export const SecretSyncs = {
}, },
RENDER: { RENDER: {
serviceId: "The ID of the Render service to sync secrets to.", 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.", scope: "The Render scope that secrets should be synced to.",
type: "The Render resource type to sync secrets to." type: "The Render resource type to sync secrets to."
}, },
+4 -4
View File
@@ -1,11 +1,11 @@
/** /**
* Safely retrieves a value from a nested object using dot notation path * Safely retrieves a value from a nested object using dot notation path
*/ */
export const getStringValueByDot = ( export const getValueByDot = (
obj: Record<string, unknown> | null | undefined, obj: Record<string, unknown> | null | undefined,
path: string, path: string,
defaultValue?: string defaultValue?: string | number | boolean
): string | undefined => { ): string | number | boolean | undefined => {
// Handle null or undefined input // Handle null or undefined input
if (!obj) { if (!obj) {
return defaultValue; return defaultValue;
@@ -26,7 +26,7 @@ export const getStringValueByDot = (
current = (current as Record<string, unknown>)[part]; current = (current as Record<string, unknown>)[part];
} }
if (typeof current !== "string") { if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
return defaultValue; return defaultValue;
} }
+2 -1
View File
@@ -726,7 +726,8 @@ export const registerRoutes = async (
permissionService, permissionService,
groupProjectDAL, groupProjectDAL,
smtpService, smtpService,
projectMembershipDAL projectMembershipDAL,
userAliasDAL
}); });
const totpService = totpServiceFactory({ const totpService = totpServiceFactory({
@@ -53,4 +53,36 @@ export const registerChecklyConnectionRouter = async (server: FastifyZodProvider
return { accounts }; 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 };
}
});
}; };
@@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider)
return services; 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;
}
});
}; };
@@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
name: GenericResourceNameSchema.optional(), name: GenericResourceNameSchema.optional(),
slug: slugSchema({ max: 64 }).optional(), slug: slugSchema({ max: 64 }).optional(),
authEnforced: z.boolean().optional(), authEnforced: z.boolean().optional(),
googleSsoAuthEnforced: z.boolean().optional(),
scimEnabled: z.boolean().optional(), scimEnabled: z.boolean().optional(),
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
enforceMfa: z.boolean().optional(), enforceMfa: z.boolean().optional(),
@@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
firstName: true, firstName: true,
lastName: true, lastName: true,
id: true id: true
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), })
.merge(UserEncryptionKeysSchema.pick({ publicKey: true }))
.extend({
isOrgMembershipActive: z.boolean()
}),
project: SanitizedProjectSchema.pick({ name: true, id: true }), project: SanitizedProjectSchema.pick({ name: true, id: true }),
roles: z.array( roles: z.array(
z.object({ z.object({
+9 -2
View File
@@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
try { try {
// @ts-expect-error this is because this is express type and not fastify // @ts-expect-error this is because this is express type and not fastify
const callbackPort = req.session.get("callbackPort"); 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; const email = profile?.emails?.[0]?.value;
if (!email) if (!email)
@@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
firstName: profile?.name?.givenName || "", firstName: profile?.name?.givenName || "",
lastName: profile?.name?.familyName || "", lastName: profile?.name?.familyName || "",
authMethod: AuthMethod.GOOGLE, authMethod: AuthMethod.GOOGLE,
callbackPort callbackPort,
orgSlug
}); });
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
@@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
querystring: z.object({ querystring: z.object({
callback_port: z.string().optional(), callback_port: z.string().optional(),
org_slug: z.string().optional(),
is_admin_login: z is_admin_login: z
.string() .string()
.optional() .optional()
@@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
}, },
preValidation: [ preValidation: [
async (req, res) => { 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 // ensure fresh session state per login attempt
await req.session.regenerate(); await req.session.regenerate();
if (callbackPort) { if (callbackPort) {
req.session.set("callbackPort", callbackPort); req.session.set("callbackPort", callbackPort);
} }
if (orgSlug) {
req.session.set("orgSlug", orgSlug);
}
if (isAdminLogin) { if (isAdminLogin) {
req.session.set("isAdminLogin", isAdminLogin); req.session.set("isAdminLogin", isAdminLogin);
} }
+2 -2
View File
@@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
username: z.string().trim() token: z.string().trim()
}), }),
response: { response: {
200: z.object({}) 200: z.object({})
} }
}, },
handler: async (req) => { handler: async (req) => {
await server.services.user.sendEmailVerificationCode(req.body.username); await server.services.user.sendEmailVerificationCode(req.body.token);
return {}; return {};
} }
}); });
@@ -4,6 +4,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxi
import { createRequestClient } from "@app/lib/config/request"; import { createRequestClient } from "@app/lib/config/request";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; 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 { ChecklyConnectionMethod } from "./checkly-connection-constants";
import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types"; import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types";
@@ -181,6 +182,122 @@ class ChecklyPublicClient {
return res; 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<ChecklyGroupResponse>(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(); export const ChecklyPublicAPI = new ChecklyPublicClient();
@@ -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 { return {
listAccounts listAccounts,
listGroups
}; };
}; };
@@ -33,3 +33,15 @@ export type TChecklyAccount = {
name: string; name: string;
runtimeId: string; runtimeId: string;
}; };
export type TChecklyGroupEnvironmentVariable = {
key: string;
value: string;
locked: boolean;
};
export type TChecklyGroup = {
id: string;
name: string;
environmentVariables?: TChecklyGroupEnvironmentVariable[];
};
@@ -1,5 +1,3 @@
import { createAppAuth } from "@octokit/auth-app";
import { request } from "@octokit/request";
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import https from "https"; import https from "https";
import RE2 from "re2"; 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 { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { request as httpRequest } from "@app/lib/config/request"; import { request as httpRequest } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async <T>(
); );
}; };
export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => { export const getGitHubAppAuthToken = async (
appConnection: TGitHubConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const appCfg = getConfig(); const appCfg = getConfig();
const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; 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) { if (!appId || !appPrivateKey) {
throw new InternalServerError({ 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) { if (appConnection.method !== GitHubConnectionMethod.App) {
throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" }); throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" });
} }
const appAuth = createAppAuth({ const now = Math.floor(Date.now() / 1000);
appId, const payload = {
privateKey: appPrivateKey, iat: now,
installationId: appConnection.credentials.installationId, exp: now + 5 * 60,
request: request.defaults({ iss: appId
baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` };
})
});
const { token } = await appAuth({ type: "installation" }); const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" });
return token;
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<string, string> => { const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
@@ -174,7 +197,9 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
const { credentials, method } = appConnection; const { credentials, method } = appConnection;
const token = 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 baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const initialUrlObj = new URL(baseUrl); const initialUrlObj = new URL(baseUrl);
@@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { RenderConnectionMethod } from "./render-connection-enums"; import { RenderConnectionMethod } from "./render-connection-enums";
import { import {
TRawRenderEnvironmentGroup,
TRawRenderService, TRawRenderService,
TRenderConnection, TRenderConnection,
TRenderConnectionConfig, TRenderConnectionConfig,
TRenderEnvironmentGroup,
TRenderService TRenderService
} from "./render-connection-types"; } from "./render-connection-types";
@@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
const perPage = 100; const perPage = 100;
let cursor; let cursor;
let maxIterations = 10;
while (hasMorePages) { while (hasMorePages) {
if (maxIterations <= 0) break;
const res: TRawRenderService[] = ( const res: TRawRenderService[] = (
await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, {
params: new URLSearchParams({ params: new URLSearchParams({
@@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
} else { } else {
cursor = res[res.length - 1].cursor; cursor = res[res.length - 1].cursor;
} }
maxIterations -= 1;
} }
return services; return services;
@@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect
return inputCredentials; return inputCredentials;
}; };
export const listRenderEnvironmentGroups = async (
appConnection: TRenderConnection
): Promise<TRenderEnvironmentGroup[]> => {
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<TRawRenderEnvironmentGroup[]>(`${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;
};
@@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; 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"; import { TRenderConnection } from "./render-connection-types";
type TGetAppConnectionFunc = ( 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 { return {
listServices listServices,
listEnvironmentGroups
}; };
}; };
@@ -33,3 +33,16 @@ export type TRawRenderService = {
name: string; name: string;
}; };
}; };
export type TRenderEnvironmentGroup = {
name: string;
id: string;
};
export type TRawRenderEnvironmentGroup = {
cursor: string;
envGroup: {
id: string;
name: string;
};
};
@@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
}; };
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { 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 { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig(); const appCfg = getConfig();
const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS);
@@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
type, type,
userId, userId,
orgId, orgId,
triesLeft: tkCfg?.triesLeft triesLeft: tkCfg?.triesLeft,
aliasId
}, },
tx tx
); );
@@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = {
type: TokenType; type: TokenType;
userId: string; userId: string;
orgId?: string; orgId?: string;
aliasId?: string;
}; };
export type TCreateOrgInviteTokenDTO = { export type TCreateOrgInviteTokenDTO = {
@@ -448,15 +448,41 @@ export const authLoginServiceFactory = ({
// Check if the user actually has access to the specified organization. // Check if the user actually has access to the specified organization.
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); 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); 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({ throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}` 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 shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
@@ -502,7 +528,8 @@ export const authLoginServiceFactory = ({
selectedOrg.authEnforced && selectedOrg.authEnforced &&
selectedOrg.bypassOrgAuthEnabled && selectedOrg.bypassOrgAuthEnabled &&
!isAuthMethodSaml(decodedToken.authMethod) && !isAuthMethodSaml(decodedToken.authMethod) &&
decodedToken.authMethod !== AuthMethod.OIDC decodedToken.authMethod !== AuthMethod.OIDC &&
decodedToken.authMethod !== AuthMethod.GOOGLE
) { ) {
await auditLogService.createAuditLog({ await auditLogService.createAuditLog({
orgId: organizationId, orgId: organizationId,
@@ -705,7 +732,7 @@ export const authLoginServiceFactory = ({
/* /*
* OAuth2 login for google,github, and other oauth2 provider * 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 // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserByUsername(email); const usersByUsername = await userDAL.findUserByUsername(email);
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
@@ -759,6 +786,8 @@ export const authLoginServiceFactory = ({
const appCfg = getConfig(); const appCfg = getConfig();
let orgId = "";
let orgName: undefined | string;
if (!user) { if (!user) {
// Create a new user based on oAuth // Create a new user based on oAuth
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); 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) { if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
let orgId = "";
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
if (!defaultOrg) { if (!defaultOrg) {
throw new BadRequestError({ 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 isUserCompleted = user.isAccepted;
const providerAuthToken = crypto.jwt().sign( const providerAuthToken = crypto.jwt().sign(
{ {
authTokenType: AuthTokenType.PROVIDER_TOKEN, authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id, userId: user.id,
...(orgId && orgSlug && orgName !== undefined
? {
organizationId: orgId,
organizationName: orgName,
organizationSlug: orgSlug
}
: {}),
username: user.username, username: user.username,
email: user.email, email: user.email,
isEmailVerified: user.isEmailVerified, isEmailVerified: user.isEmailVerified,
@@ -32,6 +32,7 @@ export type TOauthLoginDTO = {
lastName?: string; lastName?: string;
authMethod: AuthMethod; authMethod: AuthMethod;
callbackPort?: string; callbackPort?: string;
orgSlug?: string;
}; };
export type TOauthTokenExchangeDTO = { export type TOauthTokenExchangeDTO = {
@@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
`${TableName.GroupProjectMembershipRole}.customRoleId`, `${TableName.GroupProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id` `${TableName.ProjectRoles}.id`
) )
.join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select( .select(
db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("id").withSchema(TableName.UserGroupMembership),
db.ref("createdAt").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("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessEndTime").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 }); .where({ isGhost: false });
@@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id, id,
userId, userId,
projectName, projectName,
createdAt createdAt,
isActive
}) => ({ }) => ({
isGroupMember: true, isGroupMember: true,
id, id,
@@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id: projectId, id: projectId,
name: projectName name: projectName
}, },
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive },
createdAt createdAt
}), }),
key: "id", key: "id",
@@ -21,7 +21,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; 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 { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({
if (identityJwtAuth.boundClaims) { if (identityJwtAuth.boundClaims) {
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey]; const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
const value = getStringValueByDot(tokenData, claimKey) || ""; const value = getValueByDot(tokenData, claimKey);
if (!value) { if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({
} }
// handle both single and multi-valued claims // handle both single and multi-valued claims
if ( if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) {
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry))
) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: claim mismatch for field ${claimKey}` message: `Access denied: claim mismatch for field ${claimKey}`
}); });
@@ -1,7 +1,16 @@
import picomatch from "picomatch"; import picomatch from "picomatch";
export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) => export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => {
policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); 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) => { export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => {
if (Array.isArray(fieldValue)) { if (Array.isArray(fieldValue)) {
@@ -22,7 +22,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; 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 { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({
if (identityOidcAuth.boundClaims) { if (identityOidcAuth.boundClaims) {
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey]; const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
const value = getStringValueByDot(tokenData, claimKey) || ""; const value = getValueByDot(tokenData, claimKey);
if (!value) { if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({
if (identityOidcAuth.claimMetadataMapping) { if (identityOidcAuth.claimMetadataMapping) {
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey]; const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
const value = getStringValueByDot(tokenData, claimKey) || ""; const value = getValueByDot(tokenData, claimKey);
if (!value) { if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field` message: `Access denied: token has no ${claimKey} field`
}); });
} }
filteredClaims[permissionKey] = value; filteredClaims[permissionKey] = value.toString();
}); });
} }
@@ -124,12 +124,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
void qb void qb
.whereNull(`${TableName.OrgMembership}.lastInvitedAt`) .whereNull(`${TableName.OrgMembership}.lastInvitedAt`)
.whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); .whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]);
})
.orWhere((qb) => {
// lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago
void qb void qb.orWhere((qbInner) => {
.where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) void qbInner
.where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo)
.where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo);
});
}); });
return memberships; return memberships;
+1
View File
@@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
authEnforced: true, authEnforced: true,
googleSsoAuthEnforced: true,
scimEnabled: true, scimEnabled: true,
kmsDefaultKeyId: true, kmsDefaultKeyId: true,
defaultMembershipRole: true, defaultMembershipRole: true,
+32
View File
@@ -364,6 +364,7 @@ export const orgServiceFactory = ({
name, name,
slug, slug,
authEnforced, authEnforced,
googleSsoAuthEnforced,
scimEnabled, scimEnabled,
defaultMembershipRoleSlug, defaultMembershipRoleSlug,
enforceMfa, 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) { if (authEnforced) {
const samlCfg = await samlConfigDAL.findOne({ const samlCfg = await samlConfigDAL.findOne({
orgId, 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; let defaultMembershipRole: string | undefined;
if (defaultMembershipRoleSlug) { if (defaultMembershipRoleSlug) {
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
@@ -474,6 +505,7 @@ export const orgServiceFactory = ({
name, name,
slug: slug ? slugify(slug) : undefined, slug: slug ? slugify(slug) : undefined,
authEnforced, authEnforced,
googleSsoAuthEnforced,
scimEnabled, scimEnabled,
defaultMembershipRole, defaultMembershipRole,
enforceMfa, enforceMfa,
+1
View File
@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
name: string; name: string;
slug: string; slug: string;
authEnforced: boolean; authEnforced: boolean;
googleSsoAuthEnforced: boolean;
scimEnabled: boolean; scimEnabled: boolean;
defaultMembershipRoleSlug: string; defaultMembershipRoleSlug: string;
enforceMfa: boolean; enforceMfa: boolean;
@@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.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) => { .where((qb) => {
if (filter.usernames) { if (filter.usernames) {
void qb.whereIn("username", 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("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").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 }) .where({ isGhost: false })
.orderBy(`${TableName.Users}.username` as "username"); .orderBy(`${TableName.Users}.username` as "username");
@@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
id, id,
userId, userId,
projectName, projectName,
createdAt createdAt,
isActive
}) => ({ }) => ({
id, id,
userId, userId,
projectId, projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, user: {
email,
username,
firstName,
lastName,
id: userId,
publicKey,
isGhost,
isOrgMembershipActive: isActive
},
project: { project: {
id: projectId, id: projectId,
name: projectName name: projectName
@@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
// projectMembers[0].project
if (includeGroupMembers) { if (includeGroupMembers) {
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
const allMembers = [ const allMembers = [
@@ -23,56 +23,120 @@ export const ChecklySyncFns = {
const config = secretSync.destinationConfig; 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)) { // Prepare all variables to update at once
try { const updatedVariables = { ...checklyGroupSecrets };
for (const key of Object.keys(secretMap)) {
const entry = secretMap[key]; 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() === "") { if (entry.value.trim() === "") {
// Delete the secret from Checkly if its empty // Delete the secret from the group if it's empty
if (!disableSecretDeletion) { if (!disableSecretDeletion) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { delete updatedVariables[key];
key
});
} }
continue; // Skip empty values continue; // Skip empty values
} }
await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { // Add or update the variable
updatedVariables[key] = {
key, key,
value: entry.value, value: entry.value,
secret: true,
locked: 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) { } catch (error) {
if (error instanceof SecretSyncError) throw error;
throw new SecretSyncError({ throw new SecretSyncError({
error, 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)) { for await (const key of Object.keys(secretMap)) {
try { try {
// eslint-disable-next-line no-continue const entry = secretMap[key];
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!secretMap[key]) { // If value is empty, we skip the upsert - checkly does not allow empty values
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { if (entry.value.trim() === "") {
key // 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) { async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) {
const config = secretSync.destinationConfig; 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 { try {
if (secret in secretMap) { await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables(
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { secretSync.connection,
key: secret config.accountId,
}); config.groupId,
} remainingVariables
);
} catch (error) { } catch (error) {
throw new SecretSyncError({ throw new SecretSyncError({
error, 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
});
}
}
} }
} }
}; };
@@ -11,7 +11,17 @@ import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"
const ChecklySyncDestinationConfigSchema = z.object({ const ChecklySyncDestinationConfigSchema = z.object({
accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), 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 }; const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
@@ -207,7 +207,7 @@ export const GithubSyncFns = {
const token = const token =
connection.method === GitHubConnectionMethod.OAuth connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken ? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection); : await getGitHubAppAuthToken(connection, gatewayService);
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
const publicKey = await getPublicKey(secretSync, gatewayService, token); const publicKey = await getPublicKey(secretSync, gatewayService, token);
@@ -264,7 +264,7 @@ export const GithubSyncFns = {
const token = const token =
connection.method === GitHubConnectionMethod.OAuth connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken ? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection); : await getGitHubAppAuthToken(connection, gatewayService);
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
@@ -1,5 +1,6 @@
export enum RenderSyncScope { export enum RenderSyncScope {
Service = "service" Service = "service",
EnvironmentGroup = "environment-group"
} }
export enum RenderSyncType { export enum RenderSyncType {
@@ -1,11 +1,13 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import { isAxiosError } from "axios"; import { AxiosRequestConfig, isAxiosError } from "axios";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { RenderSyncScope } from "./render-sync-enums";
import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types";
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
@@ -27,6 +29,80 @@ const makeRequestWithRetry = async <T>(requestFn: () => Promise<T>, 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<TRenderSecret[]> => { const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise<TRenderSecret[]> => {
const { const {
destinationConfig, destinationConfig,
@@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential
} }
} = secretSync; } = secretSync;
const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; const secrets = await getSecrets({
const allSecrets: TRenderSecret[] = []; destination: destinationConfig,
let cursor: string | undefined; token: apiKey
});
do { return secrets;
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;
}; };
const batchUpdateEnvironmentSecrets = async ( const batchUpdateEnvironmentSecrets = async (
@@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async (
} }
} = secretSync; } = secretSync;
await makeRequestWithRetry(() => const req: AxiosRequestConfig = {
request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
headers: { method: "PUT",
Authorization: `Bearer ${apiKey}`, headers: {
Accept: "application/json" 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<void> => {
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) => { const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
@@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
} }
} = secretSync; } = secretSync;
await makeRequestWithRetry(() => const req: AxiosRequestConfig = {
request.post( baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, headers: {
{}, Authorization: `Bearer ${apiKey}`,
{ Accept: "application/json"
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 = { export const RenderSyncFns = {
@@ -169,14 +321,15 @@ export const RenderSyncFns = {
const finalEnvVars: Array<{ key: string; value: string }> = []; const finalEnvVars: Array<{ key: string; value: string }> = [];
for (const renderSecret of renderSecrets) { for (const renderSecret of renderSecrets) {
if (!(renderSecret.key in secretMap)) { if (renderSecret.key in secretMap) {
finalEnvVars.push({ finalEnvVars.push({
key: renderSecret.key, key: renderSecret.key,
value: renderSecret.value value: renderSecret.value
}); });
} }
} }
await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars);
await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el)));
if (secretSync.syncOptions.autoRedeployServices) { if (secretSync.syncOptions.autoRedeployServices) {
await redeployService(secretSync); await redeployService(secretSync);
@@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.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), 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) 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)
}) })
]); ]);
@@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
throw new BadRequestError({ message: "Missing personal user id" }); throw new BadRequestError({ message: "Missing personal user id" });
} }
void bd.orWhere({ void bd.orWhere({
key: el.key, [`${TableName.SecretV2}.key` as "key"]: el.key,
type: el.type, [`${TableName.SecretV2}.type` as "type"]: el.type,
userId: el.type === SecretType.Personal ? el.userId : null [`${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.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId` `${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(selectAllTableCols(TableName.SecretV2))
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
return secrets.map((secret) => ({
...secret, const docs = sqlNestRelationships({
isRotatedSecret: Boolean(secret.rotationId) 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) { } catch (error) {
throw new DatabaseError({ error, name: "find by secret keys" }); throw new DatabaseError({ error, name: "find by secret keys" });
} }
@@ -1074,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({
currentPath: path 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 })); paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
} else { } else {
const folder = await folderDAL.findBySecretPath(projectId, environment, path); 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 }]; paths = [{ folderId: folder.id, path }];
} }
+12 -2
View File
@@ -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 })); paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
} else { } else {
@@ -647,7 +652,12 @@ export const secretServiceFactory = ({
}); });
const folder = await folderDAL.findBySecretPath(projectId, environment, path); 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 }]; paths = [{ folderId: folder.id, path }];
} }
+35 -10
View File
@@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; 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 { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; 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 { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; 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 { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
import { TUserDALFactory } from "./user-dal"; import { TUserDALFactory } from "./user-dal";
import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
@@ -37,6 +39,7 @@ type TUserServiceFactoryDep = {
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
smtpService: Pick<TSmtpService, "sendMail">; smtpService: Pick<TSmtpService, "sendMail">;
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById">;
}; };
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>; export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
@@ -48,22 +51,38 @@ export const userServiceFactory = ({
groupProjectDAL, groupProjectDAL,
tokenService, tokenService,
smtpService, smtpService,
permissionService permissionService,
userAliasDAL
}: TUserServiceFactoryDep) => { }: 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 // akhilmhdh: case sensitive email resolution
const users = await userDAL.findUserByUsername(username); const users = await userDAL.findUserByUsername(username);
const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; 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` }); 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) if (!user.email)
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); 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" }); 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, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id userId: user.id,
aliasId
}); });
await smtpService.sendMail({ await smtpService.sendMail({
@@ -71,7 +90,7 @@ export const userServiceFactory = ({
subjectLine: "Infisical confirmation code", subjectLine: "Infisical confirmation code",
recipients: [user.email], recipients: [user.email],
substitutions: { 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) throw new NotFoundError({ name: `User with username '${username}' not found` });
if (!user.email) if (!user.email)
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); 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, type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id, userId: user.id,
code 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 }) : []; const userEmails = user?.email ? await userDAL.find({ email: user.email }) : [];
await userDAL.updateById(user.id, { await userDAL.updateById(user.id, {
@@ -25,6 +25,11 @@ This functionality works in the following way:
{/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} {/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */}
![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png)
<Note>
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)
</Note>
<Info> <Info>
If the access request matches with a policy that allows break-glass approval 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 bypasses, the requester may bypass the policy and get access to the resource
Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 592 KiB

+54 -3
View File
@@ -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: The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip:
```bash ```bash
$ pip install infisical-python $ pip install infisicalsdk
``` ```
## Using this collection ## 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: 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.
<AccordionGroup>
<Accordion title="Universal Auth">
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='<client-id>', universal_auth_client_secret='<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` |
</Accordion>
<Accordion title="OIDC Auth">
To use OIDC Auth, you'll need to provide the ID of your machine identity, and the OIDC JWT to be used for authentication.
<Note>
Please note that in order to use OIDC Auth, you must have `1.0.10` or newer of the `infisicalsdk` package installed.
</Note>
```yaml
lookup('infisical.vault.read_secrets', auth_method="oidc-auth", identity_id='<identity-id>', jwt='<oidc-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` |
</Accordion>
</AccordionGroup>
### Examples
```yaml
--- ---
vars: 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') }}" 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" }] # [{ "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') }}" 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" }
``` ```
+2 -24
View File
@@ -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 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. 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
<Tabs> To install the CLI, follow the instructions for your chosen distribution [here](/cli/overview).
<Tab title="Alpine">
```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
```
</Tab>
<Tab title="RedHat/CentOs/Amazon-linux">
```dockerfile
RUN curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sh \
&& yum install -y infisical
```
</Tab>
<Tab title="Debian/Ubuntu">
```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
```
</Tab>
</Tabs>
#### ####
<Tip> <Tip>
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/) 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/)
@@ -37,6 +37,7 @@ description: "Learn how to configure a Checkly Sync for Infisical."
- **Checkly Connection**: The Checkly Connection to authenticate with. - **Checkly Connection**: The Checkly Connection to authenticate with.
- **Account**: The Checkly account to sync secrets to. - **Account**: The Checkly account to sync secrets to.
- **Group**: The Checkly check group to sync secrets to (Optional).
</Step> </Step>
<Step title="Configure Sync Options"> <Step title="Configure Sync Options">
Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
+3 -2
View File
@@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical."
![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png)
- **Render Connection**: The Render Connection to authenticate with. - **Render Connection**: The Render Connection to authenticate with.
- **Scope**: Select **Service**. - **Scope**: Select **Service** or **Environment Group**.
- **Service**: Choose the Render service you want to sync secrets to. - **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**. 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) ![Configure Options](/images/secret-syncs/render/render-sync-options.png)
@@ -5,14 +5,15 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se
import { FilterableSelect, FormControl } from "@app/components/v2"; import { FilterableSelect, FormControl } from "@app/components/v2";
import { import {
TChecklyAccount, TChecklyAccount,
useChecklyConnectionListAccounts useChecklyConnectionListAccounts,
useChecklyConnectionListGroups
} from "@app/hooks/api/appConnections/checkly"; } from "@app/hooks/api/appConnections/checkly";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas"; import { TSecretSyncForm } from "../schemas";
export const ChecklySyncFields = () => { export const ChecklySyncFields = () => {
const { control, setValue } = useFormContext< const { control, setValue, watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.Checkly } 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 ( return (
<> <>
<SecretSyncConnectionField <SecretSyncConnectionField
onChange={() => { onChange={() => {
setValue("destinationConfig.accountId", ""); setValue("destinationConfig.accountId", "");
setValue("destinationConfig.accountName", ""); setValue("destinationConfig.accountName", "");
setValue("destinationConfig.groupId", undefined);
setValue("destinationConfig.groupName", undefined);
}} }}
/> />
<Controller <Controller
@@ -60,6 +73,37 @@ export const ChecklySyncFields = () => {
</FormControl> </FormControl>
)} )}
/> />
<Controller
name="destinationConfig.groupId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Select a group"
isOptional
helperText="If provided, secrets will be scoped to a check group instead"
tooltipClassName="max-w-md"
>
<FilterableSelect
isLoading={isGroupsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
isClearable
value={groups.find((p) => p.id === value) ?? null}
onChange={(option) => {
const v = option as SingleValue<TChecklyAccount>;
onChange(v?.id ?? null);
setValue("destinationConfig.groupName", v?.name ?? undefined);
}}
options={groups}
placeholder="Select a group..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
</> </>
); );
}; };
@@ -5,7 +5,9 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs"; import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs";
import { import {
TRenderEnvironmentGroup,
TRenderService, TRenderService,
useRenderConnectionListEnvironmentGroups,
useRenderConnectionListServices useRenderConnectionListServices
} from "@app/hooks/api/appConnections/render"; } from "@app/hooks/api/appConnections/render";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -19,6 +21,7 @@ export const RenderSyncFields = () => {
>(); >();
const connectionId = useWatch({ name: "connection.id", control }); const connectionId = useWatch({ name: "connection.id", control });
const selectedScope = useWatch({ name: "destinationConfig.scope", control });
const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices( const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices(
connectionId, connectionId,
@@ -27,11 +30,17 @@ export const RenderSyncFields = () => {
} }
); );
const { data: groups = [], isPending: isGroupsPending } =
useRenderConnectionListEnvironmentGroups(connectionId, {
enabled: Boolean(connectionId) && selectedScope === RenderSyncScope.EnvironmentGroup
});
return ( return (
<> <>
<SecretSyncConnectionField <SecretSyncConnectionField
onChange={() => { onChange={() => {
setValue("destinationConfig.serviceId", ""); setValue("destinationConfig.serviceId", "");
setValue("destinationConfig.environmentGroupId", "");
setValue("destinationConfig.type", RenderSyncType.Env); setValue("destinationConfig.type", RenderSyncType.Env);
setValue("destinationConfig.scope", RenderSyncScope.Service); setValue("destinationConfig.scope", RenderSyncScope.Service);
}} }}
@@ -83,30 +92,67 @@ export const RenderSyncFields = () => {
</FormControl> </FormControl>
)} )}
/> />
<Controller {selectedScope === RenderSyncScope.Service && (
name="destinationConfig.serviceId" <Controller
control={control} name="destinationConfig.serviceId"
render={({ field: { value, onChange }, fieldState: { error } }) => ( control={control}
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Service"> render={({ field: { value, onChange }, fieldState: { error } }) => (
<FilterableSelect <FormControl
isLoading={isServicesPending && Boolean(connectionId)} errorText={error?.message}
isDisabled={!connectionId} isError={Boolean(error?.message)}
value={services ? (services.find((service) => service.id === value) ?? []) : []} label="Service"
onChange={(option) => { >
onChange((option as SingleValue<TRenderService>)?.id ?? null); <FilterableSelect
setValue( isLoading={isServicesPending && Boolean(connectionId)}
"destinationConfig.serviceName", isDisabled={!connectionId}
(option as SingleValue<TRenderService>)?.name ?? "" value={services ? (services.find((service) => service.id === value) ?? []) : []}
); onChange={(option) => {
}} onChange((option as SingleValue<TRenderService>)?.id ?? null);
options={services} setValue(
placeholder="Select a service..." "destinationConfig.serviceName",
getOptionLabel={(option) => option.name} (option as SingleValue<TRenderService>)?.name ?? ""
getOptionValue={(option) => option.id.toString()} );
/> }}
</FormControl> options={services}
)} placeholder="Select a service..."
/> getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
)}
{selectedScope === RenderSyncScope.EnvironmentGroup && (
<Controller
name="destinationConfig.environmentGroupId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Environment Group"
>
<FilterableSelect
isLoading={isGroupsPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={groups ? (groups.find((g) => g.id === value) ?? []) : []}
onChange={(option) => {
onChange((option as SingleValue<TRenderEnvironmentGroup>)?.id ?? null);
setValue(
"destinationConfig.environmentGroupName",
(option as SingleValue<TRenderEnvironmentGroup>)?.name ?? ""
);
}}
options={groups}
placeholder="Select an environment group..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
)}
</> </>
); );
}; };
@@ -6,7 +6,16 @@ import { SecretSync } from "@app/hooks/api/secretSyncs";
export const ChecklySyncReviewFields = () => { export const ChecklySyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Checkly }>(); const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Checkly }>();
const accountName = watch("destinationConfig.accountName"); const config = watch("destinationConfig");
return <GenericFieldLabel label="Account">{accountName}</GenericFieldLabel>; return (
<>
<GenericFieldLabel label="Account">
{config.accountName ?? config.accountId}
</GenericFieldLabel>
{config.groupId && (
<GenericFieldLabel label="Group">{config.groupName ?? config.groupId}</GenericFieldLabel>
)}
</>
);
}; };
@@ -4,6 +4,7 @@ import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2"; import { Badge } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync";
export const RenderSyncOptionsReviewFields = () => { export const RenderSyncOptionsReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>(); const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>();
@@ -27,13 +28,20 @@ export const RenderSyncOptionsReviewFields = () => {
export const RenderSyncReviewFields = () => { export const RenderSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>(); const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>();
const serviceName = watch("destinationConfig.serviceName"); const config = watch("destinationConfig");
const scope = watch("destinationConfig.scope");
return ( return (
<> <>
<GenericFieldLabel label="Scope">{scope}</GenericFieldLabel> <GenericFieldLabel label="Scope">{config.scope}</GenericFieldLabel>
<GenericFieldLabel label="Service">{serviceName}</GenericFieldLabel> {config.scope === RenderSyncScope.Service ? (
<GenericFieldLabel label="Service">
{config.serviceName ?? config.serviceId}
</GenericFieldLabel>
) : (
<GenericFieldLabel label="Service">
{config.environmentGroupName ?? config.environmentGroupId}
</GenericFieldLabel>
)}
</> </>
); );
}; };
@@ -8,7 +8,15 @@ export const ChecklySyncDestinationSchema = BaseSecretSyncSchema().merge(
destination: z.literal(SecretSync.Checkly), destination: z.literal(SecretSync.Checkly),
destinationConfig: z.object({ destinationConfig: z.object({
accountId: z.string(), 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)
}) })
}) })
); );
@@ -17,6 +17,12 @@ export const RenderSyncDestinationSchema = BaseSecretSyncSchema(
serviceId: z.string().trim().min(1, "Service is required"), serviceId: z.string().trim().min(1, "Service is required"),
serviceName: z.string().trim().optional(), serviceName: z.string().trim().optional(),
type: z.nativeEnum(RenderSyncType) 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)
}) })
]) ])
}) })
@@ -98,7 +98,7 @@ export const DatePicker = ({
> >
{value {value
? formatDateTime({ timestamp: value, timezone, dateFormat }) ? formatDateTime({ timestamp: value, timezone, dateFormat })
: "Pick a date and time"} : `Select Date${hideTime ? "" : " and Time"}`}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@@ -122,7 +122,8 @@ export const DatePicker = ({
root: `text-mineshaft-300 ${defaultClassNames}`, root: `text-mineshaft-300 ${defaultClassNames}`,
[UI.DayButton]: "p-3 rounded hover:text-mineshaft-100", [UI.DayButton]: "p-3 rounded hover:text-mineshaft-100",
[UI.Weekday]: "px-3 pt-3", [UI.Weekday]: "px-3 pt-3",
[UI.Chevron]: "fill-mineshaft-300" [UI.Chevron]: "fill-mineshaft-300/70 hover:fill-mineshaft-300",
disabled: "text-mineshaft-400 pointer-events-none"
}} }}
/> />
</div> </div>
+4
View File
@@ -212,5 +212,9 @@ export const RENDER_SYNC_SCOPES: Record<RenderSyncScope, { name: string; descrip
[RenderSyncScope.Service]: { [RenderSyncScope.Service]: {
name: "Service", name: "Service",
description: "Infisical will sync secrets to the specified Render service." description: "Infisical will sync secrets to the specified Render service."
},
[RenderSyncScope.EnvironmentGroup]: {
name: "EnvironmentGroup",
description: "Infisical will sync secrets to the specified Render environment group."
} }
}; };
@@ -36,6 +36,7 @@ export type Approver = {
type: ApproverType; type: ApproverType;
sequence?: number; sequence?: number;
approvalsRequired?: number; approvalsRequired?: number;
isOrgMembershipActive: boolean;
}; };
export type Bypasser = { export type Bypasser = {
@@ -82,6 +83,7 @@ export type TAccessApprovalRequest = {
name: string; name: string;
approvals: number; approvals: number;
approvers: { approvers: {
isOrgMembershipActive: boolean;
userId: string; userId: string;
sequence?: number; sequence?: number;
approvalsRequired?: number; approvalsRequired?: number;
@@ -98,6 +100,7 @@ export type TAccessApprovalRequest = {
}; };
reviewers: { reviewers: {
isOrgMembershipActive: boolean;
userId: string; userId: string;
status: string; status: string;
}[]; }[];
@@ -177,7 +180,7 @@ export type TCreateAccessPolicyDTO = {
projectSlug: string; projectSlug: string;
name?: string; name?: string;
environments: string[]; environments: string[];
approvers?: Approver[]; approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[]; bypassers?: Bypasser[];
approvals?: number; approvals?: number;
secretPath: string; secretPath: string;
@@ -190,7 +193,7 @@ export type TCreateAccessPolicyDTO = {
export type TUpdateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = {
id: string; id: string;
name?: string; name?: string;
approvers?: Approver[]; approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[]; bypassers?: Bypasser[];
secretPath?: string; secretPath?: string;
environments?: string[]; environments?: string[];
@@ -8,7 +8,9 @@ import { TChecklyAccount } from "./types";
const checklyConnectionKeys = { const checklyConnectionKeys = {
all: [...appConnectionKeys.all, "checkly"] as const, all: [...appConnectionKeys.all, "checkly"] as const,
listAccounts: (connectionId: string) => 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 = ( export const useChecklyConnectionListAccounts = (
@@ -35,3 +37,29 @@ export const useChecklyConnectionListAccounts = (
...options ...options
}); });
}; };
export const useChecklyConnectionListGroups = (
connectionId: string,
accountId: string,
options?: Omit<
UseQueryOptions<
TChecklyAccount[],
unknown,
TChecklyAccount[],
ReturnType<typeof checklyConnectionKeys.listGroups>
>,
"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
});
};
@@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries"; import { appConnectionKeys } from "../queries";
import { TRenderService } from "./types"; import { TRenderEnvironmentGroup, TRenderService } from "./types";
const renderConnectionKeys = { const renderConnectionKeys = {
all: [...appConnectionKeys.all, "render"] as const, all: [...appConnectionKeys.all, "render"] as const,
listServices: (connectionId: string) => 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 = ( export const useRenderConnectionListServices = (
@@ -35,3 +37,28 @@ export const useRenderConnectionListServices = (
...options ...options
}); });
}; };
export const useRenderConnectionListEnvironmentGroups = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TRenderEnvironmentGroup[],
unknown,
TRenderEnvironmentGroup[],
ReturnType<typeof renderConnectionKeys.listEnvironmentGroups>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: renderConnectionKeys.listEnvironmentGroups(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TRenderEnvironmentGroup[]>(
`/api/v1/app-connections/render/${connectionId}/environment-groups`
);
return data;
},
...options
});
};
@@ -2,3 +2,8 @@ export type TRenderService = {
id: string; id: string;
name: string; name: string;
}; };
export type TRenderEnvironmentGroup = {
id: string;
name: string;
};
@@ -104,6 +104,7 @@ export const useUpdateOrg = () => {
mutationFn: ({ mutationFn: ({
name, name,
authEnforced, authEnforced,
googleSsoAuthEnforced,
scimEnabled, scimEnabled,
slug, slug,
orgId, orgId,
@@ -125,6 +126,7 @@ export const useUpdateOrg = () => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, { return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name, name,
authEnforced, authEnforced,
googleSsoAuthEnforced,
scimEnabled, scimEnabled,
slug, slug,
defaultMembershipRoleSlug, defaultMembershipRoleSlug,
@@ -9,6 +9,7 @@ export type Organization = {
createAt: string; createAt: string;
updatedAt: string; updatedAt: string;
authEnforced: boolean; authEnforced: boolean;
googleSsoAuthEnforced: boolean;
bypassOrgAuthEnabled: boolean; bypassOrgAuthEnabled: boolean;
orgAuthMethod: string; orgAuthMethod: string;
scimEnabled: boolean; scimEnabled: boolean;
@@ -34,6 +35,7 @@ export type UpdateOrgDTO = {
orgId: string; orgId: string;
name?: string; name?: string;
authEnforced?: boolean; authEnforced?: boolean;
googleSsoAuthEnforced?: boolean;
scimEnabled?: boolean; scimEnabled?: boolean;
slug?: string; slug?: string;
defaultMembershipRoleSlug?: string; defaultMembershipRoleSlug?: string;
@@ -20,6 +20,7 @@ export enum ApproverType {
} }
export type Approver = { export type Approver = {
isOrgMembershipActive: boolean;
id: string; id: string;
type: ApproverType; type: ApproverType;
}; };
@@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = {
name?: string; name?: string;
environments: string[]; environments: string[];
secretPath: string; secretPath: string;
approvers?: Approver[]; approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[]; bypassers?: Bypasser[];
approvals?: number; approvals?: number;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
@@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = {
export type TUpdateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = {
id: string; id: string;
name?: string; name?: string;
approvers?: Approver[]; approvers?: Omit<Approver, "isOrgMembershipActive">[];
bypassers?: Bypasser[]; bypassers?: Bypasser[];
secretPath?: string; secretPath?: string;
approvals?: number; approvals?: number;
@@ -53,6 +53,7 @@ export type TSecretApprovalRequest = {
firstName: string; firstName: string;
lastName: string; lastName: string;
username: string; username: string;
isOrgMembershipActive: boolean;
}[]; }[];
workspace: string; workspace: string;
environment: string; environment: string;
@@ -62,6 +63,7 @@ export type TSecretApprovalRequest = {
status: "open" | "close"; status: "open" | "close";
policy: Omit<TSecretApprovalPolicy, "approvers" | "bypassers"> & { policy: Omit<TSecretApprovalPolicy, "approvers" | "bypassers"> & {
approvers: { approvers: {
isOrgMembershipActive: boolean;
userId: string; userId: string;
email: string; email: string;
firstName: string; firstName: string;
@@ -3,11 +3,17 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export enum ChecklySyncScope {
Global = "global",
Group = "group"
}
export type TChecklySync = TRootSecretSync & { export type TChecklySync = TRootSecretSync & {
destination: SecretSync.Checkly; destination: SecretSync.Checkly;
destinationConfig: { destinationConfig: {
accountId: string; accountId: string;
accountName: string; accountName: string;
groupId?: string;
groupName?: string;
}; };
connection: { connection: {
app: AppConnection.Checkly; app: AppConnection.Checkly;
@@ -4,12 +4,19 @@ import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/typ
export type TRenderSync = TRootSecretSync & { export type TRenderSync = TRootSecretSync & {
destination: SecretSync.Render; destination: SecretSync.Render;
destinationConfig: { destinationConfig:
scope: RenderSyncScope.Service; | {
type: RenderSyncType; type: RenderSyncType;
serviceId: string; scope: RenderSyncScope.Service;
serviceName?: string; serviceId: string;
}; serviceName?: string | undefined;
}
| {
type: RenderSyncType;
scope: RenderSyncScope.EnvironmentGroup;
environmentGroupId: string;
environmentGroupName?: string | undefined;
};
connection: { connection: {
app: AppConnection.Render; app: AppConnection.Render;
@@ -23,7 +30,8 @@ export type TRenderSync = TRootSecretSync & {
}; };
export enum RenderSyncScope { export enum RenderSyncScope {
Service = "service" Service = "service",
EnvironmentGroup = "environment-group"
} }
export enum RenderSyncType { export enum RenderSyncType {
@@ -48,6 +48,7 @@ export type SubscriptionPlan = {
externalKms: boolean; externalKms: boolean;
pkiEst: boolean; pkiEst: boolean;
enforceMfa: boolean; enforceMfa: boolean;
enforceGoogleSSO: boolean;
projectTemplates: boolean; projectTemplates: boolean;
kmip: boolean; kmip: boolean;
secretScanning: boolean; secretScanning: boolean;
+4 -4
View File
@@ -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", { return apiRequest.post("/api/v2/users/me/emails/code", {
username token
}); });
}; };
export const useSendEmailVerificationCode = () => { export const useSendEmailVerificationCode = () => {
return useMutation({ return useMutation({
mutationFn: async (username: string) => { mutationFn: async (token: string) => {
await sendEmailVerificationCode(username); await sendEmailVerificationCode(token);
return {}; return {};
} }
}); });
+1
View File
@@ -83,6 +83,7 @@ export type TProjectMembership = {
export type TWorkspaceUser = { export type TWorkspaceUser = {
id: string; id: string;
user: { user: {
isOrgMembershipActive: boolean;
email: string; email: string;
username: string; username: string;
firstName: string; firstName: string;
@@ -240,6 +240,13 @@ export const Navbar = () => {
return; return;
} }
if (org.googleSsoAuthEnforced) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
window.close();
return;
}
handleOrgChange(org?.id); handleOrgChange(org?.id);
}} }}
variant="plain" variant="plain"
@@ -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) // org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO // -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = ""; let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) { if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : "" callbackPort ? `&callbackPort=${callbackPort}` : ""
}`; }`;
} else { } else if (organization.orgAuthMethod === AuthMethod.SAML) {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) { if (callbackPort) {
url += `?callback_port=${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; // 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
return; // see line 103-106
if (url) {
await logout.mutateAsync();
window.location.href = url;
return;
}
} }
const { token, isMfaEnabled, mfaMethod } = await selectOrg const { token, isMfaEnabled, mfaMethod } = await selectOrg
@@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({
const resendCode = async () => { const resendCode = async () => {
try { 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({ createNotification({
text: "Successfully resent code", text: "Successfully resent code",
type: "success" type: "success"
@@ -11,7 +11,7 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { AllProjectView } from "./components/AllProjectView"; import { AllProjectView } from "./components/AllProjectView";
import { MyProjectView } from "./components/MyProjectView"; import { MyProjectView } from "./components/MyProjectView";
import { ProjectListToggle, ProjectListView } from "./components/ProjectListToggle"; import { ProjectListView } from "./components/ProjectListToggle";
// const formatDescription = (type: ProjectType) => { // const formatDescription = (type: ProjectType) => {
// if (type === ProjectType.SecretManager) // if (type === ProjectType.SecretManager)
@@ -28,7 +28,23 @@ import { ProjectListToggle, ProjectListView } from "./components/ProjectListTogg
export const ProjectsPage = () => { export const ProjectsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects); const [projectListView, setProjectListView] = useState<ProjectListView>(() => {
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([ const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs", "addNewWs",
@@ -49,11 +65,7 @@ export const ProjectsPage = () => {
</Helmet> </Helmet>
<div className="mb-4 flex flex-col items-start justify-start"> <div className="mb-4 flex flex-col items-start justify-start">
<PageHeader <PageHeader
title={ title="Projects"
<div className="flex items-center gap-4">
<ProjectListToggle value={projectListView} onChange={setProjectListView} />
</div>
}
description="Your team's complete security toolkit - organized and ready when you need them." description="Your team's complete security toolkit - organized and ready when you need them."
/> />
</div> </div>
@@ -62,12 +74,16 @@ export const ProjectsPage = () => {
onAddNewProject={() => handlePopUpOpen("addNewWs")} onAddNewProject={() => handlePopUpOpen("addNewWs")}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")} onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isAddingProjectsAllowed={isAddingProjectsAllowed} isAddingProjectsAllowed={isAddingProjectsAllowed}
projectListView={projectListView}
onProjectListViewChange={handleSetProjectListView}
/> />
) : ( ) : (
<AllProjectView <AllProjectView
onAddNewProject={() => handlePopUpOpen("addNewWs")} onAddNewProject={() => handlePopUpOpen("addNewWs")}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")} onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isAddingProjectsAllowed={isAddingProjectsAllowed} isAddingProjectsAllowed={isAddingProjectsAllowed}
projectListView={projectListView}
onProjectListViewChange={handleSetProjectListView}
/> />
)} )}
<NewProjectModal <NewProjectModal
@@ -49,11 +49,17 @@ import {
useSearchProjects useSearchProjects
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
import {
ProjectListToggle,
ProjectListView
} from "@app/pages/organization/ProjectsPage/components/ProjectListToggle";
type Props = { type Props = {
onAddNewProject: () => void; onAddNewProject: () => void;
onUpgradePlan: () => void; onUpgradePlan: () => void;
isAddingProjectsAllowed: boolean; isAddingProjectsAllowed: boolean;
projectListView: ProjectListView;
onProjectListViewChange: (value: ProjectListView) => void;
}; };
type RequestAccessModalProps = { type RequestAccessModalProps = {
@@ -106,7 +112,9 @@ const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProp
export const AllProjectView = ({ export const AllProjectView = ({
onAddNewProject, onAddNewProject,
onUpgradePlan, onUpgradePlan,
isAddingProjectsAllowed isAddingProjectsAllowed,
projectListView,
onProjectListViewChange
}: Props) => { }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchFilter, setSearchFilter] = useState(""); const [searchFilter, setSearchFilter] = useState("");
@@ -176,10 +184,10 @@ export const AllProjectView = ({
return ( return (
<div> <div>
<div className="flex w-full flex-row"> <div className="flex w-full flex-row">
<div className="flex-grow" /> <ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
<Input <Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
containerClassName="w-full" containerClassName="w-full ml-2"
placeholder="Search by project name..." placeholder="Search by project name..."
value={searchFilter} value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)} onChange={(e) => setSearchFilter(e.target.value)}
@@ -242,7 +250,7 @@ export const AllProjectView = ({
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1"> <div className="ml-2 flex gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Disabled across All Project view."> <Tooltip content="Disabled across All Project view.">
<div className="flex cursor-not-allowed items-center justify-center"> <div className="flex cursor-not-allowed items-center justify-center">
<IconButton <IconButton

Some files were not shown because too many files have changed in this diff Show More