Merge branch 'main' into feat/addHumanitecIntegration

This commit is contained in:
carlosmonastyrski
2025-03-13 10:55:40 -03:00
103 changed files with 3196 additions and 567 deletions
+3
View File
@@ -0,0 +1,3 @@
# Learn more at https://direnv.net
# We instruct direnv to use our Nix flake for a consistent development environment.
use flake
@@ -1,5 +1,16 @@
import { z } from "zod"; import { z } from "zod";
export type PasswordRequirements = {
length: number;
required: {
lowercase: number;
uppercase: number;
digits: number;
symbols: number;
};
allowedSymbols?: string;
};
export enum SqlProviders { export enum SqlProviders {
Postgres = "postgres", Postgres = "postgres",
MySQL = "mysql2", MySQL = "mysql2",
@@ -100,6 +111,28 @@ export const DynamicSecretSqlDBSchema = z.object({
database: z.string().trim(), database: z.string().trim(),
username: z.string().trim(), username: z.string().trim(),
password: z.string().trim(), password: z.string().trim(),
passwordRequirements: z
.object({
length: z.number().min(1).max(250),
required: z
.object({
lowercase: z.number().min(0),
uppercase: z.number().min(0),
digits: z.number().min(0),
symbols: z.number().min(0)
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 250;
}, "Sum of required characters cannot exceed 250"),
allowedSymbols: z.string().optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.optional()
.describe("Password generation requirements"),
creationStatement: z.string().trim(), creationStatement: z.string().trim(),
revocationStatement: z.string().trim(), revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(), renewStatement: z.string().trim().optional(),
@@ -1,6 +1,6 @@
import { randomInt } from "crypto";
import handlebars from "handlebars"; import handlebars from "handlebars";
import knex from "knex"; import knex from "knex";
import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { withGatewayProxy } from "@app/lib/gateway"; import { withGatewayProxy } from "@app/lib/gateway";
@@ -8,16 +8,99 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models"; import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
const generatePassword = (provider: SqlProviders) => { const DEFAULT_PASSWORD_REQUIREMENTS = {
// oracle has limit of 48 password length length: 48,
const size = provider === SqlProviders.Oracle ? 30 : 48; required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
};
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; const ORACLE_PASSWORD_REQUIREMENTS = {
return customAlphabet(charset, 48)(size); ...DEFAULT_PASSWORD_REQUIREMENTS,
length: 30
};
const generatePassword = (provider: SqlProviders, requirements?: PasswordRequirements) => {
const defaultReqs = provider === SqlProviders.Oracle ? ORACLE_PASSWORD_REQUIREMENTS : DEFAULT_PASSWORD_REQUIREMENTS;
const finalReqs = requirements || defaultReqs;
try {
const { length, required, allowedSymbols } = finalReqs;
const chars = {
lowercase: "abcdefghijklmnopqrstuvwxyz",
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: "0123456789",
symbols: allowedSymbols || "-_.~!*"
};
const parts: string[] = [];
if (required.lowercase > 0) {
parts.push(
...Array(required.lowercase)
.fill(0)
.map(() => chars.lowercase[randomInt(chars.lowercase.length)])
);
}
if (required.uppercase > 0) {
parts.push(
...Array(required.uppercase)
.fill(0)
.map(() => chars.uppercase[randomInt(chars.uppercase.length)])
);
}
if (required.digits > 0) {
parts.push(
...Array(required.digits)
.fill(0)
.map(() => chars.digits[randomInt(chars.digits.length)])
);
}
if (required.symbols > 0) {
parts.push(
...Array(required.symbols)
.fill(0)
.map(() => chars.symbols[randomInt(chars.symbols.length)])
);
}
const requiredTotal = Object.values(required).reduce<number>((a, b) => a + b, 0);
const remainingLength = Math.max(length - requiredTotal, 0);
const allowedChars = Object.entries(chars)
.filter(([key]) => required[key as keyof typeof required] > 0)
.map(([, value]) => value)
.join("");
parts.push(
...Array(remainingLength)
.fill(0)
.map(() => allowedChars[randomInt(allowedChars.length)])
);
// shuffle the array to mix up the characters
for (let i = parts.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[parts[i], parts[j]] = [parts[j], parts[i]];
}
return parts.join("");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to generate password: ${message}`);
}
}; };
const generateUsername = (provider: SqlProviders) => { const generateUsername = (provider: SqlProviders) => {
@@ -115,7 +198,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
const create = async (inputs: unknown, expireAt: number) => { const create = async (inputs: unknown, expireAt: number) => {
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
const username = generateUsername(providerInputs.client); const username = generateUsername(providerInputs.client);
const password = generatePassword(providerInputs.client); const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements);
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host }); const db = await $getClient({ ...providerInputs, port, host });
try { try {
@@ -6,6 +6,7 @@ import {
SecretEncryptionAlgo, SecretEncryptionAlgo,
SecretKeyEncoding, SecretKeyEncoding,
SecretType, SecretType,
TableName,
TSecretApprovalRequestsSecretsInsert, TSecretApprovalRequestsSecretsInsert,
TSecretApprovalRequestsSecretsV2Insert TSecretApprovalRequestsSecretsV2Insert
} from "@app/db/schemas"; } from "@app/db/schemas";
@@ -57,6 +58,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission";
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
@@ -77,7 +79,6 @@ import {
TSecretApprovalDetailsDTO, TSecretApprovalDetailsDTO,
TStatusChangeDTO TStatusChangeDTO
} from "./secret-approval-request-types"; } from "./secret-approval-request-types";
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
type TSecretApprovalRequestServiceFactoryDep = { type TSecretApprovalRequestServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@@ -1335,17 +1336,48 @@ export const secretApprovalRequestServiceFactory = ({
// deleted secrets // deleted secrets
const deletedSecrets = data[SecretOperations.Delete]; const deletedSecrets = data[SecretOperations.Delete];
if (deletedSecrets && deletedSecrets.length) { if (deletedSecrets && deletedSecrets.length) {
const secretsToDeleteInDB = await secretV2BridgeDAL.findBySecretKeys( const secretsToDeleteInDB = await secretV2BridgeDAL.find({
folderId, folderId,
deletedSecrets.map((el) => ({ $complex: {
key: el.secretKey, operator: "and",
type: SecretType.Shared value: [
})) {
); operator: "or",
value: deletedSecrets.map((el) => ({
operator: "and",
value: [
{
operator: "eq",
field: `${TableName.SecretV2}.key` as "key",
value: el.secretKey
},
{
operator: "eq",
field: "type",
value: SecretType.Shared
}
]
}))
}
]
}
});
if (secretsToDeleteInDB.length !== deletedSecrets.length) if (secretsToDeleteInDB.length !== deletedSecrets.length)
throw new NotFoundError({ throw new NotFoundError({
message: `Secret does not exist: ${secretsToDeleteInDB.map((el) => el.key).join(",")}` message: `Secret does not exist: ${secretsToDeleteInDB.map((el) => el.key).join(",")}`
}); });
secretsToDeleteInDB.forEach((el) => {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: el.key,
secretTags: el.tags?.map((i) => i.slug)
})
);
});
const secretsGroupedByKey = groupBy(secretsToDeleteInDB, (i) => i.key); const secretsGroupedByKey = groupBy(secretsToDeleteInDB, (i) => i.key);
const deletedSecretIds = deletedSecrets.map((el) => secretsGroupedByKey[el.secretKey][0].id); const deletedSecretIds = deletedSecrets.map((el) => secretsGroupedByKey[el.secretKey][0].id);
const latestSecretVersions = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, deletedSecretIds); const latestSecretVersions = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, deletedSecretIds);
@@ -1373,7 +1405,7 @@ export const secretApprovalRequestServiceFactory = ({
commits.forEach((commit) => { commits.forEach((commit) => {
let action = ProjectPermissionSecretActions.Create; let action = ProjectPermissionSecretActions.Create;
if (commit.op === SecretOperations.Update) action = ProjectPermissionSecretActions.Edit; if (commit.op === SecretOperations.Update) action = ProjectPermissionSecretActions.Edit;
if (commit.op === SecretOperations.Delete) action = ProjectPermissionSecretActions.Delete; if (commit.op === SecretOperations.Delete) return; // we do the validation on top
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
action, action,
+7
View File
@@ -21,3 +21,10 @@ export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInpu
message: `${field} field can only contain lowercase letters, numbers, and hyphens` message: `${field} field can only contain lowercase letters, numbers, and hyphens`
}); });
}; };
export const GenericResourceNameSchema = z
.string()
.trim()
.min(1, { message: "Name must be at least 1 character" })
.max(64, { message: "Name must be 64 or fewer characters" })
.regex(/^[a-zA-Z0-9\-_\s]+$/, "Name can only contain alphanumeric characters, dashes, underscores, and spaces");
+1
View File
@@ -635,6 +635,7 @@ export const registerRoutes = async (
}); });
const superAdminService = superAdminServiceFactory({ const superAdminService = superAdminServiceFactory({
userDAL, userDAL,
identityDAL,
userAliasDAL, userAliasDAL,
authService: loginService, authService: loginService,
serverCfgDAL: superAdminDAL, serverCfgDAL: superAdminDAL,
+38 -1
View File
@@ -1,7 +1,7 @@
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { z } from "zod"; import { z } from "zod";
import { OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
@@ -154,6 +154,43 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/identity-management/identities",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
searchTerm: z.string().default(""),
offset: z.coerce.number().default(0),
limit: z.coerce.number().max(100).default(20)
}),
response: {
200: z.object({
identities: IdentitiesSchema.pick({
name: true,
id: true
}).array()
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const identities = await server.services.superAdmin.getIdentities({
...req.query
});
return {
identities
};
}
});
server.route({ server.route({
method: "GET", method: "GET",
url: "/integrations/slack/config", url: "/integrations/slack/config",
-1
View File
@@ -91,7 +91,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await projectRouter.register(registerProjectMembershipRouter); await projectRouter.register(registerProjectMembershipRouter);
await projectRouter.register(registerSecretTagRouter); await projectRouter.register(registerSecretTagRouter);
}, },
{ prefix: "/workspace" } { prefix: "/workspace" }
); );
@@ -13,7 +13,7 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t
import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs";
import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas"; import { GenericResourceNameSchema, slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
@@ -251,7 +251,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
params: z.object({ organizationId: z.string().trim() }), params: z.object({ organizationId: z.string().trim() }),
body: z.object({ body: z.object({
name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), name: GenericResourceNameSchema.optional(),
slug: slugSchema({ max: 64 }).optional(), slug: slugSchema({ max: 64 }).optional(),
authEnforced: z.boolean().optional(), authEnforced: z.boolean().optional(),
scimEnabled: z.boolean().optional(), scimEnabled: z.boolean().optional(),
@@ -2,10 +2,12 @@ import { z } from "zod";
import { import {
IntegrationsSchema, IntegrationsSchema,
ProjectEnvironmentsSchema,
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectRolesSchema, ProjectRolesSchema,
ProjectSlackConfigsSchema, ProjectSlackConfigsSchema,
ProjectType, ProjectType,
SecretFoldersSchema,
UserEncryptionKeysSchema, UserEncryptionKeysSchema,
UsersSchema UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
@@ -675,4 +677,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
return slackConfig; return slackConfig;
} }
}); });
server.route({
method: "GET",
url: "/:workspaceId/environment-folder-tree",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
response: {
200: z.record(
ProjectEnvironmentsSchema.extend({ folders: SecretFoldersSchema.extend({ path: z.string() }).array() })
)
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const environmentsFolders = await server.services.folder.getProjectEnvironmentsFolders(
req.params.workspaceId,
req.permission
);
return environmentsFolders;
}
});
}; };
@@ -12,6 +12,7 @@ import {
import { ORGANIZATIONS } from "@app/lib/api-docs"; import { ORGANIZATIONS } from "@app/lib/api-docs";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ActorType, AuthMode } from "@app/services/auth/auth-type";
@@ -330,7 +331,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
name: z.string().trim() name: GenericResourceNameSchema
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { authRateLimit } from "@app/server/config/rateLimiter"; import { authRateLimit } from "@app/server/config/rateLimiter";
import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns";
import { AuthMode } from "@app/services/auth/auth-type";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns";
import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type"; import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerPasswordRouter = async (server: FastifyZodProvider) => { export const registerPasswordRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -4,6 +4,7 @@ import { UsersSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { ForbiddenRequestError } from "@app/lib/errors"; import { ForbiddenRequestError } from "@app/lib/errors";
import { authRateLimit } from "@app/server/config/rateLimiter"; import { authRateLimit } from "@app/server/config/rateLimiter";
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
@@ -100,7 +101,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
encryptedPrivateKeyTag: z.string().trim(), encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(), salt: z.string().trim(),
verifier: z.string().trim(), verifier: z.string().trim(),
organizationName: z.string().trim().min(1), organizationName: GenericResourceNameSchema,
providerAuthToken: z.string().trim().optional().nullish(), providerAuthToken: z.string().trim().optional().nullish(),
attributionSource: z.string().trim().optional(), attributionSource: z.string().trim().optional(),
password: z.string() password: z.string()
@@ -7,6 +7,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { generateUserSrpKeys } from "@app/lib/crypto/srp";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
@@ -25,7 +26,6 @@ import {
TSetupPasswordViaBackupKeyDTO TSetupPasswordViaBackupKeyDTO
} from "./auth-password-type"; } from "./auth-password-type";
import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; import { ActorType, AuthMethod, AuthTokenType } from "./auth-type";
import { logger } from "@app/lib/logger";
type TAuthPasswordServiceFactoryDep = { type TAuthPasswordServiceFactoryDep = {
authDAL: TAuthDALFactory; authDAL: TAuthDALFactory;
@@ -78,14 +78,22 @@ export const identityJwtAuthServiceFactory = ({
let tokenData: Record<string, string | boolean | number> = {}; let tokenData: Record<string, string | boolean | number> = {};
if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) {
const decryptedJwksCaCert = orgDataKeyDecryptor({ let client: JwksClient;
cipherTextBlob: identityJwtAuth.encryptedJwksCaCert if (identityJwtAuth.jwksUrl.includes("https:")) {
}).toString(); const decryptedJwksCaCert = orgDataKeyDecryptor({
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
const client = new JwksClient({ }).toString();
jwksUri: identityJwtAuth.jwksUrl,
requestAgent const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
}); client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl,
requestAgent
});
} else {
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl
});
}
const { kid } = decodedToken.header; const { kid } = decodedToken.header;
const jwtSigningKey = await client.getSigningKey(kid); const jwtSigningKey = await client.getSigningKey(kid);
+35 -3
View File
@@ -1,10 +1,42 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName, TIdentities } from "@app/db/schemas";
import { ormify } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { DatabaseError } from "@app/lib/errors";
export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>; export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>;
export const identityDALFactory = (db: TDbClient) => { export const identityDALFactory = (db: TDbClient) => {
const identityOrm = ormify(db, TableName.Identity); const identityOrm = ormify(db, TableName.Identity);
return identityOrm;
const getIdentitiesByFilter = async ({
limit,
offset,
searchTerm,
sortBy
}: {
limit: number;
offset: number;
searchTerm: string;
sortBy?: keyof TIdentities;
}) => {
try {
let query = db.replicaNode()(TableName.Identity);
if (searchTerm) {
query = query.where((qb) => {
void qb.whereILike("name", `%${searchTerm}%`);
});
}
if (sortBy) {
query = query.orderBy(sortBy);
}
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity));
} catch (error) {
throw new DatabaseError({ error, name: "Get identities by filter" });
}
};
return { ...identityOrm, getIdentitiesByFilter };
}; };
@@ -0,0 +1,17 @@
import { TSecretFolders } from "@app/db/schemas";
import { InternalServerError } from "@app/lib/errors";
export const buildFolderPath = (
folder: TSecretFolders,
foldersMap: Record<string, TSecretFolders>,
depth: number = 0
): string => {
if (depth > 20) {
throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" });
}
if (!folder.parentId) {
return depth === 0 ? "/" : "";
}
return `${buildFolderPath(foldersMap[folder.parentId], foldersMap, depth + 1)}/${folder.name}`;
};
@@ -8,6 +8,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -27,7 +28,7 @@ type TSecretFolderServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">; snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory; folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs" | "find">;
folderVersionDAL: TSecretFolderVersionDALFactory; folderVersionDAL: TSecretFolderVersionDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
}; };
@@ -580,6 +581,44 @@ export const secretFolderServiceFactory = ({
return folders; return folders;
}; };
const getProjectEnvironmentsFolders = async (projectId: string, actor: OrgServiceActor) => {
// folder list is allowed to be read by anyone
// permission is to check if user has access
await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager
});
const environments = await projectEnvDAL.find({ projectId });
const folders = await folderDAL.find({
$in: {
envId: environments.map((env) => env.id)
},
isReserved: false
});
const environmentFolders = Object.fromEntries(
environments.map((env) => {
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
const foldersWithPath = relevantFolders.map((folder) => ({
...folder,
path: buildFolderPath(folder, foldersMap)
}));
return [env.slug, { ...env, folders: foldersWithPath }];
})
);
return environmentFolders;
};
return { return {
createFolder, createFolder,
updateFolder, updateFolder,
@@ -589,6 +628,7 @@ export const secretFolderServiceFactory = ({
getFolderById, getFolderById,
getProjectFolderCount, getProjectFolderCount,
getFoldersMultiEnv, getFoldersMultiEnv,
getFoldersDeepByEnvs getFoldersDeepByEnvs,
getProjectEnvironmentsFolders
}; };
}; };
@@ -909,11 +909,7 @@ export const secretV2BridgeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
environment,
secretPath: path,
secretTags: params.tagSlugs
});
let paths: { folderId: string; path: string }[] = []; let paths: { folderId: string; path: string }[] = [];
+1 -1
View File
@@ -2,6 +2,7 @@ import { Knex } from "knex";
import { z } from "zod"; import { z } from "zod";
import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
@@ -20,7 +21,6 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-
import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">; type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">;
@@ -19,9 +19,11 @@ import { TUserDALFactory } from "../user/user-dal";
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
import { UserAliasType } from "../user-alias/user-alias-types"; import { UserAliasType } from "../user-alias/user-alias-types";
import { TSuperAdminDALFactory } from "./super-admin-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal";
import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; import { LoginMethod, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
type TSuperAdminServiceFactoryDep = { type TSuperAdminServiceFactoryDep = {
identityDAL: Pick<TIdentityDALFactory, "getIdentitiesByFilter">;
serverCfgDAL: TSuperAdminDALFactory; serverCfgDAL: TSuperAdminDALFactory;
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne">; userAliasDAL: Pick<TUserAliasDALFactory, "findOne">;
@@ -51,6 +53,7 @@ const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000";
export const superAdminServiceFactory = ({ export const superAdminServiceFactory = ({
serverCfgDAL, serverCfgDAL,
userDAL, userDAL,
identityDAL,
userAliasDAL, userAliasDAL,
authService, authService,
orgService, orgService,
@@ -286,6 +289,15 @@ export const superAdminServiceFactory = ({
return user; return user;
}; };
const getIdentities = ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
return identityDAL.getIdentitiesByFilter({
limit,
offset,
searchTerm,
sortBy: "name"
});
};
const grantServerAdminAccessToUser = async (userId: string) => { const grantServerAdminAccessToUser = async (userId: string) => {
if (!licenseService.onPremFeatures?.instanceUserManagement) { if (!licenseService.onPremFeatures?.instanceUserManagement) {
throw new BadRequestError({ throw new BadRequestError({
@@ -383,6 +395,7 @@ export const superAdminServiceFactory = ({
adminSignUp, adminSignUp,
getUsers, getUsers,
deleteUser, deleteUser,
getIdentities,
getAdminSlackConfig, getAdminSlackConfig,
updateRootEncryptionStrategy, updateRootEncryptionStrategy,
getConfiguredEncryptionStrategies, getConfiguredEncryptionStrategies,
@@ -23,6 +23,12 @@ export type TAdminGetUsersDTO = {
adminsOnly: boolean; adminsOnly: boolean;
}; };
export type TAdminGetIdentitiesDTO = {
offset: number;
limit: number;
searchTerm: string;
};
export enum LoginMethod { export enum LoginMethod {
EMAIL = "email", EMAIL = "email",
GOOGLE = "google", GOOGLE = "google",
+1 -1
View File
@@ -20,6 +20,7 @@ require (
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/roff v0.1.0 github.com/muesli/roff v0.1.0
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9
github.com/pion/dtls/v3 v3.0.4
github.com/pion/logging v0.2.3 github.com/pion/logging v0.2.3
github.com/pion/turn/v4 v4.0.0 github.com/pion/turn/v4 v4.0.0
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a
@@ -90,7 +91,6 @@ require (
github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.22.2 // indirect github.com/onsi/ginkgo/v2 v2.22.2 // indirect
github.com/pelletier/go-toml v1.9.3 // indirect github.com/pelletier/go-toml v1.9.3 // indirect
github.com/pion/dtls/v3 v3.0.4 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/transport/v3 v3.0.7 // indirect
-10
View File
@@ -484,8 +484,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -592,8 +590,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -644,13 +640,9 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -662,8 +654,6 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+54 -13
View File
@@ -4,7 +4,9 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"runtime"
"syscall" "syscall"
"time" "time"
@@ -16,31 +18,23 @@ import (
) )
var gatewayCmd = &cobra.Command{ var gatewayCmd = &cobra.Command{
Example: `infisical gateway`,
Short: "Used to infisical gateway",
Use: "gateway", Use: "gateway",
Short: "Run the Infisical gateway or manage its systemd service",
Long: "Run the Infisical gateway in the foreground or manage its systemd service installation. Use 'gateway install' to set up the systemd service.",
Example: `infisical gateway --token=<token>
sudo infisical gateway install --token=<token> --domain=<domain>`,
DisableFlagsInUseLine: true, DisableFlagsInUseLine: true,
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
token, err := util.GetInfisicalToken(cmd) token, err := util.GetInfisicalToken(cmd)
if err != nil { if err != nil {
util.HandleError(err, "Unable to parse flag") util.HandleError(err, "Unable to parse token flag")
} }
if token == nil { if token == nil {
util.HandleError(fmt.Errorf("Token not found")) util.HandleError(fmt.Errorf("Token not found"))
} }
domain, err := cmd.Flags().GetString("domain")
if err != nil {
util.HandleError(err, "Unable to parse domain flag")
}
// Try to install systemd service if possible
if err := gateway.InstallGatewaySystemdService(token.Token, domain); err != nil {
log.Warn().Msgf("Failed to install systemd service: %v", err)
}
Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION)) Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION))
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@@ -110,6 +104,50 @@ var gatewayCmd = &cobra.Command{
}, },
} }
var gatewayInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and enable systemd service for the gateway (requires sudo)",
Long: "Install and enable systemd service for the gateway. Must be run with sudo on Linux.",
Example: "sudo infisical gateway install --token=<token> --domain=<domain>",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
if runtime.GOOS != "linux" {
util.HandleError(fmt.Errorf("systemd service installation is only supported on Linux"))
}
if os.Geteuid() != 0 {
util.HandleError(fmt.Errorf("systemd service installation requires root/sudo privileges"))
}
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
if token == nil {
util.HandleError(fmt.Errorf("Token not found"))
}
domain, err := cmd.Flags().GetString("domain")
if err != nil {
util.HandleError(err, "Unable to parse domain flag")
}
if err := gateway.InstallGatewaySystemdService(token.Token, domain); err != nil {
util.HandleError(err, "Failed to install systemd service")
}
enableCmd := exec.Command("systemctl", "enable", "infisical-gateway")
if err := enableCmd.Run(); err != nil {
util.HandleError(err, "Failed to enable systemd service")
}
log.Info().Msg("Successfully installed and enabled infisical-gateway service")
log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway")
},
}
var gatewayRelayCmd = &cobra.Command{ var gatewayRelayCmd = &cobra.Command{
Example: `infisical gateway relay`, Example: `infisical gateway relay`,
Short: "Used to run infisical gateway relay", Short: "Used to run infisical gateway relay",
@@ -139,9 +177,12 @@ var gatewayRelayCmd = &cobra.Command{
func init() { func init() {
gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token")
gatewayInstallCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token")
gatewayInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance")
gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path") gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path")
gatewayCmd.AddCommand(gatewayInstallCmd)
gatewayCmd.AddCommand(gatewayRelayCmd) gatewayCmd.AddCommand(gatewayRelayCmd)
rootCmd.AddCommand(gatewayCmd) rootCmd.AddCommand(gatewayCmd)
} }
+1 -1
View File
@@ -17,7 +17,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
EnvironmentFile=/etc/infisical/gateway.conf EnvironmentFile=/etc/infisical/gateway.conf
ExecStart=/usr/local/bin/infisical gateway ExecStart=infisical gateway
Restart=on-failure Restart=on-failure
InaccessibleDirectories=/home InaccessibleDirectories=/home
PrivateTmp=yes PrivateTmp=yes
+107
View File
@@ -0,0 +1,107 @@
---
title: "infisical gateway"
description: "Run the Infisical gateway or manage its systemd service"
---
<Tabs>
<Tab title="Run gateway">
```bash
infisical gateway --token=<token>
```
</Tab>
<Tab title="Install service">
```bash
sudo infisical gateway install --token=<token> --domain=<domain>
```
</Tab>
</Tabs>
## Description
Run the Infisical gateway in the foreground or manage its systemd service installation. The gateway allows secure communication between your self-hosted Infisical instance and client applications.
## Subcommands & flags
<Accordion title="infisical gateway" defaultOpen="true">
Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection.
```bash
infisical gateway --token=<token> --domain=<domain>
```
### Flags
<Accordion title="--token">
The machine identity access token to authenticate with Infisical.
```bash
# Example
infisical gateway --token=<token>
```
You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the gateway command.
</Accordion>
<Accordion title="--domain">
Domain of your self-hosted Infisical instance.
```bash
# Example
sudo infisical gateway install --domain=https://app.your-domain.com
```
</Accordion>
</Accordion>
<Accordion title="infisical gateway install">
Install and enable the gateway as a systemd service. This command must be run with sudo on Linux.
```bash
sudo infisical gateway install --token=<token> --domain=<domain>
```
### Requirements
- Must be run on Linux
- Must be run with root/sudo privileges
- Requires systemd
### Flags
<Accordion title="--token">
The machine identity access token to authenticate with Infisical.
```bash
# Example
sudo infisical gateway install --token=<token>
```
You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command.
</Accordion>
<Accordion title="--domain">
Domain of your self-hosted Infisical instance.
```bash
# Example
sudo infisical gateway install --domain=https://app.your-domain.com
```
</Accordion>
### Service Details
The systemd service is installed with secure defaults:
- Service file: `/etc/systemd/system/infisical-gateway.service`
- Config file: `/etc/infisical/gateway.conf`
- Runs with restricted privileges:
- InaccessibleDirectories=/home
- PrivateTmp=yes
- Resource limits configured for stability
- Automatically restarts on failure
- Enabled to start on boot
After installation, manage the service with standard systemd commands:
```bash
sudo systemctl start infisical-gateway # Start the service
sudo systemctl stop infisical-gateway # Stop the service
sudo systemctl status infisical-gateway # Check service status
sudo systemctl disable infisical-gateway # Disable auto-start on boot
```
</Accordion>
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

@@ -4,6 +4,8 @@ sidebarTitle: "Overview"
description: "How to access private network resources from Infisical" description: "How to access private network resources from Infisical"
--- ---
![Alt text](/documentation/platform/gateways/images/gateway-highlevel-diagram.png)
The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment.
This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases.
Common use cases include generating dynamic credentials or rotating credentials for private databases. Common use cases include generating dynamic credentials or rotating credentials for private databases.
@@ -45,19 +47,53 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t
</Step> </Step>
<Step title="Deploy the Gateway"> <Step title="Deploy the Gateway">
Use the Infisical CLI to deploy the Gateway. You can log in with your machine identity and start the Gateway in one command. The example below demonstrates how to deploy the Gateway using the Universal Auth method: Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production:
```bash
infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) <Tabs>
``` <Tab title="Production (systemd)">
Alternatively, if you already have the token, use it directly with the `--token` flag: For production deployments on Linux, install the Gateway as a systemd service:
```bash ```bash
infisical gateway --token <your-machine-identity-token> sudo infisical gateway install --token <your-machine-identity-token> --domain <your-infisical-domain>
``` sudo systemctl start infisical-gateway
Or set it as an environment variable: ```
```bash This will install and start the Gateway as a secure systemd service that:
export INFISICAL_TOKEN=<your-machine-identity-token> - Runs with restricted privileges:
infisical gateway - Runs as root user (required for secure token management)
``` - Restricted access to home directories
- Private temporary directory
- Automatically restarts on failure
- Starts on system boot
- Manages token and domain configuration securely in `/etc/infisical/gateway.conf`
<Warning>
The install command requires:
- Linux operating system
- Root/sudo privileges
- Systemd
</Warning>
</Tab>
<Tab title="Development (direct)">
For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command:
```bash
infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain)
```
Alternatively, if you already have the token, use it directly with the `--token` flag:
```bash
infisical gateway --token <your-machine-identity-token>
```
Or set it as an environment variable:
```bash
export INFISICAL_TOKEN=<your-machine-identity-token>
infisical gateway
```
</Tab>
</Tabs>
For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway).
<Note> <Note>
Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical. Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical.
</Note> </Note>
@@ -78,4 +114,3 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t
Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project. Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project.
</Step> </Step>
</Steps> </Steps>
@@ -0,0 +1,68 @@
---
title: 'Secret Scanning'
description: "Scan and prevent secret leaks in your code repositories"
---
The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories.
To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes.
## Code Scanning
![Scanning Overview](/images/platform/secret-scanning/overview.png)
Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets.
If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it:
- **Date**: When the risk was first detected.
- **Secret Type**: Which type of secret was detected.
- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change.
Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret.
<Tip>
Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed.
</Tip>
![Exposed Secret](/images/platform/secret-scanning/exposed-secret.png)
## Responding to Exposed Secrets
After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible.
You can mark the risk as `Resolved` by changing the status to one of the following states:
- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner.
- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed.
- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk.
![Needs Attention](/images/platform/secret-scanning/needs-attention.png)
## Ignoring Known Secrets
If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below.
### infisical-scan:ignore
To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming.
```js example.js
function helloWorld() {
console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore
}
```
### .infisicalignore
An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root.
You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found.
By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans.
```.ignore .infisicalignore
bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147
bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5
1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+6
View File
@@ -7,6 +7,12 @@ Prerequisites:
- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Set up and add secrets to [Infisical Cloud](https://app.infisical.com)
<Note>
When integrating with Databricks, Infisical is intended to be the source of truth for the secrets in the configured Databricks scope.
Any secrets not present in Infisical will be removed from the specified scope. To prevent removal of secrets not managed by Infisical, Infisical recommends creating a designated secret scope for your integration.
</Note>
<Steps> <Steps>
<Step title="Authorize Infisical for Databricks"> <Step title="Authorize Infisical for Databricks">
Obtain a Personal Access Token in **User Settings** > **Developer** > **Access Tokens**. Obtain a Personal Access Token in **User Settings** > **Developer** > **Access Tokens**.
@@ -34,6 +34,8 @@ description: "Learn how to configure a Databricks Sync for Infisical."
<Note> <Note>
You must create a secret scope in your Databricks workspace prior to configuration. Ensure your service principal has [Write permissions](https://docs.databricks.com/en/security/auth/access-control/index.html#secret-acls) for the specified secret scope. You must create a secret scope in your Databricks workspace prior to configuration. Ensure your service principal has [Write permissions](https://docs.databricks.com/en/security/auth/access-control/index.html#secret-acls) for the specified secret scope.
Infisical recommends creating a designated Databricks secret scope for your sync to prevent removal of secrets not managed by Infisical.
</Note> </Note>
5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
+4 -3
View File
@@ -220,7 +220,8 @@
"documentation/platform/admin-panel/org-admin-console" "documentation/platform/admin-panel/org-admin-console"
] ]
}, },
"documentation/platform/secret-sharing" "documentation/platform/secret-sharing",
"documentation/platform/secret-scanning"
] ]
}, },
{ {
@@ -339,6 +340,7 @@
"cli/commands/secrets", "cli/commands/secrets",
"cli/commands/dynamic-secrets", "cli/commands/dynamic-secrets",
"cli/commands/ssh", "cli/commands/ssh",
"cli/commands/gateway",
"cli/commands/export", "cli/commands/export",
"cli/commands/token", "cli/commands/token",
"cli/commands/service-token", "cli/commands/service-token",
@@ -646,8 +648,7 @@
"api-reference/endpoints/oidc-auth/attach", "api-reference/endpoints/oidc-auth/attach",
"api-reference/endpoints/oidc-auth/retrieve", "api-reference/endpoints/oidc-auth/retrieve",
"api-reference/endpoints/oidc-auth/update", "api-reference/endpoints/oidc-auth/update",
"api-reference/endpoints/oidc-auth/revoke", "api-reference/endpoints/oidc-auth/revoke"
"integrations/frameworks/terraform-cloud"
] ]
}, },
{ {
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1741445498,
"narHash": "sha256-F5Em0iv/CxkN5mZ9hRn3vPknpoWdcdCyR0e4WklHwiE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "52e3095f6d812b91b22fb7ad0bfc1ab416453634",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+24
View File
@@ -0,0 +1,24 @@
{
description = "Flake for github:Infisical/infisical repository.";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = { self, nixpkgs }: {
devShells.aarch64-darwin.default = let
pkgs = nixpkgs.legacyPackages.aarch64-darwin;
in
pkgs.mkShell {
packages = with pkgs; [
git
lazygit
python312Full
nodejs_20
nodePackages.prettier
infisical
];
};
};
}
+245
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.2", "@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -47,8 +48,10 @@
"@tanstack/react-router": "^1.95.1", "@tanstack/react-router": "^1.95.1",
"@tanstack/virtual-file-routes": "^1.87.6", "@tanstack/virtual-file-routes": "^1.87.6",
"@tanstack/zod-adapter": "^1.91.0", "@tanstack/zod-adapter": "^1.91.0",
"@types/dagre": "^0.7.52",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"@ucast/mongo2js": "^1.3.4", "@ucast/mongo2js": "^1.3.4",
"@xyflow/react": "^12.4.4",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -507,6 +510,24 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0" "react": "^16.0.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
"integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "2.2.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
"integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
"license": "MIT",
"engines": {
"node": ">17.0.0"
}
},
"node_modules/@date-fns/tz": { "node_modules/@date-fns/tz": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
@@ -3955,6 +3976,61 @@
"@babel/types": "^7.20.7" "@babel/types": "^7.20.7"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.52",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.52.tgz",
"integrity": "sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==",
"license": "MIT"
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -4382,6 +4458,64 @@
"vite": "^4 || ^5 || ^6" "vite": "^4 || ^5 || ^6"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.4.4",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz",
"integrity": "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.52",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz",
"integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.52.tgz",
"integrity": "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -5456,6 +5590,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": { "node_modules/classnames": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -5808,6 +5948,111 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+3
View File
@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.2", "@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -51,8 +52,10 @@
"@tanstack/react-router": "^1.95.1", "@tanstack/react-router": "^1.95.1",
"@tanstack/virtual-file-routes": "^1.87.6", "@tanstack/virtual-file-routes": "^1.87.6",
"@tanstack/zod-adapter": "^1.91.0", "@tanstack/zod-adapter": "^1.91.0",
"@types/dagre": "^0.7.52",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"@ucast/mongo2js": "^1.3.4", "@ucast/mongo2js": "^1.3.4",
"@xyflow/react": "^12.4.4",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -11,6 +11,7 @@ import { encodeBase64 } from "tweetnacl-util";
import { initProjectHelper } from "@app/helpers/project"; import { initProjectHelper } from "@app/helpers/project";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { onRequestError } from "@app/hooks/api/reactQuery";
import InputField from "../basic/InputField"; import InputField from "../basic/InputField";
import checkPassword from "../utilities/checks/password/checkPassword"; import checkPassword from "../utilities/checks/password/checkPassword";
@@ -206,6 +207,7 @@ export default function UserInfoStep({
incrementStep(); incrementStep();
} catch (error) { } catch (error) {
onRequestError(error);
setIsLoading(false); setIsLoading(false);
console.error(error); console.error(error);
} }
@@ -8,10 +8,11 @@ import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useCreateOrg, useSelectOrganization } from "@app/hooks/api"; import { useCreateOrg, useSelectOrganization } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
import { GenericResourceNameSchema } from "@app/lib/schemas";
const schema = z const schema = z
.object({ .object({
name: z.string().nonempty({ message: "Name is required" }) name: GenericResourceNameSchema.nonempty({ message: "Name is required" })
}) })
.required(); .required();
@@ -78,7 +79,7 @@ export const CreateOrgModal: FC<CreateOrgModalProps> = ({ isOpen, onClose }) =>
}; };
return ( return (
<Modal isOpen={isOpen}> <Modal modal={false} isOpen={isOpen}>
<ModalContent <ModalContent
title="Create Organization" title="Create Organization"
subTitle="Looks like you're not part of any organizations. Create one to start using Infisical" subTitle="Looks like you're not part of any organizations. Create one to start using Infisical"
@@ -0,0 +1,211 @@
import { useCallback, useEffect } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import {
faArrowUpRightFromSquare,
faUpRightAndDownLeftFromCenter,
faWindowRestore
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Background,
BackgroundVariant,
ConnectionLineType,
Controls,
Node,
NodeMouseHandler,
Panel,
ReactFlow,
ReactFlowProvider,
useReactFlow
} from "@xyflow/react";
import { twMerge } from "tailwind-merge";
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
import { BasePermissionEdge } from "./edges";
import { useAccessTree } from "./hooks";
import { FolderNode, RoleNode } from "./nodes";
import { ViewMode } from "./types";
export type AccessTreeProps = {
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
};
const EdgeTypes = { base: BasePermissionEdge };
const NodeTypes = { role: RoleNode, folder: FolderNode };
const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
const accessTreeData = useAccessTree(permissions);
const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData;
const { fitView, getViewport, setCenter } = useReactFlow();
const onNodeClick: NodeMouseHandler<Node> = useCallback(
(_, node) => {
setCenter(
node.position.x + (node.width ? node.width / 2 : 0),
node.position.y + (node.height ? node.height / 2 + 50 : 50),
{ duration: 1000, zoom: 1 }
);
},
[setCenter]
);
useEffect(() => {
setTimeout(() => {
fitView({
padding: 0.2,
duration: 1000,
maxZoom: 1
});
}, 1);
}, [fitView, nodes, edges, getViewport()]);
const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
const handleToggleUndockedView = () =>
setViewMode((prev) => (prev === ViewMode.Undocked ? ViewMode.Docked : ViewMode.Undocked));
const undockButtonLabel = `${viewMode === ViewMode.Undocked ? "Dock" : "Undock"} View`;
const windowButtonLabel = `${viewMode === ViewMode.Modal ? "Dock" : "Expand"} View`;
return (
<div
className={twMerge(
"w-full",
viewMode === ViewMode.Modal && "fixed inset-0 z-50 p-10",
viewMode === ViewMode.Undocked &&
"fixed bottom-4 left-20 z-50 h-[40%] w-[38%] min-w-[32rem] lg:w-[34%]"
)}
>
<div
className={twMerge(
"mb-4 h-full w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 transition-transform duration-500",
viewMode === ViewMode.Docked ? "relative p-4" : "relative p-0"
)}
>
{viewMode === ViewMode.Docked && (
<div className="mb-4 flex items-start justify-between border-b border-mineshaft-400 pb-4">
<div>
<h3 className="text-lg font-semibold text-mineshaft-100">Access Tree</h3>
<p className="text-sm leading-3 text-mineshaft-400">
Visual access policies for the configured role.
</p>
</div>
<div className="whitespace-nowrap">
<Button
variant="outline_bg"
colorSchema="secondary"
type="submit"
className="h-10 rounded-r-none bg-mineshaft-700"
leftIcon={<FontAwesomeIcon icon={faWindowRestore} />}
onClick={handleToggleUndockedView}
>
Undock
</Button>
<Button
variant="outline_bg"
colorSchema="secondary"
type="submit"
className="h-10 rounded-l-none bg-mineshaft-600"
leftIcon={<FontAwesomeIcon icon={faUpRightAndDownLeftFromCenter} />}
onClick={handleToggleModalView}
>
Expand
</Button>
</div>
</div>
)}
<div
className={twMerge(
"flex items-center space-x-4",
viewMode === ViewMode.Docked ? "h-96" : "h-full"
)}
>
<div className="h-full w-full">
<ReactFlow
className="rounded-md border border-mineshaft"
nodes={nodes}
edges={edges}
edgeTypes={EdgeTypes}
nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick}
colorMode="dark"
nodesDraggable={false}
edgesReconnectable={false}
nodesConnectable={false}
connectionLineType={ConnectionLineType.SmoothStep}
proOptions={{
hideAttribution: false // we need pro license if we want to hide
}}
>
{isLoading && (
<Panel className="flex h-full w-full items-center justify-center">
<Spinner />
</Panel>
)}
{viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleUndockedView}
ariaLabel={undockButtonLabel}
>
<FontAwesomeIcon
icon={
viewMode === ViewMode.Undocked
? faArrowUpRightFromSquare
: faWindowRestore
}
/>
</IconButton>
</Tooltip>
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
<IconButton
className="rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleModalView}
ariaLabel={windowButtonLabel}
>
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faArrowUpRightFromSquare
: faUpRightAndDownLeftFromCenter
}
/>
</IconButton>
</Tooltip>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" />
</ReactFlow>
</div>
</div>
</div>
</div>
);
};
export const AccessTree = (props: AccessTreeProps) => {
return (
<AccessTreeErrorBoundary {...props}>
<AccessTreeProvider>
<ReactFlowProvider>
<AccessTreeContent {...props} />
</ReactFlowProvider>
</AccessTreeProvider>
</AccessTreeErrorBoundary>
);
};
@@ -0,0 +1,51 @@
import React, {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useMemo,
useState
} from "react";
import { ViewMode } from "../types";
export interface AccessTreeContextProps {
secretName: string;
setSecretName: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
}
const AccessTreeContext = createContext<AccessTreeContextProps | undefined>(undefined);
interface AccessTreeProviderProps {
children: ReactNode;
}
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
const [secretName, setSecretName] = useState("");
const [viewMode, setViewMode] = useState(ViewMode.Docked);
const value = useMemo(
() => ({
secretName,
setSecretName,
viewMode,
setViewMode
}),
[secretName, setSecretName, viewMode, setViewMode]
);
return <AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>;
};
export const useAccessTreeContext = (): AccessTreeContextProps => {
const context = useContext(AccessTreeContext);
if (!context) {
throw new Error("useAccessTreeContext must be used within a AccessTreeProvider");
}
return context;
};
@@ -0,0 +1,105 @@
import React, { ErrorInfo, ReactNode } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import { faCheck, faCopy, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useTimedReset } from "@app/hooks";
interface ErrorBoundaryProps {
children: ReactNode;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
const ErrorDisplay = ({
error,
permissions
}: {
error: Error | null;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
}) => {
const display = JSON.stringify({ errorMessage: error?.message, permissions }, null, 2);
const [isCopied, , setIsCopied] = useTimedReset<boolean>({
initialState: false
});
const copyToClipboard = () => {
navigator.clipboard.writeText(display);
setIsCopied(true);
sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN);
};
return (
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center gap-2 text-mineshaft-100">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red" />
<p>
Error displaying access tree. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-200 underline decoration-primary-500 underline-offset-4 duration-200 hover:text-mineshaft-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:[email protected]"
>
support@infisical.com
</a>{" "}
with the following information.
</p>
</div>
<div className="relative flex flex-1 flex-col overflow-hidden">
<pre className="thin-scrollbar w-full flex-1 overflow-y-auto whitespace-pre-wrap rounded bg-mineshaft-700 p-2 text-xs text-mineshaft-100">
{display}
</pre>
<IconButton
variant="plain"
colorSchema="secondary"
className="absolute right-4 top-2"
ariaLabel="Copy secret value"
onClick={copyToClipboard}
>
<FontAwesomeIcon icon={isCopied ? faCheck : faCopy} />
</IconButton>
</div>
</div>
);
};
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null
};
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("Error caught by ErrorBoundary:", error, errorInfo, this.props);
}
render(): ReactNode {
const { hasError, error } = this.state;
const { children, permissions } = this.props;
if (hasError) {
return <ErrorDisplay error={error} permissions={permissions} />;
}
return children;
}
}
export const AccessTreeErrorBoundary = ({ children, permissions }: ErrorBoundaryProps) => {
return <ErrorBoundary permissions={permissions}>{children}</ErrorBoundary>;
};
@@ -0,0 +1,141 @@
import { Dispatch, SetStateAction, useState } from "react";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Panel } from "@xyflow/react";
import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ViewMode } from "../types";
type TProps = {
secretName: string;
setSecretName: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
setEnvironment: Dispatch<SetStateAction<string>>;
environment: string;
subject: ProjectPermissionSub;
setSubject: Dispatch<SetStateAction<ProjectPermissionSub>>;
environments: { name: string; slug: string }[];
};
export const PermissionSimulation = ({
setEnvironment,
environment,
subject,
setSubject,
environments,
setViewMode,
viewMode,
secretName,
setSecretName
}: TProps) => {
const [expand, setExpand] = useState(false);
const handlePermissionSimulation = () => {
setExpand(true);
setViewMode(ViewMode.Modal);
};
if (viewMode !== ViewMode.Modal)
return (
<Panel position="top-left">
<Button
size="xs"
className="mr-1 rounded"
colorSchema="secondary"
onClick={handlePermissionSimulation}
>
Permission Simulation
</Button>
</Panel>
);
return (
<Panel
onClick={handlePermissionSimulation}
position="top-left"
className={`group flex flex-col gap-2 pb-4 pr-4 ${expand ? "" : "cursor-pointer"}`}
>
<div className="flex w-[20rem] flex-col gap-1.5 rounded border border-mineshaft-600 bg-mineshaft-800 p-2 font-inter text-gray-200">
<div>
<div className="flex w-full items-center justify-between">
<span className="text-sm">Permission Simulation</span>
<IconButton
variant="plain"
ariaLabel={expand ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
setExpand((prev) => !prev);
}}
>
<FontAwesomeIcon icon={expand ? faChevronUp : faChevronDown} />
</IconButton>
</div>
{expand && (
<p className="mb-2 mt-1 text-xs text-mineshaft-400">
Evaluate conditional policies to see what permissions will be granted given a secret
name or tags
</p>
)}
</div>
{expand && (
<>
<div>
<FormLabel label="Subject" />
<Select
value={subject}
onValueChange={(value) => setSubject(value as ProjectPermissionSub)}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
>
{[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretImports
].map((sub) => {
return (
<SelectItem className="capitalize" value={sub} key={sub}>
{sub.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</div>
<div>
<FormLabel label="Environment" />
<Select
value={environment}
onValueChange={setEnvironment}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-[19rem]"
>
{environments.map(({ name, slug }) => {
return (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
);
})}
</Select>
</div>
{subject === ProjectPermissionSub.Secrets && (
<div>
<FormLabel label="Secret Name" />
<Input
placeholder="*"
value={secretName}
onChange={(e) => setSecretName(e.target.value)}
/>
</div>
)}
</>
)}
</div>
</Panel>
);
};
@@ -0,0 +1,3 @@
export * from "./AccessTreeContext";
export * from "./AccessTreeErrorBoundary";
export * from "./PermissionSimulation";
@@ -0,0 +1,34 @@
import { BaseEdge, BaseEdgeProps, EdgeProps, getSmoothStepPath } from "@xyflow/react";
export const BasePermissionEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
style
}: Omit<BaseEdgeProps, "path"> & EdgeProps) => {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY
});
return (
<BaseEdge
id={id}
markerStart={markerStart}
markerEnd={markerEnd}
style={{
strokeDasharray: "5",
strokeWidth: 1,
stroke: "#707174",
...style
}}
path={edgePath}
/>
);
};
@@ -0,0 +1 @@
export * from "./BasePermissionEdge";
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types";
import {
createBaseEdge,
createFolderNode,
createRoleNode,
getSubjectActionRuleMap,
positionElements
} from "../utils";
export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, MongoQuery>) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]);
const [subject, setSubject] = useState(ProjectPermissionSub.Secrets);
const [environment, setEnvironment] = useState(currentWorkspace.environments[0]?.slug ?? "");
const { data: environmentsFolders, isPending } = useListProjectEnvironmentsFolders(
currentWorkspace.id
);
useEffect(() => {
if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return;
const { folders, name } = environmentsFolders[environment];
const roleNode = createRoleNode({
subject,
environment: name
});
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
const folderNodes = folders.map((folder) =>
createFolderNode({
folder,
permissions,
environment,
subject,
secretName,
actionRuleMap
})
);
const folderEdges = folderNodes.map(({ data: folder }) => {
const actions = Object.values(folder.actions);
let access: PermissionAccess;
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
access = PermissionAccess.Full;
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
access = PermissionAccess.Partial;
} else {
access = PermissionAccess.None;
}
return createBaseEdge({
source: folder.parentId ?? roleNode.id,
target: folder.id,
access
});
});
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
setNodes(init.nodes);
setEdges(init.edges);
}, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]);
return {
nodes,
edges,
subject,
environment,
setEnvironment,
setSubject,
isLoading: isPending,
environments: currentWorkspace.environments,
secretName,
setSecretName,
viewMode,
setViewMode
};
};
@@ -0,0 +1 @@
export * from "./AccessTree";
@@ -0,0 +1,78 @@
import {
faCheckCircle,
faCircleMinus,
faCircleXmark,
faFolder
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Tooltip } from "@app/components/v2";
import { PermissionAccess } from "../../types";
import { createFolderNode, formatActionName } from "../../utils";
import { FolderNodeTooltipContent } from "./components";
const AccessMap = {
[PermissionAccess.Full]: { className: "text-green", icon: faCheckCircle },
[PermissionAccess.Partial]: { className: "text-yellow", icon: faCircleMinus },
[PermissionAccess.None]: { className: "text-red", icon: faCircleXmark }
};
export const FolderNode = ({
data
}: NodeProps & { data: ReturnType<typeof createFolderNode>["data"] }) => {
const { name, actions, actionRuleMap, parentId, subject } = data;
const hasMinimalAccess = Object.values(actions).some(
(action) => action === PermissionAccess.Full || action === PermissionAccess.Partial
);
return (
<>
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div
className={`flex ${hasMinimalAccess ? "" : "opacity-40"} h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-2 py-3 font-inter shadow-lg transition-opacity duration-500`}
>
<div className="flex items-center space-x-2 text-xs text-mineshaft-100">
<FontAwesomeIcon className="mb-0.5 font-medium text-yellow" icon={faFolder} />
<span>{parentId ? `/${name}` : "/"}</span>
</div>
<div className="mt-1.5 flex w-full flex-wrap items-center justify-center gap-x-2 gap-y-1 rounded bg-mineshaft-600 px-2 py-1 text-xs">
{Object.entries(actions).map(([action, access]) => {
const { className, icon } = AccessMap[access];
return (
<Tooltip
key={action}
className="hidden" // just using the tooltip to trigger node toolbar
content={
<FolderNodeTooltipContent
action={action}
access={access}
subject={subject}
actionRuleMap={actionRuleMap}
/>
}
>
<div className="flex items-center gap-1">
<FontAwesomeIcon icon={icon} className={className} size="xs" />
<span className="capitalize">{formatActionName(action)}</span>
</div>
</Tooltip>
);
})}
</div>
</div>
<Handle
type="source"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Bottom}
/>
</>
);
};
@@ -0,0 +1,131 @@
import { ReactElement } from "react";
import { faCheckCircle, faCircleMinus, faCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { NodeToolbar, Position } from "@xyflow/react";
import {
formatedConditionsOperatorNames,
PermissionConditionOperators
} from "@app/context/ProjectPermissionContext/types";
import { camelCaseToSpaces } from "@app/lib/fn/string";
import { PermissionAccess } from "../../../types";
import { createFolderNode, formatActionName } from "../../../utils";
type Props = {
action: string;
access: PermissionAccess;
} & Pick<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">;
export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => {
let component: ReactElement;
switch (access) {
case PermissionAccess.Full:
component = (
<>
<div className="flex items-center gap-1.5 capitalize text-green">
<FontAwesomeIcon icon={faCheckCircle} size="xs" />
<span>Full {formatActionName(action)} Permissions</span>
</div>
<p className="text-mineshaft-200">
Policy grants unconditional{" "}
<span className="font-medium text-mineshaft-100">
{formatActionName(action).toLowerCase()}
</span>{" "}
permission for {subject.replaceAll("-", " ")} in this folder.
</p>
</>
);
break;
case PermissionAccess.Partial:
component = (
<>
<div className="flex items-center gap-1.5 capitalize text-yellow">
<FontAwesomeIcon icon={faCircleMinus} className="text-yellow" size="xs" />
<span>Conditional {formatActionName(action)} Permissions</span>
</div>
<p className="mb-1 text-mineshaft-200">
Policy conditionally allows{" "}
<span className="font-medium text-mineshaft-100">
{formatActionName(action).toLowerCase()}
</span>{" "}
permission for {subject.replaceAll("-", " ")} in this folder.
</p>
<ul className="flex list-disc flex-col gap-2 pl-4">
{actionRuleMap.map((ruleMap, index) => {
const rule = ruleMap[action];
if (
!rule ||
!rule.conditions ||
(!rule.conditions.secretName && !rule.conditions.secretTags)
)
return null;
return (
<li key={`${action}_${index + 1}`}>
<span className={`italic ${rule.inverted ? "text-red" : "text-green"} `}>
{rule.inverted ? "Forbids" : "Allows"}
</span>
<span> when:</span>
{Object.entries(rule.conditions).map(([key, condition]) => (
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
{Object.entries(condition as object).map(([operator, value]) => (
<li key={`${action}_${index + 1}_${key}_${operator}`}>
<span className="font-medium capitalize text-mineshaft-100">
{camelCaseToSpaces(key)}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className={rule.inverted ? "text-red" : "text-green"}>
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
))}
</ul>
))}
</li>
);
})}
</ul>
</>
);
break;
case PermissionAccess.None:
component = (
<>
<div className="flex items-center gap-1.5 capitalize text-red">
<FontAwesomeIcon icon={faCircleXmark} size="xs" />
<span>No {formatActionName(action)} Permissions</span>
</div>
<p className="text-mineshaft-200">
Policy always forbids{" "}
<span className="font-medium text-mineshaft-100">
{formatActionName(action).toLowerCase()}
</span>{" "}
permission for {subject.replaceAll("-", " ")} in this folder.
</p>
</>
);
break;
default:
throw new Error(`Unhandled access type: ${access}`);
}
return (
<NodeToolbar
className="rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-light text-bunker-100"
isVisible
position={Position.Bottom}
>
{component}
</NodeToolbar>
);
};
@@ -0,0 +1 @@
export * from "./FolderNodeTooltipContent";
@@ -0,0 +1 @@
export * from "./FolderNode";
@@ -0,0 +1,30 @@
import { Handle, NodeProps, Position } from "@xyflow/react";
import { createRoleNode } from "../utils";
export const RoleNode = ({
data: { subject, environment }
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
return (
<>
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
<span className="capitalize">{subject.replace("-", " ")} Access</span>
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
<p className="truncate capitalize">{environment}</p>
</div>
</div>
</div>
<Handle
type="source"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Bottom}
/>
</>
);
};
@@ -0,0 +1,2 @@
export * from "./FolderNode/FolderNode";
export * from "./RoleNode";
@@ -0,0 +1,21 @@
export enum PermissionAccess {
Full = "full",
Partial = "partial",
None = "None"
}
export enum PermissionNode {
Role = "role",
Folder = "folder",
Environment = "environment"
}
export enum PermissionEdge {
Base = "base"
}
export enum ViewMode {
Docked = "docked",
Modal = "modal",
Undocked = "undocked"
}
@@ -0,0 +1,26 @@
import { MarkerType } from "@xyflow/react";
import { PermissionAccess, PermissionEdge } from "../types";
export const createBaseEdge = ({
source,
target,
access
}: {
source: string;
target: string;
access: PermissionAccess;
}) => {
const color = access === PermissionAccess.None ? "#707174" : "#ccccce";
return {
id: `e-${source}-${target}`,
source,
target,
type: PermissionEdge.Base,
markerEnd: {
type: MarkerType.ArrowClosed,
color
},
style: { stroke: color }
};
};
@@ -0,0 +1,180 @@
import { MongoAbility, MongoQuery, subject as abilitySubject } from "@casl/ability";
import picomatch from "picomatch";
import {
ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSet,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext";
import {
PermissionConditionOperators,
ProjectPermissionSecretActions
} from "@app/context/ProjectPermissionContext/types";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { PermissionAccess, PermissionNode } from "../types";
import { TActionRuleMap } from "./getActionRuleMap";
const ACTION_MAP: Record<string, string[] | undefined> = {
[ProjectPermissionSub.Secrets]: [
ProjectPermissionSecretActions.DescribeSecret,
ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Delete
],
[ProjectPermissionSub.DynamicSecrets]: Object.values(ProjectPermissionDynamicSecretActions),
[ProjectPermissionSub.SecretFolders]: [
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Delete
]
};
const evaluateCondition = (
value: string,
operator: PermissionConditionOperators,
comparison: string | string[]
) => {
switch (operator) {
case PermissionConditionOperators.$EQ:
return value === comparison;
case PermissionConditionOperators.$NEQ:
return value !== comparison;
case PermissionConditionOperators.$GLOB:
return picomatch.isMatch(value, comparison);
case PermissionConditionOperators.$IN:
return (comparison as string[]).map((v: string) => v.trim()).includes(value);
default:
throw new Error(`Unhandled operator: ${operator}`);
}
};
export const createFolderNode = ({
folder,
permissions,
environment,
subject,
secretName,
actionRuleMap
}: {
folder: TSecretFolderWithPath;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
environment: string;
subject: ProjectPermissionSub;
secretName: string;
actionRuleMap: TActionRuleMap;
}) => {
const actions = Object.fromEntries(
Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => {
let access: PermissionAccess;
// wrapped in try because while editing certain conditions, if their values are empty it throws an error
try {
let hasPermission: boolean;
const subjectFields = {
secretPath: folder.path,
environment,
secretName: secretName || "*",
secretTags: ["*"]
};
if (
subject === ProjectPermissionSub.Secrets &&
(action === ProjectPermissionSecretActions.ReadValue ||
action === ProjectPermissionSecretActions.DescribeSecret)
) {
hasPermission = hasSecretReadValueOrDescribePermission(
permissions,
action,
subjectFields
);
} else {
hasPermission = permissions.can(
// @ts-expect-error we are not specifying which so can't resolve if valid
action,
abilitySubject(subject, subjectFields)
);
}
if (hasPermission) {
// we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access
if (
!secretName &&
actionRuleMap.some((el) => {
// we only show conditional if secretName/secretTags are present - environment and path can be directly determined
if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags)
return false;
// make sure condition applies to env
if (el[action]?.conditions?.environment) {
if (
!Object.entries(el[action]?.conditions?.environment).every(([operator, value]) =>
evaluateCondition(environment, operator as PermissionConditionOperators, value)
)
) {
return false;
}
}
// and applies to path
if (el[action]?.conditions?.secretPath) {
if (
!Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) =>
evaluateCondition(folder.path, operator as PermissionConditionOperators, value)
)
) {
return false;
}
}
return true;
})
) {
access = PermissionAccess.Partial;
} else {
access = PermissionAccess.Full;
}
} else {
access = PermissionAccess.None;
}
} catch (e) {
console.error(e);
access = PermissionAccess.None;
}
return [action, access];
})
);
let height: number;
switch (subject) {
case ProjectPermissionSub.DynamicSecrets:
height = 130;
break;
case ProjectPermissionSub.Secrets:
height = 85;
break;
default:
height = 64;
}
return {
type: PermissionNode.Folder,
id: folder.id,
data: {
...folder,
actions,
environment,
actionRuleMap,
subject
},
position: { x: 0, y: 0 },
width: 264,
height
};
};
@@ -0,0 +1,19 @@
import { PermissionNode } from "../types";
export const createRoleNode = ({
subject,
environment
}: {
subject: string;
environment: string;
}) => ({
id: `role-${subject}-${environment}`,
position: { x: 0, y: 0 },
data: {
subject,
environment
},
type: PermissionNode.Role,
height: 48,
width: 264
});
@@ -0,0 +1,3 @@
import { camelCaseToSpaces } from "@app/lib/fn/string";
export const formatActionName = (action: string) => camelCaseToSpaces(action.replaceAll("-", " "));
@@ -0,0 +1,27 @@
import { MongoAbility, MongoQuery } from "@casl/ability";
import { ProjectPermissionSet, ProjectPermissionSub } from "@app/context/ProjectPermissionContext";
export type TActionRuleMap = ReturnType<typeof getSubjectActionRuleMap>;
export const getSubjectActionRuleMap = (
subject: ProjectPermissionSub,
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>
) => {
const rules = permissions.rules.filter((rule) => {
const ruleSubject = typeof rule.subject === "string" ? rule.subject : rule.subject[0];
return ruleSubject === subject;
});
const actionRuleMap: Record<string, (typeof rules)[number]>[] = [];
rules.forEach((rule) => {
if (typeof rule.action === "string") {
actionRuleMap.push({ [rule.action]: rule });
} else {
actionRuleMap.push(Object.fromEntries(rule.action.map((action) => [action, rule])));
}
});
return actionRuleMap;
};
@@ -0,0 +1,6 @@
export * from "./createBaseEdge";
export * from "./createFolderNode";
export * from "./createRoleNode";
export * from "./formatActionName";
export * from "./getActionRuleMap";
export * from "./positionElements";
@@ -0,0 +1,28 @@
import Dagre from "@dagrejs/dagre";
import { Edge, Node } from "@xyflow/react";
export const positionElements = (nodes: Node[], edges: Edge[]) => {
const dagre = new Dagre.graphlib.Graph({ directed: true })
.setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: "TB" });
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
nodes.forEach((node) => dagre.setNode(node.id, node));
Dagre.layout(dagre, {});
return {
nodes: nodes.map((node) => {
const { x, y } = dagre.node(node.id);
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
}
};
}),
edges
};
};
@@ -1,3 +1,4 @@
export * from "./AccessTree";
export { GlobPermissionInfo } from "./GlobPermissionInfo"; export { GlobPermissionInfo } from "./GlobPermissionInfo";
export { OrgPermissionCan } from "./OrgPermissionCan"; export { OrgPermissionCan } from "./OrgPermissionCan";
export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; export { PermissionDeniedBanner } from "./PermissionDeniedBanner";
@@ -40,6 +40,8 @@ export const DatabricksSyncFields = () => {
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
label="Secret Scope" label="Secret Scope"
tooltipClassName="max-w-md"
tooltipText="Infisical recommends creating a designated Databricks secret scope for your sync to prevent removal of secrets not managed by Infisical."
helperText={ helperText={
<Tooltip <Tooltip
className="max-w-md" className="max-w-md"
@@ -50,7 +50,9 @@ export const PopoverContent = ({
</IconButton> </IconButton>
</PopoverPrimitive.Close> </PopoverPrimitive.Close>
)} )}
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} /> <div className="pointer-events-none">
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
</div>
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
); );
+4 -2
View File
@@ -59,7 +59,9 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
> >
<div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap"> <div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
{props.icon && <FontAwesomeIcon icon={props.icon} />} {props.icon && <FontAwesomeIcon icon={props.icon} />}
<SelectPrimitive.Value placeholder={placeholder} /> <div className="flex-1 truncate">
<SelectPrimitive.Value placeholder={placeholder} />
</div>
</div> </div>
<SelectPrimitive.Icon className="ml-3"> <SelectPrimitive.Icon className="ml-3">
@@ -122,7 +124,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
<SelectPrimitive.Item <SelectPrimitive.Item
{...props} {...props}
className={twMerge( className={twMerge(
"relative mb-0.5 flex cursor-pointer select-none items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80", "relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
isSelected && "bg-primary", isSelected && "bg-primary",
isDisabled && "cursor-not-allowed text-gray-600 opacity-80 hover:!bg-transparent", isDisabled && "cursor-not-allowed text-gray-600 opacity-80 hover:!bg-transparent",
className className
@@ -1,16 +1,11 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { MongoAbility, RawRuleOf } from "@casl/ability";
import { unpackRules } from "@casl/ability/extra"; import { unpackRules } from "@casl/ability/extra";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router"; import { useParams } from "@tanstack/react-router";
import { import { evaluatePermissionsAbility } from "@app/helpers/permissions";
conditionsMatcher, import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
fetchUserProjectPermissions,
roleQueryKeys
} from "@app/hooks/api/roles/queries";
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
import { ProjectPermissionSet } from "./types"; import { ProjectPermissionSet } from "./types";
@@ -31,33 +26,7 @@ export const useProjectPermission = () => {
staleTime: Infinity, staleTime: Infinity,
select: (data) => { select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions); const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
const negatedRules = groupBy( const ability = evaluatePermissionsAbility(rule);
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
// this allows in frontend to skip some rules using *
conditionsMatcher: (rules) => {
return (entity) => {
// skip validation if its negated rules
const isNegatedRule =
// eslint-disable-next-line no-underscore-dangle
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
if (isNegatedRule) {
const baseMatcher = conditionsMatcher(rules);
return baseMatcher(entity);
}
const rulesStrippedOfWildcard = omit(
rules,
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
);
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
return baseMatcher(entity);
};
}
});
return { return {
permission: ability, permission: ability,
membership: { membership: {
+39
View File
@@ -0,0 +1,39 @@
import { createMongoAbility, MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { conditionsMatcher } from "@app/hooks/api/roles/queries";
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
export const evaluatePermissionsAbility = (
rule: RawRuleOf<MongoAbility<ProjectPermissionSet, MongoQuery>>[]
) => {
const negatedRules = groupBy(
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
// this allows in frontend to skip some rules using *
conditionsMatcher: (rules) => {
return (entity) => {
// skip validation if its negated rules
const isNegatedRule =
// eslint-disable-next-line no-underscore-dangle
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
if (isNegatedRule) {
const baseMatcher = conditionsMatcher(rules);
return baseMatcher(entity);
}
const rulesStrippedOfWildcard = omit(
rules,
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
);
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
return baseMatcher(entity);
};
}
});
return ability;
};
+2 -2
View File
@@ -1,10 +1,10 @@
export { export {
useAdminDeleteUser, useAdminDeleteUser,
useAdminGrantServerAdminAccess,
useCreateAdminUser, useCreateAdminUser,
useUpdateAdminSlackConfig, useUpdateAdminSlackConfig,
useUpdateServerConfig, useUpdateServerConfig,
useUpdateServerEncryptionStrategy, useUpdateServerEncryptionStrategy
useAdminGrantServerAdminAccess
} from "./mutation"; } from "./mutation";
export { export {
useAdminGetUsers, useAdminGetUsers,
+28 -1
View File
@@ -4,19 +4,24 @@ import { apiRequest } from "@app/config/request";
import { User } from "../types"; import { User } from "../types";
import { import {
AdminGetIdentitiesFilters,
AdminGetUsersFilters, AdminGetUsersFilters,
AdminSlackConfig, AdminSlackConfig,
TGetServerRootKmsEncryptionDetails, TGetServerRootKmsEncryptionDetails,
TServerConfig TServerConfig
} from "./types"; } from "./types";
import { Identity } from "@app/hooks/api/identities/types";
export const adminStandaloneKeys = { export const adminStandaloneKeys = {
getUsers: "get-users" getUsers: "get-users",
getIdentities: "get-identities"
}; };
export const adminQueryKeys = { export const adminQueryKeys = {
serverConfig: () => ["server-config"] as const, serverConfig: () => ["server-config"] as const,
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
getIdentities: (filters: AdminGetIdentitiesFilters) =>
[adminStandaloneKeys.getIdentities, { filters }] as const,
getAdminSlackConfig: () => ["admin-slack-config"] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const,
getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const
}; };
@@ -68,6 +73,28 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
}); });
}; };
export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => {
return useInfiniteQuery({
initialPageParam: 0,
queryKey: adminQueryKeys.getIdentities(filters),
queryFn: async ({ pageParam }) => {
const { data } = await apiRequest.get<{ identities: Identity[] }>(
"/api/v1/admin/identity-management/identities",
{
params: {
...filters,
offset: pageParam
}
}
);
return data.identities;
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined
});
};
export const useGetAdminSlackConfig = () => { export const useGetAdminSlackConfig = () => {
return useQuery({ return useQuery({
queryKey: adminQueryKeys.getAdminSlackConfig(), queryKey: adminQueryKeys.getAdminSlackConfig(),
+5
View File
@@ -53,6 +53,11 @@ export type AdminGetUsersFilters = {
adminsOnly: boolean; adminsOnly: boolean;
}; };
export type AdminGetIdentitiesFilters = {
limit: number;
searchTerm: string;
};
export type AdminSlackConfig = { export type AdminSlackConfig = {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
+158 -263
View File
@@ -18,268 +18,44 @@ export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"];
export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"]; export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"];
export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"]; export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"];
export const queryClient = new QueryClient({ export const onRequestError = (error: unknown) => {
mutationCache: new MutationCache({ if (axios.isAxiosError(error)) {
onError: (error) => { const serverResponse = error.response?.data as TApiErrors;
if (axios.isAxiosError(error)) { if (serverResponse?.error === ApiErrorTypes.ValidationError) {
const serverResponse = error.response?.data as TApiErrors; createNotification(
if (serverResponse?.error === ApiErrorTypes.ValidationError) { {
createNotification( title: "Validation Error",
{
title: "Validation Error",
type: "error",
text: "Please check the input and try again.",
callToAction: (
<Modal>
<ModalTrigger asChild>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent title="Validation Error Details">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Field</Th>
<Th>Issue</Th>
</Tr>
</THead>
<TBody>
{serverResponse.message?.map(({ message, path }) => (
<Tr key={path.join(".")}>
<Td>{path.join(".")}</Td>
<Td>{message.toLowerCase()}</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</ModalContent>
</Modal>
),
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
return;
}
if (serverResponse?.error === ApiErrorTypes.PermissionBoundaryError) {
createNotification(
{
title: "Forbidden Access",
type: "error",
text: `${serverResponse.message}.`,
callToAction: serverResponse?.details?.missingPermissions?.length ? (
<Modal>
<ModalTrigger asChild>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent title="Missing Permission">
<div className="flex flex-col gap-2">
{serverResponse.details?.missingPermissions?.map((el, index) => {
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
return (
<div
key={`Forbidden-error-details-${index + 1}`}
className="rounded-md border border-gray-600 p-4"
>
<div>
You are not authorized to perform the <b>{el.action}</b> action on the{" "}
<b>{el.subject}</b> resource.{" "}
{hasConditions &&
"Your permission does not allow access to the following conditions:"}
</div>
{hasConditions && (
<ul className="flex list-disc flex-col gap-1 pl-5 pt-2 text-sm">
{Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => {
const operators = (
el.conditions as Record<
string,
| string
| { [K in PermissionConditionOperators]: string | string[] }
>
)[field];
const formattedFieldName = camelCaseToSpaces(field).toLowerCase();
if (typeof operators === "string") {
return (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">equal to</span>{" "}
<span className="text-yellow-600">{operators}</span>
</li>
);
}
return Object.keys(operators).map((operator, operatorIndex) => (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}-${operatorIndex + 1}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className="text-yellow-600">
{operators[
operator as PermissionConditionOperators
].toString()}
</span>
</li>
));
})}
</ul>
)}
</div>
);
})}
</div>
</ModalContent>
</Modal>
) : undefined,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
return;
}
if (serverResponse?.error === ApiErrorTypes.ForbiddenError) {
createNotification(
{
title: "Forbidden Access",
type: "error",
text: `${serverResponse.message}.`,
callToAction: serverResponse?.details?.length ? (
<Modal>
<ModalTrigger asChild>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent
title="Validation Rules"
subTitle="Please review the allowed rules below."
>
<div className="flex flex-col gap-2">
{serverResponse.details?.map((el, index) => {
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
return (
<div
key={`Forbidden-error-details-${index + 1}`}
className="rounded-md border border-gray-600 p-4"
>
<div>
{el.inverted ? "Cannot" : "Can"}{" "}
<span className="text-yellow-600">
{el.action.toString().replaceAll(",", ", ")}
</span>{" "}
{el.subject.toString()} {hasConditions && "with conditions:"}
</div>
{hasConditions && (
<ul className="flex list-disc flex-col gap-1 pl-5 pt-2 text-sm">
{Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => {
const operators = (
el.conditions as Record<
string,
| string
| { [K in PermissionConditionOperators]: string | string[] }
>
)[field];
const formattedFieldName = camelCaseToSpaces(field).toLowerCase();
if (typeof operators === "string") {
return (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">equal to</span>{" "}
<span className="text-yellow-600">{operators}</span>
</li>
);
}
return Object.keys(operators).map((operator, operatorIndex) => (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}-${operatorIndex + 1}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className="text-yellow-600">
{operators[
operator as PermissionConditionOperators
].toString()}
</span>
</li>
));
})}
</ul>
)}
</div>
);
})}
</div>
</ModalContent>
</Modal>
) : undefined,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
return;
}
createNotification({
title: "Bad Request",
type: "error", type: "error",
text: `${serverResponse.message}${serverResponse.message?.endsWith(".") ? "" : "."}`, text: "Please check the input and try again.",
callToAction: (
<Modal>
<ModalTrigger asChild>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent title="Validation Error Details">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Field</Th>
<Th>Issue</Th>
</Tr>
</THead>
<TBody>
{serverResponse.message?.map(({ message, path }) => (
<Tr key={path.join(".")}>
<Td>{path.join(".")}</Td>
<Td>{message.toLowerCase()}</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</ModalContent>
</Modal>
),
copyActions: [ copyActions: [
{ {
value: serverResponse.reqId, value: serverResponse.reqId,
@@ -287,9 +63,128 @@ export const queryClient = new QueryClient({
label: `Request ID: ${serverResponse.reqId}` label: `Request ID: ${serverResponse.reqId}`
} }
] ]
}); },
} { closeOnClick: false }
);
return;
} }
if (serverResponse?.error === ApiErrorTypes.ForbiddenError) {
createNotification(
{
title: "Forbidden Access",
type: "error",
text: `${serverResponse.message}.`,
callToAction: serverResponse?.details?.length ? (
<Modal>
<ModalTrigger asChild>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent
title="Validation Rules"
subTitle="Please review the allowed rules below."
>
<div className="flex flex-col gap-2">
{serverResponse.details?.map((el, index) => {
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
return (
<div
key={`Forbidden-error-details-${index + 1}`}
className="rounded-md border border-gray-600 p-4"
>
<div>
{el.inverted ? "Cannot" : "Can"}{" "}
<span className="text-yellow-600">
{el.action.toString().replaceAll(",", ", ")}
</span>{" "}
{el.subject.toString()} {hasConditions && "with conditions:"}
</div>
{hasConditions && (
<ul className="flex list-disc flex-col gap-1 pl-5 pt-2 text-sm">
{Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => {
const operators = (
el.conditions as Record<
string,
| string
| { [K in PermissionConditionOperators]: string | string[] }
>
)[field];
const formattedFieldName = camelCaseToSpaces(field).toLowerCase();
if (typeof operators === "string") {
return (
<li
key={`Forbidden-error-details-${index + 1}-${fieldIndex + 1}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">equal to</span>{" "}
<span className="text-yellow-600">{operators}</span>
</li>
);
}
return Object.keys(operators).map((operator, operatorIndex) => (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}-${operatorIndex + 1}`}
>
<span className="font-bold capitalize">{formattedFieldName}</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className="text-yellow-600">
{operators[operator as PermissionConditionOperators].toString()}
</span>
</li>
));
})}
</ul>
)}
</div>
);
})}
</div>
</ModalContent>
</Modal>
) : undefined,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
return;
}
createNotification({
title: "Bad Request",
type: "error",
text: `${serverResponse.message}${serverResponse.message?.endsWith(".") ? "" : "."}`,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
});
}
};
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: onRequestError
}), }),
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -16,6 +16,7 @@ import {
TDeleteFolderDTO, TDeleteFolderDTO,
TGetFoldersByEnvDTO, TGetFoldersByEnvDTO,
TGetProjectFoldersDTO, TGetProjectFoldersDTO,
TProjectEnvironmentsFolders,
TSecretFolder, TSecretFolder,
TUpdateFolderBatchDTO, TUpdateFolderBatchDTO,
TUpdateFolderDTO TUpdateFolderDTO
@@ -23,7 +24,9 @@ import {
export const folderQueryKeys = { export const folderQueryKeys = {
getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) => getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) =>
["secret-folders", { projectId, environment, path }] as const ["secret-folders", { projectId, environment, path }] as const,
getProjectEnvironmentsFolders: (projectId: string) =>
["secret-folders", "environment", projectId] as const
}; };
const fetchProjectFolders = async (workspaceId: string, environment: string, path = "/") => { const fetchProjectFolders = async (workspaceId: string, environment: string, path = "/") => {
@@ -37,6 +40,29 @@ const fetchProjectFolders = async (workspaceId: string, environment: string, pat
return data.folders; return data.folders;
}; };
export const useListProjectEnvironmentsFolders = (
projectId: string,
options?: Omit<
UseQueryOptions<
TProjectEnvironmentsFolders,
unknown,
TProjectEnvironmentsFolders,
ReturnType<typeof folderQueryKeys.getProjectEnvironmentsFolders>
>,
"queryKey" | "queryFn"
>
) =>
useQuery({
queryKey: folderQueryKeys.getProjectEnvironmentsFolders(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<TProjectEnvironmentsFolders>(
`/api/v1/workspace/${projectId}/environment-folder-tree`
);
return data;
},
...options
});
export const useGetProjectFolders = ({ export const useGetProjectFolders = ({
projectId, projectId,
environment, environment,
@@ -1,3 +1,5 @@
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
export enum ReservedFolders { export enum ReservedFolders {
SecretReplication = "__reserve_replication_" SecretReplication = "__reserve_replication_"
} }
@@ -6,6 +8,13 @@ export type TSecretFolder = {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
parentId?: string | null;
};
export type TSecretFolderWithPath = TSecretFolder & { path: string };
export type TProjectEnvironmentsFolders = {
[key: string]: WorkspaceEnv & { folders: TSecretFolderWithPath[] };
}; };
export type TGetProjectFoldersDTO = { export type TGetProjectFoldersDTO = {
@@ -10,13 +10,16 @@ type FolderNameAndDescription = {
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => { export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
const folderNamesAndDescriptions = useMemo(() => { const folderNamesAndDescriptions = useMemo(() => {
const namesAndDescriptions = new Map<string, FolderNameAndDescription>(); const namesAndDescriptions = new Map<string, FolderNameAndDescription>();
folders?.forEach((folder) => { folders?.forEach((folder) => {
if (!namesAndDescriptions.has(folder.name)) { if (!namesAndDescriptions.has(folder.name)) {
namesAndDescriptions.set(folder.name, { name: folder.name, description: folder.description }); namesAndDescriptions.set(folder.name, {
name: folder.name,
description: folder.description
});
} }
}); });
return Array.from(namesAndDescriptions.values()); return Array.from(namesAndDescriptions.values());
}, [folders]); }, [folders]);
@@ -25,8 +25,8 @@ export const MenuIconButton = <T extends ElementType = "button">({
type="button" type="button"
role="menuitem" role="menuitem"
className={twMerge( className={twMerge(
"group relative flex w-full cursor-pointer flex-col items-center justify-center rounded my-1 p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700", "group relative my-1 flex w-full cursor-pointer flex-col items-center justify-center rounded p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700",
isSelected && "bg-bunker-800 hover:bg-mineshaft-600 rounded-none", isSelected && "rounded-none bg-bunker-800 hover:bg-mineshaft-600",
isDisabled && "cursor-not-allowed hover:bg-transparent", isDisabled && "cursor-not-allowed hover:bg-transparent",
className className
)} )}
@@ -19,17 +19,17 @@ import { useGetOrgUsers } from "@app/hooks/api";
export const ServerAdminsPanel = () => { export const ServerAdminsPanel = () => {
const [searchUserFilter, setSearchUserFilter] = useState(""); const [searchUserFilter, setSearchUserFilter] = useState("");
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || ""); const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || "");
const adminUsers = orgUsers?.filter((orgUser) => { const adminUsers = orgUsers?.filter((orgUser) => {
const isSuperAdmin = orgUser.user.superAdmin; const isSuperAdmin = orgUser.user.superAdmin;
const matchesSearch = debounedSearchTerm const matchesSearch = debouncedSearchTerm
? orgUser.user.email?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || ? orgUser.user.email?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
orgUser.user.firstName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || orgUser.user.firstName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
orgUser.user.lastName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) orgUser.user.lastName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
: true; : true;
return isSuperAdmin && matchesSearch; return isSuperAdmin && matchesSearch;
}); });
+12
View File
@@ -1 +1,13 @@
import { z } from "zod";
export * from "./slugSchema"; export * from "./slugSchema";
export const GenericResourceNameSchema = z
.string()
.trim()
.min(1, { message: "Name must be at least 1 character" })
.max(64, { message: "Name must be 64 or fewer characters" })
.regex(
/^[a-zA-Z0-9\-_\s]+$/,
"Name can only contain alphanumeric characters, dashes, underscores, and spaces"
);
+1
View File
@@ -10,6 +10,7 @@ import { NotFoundPage } from "./pages/public/NotFoundPage/NotFoundPage";
// Import the generated route tree // Import the generated route tree
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import "@xyflow/react/dist/style.css";
import "nprogress/nprogress.css"; import "nprogress/nprogress.css";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import "@fortawesome/fontawesome-svg-core/styles.css"; import "@fortawesome/fontawesome-svg-core/styles.css";
@@ -34,6 +34,7 @@ import { EncryptionPanel } from "./components/EncryptionPanel";
import { IntegrationPanel } from "./components/IntegrationPanel"; import { IntegrationPanel } from "./components/IntegrationPanel";
import { RateLimitPanel } from "./components/RateLimitPanel"; import { RateLimitPanel } from "./components/RateLimitPanel";
import { UserPanel } from "./components/UserPanel"; import { UserPanel } from "./components/UserPanel";
import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel";
enum TabSections { enum TabSections {
Settings = "settings", Settings = "settings",
@@ -42,6 +43,7 @@ enum TabSections {
RateLimit = "rate-limit", RateLimit = "rate-limit",
Integrations = "integrations", Integrations = "integrations",
Users = "users", Users = "users",
Identities = "identities",
Kmip = "kmip" Kmip = "kmip"
} }
@@ -164,6 +166,7 @@ export const OverviewPage = () => {
<Tab value={TabSections.RateLimit}>Rate Limit</Tab> <Tab value={TabSections.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab> <Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>Users</Tab> <Tab value={TabSections.Users}>Users</Tab>
<Tab value={TabSections.Identities}>Identities</Tab>
</div> </div>
</TabList> </TabList>
<TabPanel value={TabSections.Settings}> <TabPanel value={TabSections.Settings}>
@@ -409,6 +412,9 @@ export const OverviewPage = () => {
<TabPanel value={TabSections.Users}> <TabPanel value={TabSections.Users}>
<UserPanel /> <UserPanel />
</TabPanel> </TabPanel>
<TabPanel value={TabSections.Identities}>
<IdentityPanel />
</TabPanel>
</Tabs> </Tabs>
</div> </div>
)} )}
@@ -0,0 +1,91 @@
import { useState } from "react";
import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import { useAdminGetIdentities } from "@app/hooks/api/admin/queries";
const IdentityPanelTable = () => {
const [searchIdentityFilter, setSearchIdentityFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500);
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities(
{
limit: 20,
searchTerm: debouncedSearchTerm
}
);
const isEmpty = !isPending && !data?.pages?.[0].length;
return (
<>
<div className="flex gap-2">
<Input
value={searchIdentityFilter}
onChange={(e) => setSearchIdentityFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
className="flex-1"
/>
</div>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="identities" />}
{!isPending &&
data?.pages?.map((identities) =>
identities.map(({ name, id }) => (
<Tr key={`identity-${id}`} className="w-full">
<Td>{name}</Td>
</Tr>
))
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
</>
);
};
export const IdentityPanel = () => (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
</div>
<IdentityPanelTable />
</div>
);
@@ -60,12 +60,12 @@ const UserPanelTable = ({
const [adminsOnly, setAdminsOnly] = useState(false); const [adminsOnly, setAdminsOnly] = useState(false);
const { user } = useUser(); const { user } = useUser();
const userId = user?.id || ""; const userId = user?.id || "";
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
limit: 20, limit: 20,
searchTerm: debounedSearchTerm, searchTerm: debouncedSearchTerm,
adminsOnly adminsOnly
}); });
@@ -28,7 +28,14 @@ type Props = {
const AUDIT_LOG_LIMIT = 15; const AUDIT_LOG_LIMIT = 15;
const TABLE_HEADERS = ["Timestamp (MM/DD/YYYY)", "Event", "Project", "Actor", "Source", "Metadata"] as const; const TABLE_HEADERS = [
"Timestamp (MM/DD/YYYY)",
"Event",
"Project",
"Actor",
"Source",
"Metadata"
] as const;
export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number]; export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number];
export const LogsTable = ({ export const LogsTable = ({
@@ -14,9 +14,10 @@ import {
} from "@app/context"; } from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles"; import { isCustomOrgRole } from "@app/helpers/roles";
import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
import { GenericResourceNameSchema } from "@app/lib/schemas";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().max(64, "Too long, maximum length is 64 characters"), name: GenericResourceNameSchema,
slug: z slug: z
.string() .string()
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens"), .regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens"),
@@ -45,8 +45,11 @@ export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }:
return ( return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2"> <div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p> <p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400"> <p className="text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added). Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p> </p>
<div className="mt-2 flex flex-col space-y-2"> <div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => { {items.fields.map((el, index) => {
@@ -39,8 +39,11 @@ export const IdentityManagementPermissionConditions = ({ position = 0, isDisable
return ( return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2"> <div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p> <p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400"> <p className="text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added). Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p> </p>
<div className="mt-2 flex flex-col space-y-2"> <div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => { {items.fields.map((el, index) => {
@@ -1,10 +1,13 @@
import { useMemo } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { AccessTree } from "@app/components/permissions";
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@@ -13,6 +16,8 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
@@ -115,94 +120,109 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
} }
}; };
const permissions = form.watch("permissions");
const formattedPermissions = useMemo(
() =>
evaluatePermissionsAbility(
formRolePermission2API(permissions) as RawRuleOf<
MongoAbility<ProjectPermissionSet, MongoQuery>
>[]
),
[JSON.stringify(permissions)]
);
return ( return (
<form <div className="w-full">
onSubmit={handleSubmit(onSubmit)} <AccessTree permissions={formattedPermissions} />
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4" <form
> onSubmit={handleSubmit(onSubmit)}
<FormProvider {...form}> className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4"> >
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3> <FormProvider {...form}>
<div className="flex items-center space-x-4"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
{isCustomRole && ( <h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
<> <div className="flex items-center space-x-4">
{isDirty && ( {isCustomRole && (
<Button <>
className="mr-4 text-mineshaft-300" {isDirty && (
variant="link" <Button
isDisabled={isSubmitting} className="mr-4 text-mineshaft-300"
isLoading={isSubmitting} variant="link"
onClick={() => reset()} isDisabled={isSubmitting}
> isLoading={isSubmitting}
Discard onClick={() => reset()}
</Button> >
)} Discard
<div className="flex items-center"> </Button>
<Button )}
variant="outline_bg" <div className="flex items-center">
type="submit" <Button
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")} variant="outline_bg"
isDisabled={isSubmitting || !isDirty} type="submit"
isLoading={isSubmitting} className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
leftIcon={<FontAwesomeIcon icon={faSave} />} isDisabled={isSubmitting || !isDirty}
> isLoading={isSubmitting}
Save leftIcon={<FontAwesomeIcon icon={faSave} />}
</Button> >
<DropdownMenu> Save
<DropdownMenuTrigger> </Button>
<Button <DropdownMenu>
isDisabled={isDisabled} <DropdownMenuTrigger>
className="h-10 rounded-l-none" <Button
variant="outline_bg" isDisabled={isDisabled}
leftIcon={<FontAwesomeIcon icon={faPlus} />} className="h-10 rounded-l-none"
> variant="outline_bg"
New policy leftIcon={<FontAwesomeIcon icon={faPlus} />}
</Button> >
</DropdownMenuTrigger> New policy
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end"> </Button>
{Object.keys(PROJECT_PERMISSION_OBJECT) </DropdownMenuTrigger>
.sort((a, b) => <DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
PROJECT_PERMISSION_OBJECT[ {Object.keys(PROJECT_PERMISSION_OBJECT)
a as keyof typeof PROJECT_PERMISSION_OBJECT .sort((a, b) =>
].title PROJECT_PERMISSION_OBJECT[
.toLowerCase() a as keyof typeof PROJECT_PERMISSION_OBJECT
.localeCompare( ].title
PROJECT_PERMISSION_OBJECT[ .toLowerCase()
b as keyof typeof PROJECT_PERMISSION_OBJECT .localeCompare(
].title.toLowerCase() PROJECT_PERMISSION_OBJECT[
) b as keyof typeof PROJECT_PERMISSION_OBJECT
) ].title.toLowerCase()
.map((subject) => ( )
<DropdownMenuItem )
key={`permission-create-${subject}`} .map((subject) => (
className="py-3" <DropdownMenuItem
onClick={() => onNewPolicy(subject as ProjectPermissionSub)} key={`permission-create-${subject}`}
> className="py-3"
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
</DropdownMenuItem> >
))} {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> ))}
</div> </DropdownMenuContent>
</> </DropdownMenu>
)} </div>
</>
)}
</div>
</div> </div>
</div> <div className="py-4">
<div className="py-4"> {!isPending && <PermissionEmptyState />}
{!isPending && <PermissionEmptyState />} {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( <GeneralPermissionPolicies
<GeneralPermissionPolicies subject={subject}
subject={subject} actions={PROJECT_PERMISSION_OBJECT[subject].actions}
actions={PROJECT_PERMISSION_OBJECT[subject].actions} title={PROJECT_PERMISSION_OBJECT[subject].title}
title={PROJECT_PERMISSION_OBJECT[subject].title} key={`project-permission-${subject}`}
key={`project-permission-${subject}`} isDisabled={isDisabled}
isDisabled={isDisabled} >
> {renderConditionalComponents(subject, isDisabled)}
{renderConditionalComponents(subject, isDisabled)} </GeneralPermissionPolicies>
</GeneralPermissionPolicies> ))}
))} </div>
</div> </FormProvider>
</FormProvider> </form>
</form> </div>
); );
}; };
@@ -43,8 +43,11 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
return ( return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2"> <div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p> <p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400"> <p className="text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added). Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p> </p>
<div className="mt-2 flex flex-col space-y-2"> <div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => { {items.fields.map((el, index) => {
@@ -120,7 +120,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<Input {...field} placeholder="API Key" type="text" autoComplete="off" autoCorrect="off" spellCheck="false" /> <Input
{...field}
placeholder="API Key"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -155,7 +162,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
errorText={error?.message} errorText={error?.message}
isOptional isOptional
> >
<Input {...field} placeholder="Password" type="password" autoComplete="new-password" autoCorrect="off" spellCheck="false" aria-autocomplete="none" data-form-type="other" /> <Input
{...field}
placeholder="Password"
type="password"
autoComplete="new-password"
autoCorrect="off"
spellCheck="false"
aria-autocomplete="none"
data-form-type="other"
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -228,7 +228,8 @@ export const OverviewPage = () => {
setPage setPage
}); });
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } =
useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } = const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets); useDynamicSecretOverview(dynamicSecrets);
@@ -251,7 +252,7 @@ export const OverviewPage = () => {
"updateFolder" "updateFolder"
] as const); ] as const);
const handleFolderCreate = async (folderName: string, description: string | null) => { const handleFolderCreate = async (folderName: string, description: string | null) => {
const promises = userAvailableEnvs.map((env) => { const promises = userAvailableEnvs.map((env) => {
const environment = env.slug; const environment = env.slug;
return createFolder({ return createFolder({
@@ -1029,7 +1030,7 @@ export const OverviewPage = () => {
)} )}
{!isOverviewLoading && visibleEnvs.length > 0 && ( {!isOverviewLoading && visibleEnvs.length > 0 && (
<> <>
{folderNamesAndDescriptions.map(({name: folderName, description}, index) => ( {folderNamesAndDescriptions.map(({ name: folderName, description }, index) => (
<SecretOverviewFolderRow <SecretOverviewFolderRow
folderName={folderName} folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv} isFolderPresentInEnv={isFolderPresentInEnv}
@@ -1161,7 +1162,9 @@ export const OverviewPage = () => {
<FolderForm <FolderForm
isEdit isEdit
defaultFolderName={(popUp.updateFolder?.data as Pick<TSecretFolder, "name">)?.name} defaultFolderName={(popUp.updateFolder?.data as Pick<TSecretFolder, "name">)?.name}
defaultDescription={(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description} defaultDescription={
(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description
}
onUpdateFolder={handleFolderUpdate} onUpdateFolder={handleFolderUpdate}
showDescriptionOverwriteWarning showDescriptionOverwriteWarning
/> />
@@ -16,10 +16,10 @@ import {
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { getKeyValue } from "@app/helpers/parseEnvVar"; import { getKeyValue } from "@app/helpers/parseEnvVar";
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { SecretType } from "@app/hooks/api/types"; import { SecretType } from "@app/hooks/api/types";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
const typeSchema = z const typeSchema = z
.object({ .object({
@@ -23,6 +23,27 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
required: z
.object({
lowercase: z.number().min(0),
uppercase: z.number().min(0),
digits: z.number().min(0),
symbols: z.number().min(0)
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 250;
}, "Sum of required characters cannot exceed 250"),
allowedSymbols: z.string().optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length");
const formSchema = z.object({ const formSchema = z.object({
provider: z.object({ provider: z.object({
client: z.nativeEnum(SqlProviders), client: z.nativeEnum(SqlProviders),
@@ -31,6 +52,7 @@ const formSchema = z.object({
database: z.string().min(1), database: z.string().min(1),
username: z.string().min(1), username: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
passwordRequirements: passwordRequirementsSchema.optional(),
creationStatement: z.string().min(1), creationStatement: z.string().min(1),
revocationStatement: z.string().min(1), revocationStatement: z.string().min(1),
renewStatement: z.string().optional(), renewStatement: z.string().optional(),
@@ -133,11 +155,24 @@ export const SqlDatabaseInputForm = ({
control, control,
setValue, setValue,
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit handleSubmit,
watch
} = useForm<TForm>({ } = useForm<TForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
provider: getSqlStatements(SqlProviders.Postgres) provider: {
...getSqlStatements(SqlProviders.Postgres),
passwordRequirements: {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
}
}
} }
}); });
@@ -174,6 +209,10 @@ export const SqlDatabaseInputForm = ({
setValue("provider.renewStatement", sqlStatment.renewStatement); setValue("provider.renewStatement", sqlStatment.renewStatement);
setValue("provider.revocationStatement", sqlStatment.revocationStatement); setValue("provider.revocationStatement", sqlStatment.revocationStatement);
setValue("provider.port", getDefaultPort(type)); setValue("provider.port", getDefaultPort(type));
// Update password requirements based on provider
const length = type === SqlProviders.Oracle ? 30 : 48;
setValue("provider.passwordRequirements.length", length);
}; };
return ( return (
@@ -197,6 +236,7 @@ export const SqlDatabaseInputForm = ({
)} )}
/> />
</div> </div>
<div className="w-32"> <div className="w-32">
<Controller <Controller
control={control} control={control}
@@ -386,10 +426,15 @@ export const SqlDatabaseInputForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700"> <Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements"> <AccordionItem value="advanced">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger> <AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing database user lifecycle
</div>
<Controller <Controller
control={control} control={control}
name="provider.creationStatement" name="provider.creationStatement"
@@ -450,6 +495,160 @@ export const SqlDatabaseInputForm = ({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="password-config">
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Set constraints on the generated database password
</div>
<div className="space-y-4">
<div>
<Controller
control={control}
name="provider.passwordRequirements.length"
defaultValue={48}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(
watch("provider.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("provider.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="provider.passwordRequirements.required.lowercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.uppercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.digits"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.symbols"
defaultValue={0}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Allowed Symbols</h4>
<Controller
control={control}
name="provider.passwordRequirements.allowedSymbols"
defaultValue="-_.~!*"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbols to use in password"
isError={Boolean(error)}
errorText={error?.message}
helperText="Default: -_.~!*"
>
<Input {...field} placeholder="-_.~!*" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
</div> </div>
</div> </div>
@@ -15,7 +15,8 @@ type Props = {
showDescriptionOverwriteWarning?: boolean; showDescriptionOverwriteWarning?: boolean;
}; };
const descriptionOverwriteWarningMessage = "Warning: Any changes made here will overwrite any custom edits in individual environment folders." const descriptionOverwriteWarningMessage =
"Warning: Any changes made here will overwrite any custom edits in individual environment folders.";
const formSchema = z.object({ const formSchema = z.object({
name: z name: z
@@ -25,9 +26,7 @@ const formSchema = z.object({
/^[a-zA-Z0-9-_]+$/, /^[a-zA-Z0-9-_]+$/,
"Folder name can only contain letters, numbers, dashes, and underscores" "Folder name can only contain letters, numbers, dashes, and underscores"
), ),
description: z description: z.string().optional()
.string()
.optional()
}); });
type TFormData = z.infer<typeof formSchema>; type TFormData = z.infer<typeof formSchema>;
@@ -59,7 +58,7 @@ export const FolderForm = ({
if (textarea) { if (textarea) {
const lines = textarea.value.split("\n"); const lines = textarea.value.split("\n");
const maxDescriptionLines = 10; const maxDescriptionLines = 10;
if (lines.length > maxDescriptionLines) { if (lines.length > maxDescriptionLines) {
textarea.value = lines.slice(0, maxDescriptionLines).join("\n"); textarea.value = lines.slice(0, maxDescriptionLines).join("\n");
} }
@@ -90,30 +89,32 @@ export const FolderForm = ({
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="description" name="description"
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Folder Description" label="Folder Description"
isError={Boolean(error)} isError={Boolean(error)}
tooltipText={showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined} tooltipText={
isOptional showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined
errorText={error?.message} }
className="flex-1" isOptional
> errorText={error?.message}
<TextArea className="flex-1"
placeholder="Folder description" >
{...field} <TextArea
rows={3} placeholder="Folder description"
ref={descriptionRef} {...field}
onInput={handleInput} rows={3}
className="thin-scrollbar w-full !resize-none bg-mineshaft-900" ref={descriptionRef}
maxLength={255} onInput={handleInput}
/> className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
</FormControl> maxLength={255}
)} />
/> </FormControl>
)}
/>
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}> <Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"} {isEdit ? "Save" : "Create"}
@@ -23,6 +23,27 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api"; import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
required: z
.object({
lowercase: z.number().min(0),
uppercase: z.number().min(0),
digits: z.number().min(0),
symbols: z.number().min(0)
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 250; // Sanity check for individual validation
}, "Sum of required characters cannot exceed 250"),
allowedSymbols: z.string().optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length");
const formSchema = z.object({ const formSchema = z.object({
inputs: z inputs: z
.object({ .object({
@@ -32,6 +53,7 @@ const formSchema = z.object({
database: z.string().min(1), database: z.string().min(1),
username: z.string().min(1), username: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
passwordRequirements: passwordRequirementsSchema.optional(),
creationStatement: z.string().min(1), creationStatement: z.string().min(1),
revocationStatement: z.string().min(1), revocationStatement: z.string().min(1),
renewStatement: z.string().optional(), renewStatement: z.string().optional(),
@@ -82,6 +104,17 @@ export const EditDynamicSecretSqlProviderForm = ({
secretPath, secretPath,
projectSlug projectSlug
}: Props) => { }: Props) => {
const getDefaultPasswordRequirements = (provider: SqlProviders) => ({
length: provider === SqlProviders.Oracle ? 30 : 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
});
const { const {
control, control,
watch, watch,
@@ -94,10 +127,16 @@ export const EditDynamicSecretSqlProviderForm = ({
maxTTL: dynamicSecret.maxTTL, maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name, newName: dynamicSecret.name,
inputs: { inputs: {
...(dynamicSecret.inputs as TForm["inputs"]) ...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements:
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
getDefaultPasswordRequirements(
(dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres
)
} }
} }
}); });
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data: projectGateways, isPending: isProjectGatewaysLoading } = useQuery( const { data: projectGateways, isPending: isProjectGatewaysLoading } = useQuery(
gatewaysQueryKeys.listProjectGateways({ projectId: currentWorkspace.id }) gatewaysQueryKeys.listProjectGateways({ projectId: currentWorkspace.id })
@@ -347,10 +386,15 @@ export const EditDynamicSecretSqlProviderForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Accordion type="multiple" className="w-full bg-mineshaft-700"> <Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="modify-sql-statement"> <AccordionItem value="advanced">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger> <AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing database user lifecycle
</div>
<Controller <Controller
control={control} control={control}
name="inputs.creationStatement" name="inputs.creationStatement"
@@ -418,6 +462,160 @@ export const EditDynamicSecretSqlProviderForm = ({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="password-config">
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Set constraints on the generated database password
</div>
<div className="space-y-4">
<div>
<Controller
control={control}
name="inputs.passwordRequirements.length"
defaultValue={48}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(
watch("inputs.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("inputs.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="inputs.passwordRequirements.required.lowercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.uppercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.digits"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.symbols"
defaultValue={0}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Allowed Symbols</h4>
<Controller
control={control}
name="inputs.passwordRequirements.allowedSymbols"
defaultValue="-_.~!*"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbols to use in password"
isError={Boolean(error)}
errorText={error?.message}
helperText="Default: -_.~!*"
>
<Input {...field} placeholder="-_.~!*" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
</div> </div>
</div> </div>
@@ -1,17 +1,17 @@
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faClose, faFolder, faPencilSquare, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2"; import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { FolderForm } from "../ActionBar/FolderForm"; import { FolderForm } from "../ActionBar/FolderForm";
@@ -118,16 +118,15 @@ export const FolderListView = ({
onClick={() => handleFolderClick(name)} onClick={() => handleFolderClick(name)}
> >
{name} {name}
{ {description && (
description &&
<Tooltip <Tooltip
position="right" position="right"
className="flex items-center space-x-4 max-w-lg py-4 whitespace-pre-wrap" className="flex max-w-lg items-center space-x-4 whitespace-pre-wrap py-4"
content={description} content={description}
> >
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400 ml-1" /> <FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
</Tooltip> </Tooltip>
} )}
</div> </div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan <ProjectPermissionCan
@@ -62,7 +62,7 @@ export const PitDrawer = ({
<div> <div>
{(() => { {(() => {
const distance = formatDistance(new Date(createdAt), new Date()); const distance = formatDistance(new Date(createdAt), new Date());
return distance.charAt(0).toUpperCase() + distance.slice(1) + " ago"; return `${distance.charAt(0).toUpperCase() + distance.slice(1)} ago`;
})()} })()}
</div> </div>
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === id)}</div> <div>{getButtonLabel(i === 0 && index === 0, snapshotId === id)}</div>
@@ -58,10 +58,10 @@ import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { camelCaseToSpaces } from "@app/lib/fn/string";
import { CreateReminderForm } from "./CreateReminderForm"; import { CreateReminderForm } from "./CreateReminderForm";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
import { camelCaseToSpaces } from "@app/lib/fn/string";
type Props = { type Props = {
isOpen?: boolean; isOpen?: boolean;

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