mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-31 15:32:32 +00:00
Compare commits
108 Commits
fix/folder
...
fix-infere
Author | SHA1 | Date | |
---|---|---|---|
|
43dd45de29 | ||
|
fc6778dd89 | ||
|
2f68ff1629 | ||
|
8884c0e6bd | ||
|
af2f21fe93 | ||
|
dcd588007c | ||
|
bceaac844f | ||
|
2f375d6b65 | ||
|
8f00bab61c | ||
|
ec12acfcdf | ||
|
34a8301617 | ||
|
8ffff7e779 | ||
|
a349dda4bc | ||
|
f63ee39f3d | ||
|
f550a2ae3f | ||
|
725e55f7e5 | ||
|
f59efc1948 | ||
|
f52e90a5c1 | ||
|
2fda307b67 | ||
|
ff7b530252 | ||
|
10cfbe0c74 | ||
|
8123be4c14 | ||
|
9a98192b9b | ||
|
991ee20ec7 | ||
|
dc48281e6a | ||
|
b3002d784e | ||
|
c782493704 | ||
|
6c7062fa16 | ||
|
5c632db282 | ||
|
817daecc6c | ||
|
461deef0d5 | ||
|
7748e03612 | ||
|
2389c64e69 | ||
|
de5ad47f77 | ||
|
e0161cd06f | ||
|
7c12fa3a4c | ||
|
0af53e82da | ||
|
f0c080187e | ||
|
47118bcf19 | ||
|
bb1975491f | ||
|
52bbe25fc5 | ||
|
28cc919ff7 | ||
|
5c21ac3182 | ||
|
6204b181e7 | ||
|
06de9d06c9 | ||
|
3cceec86c8 | ||
|
ff043f990f | ||
|
bb14231d71 | ||
|
9e177c1e45 | ||
|
5aeb823c9e | ||
|
ef6f79f7a6 | ||
|
43752e1888 | ||
|
d587e779f5 | ||
|
f9a9565630 | ||
|
05ba0abadd | ||
|
fff9a96204 | ||
|
bd72129d8c | ||
|
bf10b2f58a | ||
|
d24f5a57a8 | ||
|
166104e523 | ||
|
a7847f177c | ||
|
48e5f550e9 | ||
|
4a4a7fd325 | ||
|
91b8ed8015 | ||
|
f78556c85f | ||
|
13aa380cac | ||
|
f2a9a57c95 | ||
|
6384fa6dba | ||
|
c34ec8de09 | ||
|
ef8a7f1233 | ||
|
09db98db50 | ||
|
a37f1eb1f8 | ||
|
2113abcfdc | ||
|
ea2707651c | ||
|
b986ff9a21 | ||
|
106833328b | ||
|
0473fb0ddb | ||
|
8a72023e80 | ||
|
41a3ac6bd4 | ||
|
2fb5cc1712 | ||
|
b352428032 | ||
|
914bb3d389 | ||
|
be70bfa33f | ||
|
7758e5dbfa | ||
|
22fca374f2 | ||
|
94039ca509 | ||
|
c8f124e4c5 | ||
|
2501c57030 | ||
|
97dac1da94 | ||
|
f9f989c8af | ||
|
60b3f5c7c6 | ||
|
c2cea8cffc | ||
|
02ee418763 | ||
|
faca20c00c | ||
|
69c3687add | ||
|
1645534b54 | ||
|
dca0b0c614 | ||
|
d3d0d44778 | ||
|
67abcbfe7a | ||
|
fc772e6b89 | ||
|
c8108ff49a | ||
|
806165b9e9 | ||
|
9fde0a5787 | ||
|
9ee2581659 | ||
|
2deff0ef55 | ||
|
4312378589 | ||
|
d749a9621f | ||
|
9686d14e7f |
1
backend/src/@types/fastify.d.ts
vendored
1
backend/src/@types/fastify.d.ts
vendored
@@ -148,6 +148,7 @@ declare module "fastify" {
|
||||
interface Session {
|
||||
callbackPort: string;
|
||||
isAdminLogin: boolean;
|
||||
orgSlug?: string;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
|
@@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise<void> => {
|
||||
t.index("expiresAt");
|
||||
t.index("orgId");
|
||||
t.index("projectId");
|
||||
t.index("eventType");
|
||||
t.index("userAgentType");
|
||||
t.index("actor");
|
||||
});
|
||||
|
||||
console.log("Adding GIN indices...");
|
||||
@@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise<void> => {
|
||||
console.log("Creating audit log partitions ahead of time... next date:", nextDateStr);
|
||||
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
|
||||
|
||||
// create partitions 4 years ahead
|
||||
const partitionMonths = 4 * 12;
|
||||
// create partitions 20 years ahead
|
||||
const partitionMonths = 20 * 12;
|
||||
const partitionPromises: Promise<void>[] = [];
|
||||
for (let x = 1; x <= partitionMonths; x += 1) {
|
||||
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> {}
|
@@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
orgId: z.string().uuid().nullable().optional()
|
||||
orgId: z.string().uuid().nullable().optional(),
|
||||
aliasId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TAuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||
|
@@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({
|
||||
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
|
||||
maxSharedSecretViewLimit: z.number().nullable().optional()
|
||||
maxSharedSecretViewLimit: z.number().nullable().optional(),
|
||||
googleSsoAuthEnforced: z.boolean().default(false),
|
||||
googleSsoAuthLastUsed: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||
|
@@ -17,7 +17,8 @@ export const SecretApprovalPoliciesSchema = z.object({
|
||||
updatedAt: z.date(),
|
||||
enforcementLevel: z.string().default("hard"),
|
||||
deletedAt: z.date().nullable().optional(),
|
||||
allowedSelfApprovals: z.boolean().default(true)
|
||||
allowedSelfApprovals: z.boolean().default(true),
|
||||
shouldCheckSecretPermission: z.boolean().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;
|
||||
|
@@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({
|
||||
emails: z.string().array().nullable().optional(),
|
||||
orgId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
isEmailVerified: z.boolean().default(false).nullable().optional()
|
||||
});
|
||||
|
||||
export type TUserAliases = z.infer<typeof UserAliasesSchema>;
|
||||
|
@@ -133,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
approvals: z.number(),
|
||||
approvers: z
|
||||
.object({
|
||||
isOrgMembershipActive: z.boolean().nullable().optional(),
|
||||
userId: z.string().nullable().optional(),
|
||||
sequence: z.number().nullable().optional(),
|
||||
approvalsRequired: z.number().nullable().optional(),
|
||||
@@ -150,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
}),
|
||||
reviewers: z
|
||||
.object({
|
||||
isOrgMembershipActive: z.boolean().nullable().optional(),
|
||||
userId: z.string(),
|
||||
status: z.string()
|
||||
})
|
||||
|
@@ -294,22 +294,30 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
200: z.object({
|
||||
approval: SecretApprovalRequestsSchema.merge(
|
||||
z.object({
|
||||
// secretPath: z.string(),
|
||||
policy: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
approvals: z.number(),
|
||||
approvers: approvalRequestUser.array(),
|
||||
approvers: approvalRequestUser
|
||||
.extend({ isOrgMembershipActive: z.boolean().nullable().optional() })
|
||||
.array(),
|
||||
bypassers: approvalRequestUser.array(),
|
||||
secretPath: z.string().optional().nullable(),
|
||||
enforcementLevel: z.string(),
|
||||
deletedAt: z.date().nullish(),
|
||||
allowedSelfApprovals: z.boolean()
|
||||
allowedSelfApprovals: z.boolean(),
|
||||
shouldCheckSecretPermission: z.boolean().nullable().optional()
|
||||
}),
|
||||
environment: z.string(),
|
||||
statusChangedByUser: approvalRequestUser.optional(),
|
||||
committerUser: approvalRequestUser.nullish(),
|
||||
reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(),
|
||||
reviewers: approvalRequestUser
|
||||
.extend({
|
||||
status: z.string(),
|
||||
comment: z.string().optional(),
|
||||
isOrgMembershipActive: z.boolean().nullable().optional()
|
||||
})
|
||||
.array(),
|
||||
secretPath: z.string(),
|
||||
commits: secretRawSchema
|
||||
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
|
||||
|
@@ -5,6 +5,7 @@ import {
|
||||
AccessApprovalRequestsSchema,
|
||||
TableName,
|
||||
TAccessApprovalRequests,
|
||||
TOrgMemberships,
|
||||
TUserGroupMembership,
|
||||
TUsers
|
||||
} from "@app/db/schemas";
|
||||
@@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
| {
|
||||
userId: string;
|
||||
@@ -151,6 +153,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
)[];
|
||||
bypassers: string[];
|
||||
@@ -202,6 +205,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
||||
reviewers: {
|
||||
userId: string;
|
||||
status: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}[];
|
||||
approvers: (
|
||||
| {
|
||||
@@ -210,6 +214,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
| {
|
||||
userId: string;
|
||||
@@ -217,6 +222,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
)[];
|
||||
bypassers: string[];
|
||||
@@ -288,6 +294,24 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
`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`)
|
||||
|
||||
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
||||
@@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
|
||||
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
|
||||
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
|
||||
|
||||
db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
|
||||
db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
|
||||
db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
|
||||
db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
|
||||
)
|
||||
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
|
||||
@@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
{
|
||||
key: "reviewerUserId",
|
||||
label: "reviewers" as const,
|
||||
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined)
|
||||
mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) =>
|
||||
userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined
|
||||
},
|
||||
{
|
||||
key: "approverUserId",
|
||||
label: "approvers" as const,
|
||||
mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({
|
||||
mapper: ({
|
||||
approverUserId,
|
||||
approverSequence,
|
||||
approvalsRequired,
|
||||
approverUsername,
|
||||
approverEmail,
|
||||
approverIsOrgMembershipActive
|
||||
}) => ({
|
||||
userId: approverUserId,
|
||||
sequence: approverSequence,
|
||||
approvalsRequired,
|
||||
email: approverEmail,
|
||||
username: approverUsername
|
||||
username: approverUsername,
|
||||
isOrgMembershipActive: approverIsOrgMembershipActive
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
approverSequence,
|
||||
approvalsRequired,
|
||||
approverGroupEmail,
|
||||
approverGroupUsername
|
||||
approverGroupUsername,
|
||||
approverGroupIsOrgMembershipActive
|
||||
}) => ({
|
||||
userId: approverGroupUserId,
|
||||
sequence: approverSequence,
|
||||
approvalsRequired,
|
||||
email: approverGroupEmail,
|
||||
username: approverGroupUsername
|
||||
username: approverGroupUsername,
|
||||
isOrgMembershipActive: approverGroupIsOrgMembershipActive
|
||||
})
|
||||
},
|
||||
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
|
||||
|
@@ -87,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
| {
|
||||
userId: string;
|
||||
@@ -94,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
)[];
|
||||
bypassers: string[];
|
||||
@@ -145,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
||||
reviewers: {
|
||||
userId: string;
|
||||
status: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}[];
|
||||
approvers: (
|
||||
| {
|
||||
@@ -153,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
| {
|
||||
userId: string;
|
||||
@@ -160,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
||||
approvalsRequired: number | null | undefined;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}
|
||||
)[];
|
||||
bypassers: string[];
|
||||
|
@@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { EventType, filterableSecretEvents } from "./audit-log-types";
|
||||
|
||||
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
|
||||
pruneAuditLog: (tx?: knex.Knex) => Promise<void>;
|
||||
pruneAuditLog: () => Promise<void>;
|
||||
find: (
|
||||
arg: Omit<TFindQuery, "actor" | "eventType"> & {
|
||||
actorId?: string | undefined;
|
||||
@@ -41,6 +41,10 @@ type TFindQuery = {
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
|
||||
const MAX_RETRY_ON_FAILURE = 3;
|
||||
|
||||
export const auditLogDALFactory = (db: TDbClient) => {
|
||||
const auditLogOrm = ormify(db, TableName.AuditLog);
|
||||
|
||||
@@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
};
|
||||
|
||||
// delete all audit log that have expired
|
||||
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => {
|
||||
const runPrune = async (dbClient: knex.Knex) => {
|
||||
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
|
||||
const MAX_RETRY_ON_FAILURE = 3;
|
||||
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => {
|
||||
const today = new Date();
|
||||
let deletedAuditLogIds: { id: string }[] = [];
|
||||
let numberOfRetryOnFailure = 0;
|
||||
let isRetrying = false;
|
||||
|
||||
const today = new Date();
|
||||
let deletedAuditLogIds: { id: string }[] = [];
|
||||
let numberOfRetryOnFailure = 0;
|
||||
let isRetrying = false;
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
|
||||
do {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
deletedAuditLogIds = await db.transaction(async (trx) => {
|
||||
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
|
||||
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
|
||||
do {
|
||||
try {
|
||||
const findExpiredLogSubQuery = dbClient(TableName.AuditLog)
|
||||
const findExpiredLogSubQuery = trx(TableName.AuditLog)
|
||||
.where("expiresAt", "<", today)
|
||||
.where("createdAt", "<", today) // to use audit log partition
|
||||
.orderBy(`${TableName.AuditLog}.createdAt`, "desc")
|
||||
@@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
deletedAuditLogIds = await dbClient(TableName.AuditLog)
|
||||
.whereIn("id", findExpiredLogSubQuery)
|
||||
.del()
|
||||
.returning("id");
|
||||
numberOfRetryOnFailure = 0; // reset
|
||||
} catch (error) {
|
||||
numberOfRetryOnFailure += 1;
|
||||
logger.error(error, "Failed to delete audit log on pruning");
|
||||
} finally {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10); // time to breathe for db
|
||||
});
|
||||
}
|
||||
isRetrying = numberOfRetryOnFailure > 0;
|
||||
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
|
||||
};
|
||||
const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id");
|
||||
|
||||
if (tx) {
|
||||
await runPrune(tx);
|
||||
} else {
|
||||
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
|
||||
await runPrune(trx);
|
||||
});
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
numberOfRetryOnFailure = 0; // reset
|
||||
} catch (error) {
|
||||
numberOfRetryOnFailure += 1;
|
||||
deletedAuditLogIds = [];
|
||||
logger.error(error, "Failed to delete audit log on pruning");
|
||||
} finally {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10); // time to breathe for db
|
||||
});
|
||||
}
|
||||
isRetrying = numberOfRetryOnFailure > 0;
|
||||
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
|
||||
};
|
||||
|
||||
const create: TAuditLogDALFactory["create"] = async (tx) => {
|
||||
|
@@ -123,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
|
||||
|
||||
await redis.set(key, "1", "EX", 60);
|
||||
|
||||
stream.push("1");
|
||||
send({ type: "ping" });
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
|
@@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({
|
||||
|
||||
userAlias = await userDAL.transaction(async (tx) => {
|
||||
let newUser: TUsers | undefined;
|
||||
if (serverCfg.trustLdapEmails) {
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email: email.toLowerCase(),
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email: email.toLowerCase(),
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newUser) {
|
||||
const uniqueUsername = await normalizeUsername(username, userDAL);
|
||||
@@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({
|
||||
aliasType: UserAliasType.LDAP,
|
||||
externalId,
|
||||
emails: [email],
|
||||
orgId
|
||||
orgId,
|
||||
isEmailVerified: serverCfg.trustLdapEmails
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({
|
||||
return newUser;
|
||||
});
|
||||
|
||||
const isUserCompleted = Boolean(user.isAccepted);
|
||||
|
||||
const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified;
|
||||
const providerAuthToken = crypto.jwt().sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
hasExchangedPrivateKey: true,
|
||||
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }),
|
||||
...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
|
||||
firstName,
|
||||
lastName,
|
||||
organizationName: organization.name,
|
||||
@@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({
|
||||
organizationSlug: organization.slug,
|
||||
authMethod: AuthMethod.LDAP,
|
||||
authType: UserAliasType.LDAP,
|
||||
aliasId: userAlias.id,
|
||||
isUserCompleted,
|
||||
...(relayState
|
||||
? {
|
||||
@@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({
|
||||
}
|
||||
);
|
||||
|
||||
if (user.email && !user.isEmailVerified) {
|
||||
if (user.email && !userAlias.isEmailVerified) {
|
||||
const token = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_VERIFICATION,
|
||||
userId: user.id
|
||||
userId: user.id,
|
||||
aliasId: userAlias.id
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
|
@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
auditLogStreams: false,
|
||||
auditLogStreamLimit: 3,
|
||||
samlSSO: false,
|
||||
enforceGoogleSSO: false,
|
||||
hsm: false,
|
||||
oidcSSO: false,
|
||||
scim: false,
|
||||
|
@@ -47,6 +47,7 @@ export type TFeatureSet = {
|
||||
auditLogStreamLimit: 3;
|
||||
githubOrgSync: false;
|
||||
samlSSO: false;
|
||||
enforceGoogleSSO: false;
|
||||
hsm: false;
|
||||
oidcSSO: false;
|
||||
secretAccessInsights: false;
|
||||
|
@@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({
|
||||
}
|
||||
|
||||
const appCfg = getConfig();
|
||||
const userAlias = await userAliasDAL.findOne({
|
||||
let userAlias = await userAliasDAL.findOne({
|
||||
externalId,
|
||||
orgId,
|
||||
aliasType: UserAliasType.OIDC
|
||||
@@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({
|
||||
} else {
|
||||
user = await userDAL.transaction(async (tx) => {
|
||||
let newUser: TUsers | undefined;
|
||||
// we prioritize getting the most complete user to create the new alias under
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email,
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (serverCfg.trustOidcEmails) {
|
||||
// we prioritize getting the most complete user to create the new alias under
|
||||
if (!newUser) {
|
||||
// this fetches user entries created via invites
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email,
|
||||
isEmailVerified: true
|
||||
username: email
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newUser) {
|
||||
// this fetches user entries created via invites
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
username: email
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (newUser && !newUser.isEmailVerified) {
|
||||
// we automatically mark it as email-verified because we've configured trust for OIDC emails
|
||||
newUser = await userDAL.updateById(newUser.id, {
|
||||
isEmailVerified: true
|
||||
});
|
||||
}
|
||||
if (newUser && !newUser.isEmailVerified) {
|
||||
// we automatically mark it as email-verified because we've configured trust for OIDC emails
|
||||
newUser = await userDAL.updateById(newUser.id, {
|
||||
isEmailVerified: serverCfg.trustOidcEmails
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
await userAliasDAL.create(
|
||||
userAlias = await userAliasDAL.create(
|
||||
{
|
||||
userId: newUser.id,
|
||||
aliasType: UserAliasType.OIDC,
|
||||
externalId,
|
||||
emails: email ? [email] : [],
|
||||
orgId
|
||||
orgId,
|
||||
isEmailVerified: serverCfg.trustOidcEmails
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({
|
||||
|
||||
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
|
||||
|
||||
const isUserCompleted = Boolean(user.isAccepted);
|
||||
const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified;
|
||||
const providerAuthToken = crypto.jwt().sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }),
|
||||
...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
|
||||
firstName,
|
||||
lastName,
|
||||
organizationName: organization.name,
|
||||
organizationId: organization.id,
|
||||
organizationSlug: organization.slug,
|
||||
hasExchangedPrivateKey: true,
|
||||
aliasId: userAlias.id,
|
||||
authMethod: AuthMethod.OIDC,
|
||||
authType: UserAliasType.OIDC,
|
||||
isUserCompleted,
|
||||
@@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({
|
||||
|
||||
await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() });
|
||||
|
||||
if (user.email && !user.isEmailVerified) {
|
||||
if (user.email && !userAlias.isEmailVerified) {
|
||||
const token = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_VERIFICATION,
|
||||
userId: user.id
|
||||
userId: user.id,
|
||||
aliasId: userAlias.id
|
||||
});
|
||||
|
||||
await smtpService
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretEventActions,
|
||||
ProjectPermissionSecretRotationActions,
|
||||
ProjectPermissionSecretScanningConfigActions,
|
||||
ProjectPermissionSecretScanningDataSourceActions,
|
||||
@@ -252,6 +253,16 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretScanningConfigs
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSecretEventActions.SubscribeCreated,
|
||||
ProjectPermissionSecretEventActions.SubscribeDeleted,
|
||||
ProjectPermissionSecretEventActions.SubscribeUpdated,
|
||||
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||
],
|
||||
ProjectPermissionSub.SecretEvents
|
||||
);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -455,6 +466,16 @@ const buildMemberPermissionRules = () => {
|
||||
|
||||
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSecretEventActions.SubscribeCreated,
|
||||
ProjectPermissionSecretEventActions.SubscribeDeleted,
|
||||
ProjectPermissionSecretEventActions.SubscribeUpdated,
|
||||
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||
],
|
||||
ProjectPermissionSub.SecretEvents
|
||||
);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -505,6 +526,16 @@ const buildViewerPermissionRules = () => {
|
||||
|
||||
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSecretEventActions.SubscribeCreated,
|
||||
ProjectPermissionSecretEventActions.SubscribeDeleted,
|
||||
ProjectPermissionSecretEventActions.SubscribeUpdated,
|
||||
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||
],
|
||||
ProjectPermissionSub.SecretEvents
|
||||
);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
|
@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
|
||||
projectFavorites?: string[] | null | undefined;
|
||||
customRoleSlug?: string | null | undefined;
|
||||
orgAuthEnforced?: boolean | null | undefined;
|
||||
orgGoogleSsoAuthEnforced: boolean;
|
||||
} & {
|
||||
groups: {
|
||||
id: string;
|
||||
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
|
||||
}[];
|
||||
orgId: string;
|
||||
orgAuthEnforced: boolean | null | undefined;
|
||||
orgGoogleSsoAuthEnforced: boolean;
|
||||
orgRole: OrgMembershipRole;
|
||||
userId: string;
|
||||
projectId: string;
|
||||
@@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
|
||||
db.ref("permissions").withSchema(TableName.OrgRoles),
|
||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
|
||||
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
||||
db.ref("groupId").withSchema("userGroups"),
|
||||
db.ref("groupOrgId").withSchema("userGroups"),
|
||||
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
OrgMembershipsSchema.extend({
|
||||
permissions: z.unknown(),
|
||||
orgAuthEnforced: z.boolean().optional().nullable(),
|
||||
orgGoogleSsoAuthEnforced: z.boolean(),
|
||||
bypassOrgAuthEnabled: z.boolean(),
|
||||
customRoleSlug: z.string().optional().nullable(),
|
||||
shouldUseNewPrivilegeSystem: z.boolean()
|
||||
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
|
||||
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
|
||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
|
||||
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
||||
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
|
||||
db.ref("orgId").withSchema(TableName.Project),
|
||||
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
orgId,
|
||||
username,
|
||||
orgAuthEnforced,
|
||||
orgGoogleSsoAuthEnforced,
|
||||
orgRole,
|
||||
membershipId,
|
||||
groupMembershipId,
|
||||
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
}) => ({
|
||||
orgId,
|
||||
orgAuthEnforced,
|
||||
orgGoogleSsoAuthEnforced,
|
||||
orgRole: orgRole as OrgMembershipRole,
|
||||
userId,
|
||||
projectId,
|
||||
|
@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
|
||||
function validateOrgSSO(
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
isOrgSsoEnforced: TOrganizations["authEnforced"],
|
||||
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
|
||||
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
|
||||
orgRole: OrgMembershipRole
|
||||
) {
|
||||
@@ -128,10 +129,16 @@ function validateOrgSSO(
|
||||
throw new UnauthorizedError({ name: "No auth method defined" });
|
||||
}
|
||||
|
||||
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
|
||||
if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// case: google sso is enforced, but the actor is not using google sso
|
||||
if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) {
|
||||
throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" });
|
||||
}
|
||||
|
||||
// case: SAML SSO is enforced, but the actor is not using SAML SSO
|
||||
if (
|
||||
isOrgSsoEnforced &&
|
||||
actorAuthMethod !== null &&
|
||||
|
@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
|
||||
validateOrgSSO(
|
||||
authMethod,
|
||||
membership.orgAuthEnforced,
|
||||
membership.orgGoogleSsoAuthEnforced,
|
||||
membership.bypassOrgAuthEnabled,
|
||||
membership.role as OrgMembershipRole
|
||||
);
|
||||
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
|
||||
validateOrgSSO(
|
||||
authMethod,
|
||||
userProjectPermission.orgAuthEnforced,
|
||||
userProjectPermission.orgGoogleSsoAuthEnforced,
|
||||
userProjectPermission.bypassOrgAuthEnabled,
|
||||
userProjectPermission.orgRole
|
||||
);
|
||||
|
@@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const userAlias = await userAliasDAL.findOne({
|
||||
let userAlias = await userAliasDAL.findOne({
|
||||
externalId,
|
||||
orgId,
|
||||
aliasType: UserAliasType.SAML
|
||||
@@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({
|
||||
|
||||
user = await userDAL.transaction(async (tx) => {
|
||||
let newUser: TUsers | undefined;
|
||||
if (serverCfg.trustSamlEmails) {
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email,
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email,
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!newUser) {
|
||||
const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL);
|
||||
@@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
await userAliasDAL.create(
|
||||
userAlias = await userAliasDAL.create(
|
||||
{
|
||||
userId: newUser.id,
|
||||
aliasType: UserAliasType.SAML,
|
||||
externalId,
|
||||
emails: email ? [email] : [],
|
||||
orgId
|
||||
orgId,
|
||||
isEmailVerified: serverCfg.trustSamlEmails
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({
|
||||
}
|
||||
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
|
||||
|
||||
const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified);
|
||||
const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified);
|
||||
const providerAuthToken = crypto.jwt().sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }),
|
||||
...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }),
|
||||
firstName,
|
||||
lastName,
|
||||
organizationName: organization.name,
|
||||
@@ -424,6 +423,7 @@ export const samlConfigServiceFactory = ({
|
||||
organizationSlug: organization.slug,
|
||||
authMethod: authProvider,
|
||||
hasExchangedPrivateKey: true,
|
||||
aliasId: userAlias.id,
|
||||
authType: UserAliasType.SAML,
|
||||
isUserCompleted,
|
||||
...(relayState
|
||||
@@ -440,10 +440,11 @@ export const samlConfigServiceFactory = ({
|
||||
|
||||
await samlConfigDAL.update({ orgId }, { lastUsed: new Date() });
|
||||
|
||||
if (user.email && !user.isEmailVerified) {
|
||||
if (user.email && !userAlias.isEmailVerified) {
|
||||
const token = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_VERIFICATION,
|
||||
userId: user.id
|
||||
userId: user.id,
|
||||
aliasId: userAlias.id
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
|
@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
|
||||
import {
|
||||
SecretApprovalRequestsSchema,
|
||||
TableName,
|
||||
TOrgMemberships,
|
||||
TSecretApprovalRequests,
|
||||
TSecretApprovalRequestsSecrets,
|
||||
TUserGroupMembership,
|
||||
@@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
|
||||
`secretApprovalReviewerUser.id`
|
||||
)
|
||||
|
||||
.leftJoin<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(
|
||||
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
|
||||
tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"),
|
||||
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"),
|
||||
tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
|
||||
tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
|
||||
tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
|
||||
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
|
||||
tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"),
|
||||
@@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
|
||||
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
|
||||
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
|
||||
tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
|
||||
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
|
||||
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
|
||||
tx.ref("projectId").withSchema(TableName.Environment),
|
||||
@@ -157,7 +180,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
|
||||
tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"),
|
||||
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
|
||||
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt")
|
||||
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt"),
|
||||
tx
|
||||
.ref("shouldCheckSecretPermission")
|
||||
.withSchema(TableName.SecretApprovalPolicy)
|
||||
.as("policySecretReadAccessCompat")
|
||||
);
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
@@ -197,7 +224,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
enforcementLevel: el.policyEnforcementLevel,
|
||||
envId: el.policyEnvId,
|
||||
deletedAt: el.policyDeletedAt,
|
||||
allowedSelfApprovals: el.policyAllowedSelfApprovals
|
||||
allowedSelfApprovals: el.policyAllowedSelfApprovals,
|
||||
shouldCheckSecretPermission: el.policySecretReadAccessCompat
|
||||
}
|
||||
}),
|
||||
childrenMapper: [
|
||||
@@ -211,9 +239,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
reviewerLastName: lastName,
|
||||
reviewerUsername: username,
|
||||
reviewerFirstName: firstName,
|
||||
reviewerComment: comment
|
||||
reviewerComment: comment,
|
||||
reviewerIsOrgMembershipActive: isOrgMembershipActive
|
||||
}) =>
|
||||
userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined
|
||||
userId
|
||||
? {
|
||||
userId,
|
||||
status,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
comment: comment ?? "",
|
||||
isOrgMembershipActive
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: "approverUserId",
|
||||
@@ -223,13 +263,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
approverEmail: email,
|
||||
approverUsername: username,
|
||||
approverLastName: lastName,
|
||||
approverFirstName: firstName
|
||||
approverFirstName: firstName,
|
||||
approverIsOrgMembershipActive: isOrgMembershipActive
|
||||
}) => ({
|
||||
userId,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
username
|
||||
username,
|
||||
isOrgMembershipActive
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -240,13 +282,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
approverGroupEmail: email,
|
||||
approverGroupUsername: username,
|
||||
approverGroupLastName: lastName,
|
||||
approverGroupFirstName: firstName
|
||||
approverGroupFirstName: firstName,
|
||||
approverGroupIsOrgMembershipActive: isOrgMembershipActive
|
||||
}) => ({
|
||||
userId,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
username
|
||||
username,
|
||||
isOrgMembershipActive
|
||||
})
|
||||
},
|
||||
{
|
||||
|
@@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
|
||||
|
||||
const secretApprovalRequest = await secretApprovalRequestDAL.findById(id);
|
||||
|
||||
if (!secretApprovalRequest)
|
||||
throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` });
|
||||
|
||||
@@ -280,13 +281,22 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
) {
|
||||
throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
|
||||
}
|
||||
const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => {
|
||||
const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: secretPath || "/",
|
||||
secretTags: tags.map((i) => i.slug)
|
||||
});
|
||||
return canRead;
|
||||
const getHasSecretReadAccess = (
|
||||
shouldCheckSecretPermission: boolean | null | undefined,
|
||||
environment: string,
|
||||
tags: { slug: string }[],
|
||||
secretPath?: string
|
||||
) => {
|
||||
if (shouldCheckSecretPermission) {
|
||||
const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath: secretPath || "/",
|
||||
secretTags: tags.map((i) => i.slug)
|
||||
});
|
||||
return canRead;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let secrets;
|
||||
@@ -308,8 +318,18 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
version: el.version,
|
||||
secretMetadata: el.secretMetadata as ResourceMetadataDTO,
|
||||
isRotatedSecret: el.secret?.isRotatedSecret ?? false,
|
||||
secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path),
|
||||
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path)
|
||||
secretValueHidden: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
),
|
||||
secretValue: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
)
|
||||
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
|
||||
: el.secret && el.secret.isRotatedSecret
|
||||
? undefined
|
||||
@@ -325,11 +345,17 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
id: el.secret.id,
|
||||
version: el.secret.version,
|
||||
secretValueHidden: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
),
|
||||
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path)
|
||||
secretValue: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
)
|
||||
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
|
||||
: el.secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
|
||||
@@ -345,11 +371,17 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
id: el.secretVersion.id,
|
||||
version: el.secretVersion.version,
|
||||
secretValueHidden: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
),
|
||||
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path)
|
||||
secretValue: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
)
|
||||
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
|
||||
: el.secretVersion.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
|
||||
@@ -367,7 +399,12 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
|
||||
secrets = encryptedSecrets.map((el) => ({
|
||||
...el,
|
||||
secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path),
|
||||
secretValueHidden: !getHasSecretReadAccess(
|
||||
secretApprovalRequest.policy.shouldCheckSecretPermission,
|
||||
secretApprovalRequest.environment,
|
||||
el.tags,
|
||||
secretPath?.[0]?.path
|
||||
),
|
||||
...decryptSecretWithBot(el, botKey),
|
||||
secret: el.secret
|
||||
? {
|
||||
@@ -1447,6 +1484,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
|
||||
const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = [];
|
||||
const commitTagIds: Record<string, string[]> = {};
|
||||
const existingTagIds: Record<string, string[]> = {};
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
@@ -1512,6 +1550,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
type: SecretType.Shared
|
||||
}))
|
||||
);
|
||||
|
||||
secretsToUpdateStoredInDB.forEach((el) => {
|
||||
if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id);
|
||||
});
|
||||
|
||||
if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length)
|
||||
throw new NotFoundError({
|
||||
message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}`
|
||||
@@ -1555,7 +1598,10 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretMetadata
|
||||
}) => {
|
||||
const secretId = updatingSecretsGroupByKey[secretKey][0].id;
|
||||
if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds;
|
||||
if (tagIds?.length || existingTagIds[secretKey]?.length) {
|
||||
commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey];
|
||||
}
|
||||
|
||||
return {
|
||||
...latestSecretVersions[secretId],
|
||||
secretMetadata,
|
||||
|
@@ -2491,6 +2491,7 @@ export const SecretSyncs = {
|
||||
},
|
||||
RENDER: {
|
||||
serviceId: "The ID of the Render service to sync secrets to.",
|
||||
environmentGroupId: "The ID of the Render environment group to sync secrets to.",
|
||||
scope: "The Render scope that secrets should be synced to.",
|
||||
type: "The Render resource type to sync secrets to."
|
||||
},
|
||||
|
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Safely retrieves a value from a nested object using dot notation path
|
||||
*/
|
||||
export const getStringValueByDot = (
|
||||
export const getValueByDot = (
|
||||
obj: Record<string, unknown> | null | undefined,
|
||||
path: string,
|
||||
defaultValue?: string
|
||||
): string | undefined => {
|
||||
defaultValue?: string | number | boolean
|
||||
): string | number | boolean | undefined => {
|
||||
// Handle null or undefined input
|
||||
if (!obj) {
|
||||
return defaultValue;
|
||||
@@ -26,7 +26,7 @@ export const getStringValueByDot = (
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
if (typeof current !== "string") {
|
||||
if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
|
@@ -726,7 +726,8 @@ export const registerRoutes = async (
|
||||
permissionService,
|
||||
groupProjectDAL,
|
||||
smtpService,
|
||||
projectMembershipDAL
|
||||
projectMembershipDAL,
|
||||
userAliasDAL
|
||||
});
|
||||
|
||||
const totpService = totpServiceFactory({
|
||||
|
@@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider)
|
||||
return services;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/:connectionId/environment-groups`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
connectionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
.array()
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { connectionId } = req.params;
|
||||
const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission);
|
||||
|
||||
return groups;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||
handler: () => ({ message: "Authenticated" as const })
|
||||
});
|
||||
|
||||
|
@@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
name: GenericResourceNameSchema.optional(),
|
||||
slug: slugSchema({ max: 64 }).optional(),
|
||||
authEnforced: z.boolean().optional(),
|
||||
googleSsoAuthEnforced: z.boolean().optional(),
|
||||
scimEnabled: z.boolean().optional(),
|
||||
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
|
||||
enforceMfa: z.boolean().optional(),
|
||||
|
@@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
id: true
|
||||
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
|
||||
})
|
||||
.merge(UserEncryptionKeysSchema.pick({ publicKey: true }))
|
||||
.extend({
|
||||
isOrgMembershipActive: z.boolean()
|
||||
}),
|
||||
project: SanitizedProjectSchema.pick({ name: true, id: true }),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
|
@@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
|
||||
try {
|
||||
// @ts-expect-error this is because this is express type and not fastify
|
||||
const callbackPort = req.session.get("callbackPort");
|
||||
// @ts-expect-error this is because this is express type and not fastify
|
||||
const orgSlug = req.session.get("orgSlug");
|
||||
|
||||
const email = profile?.emails?.[0]?.value;
|
||||
if (!email)
|
||||
@@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
|
||||
firstName: profile?.name?.givenName || "",
|
||||
lastName: profile?.name?.familyName || "",
|
||||
authMethod: AuthMethod.GOOGLE,
|
||||
callbackPort
|
||||
callbackPort,
|
||||
orgSlug
|
||||
});
|
||||
cb(null, { isUserCompleted, providerAuthToken });
|
||||
} catch (error) {
|
||||
@@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
callback_port: z.string().optional(),
|
||||
org_slug: z.string().optional(),
|
||||
is_admin_login: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
preValidation: [
|
||||
async (req, res) => {
|
||||
const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query;
|
||||
const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query;
|
||||
// ensure fresh session state per login attempt
|
||||
await req.session.regenerate();
|
||||
if (callbackPort) {
|
||||
req.session.set("callbackPort", callbackPort);
|
||||
}
|
||||
if (orgSlug) {
|
||||
req.session.set("orgSlug", orgSlug);
|
||||
}
|
||||
if (isAdminLogin) {
|
||||
req.session.set("isAdminLogin", isAdminLogin);
|
||||
}
|
||||
|
@@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
username: z.string().trim()
|
||||
token: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
await server.services.user.sendEmailVerificationCode(req.body.username);
|
||||
await server.services.user.sendEmailVerificationCode(req.body.token);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { request } from "@octokit/request";
|
||||
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import RE2 from "re2";
|
||||
@@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic
|
||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request as httpRequest } from "@app/lib/config/request";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
|
||||
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||
import { logger } from "@app/lib/logger";
|
||||
@@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async <T>(
|
||||
);
|
||||
};
|
||||
|
||||
export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => {
|
||||
export const getGitHubAppAuthToken = async (
|
||||
appConnection: TGitHubConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const appCfg = getConfig();
|
||||
const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID;
|
||||
const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
|
||||
let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
|
||||
|
||||
if (!appId || !appPrivateKey) {
|
||||
throw new InternalServerError({
|
||||
@@ -125,21 +127,42 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
|
||||
});
|
||||
}
|
||||
|
||||
appPrivateKey = appPrivateKey
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n");
|
||||
|
||||
if (appConnection.method !== GitHubConnectionMethod.App) {
|
||||
throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" });
|
||||
}
|
||||
|
||||
const appAuth = createAppAuth({
|
||||
appId,
|
||||
privateKey: appPrivateKey,
|
||||
installationId: appConnection.credentials.installationId,
|
||||
request: request.defaults({
|
||||
baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}`
|
||||
})
|
||||
});
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iat: now,
|
||||
exp: now + 5 * 60,
|
||||
iss: appId
|
||||
};
|
||||
|
||||
const { token } = await appAuth({ type: "installation" });
|
||||
return token;
|
||||
const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" });
|
||||
|
||||
const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection);
|
||||
const { installationId } = appConnection.credentials;
|
||||
|
||||
const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>(
|
||||
appConnection,
|
||||
gatewayService,
|
||||
{
|
||||
url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${appJwt}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.token;
|
||||
};
|
||||
|
||||
const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
|
||||
@@ -174,7 +197,9 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
||||
const { credentials, method } = appConnection;
|
||||
|
||||
const token =
|
||||
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
|
||||
method === GitHubConnectionMethod.OAuth
|
||||
? credentials.accessToken
|
||||
: await getGitHubAppAuthToken(appConnection, gatewayService);
|
||||
|
||||
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
|
||||
const initialUrlObj = new URL(baseUrl);
|
||||
|
@@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { RenderConnectionMethod } from "./render-connection-enums";
|
||||
import {
|
||||
TRawRenderEnvironmentGroup,
|
||||
TRawRenderService,
|
||||
TRenderConnection,
|
||||
TRenderConnectionConfig,
|
||||
TRenderEnvironmentGroup,
|
||||
TRenderService
|
||||
} from "./render-connection-types";
|
||||
|
||||
@@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
|
||||
const perPage = 100;
|
||||
let cursor;
|
||||
|
||||
let maxIterations = 10;
|
||||
|
||||
while (hasMorePages) {
|
||||
if (maxIterations <= 0) break;
|
||||
|
||||
const res: TRawRenderService[] = (
|
||||
await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, {
|
||||
params: new URLSearchParams({
|
||||
@@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
|
||||
} else {
|
||||
cursor = res[res.length - 1].cursor;
|
||||
}
|
||||
|
||||
maxIterations -= 1;
|
||||
}
|
||||
|
||||
return services;
|
||||
@@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect
|
||||
|
||||
return inputCredentials;
|
||||
};
|
||||
|
||||
export const listRenderEnvironmentGroups = async (
|
||||
appConnection: TRenderConnection
|
||||
): Promise<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 { AppConnection } from "../app-connection-enums";
|
||||
import { listRenderServices } from "./render-connection-fns";
|
||||
import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns";
|
||||
import { TRenderConnection } from "./render-connection-types";
|
||||
|
||||
type TGetAppConnectionFunc = (
|
||||
@@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc)
|
||||
}
|
||||
};
|
||||
|
||||
const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor);
|
||||
try {
|
||||
const groups = await listRenderEnvironmentGroups(appConnection);
|
||||
|
||||
return groups;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to list environment groups for Render connection");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listServices
|
||||
listServices,
|
||||
listEnvironmentGroups
|
||||
};
|
||||
};
|
||||
|
@@ -33,3 +33,16 @@ export type TRawRenderService = {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TRenderEnvironmentGroup = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TRawRenderEnvironmentGroup = {
|
||||
cursor: string;
|
||||
envGroup: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
@@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
|
||||
};
|
||||
|
||||
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => {
|
||||
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
|
||||
const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => {
|
||||
const { token, ...tkCfg } = getTokenConfig(type);
|
||||
const appCfg = getConfig();
|
||||
const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS);
|
||||
@@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
|
||||
type,
|
||||
userId,
|
||||
orgId,
|
||||
triesLeft: tkCfg?.triesLeft
|
||||
triesLeft: tkCfg?.triesLeft,
|
||||
aliasId
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
@@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = {
|
||||
type: TokenType;
|
||||
userId: string;
|
||||
orgId?: string;
|
||||
aliasId?: string;
|
||||
};
|
||||
|
||||
export type TCreateOrgInviteTokenDTO = {
|
||||
|
@@ -448,15 +448,41 @@ export const authLoginServiceFactory = ({
|
||||
|
||||
// Check if the user actually has access to the specified organization.
|
||||
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
|
||||
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited");
|
||||
|
||||
const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited");
|
||||
|
||||
const selectedOrg = await orgDAL.findById(organizationId);
|
||||
|
||||
if (!hasOrganizationMembership) {
|
||||
// Check if authEnforced is true, if that's the case, throw an error
|
||||
if (selectedOrg.authEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "Authentication is required by your organization before you can log in."
|
||||
});
|
||||
}
|
||||
|
||||
if (!selectedOrgMembership) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `User does not have access to the organization named ${selectedOrg?.name}`
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
|
||||
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
|
||||
|
||||
if (!canBypass) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Google SSO is enforced for this organization. Please use Google SSO to login.",
|
||||
error: "GoogleSsoEnforced"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedToken.authMethod === AuthMethod.GOOGLE) {
|
||||
await orgDAL.updateById(selectedOrg.id, {
|
||||
googleSsoAuthLastUsed: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
|
||||
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
||||
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
||||
@@ -502,7 +528,8 @@ export const authLoginServiceFactory = ({
|
||||
selectedOrg.authEnforced &&
|
||||
selectedOrg.bypassOrgAuthEnabled &&
|
||||
!isAuthMethodSaml(decodedToken.authMethod) &&
|
||||
decodedToken.authMethod !== AuthMethod.OIDC
|
||||
decodedToken.authMethod !== AuthMethod.OIDC &&
|
||||
decodedToken.authMethod !== AuthMethod.GOOGLE
|
||||
) {
|
||||
await auditLogService.createAuditLog({
|
||||
orgId: organizationId,
|
||||
@@ -705,7 +732,7 @@ export const authLoginServiceFactory = ({
|
||||
/*
|
||||
* OAuth2 login for google,github, and other oauth2 provider
|
||||
* */
|
||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
|
||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => {
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserByUsername(email);
|
||||
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
@@ -759,6 +786,8 @@ export const authLoginServiceFactory = ({
|
||||
|
||||
const appCfg = getConfig();
|
||||
|
||||
let orgId = "";
|
||||
let orgName: undefined | string;
|
||||
if (!user) {
|
||||
// Create a new user based on oAuth
|
||||
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
|
||||
@@ -784,7 +813,6 @@ export const authLoginServiceFactory = ({
|
||||
});
|
||||
|
||||
if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
|
||||
let orgId = "";
|
||||
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
|
||||
if (!defaultOrg) {
|
||||
throw new BadRequestError({
|
||||
@@ -824,11 +852,39 @@ export const authLoginServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!orgId && orgSlug) {
|
||||
const org = await orgDAL.findOrgBySlug(orgSlug);
|
||||
|
||||
if (org) {
|
||||
// checks for the membership and only sets the orgId / orgName if the user is a member of the specified org
|
||||
const orgMembership = await orgDAL.findMembership({
|
||||
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
|
||||
[`${TableName.OrgMembership}.orgId` as "orgId"]: org.id,
|
||||
[`${TableName.OrgMembership}.isActive` as "isActive"]: true,
|
||||
[`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted
|
||||
});
|
||||
|
||||
if (orgMembership) {
|
||||
orgId = org.id;
|
||||
orgName = org.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isUserCompleted = user.isAccepted;
|
||||
const providerAuthToken = crypto.jwt().sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||
userId: user.id,
|
||||
|
||||
...(orgId && orgSlug && orgName !== undefined
|
||||
? {
|
||||
organizationId: orgId,
|
||||
organizationName: orgName,
|
||||
organizationSlug: orgSlug
|
||||
}
|
||||
: {}),
|
||||
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isEmailVerified: user.isEmailVerified,
|
||||
|
@@ -32,6 +32,7 @@ export type TOauthLoginDTO = {
|
||||
lastName?: string;
|
||||
authMethod: AuthMethod;
|
||||
callbackPort?: string;
|
||||
orgSlug?: string;
|
||||
};
|
||||
|
||||
export type TOauthTokenExchangeDTO = {
|
||||
|
@@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
||||
`${TableName.GroupProjectMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.UserGroupMembership),
|
||||
db.ref("createdAt").withSchema(TableName.UserGroupMembership),
|
||||
@@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
||||
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
|
||||
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
|
||||
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole),
|
||||
db.ref("name").as("projectName").withSchema(TableName.Project)
|
||||
db.ref("name").as("projectName").withSchema(TableName.Project),
|
||||
db.ref("isActive").withSchema(TableName.OrgMembership)
|
||||
)
|
||||
.where({ isGhost: false });
|
||||
|
||||
@@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
||||
id,
|
||||
userId,
|
||||
projectName,
|
||||
createdAt
|
||||
createdAt,
|
||||
isActive
|
||||
}) => ({
|
||||
isGroupMember: true,
|
||||
id,
|
||||
@@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
||||
id: projectId,
|
||||
name: projectName
|
||||
},
|
||||
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
|
||||
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive },
|
||||
createdAt
|
||||
}),
|
||||
key: "id",
|
||||
|
@@ -21,7 +21,7 @@ import {
|
||||
UnauthorizedError
|
||||
} from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
import { getStringValueByDot } from "@app/lib/template/dot-access";
|
||||
import { getValueByDot } from "@app/lib/template/dot-access";
|
||||
|
||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
@@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({
|
||||
if (identityJwtAuth.boundClaims) {
|
||||
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
|
||||
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
|
||||
const value = getStringValueByDot(tokenData, claimKey) || "";
|
||||
const value = getValueByDot(tokenData, claimKey);
|
||||
|
||||
if (!value) {
|
||||
throw new UnauthorizedError({
|
||||
@@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({
|
||||
}
|
||||
|
||||
// handle both single and multi-valued claims
|
||||
if (
|
||||
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry))
|
||||
) {
|
||||
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: claim mismatch for field ${claimKey}`
|
||||
});
|
||||
|
@@ -1,7 +1,16 @@
|
||||
import picomatch from "picomatch";
|
||||
|
||||
export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) =>
|
||||
policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
|
||||
export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => {
|
||||
if (typeof fieldValue === "boolean") {
|
||||
return fieldValue === (policyValue === "true");
|
||||
}
|
||||
|
||||
if (typeof fieldValue === "number") {
|
||||
return fieldValue === parseInt(policyValue, 10);
|
||||
}
|
||||
|
||||
return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
|
||||
};
|
||||
|
||||
export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => {
|
||||
if (Array.isArray(fieldValue)) {
|
||||
|
@@ -22,7 +22,7 @@ import {
|
||||
UnauthorizedError
|
||||
} from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
import { getStringValueByDot } from "@app/lib/template/dot-access";
|
||||
import { getValueByDot } from "@app/lib/template/dot-access";
|
||||
|
||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
@@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({
|
||||
if (identityOidcAuth.boundClaims) {
|
||||
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
|
||||
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
|
||||
const value = getStringValueByDot(tokenData, claimKey) || "";
|
||||
const value = getValueByDot(tokenData, claimKey);
|
||||
|
||||
if (!value) {
|
||||
throw new UnauthorizedError({
|
||||
@@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({
|
||||
if (identityOidcAuth.claimMetadataMapping) {
|
||||
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
|
||||
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
|
||||
const value = getStringValueByDot(tokenData, claimKey) || "";
|
||||
const value = getValueByDot(tokenData, claimKey);
|
||||
if (!value) {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: token has no ${claimKey} field`
|
||||
});
|
||||
}
|
||||
filteredClaims[permissionKey] = value;
|
||||
filteredClaims[permissionKey] = value.toString();
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -124,12 +124,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
|
||||
void qb
|
||||
.whereNull(`${TableName.OrgMembership}.lastInvitedAt`)
|
||||
.whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]);
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
// lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago
|
||||
void qb
|
||||
.where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo)
|
||||
.where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo);
|
||||
void qb.orWhere((qbInner) => {
|
||||
void qbInner
|
||||
.where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo)
|
||||
.where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo);
|
||||
});
|
||||
});
|
||||
|
||||
return memberships;
|
||||
|
@@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
authEnforced: true,
|
||||
googleSsoAuthEnforced: true,
|
||||
scimEnabled: true,
|
||||
kmsDefaultKeyId: true,
|
||||
defaultMembershipRole: true,
|
||||
|
@@ -364,6 +364,7 @@ export const orgServiceFactory = ({
|
||||
name,
|
||||
slug,
|
||||
authEnforced,
|
||||
googleSsoAuthEnforced,
|
||||
scimEnabled,
|
||||
defaultMembershipRoleSlug,
|
||||
enforceMfa,
|
||||
@@ -430,6 +431,21 @@ export const orgServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (googleSsoAuthEnforced !== undefined) {
|
||||
if (!plan.enforceGoogleSSO) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO."
|
||||
});
|
||||
}
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||
}
|
||||
|
||||
if (authEnforced && googleSsoAuthEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time."
|
||||
});
|
||||
}
|
||||
|
||||
if (authEnforced) {
|
||||
const samlCfg = await samlConfigDAL.findOne({
|
||||
orgId,
|
||||
@@ -460,6 +476,21 @@ export const orgServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (googleSsoAuthEnforced) {
|
||||
if (googleSsoAuthEnforced && currentOrg.authEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled."
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentOrg.googleSsoAuthLastUsed) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let defaultMembershipRole: string | undefined;
|
||||
if (defaultMembershipRoleSlug) {
|
||||
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
|
||||
@@ -474,6 +505,7 @@ export const orgServiceFactory = ({
|
||||
name,
|
||||
slug: slug ? slugify(slug) : undefined,
|
||||
authEnforced,
|
||||
googleSsoAuthEnforced,
|
||||
scimEnabled,
|
||||
defaultMembershipRole,
|
||||
enforceMfa,
|
||||
|
@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
|
||||
name: string;
|
||||
slug: string;
|
||||
authEnforced: boolean;
|
||||
googleSsoAuthEnforced: boolean;
|
||||
scimEnabled: boolean;
|
||||
defaultMembershipRoleSlug: string;
|
||||
enforceMfa: boolean;
|
||||
|
@@ -177,6 +177,18 @@ export const projectEnvServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
const envs = await projectEnvDAL.find({ projectId });
|
||||
const project = await projectDAL.findById(projectId);
|
||||
const plan = await licenseService.getPlan(project.orgId);
|
||||
if (plan.environmentLimit !== null && envs.length > plan.environmentLimit) {
|
||||
// case: limit imposed on number of environments allowed
|
||||
// case: number of environments used exceeds the number of environments allowed
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to update environment due to environment limit exceeded. To update an environment, please upgrade your plan or remove unused environments."
|
||||
});
|
||||
}
|
||||
|
||||
const env = await projectEnvDAL.transaction(async (tx) => {
|
||||
if (position) {
|
||||
const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx);
|
||||
|
@@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
||||
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
|
||||
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||
.join(TableName.OrgMembership, (qb) => {
|
||||
qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn(
|
||||
`${TableName.OrgMembership}.orgId`,
|
||||
"=",
|
||||
`${TableName.Project}.orgId`
|
||||
);
|
||||
})
|
||||
|
||||
.where((qb) => {
|
||||
if (filter.usernames) {
|
||||
void qb.whereIn("username", filter.usernames);
|
||||
@@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
||||
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
|
||||
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
|
||||
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
|
||||
db.ref("name").as("projectName").withSchema(TableName.Project)
|
||||
db.ref("name").as("projectName").withSchema(TableName.Project),
|
||||
db.ref("isActive").withSchema(TableName.OrgMembership)
|
||||
)
|
||||
.where({ isGhost: false })
|
||||
.orderBy(`${TableName.Users}.username` as "username");
|
||||
@@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
||||
id,
|
||||
userId,
|
||||
projectName,
|
||||
createdAt
|
||||
createdAt,
|
||||
isActive
|
||||
}) => ({
|
||||
id,
|
||||
userId,
|
||||
projectId,
|
||||
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
|
||||
user: {
|
||||
email,
|
||||
username,
|
||||
firstName,
|
||||
lastName,
|
||||
id: userId,
|
||||
publicKey,
|
||||
isGhost,
|
||||
isOrgMembershipActive: isActive
|
||||
},
|
||||
project: {
|
||||
id: projectId,
|
||||
name: projectName
|
||||
|
@@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({
|
||||
|
||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
|
||||
|
||||
// projectMembers[0].project
|
||||
if (includeGroupMembers) {
|
||||
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
|
||||
const allMembers = [
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import AWS, { AWSError } from "aws-sdk";
|
||||
import handlebars from "handlebars";
|
||||
|
||||
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
@@ -34,18 +35,51 @@ const sleep = async () =>
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => {
|
||||
const getFullPath = ({ path, keySchema, environment }: { path: string; keySchema?: string; environment: string }) => {
|
||||
if (!keySchema || !keySchema.includes("/")) return path;
|
||||
|
||||
if (keySchema.startsWith("/")) {
|
||||
throw new SecretSyncError({ message: `Key schema cannot contain leading '/'`, shouldRetry: false });
|
||||
}
|
||||
|
||||
const keySchemaSegments = handlebars
|
||||
.compile(keySchema)({
|
||||
environment,
|
||||
secretKey: "{{secretKey}}"
|
||||
})
|
||||
.split("/");
|
||||
|
||||
const pathSegments = keySchemaSegments.slice(0, keySchemaSegments.length - 1);
|
||||
|
||||
if (pathSegments.some((segment) => segment.includes("{{secretKey}}"))) {
|
||||
throw new SecretSyncError({
|
||||
message: "Key schema cannot contain '/' after {{secretKey}}",
|
||||
shouldRetry: false
|
||||
});
|
||||
}
|
||||
|
||||
return `${path}${pathSegments.join("/")}/`;
|
||||
};
|
||||
|
||||
const getParametersByPath = async (
|
||||
ssm: AWS.SSM,
|
||||
path: string,
|
||||
keySchema: string | undefined,
|
||||
environment: string
|
||||
): Promise<TAWSParameterStoreRecord> => {
|
||||
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
|
||||
let hasNext = true;
|
||||
let nextToken: string | undefined;
|
||||
let attempt = 0;
|
||||
|
||||
const fullPath = getFullPath({ path, keySchema, environment });
|
||||
|
||||
while (hasNext) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const parameters = await ssm
|
||||
.getParametersByPath({
|
||||
Path: path,
|
||||
Path: fullPath,
|
||||
Recursive: false,
|
||||
WithDecryption: true,
|
||||
MaxResults: BATCH_SIZE,
|
||||
@@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
|
||||
parameters.Parameters.forEach((parameter) => {
|
||||
if (parameter.Name) {
|
||||
// no leading slash if path is '/'
|
||||
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||
const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||
awsParameterStoreSecretsRecord[secKey] = parameter;
|
||||
}
|
||||
});
|
||||
@@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
|
||||
return awsParameterStoreSecretsRecord;
|
||||
};
|
||||
|
||||
const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreMetadataRecord> => {
|
||||
const getParameterMetadataByPath = async (
|
||||
ssm: AWS.SSM,
|
||||
path: string,
|
||||
keySchema: string | undefined,
|
||||
environment: string
|
||||
): Promise<TAWSParameterStoreMetadataRecord> => {
|
||||
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
|
||||
let hasNext = true;
|
||||
let nextToken: string | undefined;
|
||||
let attempt = 0;
|
||||
|
||||
const fullPath = getFullPath({ path, keySchema, environment });
|
||||
|
||||
while (hasNext) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "OneLevel",
|
||||
Values: [path]
|
||||
Values: [fullPath]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -112,7 +153,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
|
||||
parameters.Parameters.forEach((parameter) => {
|
||||
if (parameter.Name) {
|
||||
// no leading slash if path is '/'
|
||||
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||
const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||
awsParameterStoreMetadataRecord[secKey] = parameter;
|
||||
}
|
||||
});
|
||||
@@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = {
|
||||
|
||||
const ssm = await getSSM(secretSync);
|
||||
|
||||
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
||||
const awsParameterStoreSecretsRecord = await getParametersByPath(
|
||||
ssm,
|
||||
destinationConfig.path,
|
||||
syncOptions.keySchema,
|
||||
environment!.slug
|
||||
);
|
||||
|
||||
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path);
|
||||
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(
|
||||
ssm,
|
||||
destinationConfig.path,
|
||||
syncOptions.keySchema,
|
||||
environment!.slug
|
||||
);
|
||||
|
||||
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
|
||||
ssm,
|
||||
@@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = {
|
||||
await deleteParametersBatch(ssm, parametersToDelete);
|
||||
},
|
||||
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
|
||||
const { destinationConfig } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||
|
||||
const ssm = await getSSM(secretSync);
|
||||
|
||||
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
||||
const awsParameterStoreSecretsRecord = await getParametersByPath(
|
||||
ssm,
|
||||
destinationConfig.path,
|
||||
syncOptions.keySchema,
|
||||
environment!.slug
|
||||
);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
|
||||
);
|
||||
},
|
||||
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const { destinationConfig } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||
|
||||
const ssm = await getSSM(secretSync);
|
||||
|
||||
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
||||
const awsParameterStoreSecretsRecord = await getParametersByPath(
|
||||
ssm,
|
||||
destinationConfig.path,
|
||||
syncOptions.keySchema,
|
||||
environment!.slug
|
||||
);
|
||||
|
||||
const parametersToDelete: AWS.SSM.Parameter[] = [];
|
||||
|
||||
|
@@ -207,7 +207,7 @@ export const GithubSyncFns = {
|
||||
const token =
|
||||
connection.method === GitHubConnectionMethod.OAuth
|
||||
? connection.credentials.accessToken
|
||||
: await getGitHubAppAuthToken(connection);
|
||||
: await getGitHubAppAuthToken(connection, gatewayService);
|
||||
|
||||
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
|
||||
const publicKey = await getPublicKey(secretSync, gatewayService, token);
|
||||
@@ -264,7 +264,7 @@ export const GithubSyncFns = {
|
||||
const token =
|
||||
connection.method === GitHubConnectionMethod.OAuth
|
||||
? connection.credentials.accessToken
|
||||
: await getGitHubAppAuthToken(connection);
|
||||
: await getGitHubAppAuthToken(connection, gatewayService);
|
||||
|
||||
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
export enum RenderSyncScope {
|
||||
Service = "service"
|
||||
Service = "service",
|
||||
EnvironmentGroup = "environment-group"
|
||||
}
|
||||
|
||||
export enum RenderSyncType {
|
||||
|
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { isAxiosError } from "axios";
|
||||
import { AxiosRequestConfig, isAxiosError } from "axios";
|
||||
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
import { RenderSyncScope } from "./render-sync-enums";
|
||||
import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types";
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
@@ -27,6 +29,80 @@ const makeRequestWithRetry = async <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 {
|
||||
destinationConfig,
|
||||
@@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential
|
||||
}
|
||||
} = secretSync;
|
||||
|
||||
const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`;
|
||||
const allSecrets: TRenderSecret[] = [];
|
||||
let cursor: string | undefined;
|
||||
const secrets = await getSecrets({
|
||||
destination: destinationConfig,
|
||||
token: apiKey
|
||||
});
|
||||
|
||||
do {
|
||||
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
|
||||
|
||||
const { data } = await makeRequestWithRetry(() =>
|
||||
request.get<
|
||||
{
|
||||
envVar: {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
cursor: string;
|
||||
}[]
|
||||
>(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const secrets = data.map((item) => ({
|
||||
key: item.envVar.key,
|
||||
value: item.envVar.value
|
||||
}));
|
||||
|
||||
allSecrets.push(...secrets);
|
||||
|
||||
if (data.length > 0 && data[data.length - 1]?.cursor) {
|
||||
cursor = data[data.length - 1].cursor;
|
||||
} else {
|
||||
cursor = undefined;
|
||||
}
|
||||
} while (cursor);
|
||||
|
||||
return allSecrets;
|
||||
return secrets;
|
||||
};
|
||||
|
||||
const batchUpdateEnvironmentSecrets = async (
|
||||
@@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async (
|
||||
}
|
||||
} = secretSync;
|
||||
|
||||
await makeRequestWithRetry(() =>
|
||||
request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json"
|
||||
const req: AxiosRequestConfig = {
|
||||
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
switch (destinationConfig.scope) {
|
||||
case RenderSyncScope.Service: {
|
||||
await makeRequestWithRetry(() =>
|
||||
request.request({
|
||||
...req,
|
||||
url: `/services/${destinationConfig.serviceId}/env-vars`,
|
||||
data: envVars
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case RenderSyncScope.EnvironmentGroup: {
|
||||
for await (const variable of envVars) {
|
||||
await makeRequestWithRetry(() =>
|
||||
request.request({
|
||||
...req,
|
||||
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`,
|
||||
data: {
|
||||
value: variable.value
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new BadRequestError({ message: "Unknown render sync destination scope" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEnvironmentSecret = async (
|
||||
secretSync: TRenderSyncWithCredentials,
|
||||
envVar: { key: string; value: string }
|
||||
): Promise<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) => {
|
||||
@@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
|
||||
}
|
||||
} = secretSync;
|
||||
|
||||
await makeRequestWithRetry(() =>
|
||||
request.post(
|
||||
`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
const req: AxiosRequestConfig = {
|
||||
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
switch (destinationConfig.scope) {
|
||||
case RenderSyncScope.Service: {
|
||||
await makeRequestWithRetry(() =>
|
||||
request.request({
|
||||
...req,
|
||||
method: "POST",
|
||||
url: `/services/${destinationConfig.serviceId}/deploys`,
|
||||
data: {}
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case RenderSyncScope.EnvironmentGroup: {
|
||||
const { data } = await request.request<{ serviceLinks: { id: string }[] }>({
|
||||
...req,
|
||||
method: "GET",
|
||||
url: `/env-groups/${destinationConfig.environmentGroupId}`
|
||||
});
|
||||
|
||||
for await (const link of data.serviceLinks) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
await makeRequestWithRetry(() =>
|
||||
request.request({
|
||||
...req,
|
||||
url: `/services/${link.id}/deploys`,
|
||||
data: {}
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new BadRequestError({ message: "Unknown render sync destination scope" });
|
||||
}
|
||||
};
|
||||
|
||||
export const RenderSyncFns = {
|
||||
@@ -169,14 +321,15 @@ export const RenderSyncFns = {
|
||||
const finalEnvVars: Array<{ key: string; value: string }> = [];
|
||||
|
||||
for (const renderSecret of renderSecrets) {
|
||||
if (!(renderSecret.key in secretMap)) {
|
||||
if (renderSecret.key in secretMap) {
|
||||
finalEnvVars.push({
|
||||
key: renderSecret.key,
|
||||
value: renderSecret.value
|
||||
});
|
||||
}
|
||||
}
|
||||
await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars);
|
||||
|
||||
await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el)));
|
||||
|
||||
if (secretSync.syncOptions.autoRedeployServices) {
|
||||
await redeployService(secretSync);
|
||||
|
@@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
|
||||
scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
|
||||
serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId),
|
||||
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
|
||||
environmentGroupId: z
|
||||
.string()
|
||||
.min(1, "Environment Group ID is required")
|
||||
.describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId),
|
||||
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
|
||||
})
|
||||
]);
|
||||
|
||||
|
@@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
throw new BadRequestError({ message: "Missing personal user id" });
|
||||
}
|
||||
void bd.orWhere({
|
||||
key: el.key,
|
||||
type: el.type,
|
||||
userId: el.type === SecretType.Personal ? el.userId : null
|
||||
[`${TableName.SecretV2}.key` as "key"]: el.key,
|
||||
[`${TableName.SecretV2}.type` as "type"]: el.type,
|
||||
[`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretRotationV2SecretMapping}.secretId`
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
TableName.SecretV2JnTag,
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.SecretTag,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
|
||||
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
|
||||
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
|
||||
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
|
||||
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
|
||||
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
|
||||
)
|
||||
.select(selectAllTableCols(TableName.SecretV2))
|
||||
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
|
||||
return secrets.map((secret) => ({
|
||||
...secret,
|
||||
isRotatedSecret: Boolean(secret.rotationId)
|
||||
}));
|
||||
|
||||
const docs = sqlNestRelationships({
|
||||
data: secrets,
|
||||
key: "id",
|
||||
parentMapper: (secret) => ({
|
||||
...secret,
|
||||
isRotatedSecret: Boolean(secret.rotationId)
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "tagId",
|
||||
label: "tags" as const,
|
||||
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
|
||||
id,
|
||||
color,
|
||||
slug,
|
||||
name: slug
|
||||
})
|
||||
},
|
||||
{
|
||||
key: "metadataId",
|
||||
label: "secretMetadata" as const,
|
||||
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
|
||||
id: metadataId,
|
||||
key: metadataKey,
|
||||
value: metadataValue
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return docs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "find by secret keys" });
|
||||
}
|
||||
|
@@ -1074,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({
|
||||
currentPath: path
|
||||
});
|
||||
|
||||
if (!deepPaths) return { secrets: [], imports: [] };
|
||||
if (!deepPaths?.length) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
|
||||
name: "SecretPathNotFound"
|
||||
});
|
||||
}
|
||||
|
||||
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
|
||||
} else {
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) return { secrets: [], imports: [] };
|
||||
if (!folder) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
|
||||
name: "SecretPathNotFound"
|
||||
});
|
||||
}
|
||||
|
||||
paths = [{ folderId: folder.id, path }];
|
||||
}
|
||||
|
@@ -637,7 +637,12 @@ export const secretServiceFactory = ({
|
||||
}
|
||||
});
|
||||
|
||||
if (!deepPaths) return { secrets: [], imports: [] };
|
||||
if (!deepPaths?.length) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
|
||||
name: "SecretPathNotFound"
|
||||
});
|
||||
}
|
||||
|
||||
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
|
||||
} else {
|
||||
@@ -647,7 +652,12 @@ export const secretServiceFactory = ({
|
||||
});
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) return { secrets: [], imports: [] };
|
||||
if (!folder) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
|
||||
name: "SecretPathNotFound"
|
||||
});
|
||||
}
|
||||
|
||||
paths = [{ folderId: folder.id, path }];
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||
@@ -9,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types";
|
||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
import { AuthMethod } from "../auth/auth-type";
|
||||
import { AuthMethod, AuthTokenType } from "../auth/auth-type";
|
||||
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
|
||||
import { TUserDALFactory } from "./user-dal";
|
||||
import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
|
||||
|
||||
@@ -37,6 +39,7 @@ type TUserServiceFactoryDep = {
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById">;
|
||||
};
|
||||
|
||||
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
|
||||
@@ -48,22 +51,38 @@ export const userServiceFactory = ({
|
||||
groupProjectDAL,
|
||||
tokenService,
|
||||
smtpService,
|
||||
permissionService
|
||||
permissionService,
|
||||
userAliasDAL
|
||||
}: TUserServiceFactoryDep) => {
|
||||
const sendEmailVerificationCode = async (username: string) => {
|
||||
const sendEmailVerificationCode = async (token: string) => {
|
||||
const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as {
|
||||
authType: string;
|
||||
aliasId?: string;
|
||||
username: string;
|
||||
authTokenType: AuthTokenType;
|
||||
};
|
||||
if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" });
|
||||
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const users = await userDAL.findUserByUsername(username);
|
||||
const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0];
|
||||
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||
let { isEmailVerified } = user;
|
||||
if (aliasId) {
|
||||
const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId });
|
||||
if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` });
|
||||
isEmailVerified = userAlias.isEmailVerified;
|
||||
}
|
||||
|
||||
if (!user.email)
|
||||
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
|
||||
if (user.isEmailVerified)
|
||||
if (isEmailVerified)
|
||||
throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" });
|
||||
|
||||
const token = await tokenService.createTokenForUser({
|
||||
const userToken = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_VERIFICATION,
|
||||
userId: user.id
|
||||
userId: user.id,
|
||||
aliasId
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
@@ -71,7 +90,7 @@ export const userServiceFactory = ({
|
||||
subjectLine: "Infisical confirmation code",
|
||||
recipients: [user.email],
|
||||
substitutions: {
|
||||
code: token
|
||||
code: userToken
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -95,15 +114,21 @@ export const userServiceFactory = ({
|
||||
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||
if (!user.email)
|
||||
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
|
||||
if (user.isEmailVerified)
|
||||
throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });
|
||||
|
||||
await tokenService.validateTokenForUser({
|
||||
const token = await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_VERIFICATION,
|
||||
userId: user.id,
|
||||
code
|
||||
});
|
||||
|
||||
if (token?.aliasId) {
|
||||
const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId });
|
||||
if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` });
|
||||
if (userAlias?.isEmailVerified)
|
||||
throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });
|
||||
|
||||
await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true });
|
||||
}
|
||||
const userEmails = user?.email ? await userDAL.find({ email: user.email }) : [];
|
||||
|
||||
await userDAL.updateById(user.id, {
|
||||
|
@@ -310,7 +310,8 @@
|
||||
"self-hosting/guides/mongo-to-postgres",
|
||||
"self-hosting/guides/custom-certificates",
|
||||
"self-hosting/guides/automated-bootstrapping",
|
||||
"self-hosting/guides/production-hardening"
|
||||
"self-hosting/guides/production-hardening",
|
||||
"self-hosting/guides/monitoring-telemetry"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -416,6 +417,9 @@
|
||||
"pages": [
|
||||
"documentation/platform/secrets-mgmt/project",
|
||||
"documentation/platform/folder",
|
||||
"documentation/platform/secret-versioning",
|
||||
"documentation/platform/pit-recovery",
|
||||
"documentation/platform/secret-reference",
|
||||
{
|
||||
"group": "Secret Rotation",
|
||||
"pages": [
|
||||
@@ -459,7 +463,8 @@
|
||||
"documentation/platform/dynamic-secrets/kubernetes",
|
||||
"documentation/platform/dynamic-secrets/vertica"
|
||||
]
|
||||
}
|
||||
},
|
||||
"documentation/platform/webhooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Delivering Secrets"
|
||||
description: "Learn how to get secrets out of Infisical and into the systems, applications, and environments that need them."
|
||||
title: "Fetching Secrets"
|
||||
description: "Learn how to deliver secrets from Infisical into the systems, applications, and environments that need them."
|
||||
---
|
||||
|
||||
Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them.
|
||||
|
@@ -27,7 +27,7 @@ $ ansible-galaxy collection install infisical.vault
|
||||
The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip:
|
||||
|
||||
```bash
|
||||
$ pip install infisical-python
|
||||
$ pip install infisicalsdk
|
||||
```
|
||||
|
||||
## Using this collection
|
||||
@@ -41,8 +41,13 @@ vars:
|
||||
read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}"
|
||||
# [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }]
|
||||
|
||||
|
||||
read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}"
|
||||
# { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1
|
||||
|
||||
|
||||
read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}"
|
||||
# [{ "key": "HOST", "value": "google.com" }]
|
||||
# { "key": "HOST", "value": "google.com" }
|
||||
```
|
||||
|
||||
|
||||
|
@@ -6,32 +6,10 @@ description: "Learn how to use Infisical to inject environment variables into a
|
||||
This approach allows you to inject secrets from Infisical directly into your application.
|
||||
This is achieved by installing the Infisical CLI into your docker image and modifying your start command to execute with Infisical.
|
||||
|
||||
## Add the Infisical CLI to your Dockerfile
|
||||
## Install the Infisical CLI to your Dockerfile
|
||||
|
||||
<Tabs>
|
||||
<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
|
||||
```
|
||||
To install the CLI, follow the instructions for your chosen distribution [here](/cli/overview).
|
||||
|
||||
</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>
|
||||
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/)
|
||||
|
@@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical."
|
||||

|
||||
|
||||
- **Render Connection**: The Render Connection to authenticate with.
|
||||
- **Scope**: Select **Service**.
|
||||
- **Service**: Choose the Render service you want to sync secrets to.
|
||||
- **Scope**: Select **Service** or **Environment Group**.
|
||||
- **Service**: Choose the Render service you want to sync secrets to.
|
||||
- **Environment Group**: Choose the Render environment group you want to sync secrets to.
|
||||
|
||||
5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
|
||||

|
||||
|
440
docs/self-hosting/guides/monitoring-telemetry.mdx
Normal file
440
docs/self-hosting/guides/monitoring-telemetry.mdx
Normal file
@@ -0,0 +1,440 @@
|
||||
---
|
||||
title: "Monitoring and Telemetry Setup"
|
||||
description: "Learn how to set up monitoring and telemetry for your self-hosted Infisical instance using Grafana, Prometheus, and OpenTelemetry."
|
||||
---
|
||||
|
||||
Infisical provides comprehensive monitoring and telemetry capabilities to help you monitor the health, performance, and usage of your self-hosted instance. This guide covers setting up monitoring using Grafana with two different telemetry collection approaches.
|
||||
|
||||
## Overview
|
||||
|
||||
Infisical exports metrics in **OpenTelemetry (OTEL) format**, which provides maximum flexibility for your monitoring infrastructure. While this guide focuses on Grafana, the OTEL format means you can easily integrate with:
|
||||
|
||||
- **Cloud-native monitoring**: AWS CloudWatch, Google Cloud Monitoring, Azure Monitor
|
||||
- **Observability platforms**: Datadog, New Relic, Splunk, Dynatrace
|
||||
- **Custom backends**: Any system that supports OTEL ingestion
|
||||
- **Traditional monitoring**: Prometheus, Grafana (as covered in this guide)
|
||||
|
||||
Infisical supports two telemetry collection methods:
|
||||
|
||||
1. **Pull-based (Prometheus)**: Exposes metrics on a dedicated endpoint for Prometheus to scrape
|
||||
2. **Push-based (OTLP)**: Sends metrics to an OpenTelemetry Collector via OTLP protocol
|
||||
|
||||
Both approaches provide the same metrics data in OTEL format, so you can choose the one that best fits your infrastructure and monitoring strategy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Self-hosted Infisical instance running
|
||||
- Access to deploy monitoring services (Prometheus, Grafana, etc.)
|
||||
- Basic understanding of Prometheus and Grafana
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure the following environment variables in your Infisical backend:
|
||||
|
||||
```bash
|
||||
# Enable telemetry collection
|
||||
OTEL_TELEMETRY_COLLECTION_ENABLED=true
|
||||
|
||||
# Choose export type: "prometheus" or "otlp"
|
||||
OTEL_EXPORT_TYPE=prometheus
|
||||
|
||||
# For OTLP push mode, also configure:
|
||||
# OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics
|
||||
# OTEL_COLLECTOR_BASIC_AUTH_USERNAME=your_collector_username
|
||||
# OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=your_collector_password
|
||||
# OTEL_OTLP_PUSH_INTERVAL=30000
|
||||
```
|
||||
|
||||
**Note**: The `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` values must match the credentials configured in your OpenTelemetry Collector's `basicauth/server` extension. These are not hardcoded values - you configure them in your collector configuration file.
|
||||
|
||||
## Option 1: Pull-based Monitoring (Prometheus)
|
||||
|
||||
This approach exposes metrics on port 9464 at the `/metrics` endpoint, allowing Prometheus to scrape the data. The metrics are exposed in Prometheus format but originate from OpenTelemetry instrumentation.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Enable Prometheus export in Infisical**:
|
||||
|
||||
```bash
|
||||
OTEL_TELEMETRY_COLLECTION_ENABLED=true
|
||||
OTEL_EXPORT_TYPE=prometheus
|
||||
```
|
||||
|
||||
2. **Expose the metrics port** in your Infisical backend:
|
||||
|
||||
- **Docker**: Expose port 9464
|
||||
- **Kubernetes**: Create a service exposing port 9464
|
||||
- **Other**: Ensure port 9464 is accessible to your monitoring stack
|
||||
|
||||
3. **Create Prometheus configuration** (`prometheus.yml`):
|
||||
|
||||
```yaml
|
||||
global:
|
||||
scrape_interval: 30s
|
||||
evaluation_interval: 30s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "infisical"
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ["infisical-backend:9464"] # Adjust hostname/port based on your deployment
|
||||
metrics_path: "/metrics"
|
||||
```
|
||||
|
||||
**Note**: Replace `infisical-backend:9464` with the actual hostname and port where your Infisical backend is running. This could be:
|
||||
|
||||
- **Docker Compose**: `infisical-backend:9464` (service name)
|
||||
- **Kubernetes**: `infisical-backend.default.svc.cluster.local:9464` (service name)
|
||||
- **Bare Metal**: `192.168.1.100:9464` (actual IP address)
|
||||
- **Cloud**: `your-infisical.example.com:9464` (domain name)
|
||||
|
||||
### Deployment Options
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
```
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
```yaml
|
||||
# prometheus-deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: prometheus
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: prometheus
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: prometheus
|
||||
spec:
|
||||
containers:
|
||||
- name: prometheus
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
- containerPort: 9090
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/prometheus
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: prometheus-config
|
||||
|
||||
---
|
||||
# prometheus-service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: prometheus
|
||||
spec:
|
||||
selector:
|
||||
app: prometheus
|
||||
ports:
|
||||
- port: 9090
|
||||
targetPort: 9090
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
#### Helm
|
||||
|
||||
```bash
|
||||
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||
helm install prometheus prometheus-community/prometheus \
|
||||
--set server.config.global.scrape_interval=30s \
|
||||
--set server.config.scrape_configs[0].job_name=infisical \
|
||||
--set server.config.scrape_configs[0].static_configs[0].targets[0]=infisical-backend:9464
|
||||
```
|
||||
|
||||
## Option 2: Push-based Monitoring (OTLP)
|
||||
|
||||
This approach sends metrics directly to an OpenTelemetry Collector via the OTLP protocol. This gives you the most flexibility as you can configure the collector to export to multiple backends simultaneously.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Enable OTLP export in Infisical**:
|
||||
|
||||
```bash
|
||||
OTEL_TELEMETRY_COLLECTION_ENABLED=true
|
||||
OTEL_EXPORT_TYPE=otlp
|
||||
OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics
|
||||
OTEL_COLLECTOR_BASIC_AUTH_USERNAME=infisical
|
||||
OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=infisical
|
||||
OTEL_OTLP_PUSH_INTERVAL=30000
|
||||
```
|
||||
|
||||
2. **Create OpenTelemetry Collector configuration** (`otel-collector-config.yaml`):
|
||||
|
||||
```yaml
|
||||
extensions:
|
||||
health_check:
|
||||
pprof:
|
||||
zpages:
|
||||
basicauth/server:
|
||||
htpasswd:
|
||||
inline: |
|
||||
your_username:your_password
|
||||
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
auth:
|
||||
authenticator: basicauth/server
|
||||
|
||||
prometheus:
|
||||
config:
|
||||
scrape_configs:
|
||||
- job_name: otel-collector
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: [infisical-backend:9464]
|
||||
metric_relabel_configs:
|
||||
- action: labeldrop
|
||||
regex: "service_instance_id|service_name"
|
||||
|
||||
processors:
|
||||
batch:
|
||||
|
||||
exporters:
|
||||
prometheus:
|
||||
endpoint: "0.0.0.0:8889"
|
||||
auth:
|
||||
authenticator: basicauth/server
|
||||
resource_to_telemetry_conversion:
|
||||
enabled: true
|
||||
|
||||
service:
|
||||
extensions: [basicauth/server, health_check, pprof, zpages]
|
||||
pipelines:
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [prometheus]
|
||||
```
|
||||
|
||||
**Important**: Replace `your_username:your_password` with your chosen credentials. These must match the values you set in Infisical's `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` environment variables.
|
||||
|
||||
3. **Create Prometheus configuration** for the collector:
|
||||
|
||||
```yaml
|
||||
global:
|
||||
scrape_interval: 30s
|
||||
evaluation_interval: 30s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "otel-collector"
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ["otel-collector:8889"] # Adjust hostname/port based on your deployment
|
||||
metrics_path: "/metrics"
|
||||
```
|
||||
|
||||
**Note**: Replace `otel-collector:8889` with the actual hostname and port where your OpenTelemetry Collector is running. This could be:
|
||||
|
||||
- **Docker Compose**: `otel-collector:8889` (service name)
|
||||
- **Kubernetes**: `otel-collector.default.svc.cluster.local:8889` (service name)
|
||||
- **Bare Metal**: `192.168.1.100:8889` (actual IP address)
|
||||
- **Cloud**: `your-collector.example.com:8889` (domain name)
|
||||
|
||||
### Deployment Options
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
ports:
|
||||
- 4318:4318 # OTLP http receiver
|
||||
- 8889:8889 # Prometheus exporter metrics
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||
command:
|
||||
- "--config=/etc/otelcol-contrib/config.yaml"
|
||||
```
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
```yaml
|
||||
# otel-collector-deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: otel-collector
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: otel-collector
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: otel-collector
|
||||
spec:
|
||||
containers:
|
||||
- name: otel-collector
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
ports:
|
||||
- containerPort: 4318
|
||||
- containerPort: 8889
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/otelcol-contrib
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: otel-collector-config
|
||||
```
|
||||
|
||||
#### Helm
|
||||
|
||||
```bash
|
||||
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
|
||||
helm install otel-collector open-telemetry/opentelemetry-collector \
|
||||
--set config.receivers.otlp.protocols.http.endpoint=0.0.0.0:4318 \
|
||||
--set config.exporters.prometheus.endpoint=0.0.0.0:8889
|
||||
```
|
||||
|
||||
## Alternative Backends
|
||||
|
||||
Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus:
|
||||
|
||||
### Cloud-Native Examples
|
||||
|
||||
```yaml
|
||||
# Add to your otel-collector-config.yaml exporters section
|
||||
exporters:
|
||||
# AWS CloudWatch
|
||||
awsemf:
|
||||
region: us-west-2
|
||||
log_group_name: /aws/emf/infisical
|
||||
log_stream_name: metrics
|
||||
|
||||
# Google Cloud Monitoring
|
||||
googlecloud:
|
||||
project_id: your-project-id
|
||||
|
||||
# Azure Monitor
|
||||
azuremonitor:
|
||||
connection_string: "your-connection-string"
|
||||
|
||||
# Datadog
|
||||
datadog:
|
||||
api:
|
||||
key: "your-api-key"
|
||||
site: "datadoghq.com"
|
||||
|
||||
# New Relic
|
||||
newrelic:
|
||||
apikey: "your-api-key"
|
||||
host_override: "otlp.nr-data.net"
|
||||
```
|
||||
|
||||
### Multi-Backend Configuration
|
||||
|
||||
```yaml
|
||||
service:
|
||||
pipelines:
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [prometheus, awsemf, datadog] # Send to multiple backends
|
||||
```
|
||||
|
||||
## Setting Up Grafana
|
||||
|
||||
1. **Access Grafana**: Navigate to your Grafana instance
|
||||
2. **Login**: Use your configured credentials
|
||||
3. **Add Prometheus Data Source**:
|
||||
- Go to Configuration → Data Sources
|
||||
- Click "Add data source"
|
||||
- Select "Prometheus"
|
||||
- Set URL to your Prometheus endpoint
|
||||
- Click "Save & Test"
|
||||
|
||||
## Available Metrics
|
||||
|
||||
Infisical exposes the following key metrics in OpenTelemetry format:
|
||||
|
||||
### API Performance Metrics
|
||||
|
||||
- `API_latency` - API request latency histogram in milliseconds
|
||||
|
||||
- **Labels**: `route`, `method`, `statusCode`
|
||||
- **Example**: Monitor response times for specific endpoints
|
||||
|
||||
- `API_errors` - API error count histogram
|
||||
- **Labels**: `route`, `method`, `type`, `name`
|
||||
- **Example**: Track error rates by endpoint and error type
|
||||
|
||||
### Integration & Secret Sync Metrics
|
||||
|
||||
- `integration_secret_sync_errors` - Integration secret sync error count
|
||||
|
||||
- **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId`
|
||||
- **Example**: Monitor integration sync failures across different services
|
||||
|
||||
- `secret_sync_sync_secrets_errors` - Secret sync operation error count
|
||||
|
||||
- **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name`
|
||||
- **Example**: Track secret sync failures to external systems
|
||||
|
||||
- `secret_sync_import_secrets_errors` - Secret import operation error count
|
||||
|
||||
- **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name`
|
||||
- **Example**: Monitor secret import failures
|
||||
|
||||
- `secret_sync_remove_secrets_errors` - Secret removal operation error count
|
||||
- **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name`
|
||||
- **Example**: Track secret removal operation failures
|
||||
|
||||
### System Metrics
|
||||
|
||||
These metrics are automatically collected by OpenTelemetry's HTTP instrumentation:
|
||||
|
||||
- `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum)
|
||||
- `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum)
|
||||
|
||||
### Custom Business Metrics
|
||||
|
||||
- `infisical_secret_operations_total` - Total secret operations
|
||||
- `infisical_secrets_processed_total` - Total secrets processed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Metrics not appearing**:
|
||||
|
||||
- Check if `OTEL_TELEMETRY_COLLECTION_ENABLED=true`
|
||||
- Verify the correct `OTEL_EXPORT_TYPE` is set
|
||||
- Check network connectivity between services
|
||||
|
||||
2. **Authentication errors**:
|
||||
|
||||
- Verify basic auth credentials in OTLP configuration
|
||||
- Check if credentials match between Infisical and collector
|
@@ -5,7 +5,9 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se
|
||||
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
|
||||
import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs";
|
||||
import {
|
||||
TRenderEnvironmentGroup,
|
||||
TRenderService,
|
||||
useRenderConnectionListEnvironmentGroups,
|
||||
useRenderConnectionListServices
|
||||
} from "@app/hooks/api/appConnections/render";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
@@ -19,6 +21,7 @@ export const RenderSyncFields = () => {
|
||||
>();
|
||||
|
||||
const connectionId = useWatch({ name: "connection.id", control });
|
||||
const selectedScope = useWatch({ name: "destinationConfig.scope", control });
|
||||
|
||||
const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices(
|
||||
connectionId,
|
||||
@@ -27,11 +30,17 @@ export const RenderSyncFields = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const { data: groups = [], isPending: isGroupsPending } =
|
||||
useRenderConnectionListEnvironmentGroups(connectionId, {
|
||||
enabled: Boolean(connectionId) && selectedScope === RenderSyncScope.EnvironmentGroup
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncConnectionField
|
||||
onChange={() => {
|
||||
setValue("destinationConfig.serviceId", "");
|
||||
setValue("destinationConfig.environmentGroupId", "");
|
||||
setValue("destinationConfig.type", RenderSyncType.Env);
|
||||
setValue("destinationConfig.scope", RenderSyncScope.Service);
|
||||
}}
|
||||
@@ -83,30 +92,67 @@ export const RenderSyncFields = () => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationConfig.serviceId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Service">
|
||||
<FilterableSelect
|
||||
isLoading={isServicesPending && Boolean(connectionId)}
|
||||
isDisabled={!connectionId}
|
||||
value={services ? (services.find((service) => service.id === value) ?? []) : []}
|
||||
onChange={(option) => {
|
||||
onChange((option as SingleValue<TRenderService>)?.id ?? null);
|
||||
setValue(
|
||||
"destinationConfig.serviceName",
|
||||
(option as SingleValue<TRenderService>)?.name ?? ""
|
||||
);
|
||||
}}
|
||||
options={services}
|
||||
placeholder="Select a service..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id.toString()}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{selectedScope === RenderSyncScope.Service && (
|
||||
<Controller
|
||||
name="destinationConfig.serviceId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Service"
|
||||
>
|
||||
<FilterableSelect
|
||||
isLoading={isServicesPending && Boolean(connectionId)}
|
||||
isDisabled={!connectionId}
|
||||
value={services ? (services.find((service) => service.id === value) ?? []) : []}
|
||||
onChange={(option) => {
|
||||
onChange((option as SingleValue<TRenderService>)?.id ?? null);
|
||||
setValue(
|
||||
"destinationConfig.serviceName",
|
||||
(option as SingleValue<TRenderService>)?.name ?? ""
|
||||
);
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -4,6 +4,7 @@ import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
|
||||
import { Badge } from "@app/components/v2";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
|
||||
export const RenderSyncOptionsReviewFields = () => {
|
||||
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>();
|
||||
@@ -27,13 +28,20 @@ export const RenderSyncOptionsReviewFields = () => {
|
||||
|
||||
export const RenderSyncReviewFields = () => {
|
||||
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>();
|
||||
const serviceName = watch("destinationConfig.serviceName");
|
||||
const scope = watch("destinationConfig.scope");
|
||||
const config = watch("destinationConfig");
|
||||
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Scope">{scope}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Service">{serviceName}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Scope">{config.scope}</GenericFieldLabel>
|
||||
{config.scope === RenderSyncScope.Service ? (
|
||||
<GenericFieldLabel label="Service">
|
||||
{config.serviceName ?? config.serviceId}
|
||||
</GenericFieldLabel>
|
||||
) : (
|
||||
<GenericFieldLabel label="Service">
|
||||
{config.environmentGroupName ?? config.environmentGroupId}
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -17,6 +17,12 @@ export const RenderSyncDestinationSchema = BaseSecretSyncSchema(
|
||||
serviceId: z.string().trim().min(1, "Service is required"),
|
||||
serviceName: z.string().trim().optional(),
|
||||
type: z.nativeEnum(RenderSyncType)
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal(RenderSyncScope.EnvironmentGroup),
|
||||
environmentGroupId: z.string().trim().min(1, "Environment Group ID is required"),
|
||||
environmentGroupName: z.string().trim().optional(),
|
||||
type: z.nativeEnum(RenderSyncType)
|
||||
})
|
||||
])
|
||||
})
|
||||
|
@@ -98,7 +98,7 @@ export const DatePicker = ({
|
||||
>
|
||||
{value
|
||||
? formatDateTime({ timestamp: value, timezone, dateFormat })
|
||||
: "Pick a date and time"}
|
||||
: `Select Date${hideTime ? "" : " and Time"}`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -122,7 +122,8 @@ export const DatePicker = ({
|
||||
root: `text-mineshaft-300 ${defaultClassNames}`,
|
||||
[UI.DayButton]: "p-3 rounded hover:text-mineshaft-100",
|
||||
[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>
|
||||
|
@@ -6,20 +6,12 @@ import { useToggle } from "@app/hooks";
|
||||
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
|
||||
|
||||
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
|
||||
const replaceContentWithDot = (str: string) => {
|
||||
let finalStr = "";
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
const char = str.at(i);
|
||||
finalStr += char === "\n" ? "\n" : "*";
|
||||
}
|
||||
return finalStr;
|
||||
};
|
||||
|
||||
const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => {
|
||||
if (isImport && !content) return "IMPORTED";
|
||||
if (content === "") return "EMPTY";
|
||||
if (!content) return "EMPTY";
|
||||
if (!isVisible) return replaceContentWithDot(content);
|
||||
if (!isVisible) return HIDDEN_SECRET_VALUE;
|
||||
|
||||
let skipNext = false;
|
||||
const formattedContent = content.split(REGEX).flatMap((el, i) => {
|
||||
|
@@ -212,5 +212,9 @@ export const RENDER_SYNC_SCOPES: Record<RenderSyncScope, { name: string; descrip
|
||||
[RenderSyncScope.Service]: {
|
||||
name: "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;
|
||||
sequence?: number;
|
||||
approvalsRequired?: number;
|
||||
isOrgMembershipActive: boolean;
|
||||
};
|
||||
|
||||
export type Bypasser = {
|
||||
@@ -82,6 +83,7 @@ export type TAccessApprovalRequest = {
|
||||
name: string;
|
||||
approvals: number;
|
||||
approvers: {
|
||||
isOrgMembershipActive: boolean;
|
||||
userId: string;
|
||||
sequence?: number;
|
||||
approvalsRequired?: number;
|
||||
@@ -98,6 +100,7 @@ export type TAccessApprovalRequest = {
|
||||
};
|
||||
|
||||
reviewers: {
|
||||
isOrgMembershipActive: boolean;
|
||||
userId: string;
|
||||
status: string;
|
||||
}[];
|
||||
@@ -177,7 +180,7 @@ export type TCreateAccessPolicyDTO = {
|
||||
projectSlug: string;
|
||||
name?: string;
|
||||
environments: string[];
|
||||
approvers?: Approver[];
|
||||
approvers?: Omit<Approver, "isOrgMembershipActive">[];
|
||||
bypassers?: Bypasser[];
|
||||
approvals?: number;
|
||||
secretPath: string;
|
||||
@@ -190,7 +193,7 @@ export type TCreateAccessPolicyDTO = {
|
||||
export type TUpdateAccessPolicyDTO = {
|
||||
id: string;
|
||||
name?: string;
|
||||
approvers?: Approver[];
|
||||
approvers?: Omit<Approver, "isOrgMembershipActive">[];
|
||||
bypassers?: Bypasser[];
|
||||
secretPath?: string;
|
||||
environments?: string[];
|
||||
|
@@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { appConnectionKeys } from "../queries";
|
||||
import { TRenderService } from "./types";
|
||||
import { TRenderEnvironmentGroup, TRenderService } from "./types";
|
||||
|
||||
const renderConnectionKeys = {
|
||||
all: [...appConnectionKeys.all, "render"] as const,
|
||||
listServices: (connectionId: string) =>
|
||||
[...renderConnectionKeys.all, "services", connectionId] as const
|
||||
[...renderConnectionKeys.all, "services", connectionId] as const,
|
||||
listEnvironmentGroups: (connectionId: string) =>
|
||||
[...renderConnectionKeys.all, "environment-groups", connectionId] as const
|
||||
};
|
||||
|
||||
export const useRenderConnectionListServices = (
|
||||
@@ -35,3 +37,28 @@ export const useRenderConnectionListServices = (
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useRenderConnectionListEnvironmentGroups = (
|
||||
connectionId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TRenderEnvironmentGroup[],
|
||||
unknown,
|
||||
TRenderEnvironmentGroup[],
|
||||
ReturnType<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;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TRenderEnvironmentGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
@@ -104,6 +104,7 @@ export const useUpdateOrg = () => {
|
||||
mutationFn: ({
|
||||
name,
|
||||
authEnforced,
|
||||
googleSsoAuthEnforced,
|
||||
scimEnabled,
|
||||
slug,
|
||||
orgId,
|
||||
@@ -125,6 +126,7 @@ export const useUpdateOrg = () => {
|
||||
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
||||
name,
|
||||
authEnforced,
|
||||
googleSsoAuthEnforced,
|
||||
scimEnabled,
|
||||
slug,
|
||||
defaultMembershipRoleSlug,
|
||||
|
@@ -9,6 +9,7 @@ export type Organization = {
|
||||
createAt: string;
|
||||
updatedAt: string;
|
||||
authEnforced: boolean;
|
||||
googleSsoAuthEnforced: boolean;
|
||||
bypassOrgAuthEnabled: boolean;
|
||||
orgAuthMethod: string;
|
||||
scimEnabled: boolean;
|
||||
@@ -34,6 +35,7 @@ export type UpdateOrgDTO = {
|
||||
orgId: string;
|
||||
name?: string;
|
||||
authEnforced?: boolean;
|
||||
googleSsoAuthEnforced?: boolean;
|
||||
scimEnabled?: boolean;
|
||||
slug?: string;
|
||||
defaultMembershipRoleSlug?: string;
|
||||
|
@@ -20,6 +20,7 @@ export enum ApproverType {
|
||||
}
|
||||
|
||||
export type Approver = {
|
||||
isOrgMembershipActive: boolean;
|
||||
id: string;
|
||||
type: ApproverType;
|
||||
};
|
||||
@@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = {
|
||||
name?: string;
|
||||
environments: string[];
|
||||
secretPath: string;
|
||||
approvers?: Approver[];
|
||||
approvers?: Omit<Approver, "isOrgMembershipActive">[];
|
||||
bypassers?: Bypasser[];
|
||||
approvals?: number;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
@@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = {
|
||||
export type TUpdateSecretPolicyDTO = {
|
||||
id: string;
|
||||
name?: string;
|
||||
approvers?: Approver[];
|
||||
approvers?: Omit<Approver, "isOrgMembershipActive">[];
|
||||
bypassers?: Bypasser[];
|
||||
secretPath?: string;
|
||||
approvals?: number;
|
||||
|
@@ -53,6 +53,7 @@ export type TSecretApprovalRequest = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
}[];
|
||||
workspace: string;
|
||||
environment: string;
|
||||
@@ -62,6 +63,7 @@ export type TSecretApprovalRequest = {
|
||||
status: "open" | "close";
|
||||
policy: Omit<TSecretApprovalPolicy, "approvers" | "bypassers"> & {
|
||||
approvers: {
|
||||
isOrgMembershipActive: boolean;
|
||||
userId: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
|
@@ -4,12 +4,19 @@ import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/typ
|
||||
|
||||
export type TRenderSync = TRootSecretSync & {
|
||||
destination: SecretSync.Render;
|
||||
destinationConfig: {
|
||||
scope: RenderSyncScope.Service;
|
||||
type: RenderSyncType;
|
||||
serviceId: string;
|
||||
serviceName?: string;
|
||||
};
|
||||
destinationConfig:
|
||||
| {
|
||||
type: RenderSyncType;
|
||||
scope: RenderSyncScope.Service;
|
||||
serviceId: string;
|
||||
serviceName?: string | undefined;
|
||||
}
|
||||
| {
|
||||
type: RenderSyncType;
|
||||
scope: RenderSyncScope.EnvironmentGroup;
|
||||
environmentGroupId: string;
|
||||
environmentGroupName?: string | undefined;
|
||||
};
|
||||
|
||||
connection: {
|
||||
app: AppConnection.Render;
|
||||
@@ -23,7 +30,8 @@ export type TRenderSync = TRootSecretSync & {
|
||||
};
|
||||
|
||||
export enum RenderSyncScope {
|
||||
Service = "service"
|
||||
Service = "service",
|
||||
EnvironmentGroup = "environment-group"
|
||||
}
|
||||
|
||||
export enum RenderSyncType {
|
||||
|
@@ -48,6 +48,7 @@ export type SubscriptionPlan = {
|
||||
externalKms: boolean;
|
||||
pkiEst: boolean;
|
||||
enforceMfa: boolean;
|
||||
enforceGoogleSSO: boolean;
|
||||
projectTemplates: boolean;
|
||||
kmip: boolean;
|
||||
secretScanning: boolean;
|
||||
|
@@ -26,16 +26,16 @@ export const useAddUserToWsNonE2EE = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const sendEmailVerificationCode = async (username: string) => {
|
||||
export const sendEmailVerificationCode = async (token: string) => {
|
||||
return apiRequest.post("/api/v2/users/me/emails/code", {
|
||||
username
|
||||
token
|
||||
});
|
||||
};
|
||||
|
||||
export const useSendEmailVerificationCode = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (username: string) => {
|
||||
await sendEmailVerificationCode(username);
|
||||
mutationFn: async (token: string) => {
|
||||
await sendEmailVerificationCode(token);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
@@ -83,6 +83,7 @@ export type TProjectMembership = {
|
||||
export type TWorkspaceUser = {
|
||||
id: string;
|
||||
user: {
|
||||
isOrgMembershipActive: boolean;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
|
@@ -240,6 +240,13 @@ export const Navbar = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (org.googleSsoAuthEnforced) {
|
||||
await logout.mutateAsync();
|
||||
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
handleOrgChange(org?.id);
|
||||
}}
|
||||
variant="plain"
|
||||
|
@@ -82,25 +82,40 @@ export const SelectOrganizationSection = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.authEnforced && !canBypassOrgAuth) {
|
||||
if ((organization.authEnforced || organization.googleSsoAuthEnforced) && !canBypassOrgAuth) {
|
||||
const authToken = jwtDecode(getAuthToken()) as { authMethod: AuthMethod };
|
||||
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
await logout.mutateAsync();
|
||||
let url = "";
|
||||
if (organization.orgAuthMethod === AuthMethod.OIDC) {
|
||||
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
|
||||
callbackPort ? `&callbackPort=${callbackPort}` : ""
|
||||
}`;
|
||||
} else {
|
||||
} else if (organization.orgAuthMethod === AuthMethod.SAML) {
|
||||
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
|
||||
|
||||
if (callbackPort) {
|
||||
url += `?callback_port=${callbackPort}`;
|
||||
}
|
||||
} else if (
|
||||
organization.googleSsoAuthEnforced &&
|
||||
authToken.authMethod !== AuthMethod.GOOGLE
|
||||
) {
|
||||
url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`;
|
||||
|
||||
if (callbackPort) {
|
||||
url += `&callback_port=${callbackPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
return;
|
||||
// we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO
|
||||
// see line 103-106
|
||||
if (url) {
|
||||
await logout.mutateAsync();
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrg
|
||||
|
@@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({
|
||||
|
||||
const resendCode = async () => {
|
||||
try {
|
||||
await sendEmailVerificationCode(username);
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const token = queryParams.get("token");
|
||||
if (!token) {
|
||||
createNotification({
|
||||
text: "Failed to resend code, no token found",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sendEmailVerificationCode(token);
|
||||
createNotification({
|
||||
text: "Successfully resent code",
|
||||
type: "success"
|
||||
|
@@ -36,7 +36,8 @@ type GithubFormData = BaseFormData &
|
||||
type GithubRadarFormData = BaseFormData &
|
||||
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
||||
|
||||
type GitLabFormData = BaseFormData & Pick<TGitLabConnection, "name" | "method" | "description">;
|
||||
type GitLabFormData = BaseFormData &
|
||||
Pick<TGitLabConnection, "name" | "method" | "description" | "credentials">;
|
||||
|
||||
type AzureKeyVaultFormData = BaseFormData &
|
||||
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
|
||||
@@ -147,7 +148,7 @@ export const OAuthCallbackPage = () => {
|
||||
|
||||
clearState(AppConnection.GitLab);
|
||||
|
||||
const { connectionId, name, description, returnUrl, isUpdate } = formData;
|
||||
const { connectionId, name, description, returnUrl, isUpdate, credentials } = formData;
|
||||
|
||||
try {
|
||||
if (isUpdate && connectionId) {
|
||||
@@ -155,7 +156,8 @@ export const OAuthCallbackPage = () => {
|
||||
app: AppConnection.GitLab,
|
||||
connectionId,
|
||||
credentials: {
|
||||
code: code as string
|
||||
code: code as string,
|
||||
instanceUrl: credentials.instanceUrl as string
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -165,7 +167,8 @@ export const OAuthCallbackPage = () => {
|
||||
description,
|
||||
method: GitLabConnectionMethod.OAuth,
|
||||
credentials: {
|
||||
code: code as string
|
||||
code: code as string,
|
||||
instanceUrl: credentials.instanceUrl as string
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -14,7 +15,21 @@ import {
|
||||
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
export const OrgGeneralAuthSection = () => {
|
||||
enum EnforceAuthType {
|
||||
SAML = "saml",
|
||||
GOOGLE = "google",
|
||||
OIDC = "oidc"
|
||||
}
|
||||
|
||||
export const OrgGeneralAuthSection = ({
|
||||
isSamlConfigured,
|
||||
isOidcConfigured,
|
||||
isGoogleConfigured
|
||||
}: {
|
||||
isSamlConfigured: boolean;
|
||||
isOidcConfigured: boolean;
|
||||
isGoogleConfigured: boolean;
|
||||
}) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { subscription } = useSubscription();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
|
||||
@@ -23,27 +38,61 @@ export const OrgGeneralAuthSection = () => {
|
||||
|
||||
const logout = useLogoutUser();
|
||||
|
||||
const handleEnforceOrgAuthToggle = async (value: boolean) => {
|
||||
const handleEnforceOrgAuthToggle = async (value: boolean, type: EnforceAuthType) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
if (!subscription?.samlSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
|
||||
if (type === EnforceAuthType.SAML) {
|
||||
if (!subscription?.samlSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg?.id,
|
||||
authEnforced: value
|
||||
});
|
||||
} else if (type === EnforceAuthType.GOOGLE) {
|
||||
if (!subscription?.enforceGoogleSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg?.id,
|
||||
googleSsoAuthEnforced: value
|
||||
});
|
||||
} else if (type === EnforceAuthType.OIDC) {
|
||||
if (!subscription?.oidcSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg?.id,
|
||||
authEnforced: value
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
text: `Invalid auth enforcement type ${type}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
orgId: currentOrg?.id,
|
||||
authEnforced: value
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
|
||||
text: `Successfully ${value ? "enabled" : "disabled"} org-level auth`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (value) {
|
||||
await logout.mutateAsync();
|
||||
window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`);
|
||||
|
||||
if (type === EnforceAuthType.SAML) {
|
||||
window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`);
|
||||
} else if (type === EnforceAuthType.GOOGLE) {
|
||||
window.open(`/api/v1/sso/redirect/google?org_slug=${currentOrg.slug}`);
|
||||
}
|
||||
|
||||
window.close();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -78,45 +127,91 @@ export const OrgGeneralAuthSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <div className="py-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<h3 className="text-md text-mineshaft-100">Allow users to send invites</h3>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="allow-org-invites"
|
||||
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
|
||||
isChecked={currentOrg?.authEnforced ?? false}
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">Allow members to invite new users to this organization</p>
|
||||
</div> */}
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enforce SAML SSO</span>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="enforce-org-auth"
|
||||
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
|
||||
isChecked={currentOrg?.authEnforced ?? false}
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Enforce users to authenticate via SAML to access this organization
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-gray-200">SSO Enforcement</p>
|
||||
<p className="mb-2 mt-1 text-gray-400">
|
||||
Manage strict enforcement of specific authentication methods for your organization.
|
||||
</p>
|
||||
</div>
|
||||
{currentOrg?.authEnforced && (
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col gap-2 py-4">
|
||||
<div className={twMerge("mt-4", !isSamlConfigured && "hidden")}>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enforce SAML SSO</span>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="enforce-saml-auth"
|
||||
onCheckedChange={(value) =>
|
||||
handleEnforceOrgAuthToggle(value, EnforceAuthType.SAML)
|
||||
}
|
||||
isChecked={currentOrg?.authEnforced ?? false}
|
||||
isDisabled={!isAllowed || currentOrg?.googleSsoAuthEnforced}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Enforce users to authenticate via SAML to access this organization.
|
||||
<br />
|
||||
When this is enabled your organization members will only be able to login with SAML.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={twMerge("mt-4", !isOidcConfigured && "hidden")}>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enforce OIDC SSO</span>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="enforce-oidc-auth"
|
||||
isChecked={currentOrg?.authEnforced ?? false}
|
||||
onCheckedChange={(value) =>
|
||||
handleEnforceOrgAuthToggle(value, EnforceAuthType.OIDC)
|
||||
}
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Enforce users to authenticate via OIDC to access this organization.
|
||||
<br />
|
||||
When this is enabled your organization members will only be able to login with OIDC.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={twMerge("mt-2", !isGoogleConfigured && "hidden")}>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enforce Google SSO</span>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="enforce-google-sso"
|
||||
onCheckedChange={(value) =>
|
||||
handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE)
|
||||
}
|
||||
isChecked={currentOrg?.googleSsoAuthEnforced ?? false}
|
||||
isDisabled={!isAllowed || currentOrg?.authEnforced}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Enforce users to authenticate via Google to access this organization.
|
||||
<br />
|
||||
When this is enabled your organization members will only be able to login with Google.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(currentOrg?.authEnforced || currentOrg?.googleSsoAuthEnforced) && (
|
||||
<div className="mt-4 py-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enable Admin SSO Bypass</span>
|
||||
@@ -125,8 +220,8 @@ export const OrgGeneralAuthSection = () => {
|
||||
content={
|
||||
<div>
|
||||
<span>
|
||||
When this is enabled, we strongly recommend enforcing MFA at the organization
|
||||
level.
|
||||
When enabling admin SSO bypass, we highly recommend enabling MFA enforcement
|
||||
at the organization-level for security reasons.
|
||||
</span>
|
||||
<p className="mt-4">
|
||||
In case of a lockout, admins can use the{" "}
|
||||
@@ -182,6 +277,6 @@ export const OrgGeneralAuthSection = () => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -95,43 +95,25 @@ export const OrgLDAPSection = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<div className="mb-4">
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">LDAP</h2>
|
||||
<div className="flex">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
|
||||
{(isAllowed) => (
|
||||
<Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-gray-200">LDAP</p>
|
||||
<p className="mb-2 text-gray-400">Manage LDAP authentication configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">LDAP Group Mappings</h2>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={openLDAPGroupMapModal}
|
||||
colorSchema="secondary"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Manage how LDAP groups are mapped to internal groups in Infisical
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="py-4">
|
||||
<div className="pt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">Enable LDAP</h2>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Ldap}>
|
||||
@@ -152,6 +134,27 @@ export const OrgLDAPSection = (): JSX.Element => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">LDAP Group Mappings</h2>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={openLDAPGroupMapModal}
|
||||
colorSchema="secondary"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Manage how LDAP groups are mapped to internal groups in Infisical
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LDAPModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
useOrganization,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useGetOIDCConfig, useLogoutUser, useUpdateOrg } from "@app/hooks/api";
|
||||
import { useGetOIDCConfig } from "@app/hooks/api";
|
||||
import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -23,9 +23,7 @@ export const OrgOIDCSection = (): JSX.Element => {
|
||||
|
||||
const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? "");
|
||||
const { mutateAsync } = useUpdateOIDCConfig();
|
||||
const { mutateAsync: updateOrg } = useUpdateOrg();
|
||||
|
||||
const logout = useLogoutUser();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"addOIDC",
|
||||
"upgradePlan"
|
||||
@@ -54,56 +52,6 @@ export const OrgOIDCSection = (): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnforceOrgAuthToggle = async (value: boolean) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
if (!subscription?.oidcSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOrg({
|
||||
orgId: currentOrg?.id,
|
||||
authEnforced: value
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (value) {
|
||||
await logout.mutateAsync();
|
||||
window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`);
|
||||
window.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableBypassOrgAuthToggle = async (value: boolean) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
if (!subscription?.oidcSSO) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOrg({
|
||||
orgId: currentOrg?.id,
|
||||
bypassOrgAuthEnabled: value
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCGroupManagement = async (value: boolean) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
@@ -136,25 +84,22 @@ export const OrgOIDCSection = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">OIDC</h2>
|
||||
{!isPending && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={addOidcButtonClick}
|
||||
colorSchema="secondary"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<div className="mb-4 rounded-lg border-mineshaft-600 bg-mineshaft-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-gray-200">OIDC</p>
|
||||
<p className="mb-2 text-gray-400">Manage OIDC authentication configuration</p>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">Manage OIDC authentication configuration</p>
|
||||
|
||||
{!isPending && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Button onClick={addOidcButtonClick} colorSchema="secondary" isDisabled={!isAllowed}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<div className="py-4">
|
||||
@@ -178,88 +123,6 @@ export const OrgOIDCSection = (): JSX.Element => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enforce OIDC SSO</span>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="enforce-org-auth"
|
||||
isChecked={currentOrg?.authEnforced ?? false}
|
||||
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
<span>Enforce users to authenticate via OIDC to access this organization.</span>
|
||||
</p>
|
||||
</div>
|
||||
{currentOrg?.authEnforced && (
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-md text-mineshaft-100">Enable Admin SSO Bypass</span>
|
||||
<Tooltip
|
||||
className="max-w-lg"
|
||||
content={
|
||||
<div>
|
||||
<span>
|
||||
When this is enabled, we strongly recommend enforcing MFA at the organization
|
||||
level.
|
||||
</span>
|
||||
<p className="mt-4">
|
||||
In case of a lockout, admins can use the{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline underline-offset-2 hover:text-mineshaft-300"
|
||||
href="https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Admin Login Portal
|
||||
</a>{" "}
|
||||
at{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 hover:text-mineshaft-300"
|
||||
href={`${window.location.origin}/login/admin`}
|
||||
>
|
||||
{window.location.origin}/login/admin
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faInfoCircle}
|
||||
size="sm"
|
||||
className="mt-0.5 inline-block text-mineshaft-400"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="allow-admin-bypass"
|
||||
isChecked={currentOrg?.bypassOrgAuthEnabled ?? false}
|
||||
onCheckedChange={(value) => handleEnableBypassOrgAuthToggle(value)}
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
<span>
|
||||
Allow organization admins to bypass OIDC enforcement when SSO is unavailable,
|
||||
misconfigured, or inaccessible.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="text-md flex items-center text-mineshaft-100">
|
||||
|
@@ -79,25 +79,24 @@ export const OrgSSOSection = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className="border-mineshaft-600" />
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-md text-mineshaft-100">SAML</h2>
|
||||
{!isPending && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Button onClick={addSSOBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-gray-200">SAML</p>
|
||||
<p className="mb-2 text-gray-400">Manage SAML authentication configuration</p>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">Manage SAML authentication configuration</p>
|
||||
{!isPending && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
|
||||
{(isAllowed) => (
|
||||
<Button onClick={addSSOBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between pt-4">
|
||||
<h2 className="text-md text-mineshaft-100">Enable SAML</h2>
|
||||
{!isPending && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
|
||||
@@ -126,6 +125,6 @@ export const OrgSSOSection = (): JSX.Element => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use SAML SSO if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -49,13 +49,19 @@ export const OrgSsoTab = withPermission(
|
||||
);
|
||||
const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig;
|
||||
|
||||
const shouldDisplaySection = (method: LoginMethod) =>
|
||||
!enabledLoginMethods || enabledLoginMethods.includes(method);
|
||||
const shouldDisplaySection = (method: LoginMethod[] | LoginMethod) => {
|
||||
if (Array.isArray(method)) {
|
||||
return method.some((m) => !enabledLoginMethods || enabledLoginMethods.includes(m));
|
||||
}
|
||||
|
||||
const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer);
|
||||
return !enabledLoginMethods || enabledLoginMethods.includes(method);
|
||||
};
|
||||
|
||||
const isOidcConfigured = Boolean(oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer));
|
||||
const isSamlConfigured =
|
||||
samlConfig && (samlConfig.entryPoint || samlConfig.issuer || samlConfig.cert);
|
||||
const isLdapConfigured = ldapConfig && ldapConfig.url;
|
||||
const isGoogleConfigured = shouldDisplaySection(LoginMethod.GOOGLE);
|
||||
|
||||
const shouldShowCreateIdentityProviderView =
|
||||
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
|
||||
@@ -65,11 +71,14 @@ export const OrgSsoTab = withPermission(
|
||||
shouldDisplaySection(LoginMethod.OIDC) ||
|
||||
shouldDisplaySection(LoginMethod.LDAP) ? (
|
||||
<>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
|
||||
<p className="mb-2 mt-1 text-gray-400">
|
||||
Connect your identity provider to simplify user management
|
||||
</p>
|
||||
<div className="mb-4 space-y-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
|
||||
<p className="mb-2 mt-1 text-gray-400">
|
||||
Connect your identity provider to simplify user management with options like SAML,
|
||||
OIDC, and LDAP.
|
||||
</p>
|
||||
</div>
|
||||
{shouldDisplaySection(LoginMethod.SAML) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -169,20 +178,27 @@ export const OrgSsoTab = withPermission(
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShowCreateIdentityProviderView ? (
|
||||
createIdentityProviderView
|
||||
) : (
|
||||
<>
|
||||
{isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && (
|
||||
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<OrgGeneralAuthSection />
|
||||
<OrgSSOSection />
|
||||
<div className="space-y-4">
|
||||
{shouldDisplaySection([LoginMethod.SAML, LoginMethod.GOOGLE]) && (
|
||||
<OrgGeneralAuthSection
|
||||
isSamlConfigured={isSamlConfigured}
|
||||
isOidcConfigured={isOidcConfigured}
|
||||
isGoogleConfigured={isGoogleConfigured}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowCreateIdentityProviderView ? (
|
||||
createIdentityProviderView
|
||||
) : (
|
||||
<div className="mb-4 space-y-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<div>
|
||||
{isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && <OrgSSOSection />}
|
||||
{isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && <OrgOIDCSection />}
|
||||
{isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && <OrgLDAPSection />}
|
||||
</div>
|
||||
)}
|
||||
{isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && <OrgOIDCSection />}
|
||||
{isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && <OrgLDAPSection />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render";
|
||||
import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
import {
|
||||
useRenderConnectionListEnvironmentGroups,
|
||||
useRenderConnectionListServices
|
||||
} from "@app/hooks/api/appConnections/render";
|
||||
import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
|
||||
import { getSecretSyncDestinationColValues } from "../helpers";
|
||||
import { SecretSyncTableCell } from "../SecretSyncTableCell";
|
||||
@@ -9,21 +12,59 @@ type Props = {
|
||||
};
|
||||
|
||||
export const RenderSyncDestinationCol = ({ secretSync }: Props) => {
|
||||
const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service;
|
||||
|
||||
const { data: services = [], isPending } = useRenderConnectionListServices(
|
||||
secretSync.connectionId
|
||||
secretSync.connectionId,
|
||||
{
|
||||
enabled: isServiceScope
|
||||
}
|
||||
);
|
||||
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
|
||||
...secretSync,
|
||||
destinationConfig: {
|
||||
...secretSync.destinationConfig,
|
||||
serviceName: services.find((s) => s.id === secretSync.destinationConfig.serviceId)?.name
|
||||
const { data: groups = [], isPending: isGroupsPending } =
|
||||
useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope });
|
||||
|
||||
switch (secretSync.destinationConfig.scope) {
|
||||
case RenderSyncScope.Service: {
|
||||
const id = secretSync.destinationConfig.serviceId;
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
|
||||
...secretSync,
|
||||
destinationConfig: {
|
||||
...secretSync.destinationConfig,
|
||||
serviceName: services.find((s) => s.id === id)?.name
|
||||
}
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<SecretSyncTableCell primaryText="Loading service info..." secondaryText="Service" />
|
||||
);
|
||||
}
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
}
|
||||
});
|
||||
case RenderSyncScope.EnvironmentGroup: {
|
||||
const id = secretSync.destinationConfig.environmentGroupId;
|
||||
const { primaryText, secondaryText } = getSecretSyncDestinationColValues({
|
||||
...secretSync,
|
||||
destinationConfig: {
|
||||
...secretSync.destinationConfig,
|
||||
environmentGroupName: groups.find((s) => s.id === id)?.name
|
||||
}
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <SecretSyncTableCell primaryText="Loading service info..." secondaryText="Service" />;
|
||||
if (isGroupsPending) {
|
||||
return (
|
||||
<SecretSyncTableCell
|
||||
primaryText="Loading environment group info..."
|
||||
secondaryText="Environment Group"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown render sync destination scope");
|
||||
}
|
||||
|
||||
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
|
||||
};
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
} from "@app/hooks/api/secretSyncs/types/github-sync";
|
||||
import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync";
|
||||
import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync";
|
||||
import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync";
|
||||
|
||||
// This functional ensures parity across what is displayed in the destination column
|
||||
// and the values used when search filtering
|
||||
@@ -125,8 +126,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
|
||||
secondaryText = destinationConfig.app;
|
||||
break;
|
||||
case SecretSync.Render:
|
||||
primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId;
|
||||
secondaryText = "Service";
|
||||
if (destinationConfig.scope === RenderSyncScope.Service) {
|
||||
primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId;
|
||||
secondaryText = "Service";
|
||||
} else {
|
||||
primaryText =
|
||||
destinationConfig.environmentGroupName ?? destinationConfig.environmentGroupId;
|
||||
secondaryText = "Environment Group";
|
||||
}
|
||||
|
||||
break;
|
||||
case SecretSync.Flyio:
|
||||
primaryText = destinationConfig.appId;
|
||||
|
@@ -4,7 +4,9 @@ import {
|
||||
faCheck,
|
||||
faEdit,
|
||||
faHourglass,
|
||||
faTriangleExclamation
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUserSlash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import ms from "ms";
|
||||
@@ -37,7 +39,7 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
|
||||
|
||||
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||
const getReviewedStatusSymbol = (status?: ApprovalStatus, isOrgMembershipActive?: boolean) => {
|
||||
if (status === ApprovalStatus.APPROVED)
|
||||
return (
|
||||
<Badge variant="success" className="flex h-4 items-center justify-center">
|
||||
@@ -50,6 +52,17 @@ const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||
<FontAwesomeIcon icon={faBan} size="xs" />
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!isOrgMembershipActive) {
|
||||
return (
|
||||
// Can't do a tooltip here because nested tooltips doesn't work properly as of yet.
|
||||
// TODO(daniel): Fix nested tooltips in the future.
|
||||
|
||||
<Badge className="flex h-4 items-center justify-center bg-mineshaft-400/50 text-bunker-300">
|
||||
<FontAwesomeIcon size="xs" icon={faUserSlash} />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="primary" className="flex h-4 items-center justify-center">
|
||||
<FontAwesomeIcon icon={faHourglass} size="xs" />
|
||||
@@ -87,6 +100,7 @@ export const ReviewAccessRequestModal = ({
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||
const [bypassApproval, setBypassApproval] = useState(false);
|
||||
|
||||
const [bypassReason, setBypassReason] = useState("");
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||
@@ -192,6 +206,7 @@ export const ReviewAccessRequestModal = ({
|
||||
(acc, curr) => {
|
||||
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
|
||||
acc[acc.length - 1][curr.type]?.push(curr);
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -203,6 +218,7 @@ export const ReviewAccessRequestModal = ({
|
||||
? { user: [curr], group: [], sequence, approvals }
|
||||
: { group: [curr], user: [], sequence, approvals }
|
||||
);
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as {
|
||||
@@ -216,7 +232,10 @@ export const ReviewAccessRequestModal = ({
|
||||
const approvers = approversBySequence?.map((approverChain) => {
|
||||
const reviewers = request.policy.approvers
|
||||
.filter((el) => (el.sequence || 1) === approverChain.sequence)
|
||||
.map((el) => ({ ...el, status: reviewesGroupById?.[el.userId]?.[0]?.status }));
|
||||
.map((el) => ({
|
||||
...el,
|
||||
status: reviewesGroupById?.[el.userId]?.[0]?.status
|
||||
}));
|
||||
const hasApproved =
|
||||
reviewers.filter((el) => el.status === "approved").length >=
|
||||
(approverChain?.approvals || 1);
|
||||
@@ -410,12 +429,39 @@ export const ReviewAccessRequestModal = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
|
||||
<GenericFieldLabel className="col-span-2" label="Users">
|
||||
{approver?.user
|
||||
?.map(
|
||||
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
|
||||
)
|
||||
.join(", ")}
|
||||
<GenericFieldLabel className="col-span-2" icon={faUser} label="Users">
|
||||
{Boolean(approver.user.length) && (
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{approver?.user?.map((el, idx) => {
|
||||
const member = approverSequence?.membersGroupById?.[el.id]?.[0];
|
||||
if (!member) return null;
|
||||
|
||||
return member.user.isOrgMembershipActive ? (
|
||||
<div className="flex items-center" key={member.user.id}>
|
||||
<span>{member.user.username}</span>
|
||||
{idx < approver.user.length - 1 && ","}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center" key={member.user.id}>
|
||||
<span className="flex items-center opacity-40">
|
||||
{member.user.username}
|
||||
<span className="text-xs">
|
||||
<Tooltip content="This user has been deactivated and no longer has an active organization membership.">
|
||||
<div>
|
||||
<Badge className="pointer-events-none ml-1 mr-auto flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
|
||||
<FontAwesomeIcon icon={faBan} />
|
||||
Inactive
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</span>
|
||||
{idx < approver.user.length - 1 && ","}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel className="col-span-2" label="Groups">
|
||||
{approver?.group
|
||||
@@ -440,8 +486,18 @@ export const ReviewAccessRequestModal = ({
|
||||
key={`reviewer-${idx + 1}`}
|
||||
className="flex items-center gap-2 px-2 py-2 text-sm"
|
||||
>
|
||||
<div className="flex-1">{el.username}</div>
|
||||
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex-1",
|
||||
!el.isOrgMembershipActive && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{el.username}
|
||||
</div>
|
||||
{getReviewedStatusSymbol(
|
||||
el?.status as ApprovalStatus,
|
||||
el.isOrgMembershipActive
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@@ -43,6 +43,8 @@ import {
|
||||
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
import { PolicyMemberOption } from "./PolicyMemberOption";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
@@ -59,7 +61,11 @@ const formSchema = z
|
||||
secretPath: z.string().trim().min(1),
|
||||
approvals: z.number().min(1).default(1),
|
||||
userApprovers: z
|
||||
.object({ type: z.literal(ApproverType.User), id: z.string() })
|
||||
.object({
|
||||
type: z.literal(ApproverType.User),
|
||||
id: z.string(),
|
||||
isOrgMembershipActive: z.boolean().optional()
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
groupApprovers: z
|
||||
@@ -67,7 +73,11 @@ const formSchema = z
|
||||
.array()
|
||||
.default([]),
|
||||
userBypassers: z
|
||||
.object({ type: z.literal(BypasserType.User), id: z.string() })
|
||||
.object({
|
||||
type: z.literal(BypasserType.User),
|
||||
id: z.string(),
|
||||
isOrgMembershipActive: z.boolean().optional()
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
groupBypassers: z
|
||||
@@ -80,7 +90,11 @@ const formSchema = z
|
||||
sequenceApprovers: z
|
||||
.object({
|
||||
user: z
|
||||
.object({ type: z.literal(ApproverType.User), id: z.string() })
|
||||
.object({
|
||||
type: z.literal(ApproverType.User),
|
||||
id: z.string(),
|
||||
isOrgMembershipActive: z.boolean().optional()
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
group: z
|
||||
@@ -139,7 +153,11 @@ const Form = ({
|
||||
userApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
|
||||
.map(({ id, type, isOrgMembershipActive }) => ({
|
||||
id,
|
||||
type: type as ApproverType.User,
|
||||
isOrgMembershipActive
|
||||
})) || [],
|
||||
groupApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
@@ -235,7 +253,9 @@ const Form = ({
|
||||
...data,
|
||||
approvers: sequenceApprovers?.flatMap((approvers, index) =>
|
||||
approvers.user
|
||||
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
|
||||
.map(
|
||||
(el) => ({ ...el, sequence: index + 1 }) as Omit<Approver, "isOrgMembershipActive">
|
||||
)
|
||||
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
|
||||
),
|
||||
approvalsRequired: sequenceApprovers?.map((el, index) => ({
|
||||
@@ -291,7 +311,9 @@ const Form = ({
|
||||
...data,
|
||||
approvers: sequenceApprovers?.flatMap((approvers, index) =>
|
||||
approvers.user
|
||||
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
|
||||
.map(
|
||||
(el) => ({ ...el, sequence: index + 1 }) as Omit<Approver, "isOrgMembershipActive">
|
||||
)
|
||||
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
|
||||
),
|
||||
approvalsRequired: sequenceApprovers?.map((el, index) => ({
|
||||
@@ -329,7 +351,8 @@ const Form = ({
|
||||
() =>
|
||||
members.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: ApproverType.User
|
||||
type: ApproverType.User,
|
||||
isOrgMembershipActive: member.user.isOrgMembershipActive
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
@@ -347,7 +370,8 @@ const Form = ({
|
||||
() =>
|
||||
members.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: BypasserType.User
|
||||
type: BypasserType.User,
|
||||
isOrgMembershipActive: member.user.isOrgMembershipActive
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
@@ -608,6 +632,7 @@ const Form = ({
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
options={memberOptions}
|
||||
components={{ Option: PolicyMemberOption }}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
const member = members?.find((m) => m.user.id === option.id);
|
||||
@@ -685,6 +710,7 @@ const Form = ({
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
components={{ Option: PolicyMemberOption }}
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
@@ -783,6 +809,7 @@ const Form = ({
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
components={{ Option: PolicyMemberOption }}
|
||||
options={bypasserMemberOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user