mirror of
https://github.com/Infisical/infisical.git
synced 2025-07-31 10:38:12 +00:00
Compare commits
72 Commits
daniel/sec
...
gcp-sync-h
Author | SHA1 | Date | |
---|---|---|---|
|
48943b4d78 | ||
|
fd1afc2cbe | ||
|
5ebf142e3e | ||
|
bdceea4c91 | ||
|
32fa6866e4 | ||
|
b4faef797c | ||
|
08732cab62 | ||
|
81d5f639ae | ||
|
25b83d4b86 | ||
|
a500f00a49 | ||
|
6842f7aa8b | ||
|
ad207786e2 | ||
|
ace8c37c25 | ||
|
4c82408b51 | ||
|
8146dcef16 | ||
|
2e90addbc5 | ||
|
427201a634 | ||
|
0b55ac141c | ||
|
aecfa268ae | ||
|
fdfc020efc | ||
|
62aa80a104 | ||
|
cf9d8035bd | ||
|
d0c9f1ca53 | ||
|
2ecc7424d9 | ||
|
c04b97c689 | ||
|
7600a86dfc | ||
|
8924eaf251 | ||
|
82e9504285 | ||
|
c4e10df754 | ||
|
ce60e96008 | ||
|
930b59cb4f | ||
|
ec363a5ad4 | ||
|
de7e92ccfc | ||
|
522d81ae1a | ||
|
02153ffb32 | ||
|
d9d62384e7 | ||
|
76f34501dc | ||
|
7415bb93b8 | ||
|
7a1c08a7f2 | ||
|
84f9eb5f9f | ||
|
87ac723fcb | ||
|
a6dab47552 | ||
|
08bac83bcc | ||
|
46c90f03f0 | ||
|
d7722f7587 | ||
|
a42bcb3393 | ||
|
192dba04a5 | ||
|
0cc3240956 | ||
|
667580546b | ||
|
9fd662b7f7 | ||
|
a56cbbc02f | ||
|
dc30465afb | ||
|
f1caab2d00 | ||
|
1d186b1950 | ||
|
9cf5908cc1 | ||
|
38cf43176e | ||
|
f5c7943f2f | ||
|
3c59f7f350 | ||
|
84cc7bcd6c | ||
|
159c27ac67 | ||
|
de5a432745 | ||
|
387780aa94 | ||
|
3887ce800b | ||
|
1a06b3e1f5 | ||
|
627e17b3ae | ||
|
39b7a4a111 | ||
|
e7c512999e | ||
|
c9da8477c8 | ||
|
5e4b478b74 | ||
|
17cf602a65 | ||
|
23f6f5dfd4 | ||
|
abc2ffca57 |
3
.envrc
Normal file
3
.envrc
Normal 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
|
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.direnv/
|
||||
|
||||
# backend
|
||||
node_modules
|
||||
.env
|
||||
@@ -26,8 +28,6 @@ node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
.env
|
||||
|
||||
# testing
|
||||
coverage
|
||||
reports
|
||||
@@ -63,10 +63,12 @@ yarn-error.log*
|
||||
|
||||
# Editor specific
|
||||
.vscode/*
|
||||
.idea/*
|
||||
**/.idea/*
|
||||
|
||||
frontend-build
|
||||
|
||||
# cli
|
||||
.go/
|
||||
*.tgz
|
||||
cli/infisical-merge
|
||||
cli/test/infisical-merge
|
||||
|
@@ -6,6 +6,7 @@ import {
|
||||
SecretEncryptionAlgo,
|
||||
SecretKeyEncoding,
|
||||
SecretType,
|
||||
TableName,
|
||||
TSecretApprovalRequestsSecretsInsert,
|
||||
TSecretApprovalRequestsSecretsV2Insert
|
||||
} 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 { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
|
||||
@@ -77,7 +79,6 @@ import {
|
||||
TSecretApprovalDetailsDTO,
|
||||
TStatusChangeDTO
|
||||
} from "./secret-approval-request-types";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
|
||||
|
||||
type TSecretApprovalRequestServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
@@ -1335,17 +1336,48 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
// deleted secrets
|
||||
const deletedSecrets = data[SecretOperations.Delete];
|
||||
if (deletedSecrets && deletedSecrets.length) {
|
||||
const secretsToDeleteInDB = await secretV2BridgeDAL.findBySecretKeys(
|
||||
const secretsToDeleteInDB = await secretV2BridgeDAL.find({
|
||||
folderId,
|
||||
deletedSecrets.map((el) => ({
|
||||
key: el.secretKey,
|
||||
type: SecretType.Shared
|
||||
}))
|
||||
);
|
||||
$complex: {
|
||||
operator: "and",
|
||||
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)
|
||||
throw new NotFoundError({
|
||||
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 deletedSecretIds = deletedSecrets.map((el) => secretsGroupedByKey[el.secretKey][0].id);
|
||||
const latestSecretVersions = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, deletedSecretIds);
|
||||
@@ -1373,7 +1405,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
commits.forEach((commit) => {
|
||||
let action = ProjectPermissionSecretActions.Create;
|
||||
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(
|
||||
action,
|
||||
|
@@ -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`
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
|
@@ -635,6 +635,7 @@ export const registerRoutes = async (
|
||||
});
|
||||
const superAdminService = superAdminServiceFactory({
|
||||
userDAL,
|
||||
identityDAL,
|
||||
userAliasDAL,
|
||||
authService: loginService,
|
||||
serverCfgDAL: superAdminDAL,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
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 { BadRequestError } from "@app/lib/errors";
|
||||
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({
|
||||
method: "GET",
|
||||
url: "/integrations/slack/config",
|
||||
|
@@ -91,7 +91,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await projectRouter.register(registerProjectMembershipRouter);
|
||||
await projectRouter.register(registerSecretTagRouter);
|
||||
},
|
||||
|
||||
{ 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 { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn";
|
||||
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 { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
|
||||
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
|
||||
@@ -251,7 +251,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
params: z.object({ organizationId: z.string().trim() }),
|
||||
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(),
|
||||
authEnforced: z.boolean().optional(),
|
||||
scimEnabled: z.boolean().optional(),
|
||||
|
@@ -2,10 +2,12 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
IntegrationsSchema,
|
||||
ProjectEnvironmentsSchema,
|
||||
ProjectMembershipsSchema,
|
||||
ProjectRolesSchema,
|
||||
ProjectSlackConfigsSchema,
|
||||
ProjectType,
|
||||
SecretFoldersSchema,
|
||||
UserEncryptionKeysSchema,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
@@ -675,4 +677,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
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 { getConfig } from "@app/lib/config/env";
|
||||
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 { ActorType, AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
@@ -330,7 +331,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string().trim()
|
||||
name: GenericResourceNameSchema
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@@ -4,6 +4,7 @@ import { UsersSchema } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { ForbiddenRequestError } from "@app/lib/errors";
|
||||
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 { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
|
||||
@@ -100,7 +101,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
||||
encryptedPrivateKeyTag: z.string().trim(),
|
||||
salt: z.string().trim(),
|
||||
verifier: z.string().trim(),
|
||||
organizationName: z.string().trim().min(1),
|
||||
organizationName: GenericResourceNameSchema,
|
||||
providerAuthToken: z.string().trim().optional().nullish(),
|
||||
attributionSource: z.string().trim().optional(),
|
||||
password: z.string()
|
||||
|
@@ -78,9 +78,7 @@ export const identityAccessTokenServiceFactory = ({
|
||||
const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const decodedToken = jwt.verify(accessToken, appCfg.AUTH_SECRET) as JwtPayload & {
|
||||
identityAccessTokenId: string;
|
||||
};
|
||||
const decodedToken = jwt.verify(accessToken, appCfg.AUTH_SECRET) as TIdentityAccessTokenJwtPayload;
|
||||
if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN) {
|
||||
throw new BadRequestError({ message: "Only identity access tokens can be renewed" });
|
||||
}
|
||||
@@ -127,7 +125,23 @@ export const identityAccessTokenServiceFactory = ({
|
||||
accessTokenLastRenewedAt: new Date()
|
||||
});
|
||||
|
||||
return { accessToken, identityAccessToken: updatedIdentityAccessToken };
|
||||
const renewedToken = jwt.sign(
|
||||
{
|
||||
identityId: decodedToken.identityId,
|
||||
clientSecretId: decodedToken.clientSecretId,
|
||||
identityAccessTokenId: decodedToken.identityAccessTokenId,
|
||||
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
||||
} as TIdentityAccessTokenJwtPayload,
|
||||
appCfg.AUTH_SECRET,
|
||||
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
|
||||
Number(identityAccessToken.accessTokenTTL) === 0
|
||||
? undefined
|
||||
: {
|
||||
expiresIn: Number(identityAccessToken.accessTokenTTL)
|
||||
}
|
||||
);
|
||||
|
||||
return { accessToken: renewedToken, identityAccessToken: updatedIdentityAccessToken };
|
||||
};
|
||||
|
||||
const revokeAccessToken = async (accessToken: string) => {
|
||||
|
@@ -1,10 +1,42 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
import { TableName, TIdentities } from "@app/db/schemas";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
|
||||
export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>;
|
||||
|
||||
export const identityDALFactory = (db: TDbClient) => {
|
||||
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 };
|
||||
};
|
||||
|
17
backend/src/services/secret-folder/secret-folder-fns.ts
Normal file
17
backend/src/services/secret-folder/secret-folder-fns.ts
Normal file
@@ -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 { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
|
||||
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
|
||||
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
@@ -27,7 +28,7 @@ type TSecretFolderServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
folderDAL: TSecretFolderDALFactory;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs" | "find">;
|
||||
folderVersionDAL: TSecretFolderVersionDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||
};
|
||||
@@ -580,6 +581,44 @@ export const secretFolderServiceFactory = ({
|
||||
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 {
|
||||
createFolder,
|
||||
updateFolder,
|
||||
@@ -589,6 +628,7 @@ export const secretFolderServiceFactory = ({
|
||||
getFolderById,
|
||||
getProjectFolderCount,
|
||||
getFoldersMultiEnv,
|
||||
getFoldersDeepByEnvs
|
||||
getFoldersDeepByEnvs,
|
||||
getProjectEnvironmentsFolders
|
||||
};
|
||||
};
|
||||
|
@@ -71,8 +71,16 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden
|
||||
|
||||
res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8");
|
||||
} catch (error) {
|
||||
// when a secret in GCP has no versions, we treat it as if it's a blank value
|
||||
if (error instanceof AxiosError && error.response?.status === 404) {
|
||||
// when a secret in GCP has no versions, or is disabled/destroyed, we treat it as if it's a blank value
|
||||
if (
|
||||
error instanceof AxiosError &&
|
||||
(error.response?.status === 404 ||
|
||||
(error.response?.status === 400 &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
error.response.data.error.status === "FAILED_PRECONDITION" &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call
|
||||
error.response.data.error.message.match(/(?:disabled|destroyed)/i)))
|
||||
) {
|
||||
res[key] = "";
|
||||
} else {
|
||||
throw new SecretSyncError({
|
||||
|
@@ -94,7 +94,7 @@ export const fnSecretBulkInsert = async ({
|
||||
);
|
||||
|
||||
const userActorId = actor && actor.type === ActorType.USER ? actor.actorId : undefined;
|
||||
const identityActorId = actor && actor.type !== ActorType.USER ? actor.actorId : undefined;
|
||||
const identityActorId = actor && actor.type === ActorType.IDENTITY ? actor.actorId : undefined;
|
||||
const actorType = actor?.type || ActorType.PLATFORM;
|
||||
|
||||
const newSecrets = await secretDAL.insertMany(
|
||||
@@ -182,7 +182,7 @@ export const fnSecretBulkUpdate = async ({
|
||||
actor
|
||||
}: TFnSecretBulkUpdate) => {
|
||||
const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined;
|
||||
const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined;
|
||||
const identityActorId = actor && actor?.type === ActorType.IDENTITY ? actor?.actorId : undefined;
|
||||
const actorType = actor?.type || ActorType.PLATFORM;
|
||||
|
||||
const sanitizedInputSecrets = inputSecrets.map(
|
||||
|
@@ -2,6 +2,7 @@ import { Knex } from "knex";
|
||||
import { z } from "zod";
|
||||
|
||||
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 { TProjectDALFactory } from "@app/services/project/project-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 { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-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">;
|
||||
|
||||
|
@@ -19,9 +19,11 @@ import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
|
||||
import { UserAliasType } from "../user-alias/user-alias-types";
|
||||
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 = {
|
||||
identityDAL: Pick<TIdentityDALFactory, "getIdentitiesByFilter">;
|
||||
serverCfgDAL: TSuperAdminDALFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "findOne">;
|
||||
@@ -51,6 +53,7 @@ const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
export const superAdminServiceFactory = ({
|
||||
serverCfgDAL,
|
||||
userDAL,
|
||||
identityDAL,
|
||||
userAliasDAL,
|
||||
authService,
|
||||
orgService,
|
||||
@@ -286,6 +289,15 @@ export const superAdminServiceFactory = ({
|
||||
return user;
|
||||
};
|
||||
|
||||
const getIdentities = ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
|
||||
return identityDAL.getIdentitiesByFilter({
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy: "name"
|
||||
});
|
||||
};
|
||||
|
||||
const grantServerAdminAccessToUser = async (userId: string) => {
|
||||
if (!licenseService.onPremFeatures?.instanceUserManagement) {
|
||||
throw new BadRequestError({
|
||||
@@ -383,6 +395,7 @@ export const superAdminServiceFactory = ({
|
||||
adminSignUp,
|
||||
getUsers,
|
||||
deleteUser,
|
||||
getIdentities,
|
||||
getAdminSlackConfig,
|
||||
updateRootEncryptionStrategy,
|
||||
getConfiguredEncryptionStrategies,
|
||||
|
@@ -23,6 +23,12 @@ export type TAdminGetUsersDTO = {
|
||||
adminsOnly: boolean;
|
||||
};
|
||||
|
||||
export type TAdminGetIdentitiesDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export enum LoginMethod {
|
||||
EMAIL = "email",
|
||||
GOOGLE = "google",
|
||||
|
@@ -20,6 +20,7 @@ require (
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/roff v0.1.0
|
||||
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/turn/v4 v4.0.0
|
||||
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/onsi/ginkgo/v2 v2.22.2 // 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/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
|
10
cli/go.sum
10
cli/go.sum
@@ -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-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.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/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
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-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.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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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-20220811171246-fbc7d0a398ab/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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
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.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.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/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
@@ -2,6 +2,12 @@ package api
|
||||
|
||||
import "time"
|
||||
|
||||
type Environment struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Stores info for login one
|
||||
type LoginOneRequest struct {
|
||||
Email string `json:"email"`
|
||||
@@ -14,7 +20,6 @@ type LoginOneResponse struct {
|
||||
}
|
||||
|
||||
// Stores info for login two
|
||||
|
||||
type LoginTwoRequest struct {
|
||||
Email string `json:"email"`
|
||||
ClientProof string `json:"clientProof"`
|
||||
@@ -168,9 +173,10 @@ type Secret struct {
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Environments []Environment `json:"environments"`
|
||||
}
|
||||
|
||||
type RawSecret struct {
|
||||
|
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -16,31 +18,23 @@ import (
|
||||
)
|
||||
|
||||
var gatewayCmd = &cobra.Command{
|
||||
Example: `infisical gateway`,
|
||||
Short: "Used to infisical 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,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
token, err := util.GetInfisicalToken(cmd)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
util.HandleError(err, "Unable to parse token 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")
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
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{
|
||||
Example: `infisical gateway relay`,
|
||||
Short: "Used to run infisical gateway relay",
|
||||
@@ -139,9 +177,12 @@ var gatewayRelayCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
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")
|
||||
|
||||
gatewayCmd.AddCommand(gatewayInstallCmd)
|
||||
gatewayCmd.AddCommand(gatewayRelayCmd)
|
||||
rootCmd.AddCommand(gatewayCmd)
|
||||
}
|
||||
|
@@ -15,6 +15,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/api"
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/models"
|
||||
"github.com/Infisical/infisical-merge/packages/util"
|
||||
"github.com/fatih/color"
|
||||
@@ -59,11 +62,11 @@ var runCmd = &cobra.Command{
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
environmentName, _ := cmd.Flags().GetString("env")
|
||||
environmentSlug, _ := cmd.Flags().GetString("env")
|
||||
if !cmd.Flags().Changed("env") {
|
||||
environmentFromWorkspace := util.GetEnvFromWorkspaceFile()
|
||||
if environmentFromWorkspace != "" {
|
||||
environmentName = environmentFromWorkspace
|
||||
environmentSlug = environmentFromWorkspace
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +139,20 @@ var runCmd = &cobra.Command{
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Confirming selected environment is valid: %s", environmentSlug)
|
||||
|
||||
hasEnvironment, err := confirmProjectHasEnvironment(environmentSlug, projectId, token)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Could not confirm project has environment")
|
||||
}
|
||||
if !hasEnvironment {
|
||||
util.HandleError(fmt.Errorf("project does not have environment '%s'", environmentSlug))
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Project '%s' has environment '%s'", projectId, environmentSlug)
|
||||
|
||||
request := models.GetAllSecretsParameters{
|
||||
Environment: environmentName,
|
||||
Environment: environmentSlug,
|
||||
WorkspaceId: projectId,
|
||||
TagSlugs: tagSlugs,
|
||||
SecretsPath: secretsPath,
|
||||
@@ -308,7 +323,6 @@ func waitForExitCommand(cmd *exec.Cmd) (int, error) {
|
||||
}
|
||||
|
||||
func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInterval int, request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails) {
|
||||
|
||||
var cmd *exec.Cmd
|
||||
var err error
|
||||
var lastSecretsFetch time.Time
|
||||
@@ -439,8 +453,53 @@ func executeCommandWithWatchMode(commandFlag string, args []string, watchModeInt
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAndFormatSecretsForShell(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails) (models.InjectableEnvironmentResult, error) {
|
||||
func confirmProjectHasEnvironment(environmentSlug, projectId string, token *models.TokenDetails) (bool, error) {
|
||||
var accessToken string
|
||||
|
||||
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
|
||||
accessToken = token.Token
|
||||
} else {
|
||||
util.RequireLogin()
|
||||
util.RequireLocalWorkspaceFile()
|
||||
|
||||
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to authenticate")
|
||||
}
|
||||
|
||||
if loggedInUserDetails.LoginExpired {
|
||||
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
|
||||
}
|
||||
accessToken = loggedInUserDetails.UserCredentials.JTWToken
|
||||
}
|
||||
|
||||
if projectId == "" {
|
||||
workspaceFile, err := util.GetWorkSpaceFromFile()
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to get local project details")
|
||||
}
|
||||
|
||||
projectId = workspaceFile.WorkspaceId
|
||||
}
|
||||
|
||||
httpClient := resty.New()
|
||||
httpClient.SetAuthToken(accessToken).
|
||||
SetHeader("Accept", "application/json")
|
||||
|
||||
project, err := api.CallGetProjectById(httpClient, projectId)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, env := range project.Environments {
|
||||
if env.Slug == environmentSlug {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func fetchAndFormatSecretsForShell(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, token *models.TokenDetails) (models.InjectableEnvironmentResult, error) {
|
||||
if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER {
|
||||
request.InfisicalToken = token.Token
|
||||
} else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER {
|
||||
|
@@ -17,7 +17,7 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/infisical/gateway.conf
|
||||
ExecStart=/usr/local/bin/infisical gateway
|
||||
ExecStart=infisical gateway
|
||||
Restart=on-failure
|
||||
InaccessibleDirectories=/home
|
||||
PrivateTmp=yes
|
||||
|
@@ -232,7 +232,6 @@ func FilterSecretsByTag(plainTextSecrets []models.SingleEnvironmentVariable, tag
|
||||
|
||||
func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectConfigFilePath string) ([]models.SingleEnvironmentVariable, error) {
|
||||
var secretsToReturn []models.SingleEnvironmentVariable
|
||||
// var serviceTokenDetails api.GetServiceTokenDetailsResponse
|
||||
var errorToReturn error
|
||||
|
||||
if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" {
|
||||
|
@@ -76,7 +76,6 @@ func TestUniversalAuth_SecretsGetWrongEnvironment(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot failed: %v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestUserAuth_SecretsGetAll(t *testing.T) {
|
||||
|
107
docs/cli/commands/gateway.mdx
Normal file
107
docs/cli/commands/gateway.mdx
Normal 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"
|
||||
---
|
||||
|
||||

|
||||
|
||||
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.
|
||||
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 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:
|
||||
```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
|
||||
```
|
||||
Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Production (systemd)">
|
||||
For production deployments on Linux, install the Gateway as a systemd service:
|
||||
```bash
|
||||
sudo infisical gateway install --token <your-machine-identity-token> --domain <your-infisical-domain>
|
||||
sudo systemctl start infisical-gateway
|
||||
```
|
||||
This will install and start the Gateway as a secure systemd service that:
|
||||
- Runs with restricted privileges:
|
||||
- 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>
|
||||
Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical.
|
||||
</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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
@@ -126,21 +126,19 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud.
|
||||
<Accordion title="leaseTTL">
|
||||
The `leaseTTL` is a string-formatted duration that defines the time the lease should last for the dynamic secret.
|
||||
|
||||
The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time.
|
||||
The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time.
|
||||
|
||||
The following units are supported:
|
||||
The following units are supported:
|
||||
|
||||
- `s` for seconds (must be at least 5 seconds)
|
||||
- `m` for minutes
|
||||
- `h` for hours
|
||||
- `d` for days
|
||||
- `s` for seconds (must be at least 5 seconds)
|
||||
- `m` for minutes
|
||||
- `h` for hours
|
||||
- `d` for days
|
||||
|
||||
<Note>
|
||||
The lease duration at most be 1 day (24 hours). And the TTL must be less than the max TTL defined on the dynamic secret.
|
||||
|
||||
</Note>
|
||||
|
||||
</Accordion>
|
||||
<Note>
|
||||
The lease duration at most be 1 day (24 hours). And the TTL must be less than the max TTL defined on the dynamic secret.
|
||||
</Note>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="managedSecretReference">
|
||||
The `managedSecretReference` field is used to define the Kubernetes secret where the dynamic secret lease should be stored. The required fields are `secretName` and `secretNamespace`.
|
||||
|
@@ -93,7 +93,7 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud.
|
||||
CA certificate to use for connecting to the Infisical instance with SSL/TLS.
|
||||
</Accordion>
|
||||
|
||||
### Authentication methods
|
||||
### Authentication Methods
|
||||
|
||||
To retrieve the requested secrets, the operator must first authenticate with Infisical.
|
||||
The list of available authentication methods are shown below.
|
||||
@@ -535,7 +535,7 @@ spec:
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Operator managed secrets
|
||||
### Operator Managed Secrets
|
||||
|
||||
The managed secret properties specify where to store the secrets retrieved from your Infisical project.
|
||||
This includes defining the name and namespace of the Kubernetes secret that will hold these secrets.
|
||||
@@ -584,7 +584,7 @@ This is useful for tools such as ArgoCD, where every resource requires an owner
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Manged secret templating
|
||||
#### Managed Secret Templating
|
||||
|
||||
Fetching secrets from Infisical as is via the operator may not be enough. This is where templating functionality may be helpful.
|
||||
Using Go templates, you can format, combine, and create new key-value pairs from secrets fetched from Infisical before storing them as Kubernetes Secrets.
|
||||
@@ -681,6 +681,135 @@ template:
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Operator Managed ConfigMaps
|
||||
|
||||
The managed config map properties specify where to store the secrets retrieved from your Infisical project. Config maps can be used to store **non-sensitive** data, such as application configuration variables.
|
||||
The properties includes defining the name and namespace of the Kubernetes config map that will hold the data retrieved from your Infisical project.
|
||||
The Infisical operator will automatically create the Kubernetes config map in the specified name/namespace and ensure it stays up-to-date. If a config map already exists in the specified namespace, the operator will update the existing config map with the new data.
|
||||
|
||||
<Warning>
|
||||
The usage of config maps is only intended for storing non-sensitive data. If you are looking to store sensitive data, please use the [managed secret](#operator-managed-secrets) property instead.
|
||||
</Warning>
|
||||
|
||||
<Accordion title="managedKubeConfigMapReferences">
|
||||
</Accordion>
|
||||
<Accordion title="managedKubeConfigMapReferences[].configMapName">
|
||||
The name of the managed Kubernetes config map that your Infisical data will be stored in.
|
||||
</Accordion>
|
||||
<Accordion title="managedKubeConfigMapReferences[].configMapNamespace">
|
||||
The namespace of the managed Kubernetes config map that your Infisical data will be stored in.
|
||||
</Accordion>
|
||||
<Accordion title="managedKubeConfigMapReferences[].creationPolicy">
|
||||
Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes config map that is generated by the Infisical operator.
|
||||
This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically.
|
||||
|
||||
#### Available options
|
||||
|
||||
- `Orphan` (default)
|
||||
- `Owner`
|
||||
|
||||
<Tip>
|
||||
When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in
|
||||
the same namespace as where the managed kubernetes config map.
|
||||
</Tip>
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
#### Managed ConfigMap Templating
|
||||
|
||||
Fetching secrets from Infisical as is via the operator may not be enough. This is where templating functionality may be helpful.
|
||||
Using Go templates, you can format, combine, and create new key-value pairs from secrets fetched from Infisical before storing them as Kubernetes Config Maps.
|
||||
|
||||
<Accordion title="managedKubeConfigMapReferences[].template">
|
||||
</Accordion>
|
||||
<Accordion title="managedKubeConfigMapReferences[].template.includeAllSecrets">
|
||||
This property controls what secrets are included in your managed config map when using templates.
|
||||
When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes config map resource.
|
||||
**Use this option when you would like to sync all secrets from Infisical to Kubernetes but want to template a subset of them.**
|
||||
|
||||
When set to `false`, only secrets defined in the `managedKubeConfigMapReferences[].template.data` field of the template will be included in the managed config map.
|
||||
Use this option when you would like to sync **only** a subset of secrets from Infisical to Kubernetes.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="managedKubeConfigMapReferences[].template.data">
|
||||
Define secret keys and their corresponding templates.
|
||||
Each data value uses a Golang template with access to all secrets retrieved from the specified scope.
|
||||
|
||||
Secrets are structured as follows:
|
||||
|
||||
```golang
|
||||
type TemplateSecret struct {
|
||||
Value string `json:"value"`
|
||||
SecretPath string `json:"secretPath"`
|
||||
}
|
||||
```
|
||||
|
||||
#### Example template configuration:
|
||||
|
||||
```yaml
|
||||
managedKubeConfigMapReferences:
|
||||
- configMapName: managed-configmap
|
||||
configMapNamespace: default
|
||||
template:
|
||||
includeAllSecrets: true
|
||||
data:
|
||||
# Create new key that doesn't exist in your Infisical project using values of other secrets
|
||||
SITE_URL: "{{ .SITE_URL.Value }}"
|
||||
# Override an existing key in Infisical project with a new value using values of other secrets
|
||||
API_URL: "https://api.{{.SITE_URL.Value}}.{{.REGION.Value}}.com"
|
||||
```
|
||||
|
||||
For this example, let's assume the following secrets exist in your Infisical project:
|
||||
|
||||
```
|
||||
SITE_URL = "https://example.com"
|
||||
REGION = "us-east-1"
|
||||
API_URL = "old-url" # This will be overridden
|
||||
```
|
||||
|
||||
The resulting managed Kubernetes config map will then contain:
|
||||
|
||||
```
|
||||
# Original config map data (from includeAllSecrets: true)
|
||||
SITE_URL = "https://example.com"
|
||||
REGION = "us-east-1"
|
||||
|
||||
# New and overridden config map data
|
||||
SITE_URL = "https://example.com"
|
||||
API_URL = "https://api.example.com.us-east-1.com" # Existing secret overridden by template
|
||||
```
|
||||
|
||||
To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates.
|
||||
|
||||
### Available templating functions
|
||||
|
||||
<Accordion title="decodeBase64ToBytes">
|
||||
**Function name**: decodeBase64ToBytes
|
||||
|
||||
**Description**:
|
||||
Given a base64 encoded string, this function will decodes the base64-encoded string.
|
||||
This function is useful when your Infisical secrets are already stored as base64 encoded value in Infisical.
|
||||
|
||||
**Returns**: The decoded base64 string as bytes.
|
||||
|
||||
**Example**:
|
||||
The example below assumes that the `BINARY_KEY_BASE64` secret is stored as a base64 encoded value in Infisical.
|
||||
The resulting managed config map will contain the decoded value of `BINARY_KEY_BASE64`.
|
||||
|
||||
```yaml
|
||||
managedKubeConfigMapReferences:
|
||||
- configMapName: managed-configmap
|
||||
configMapNamespace: default
|
||||
template:
|
||||
includeAllSecrets: true
|
||||
data:
|
||||
BINARY_KEY: "{{ decodeBase64ToBytes .BINARY_KEY_BASE64.Value }}"
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
|
||||
## Applying CRD
|
||||
|
||||
Once you have configured the InfisicalSecret CRD with the required fields, you can apply it to your cluster.
|
||||
@@ -692,17 +821,32 @@ kubectl apply -f example-infisical-secret-crd.yaml
|
||||
|
||||
To verify that the operator has successfully created the managed secret, you can check the secrets in the namespace that was specified.
|
||||
|
||||
```bash
|
||||
# Verify managed secret is created
|
||||
kubectl get secrets -n <namespace of managed secret>
|
||||
```
|
||||
<Tabs>
|
||||
<Tab title="Managed Secret">
|
||||
```bash
|
||||
# Verify managed secret is created
|
||||
kubectl get secrets -n <namespace of managed secret>
|
||||
```
|
||||
<Info>
|
||||
The Infisical secrets will be synced and stored into the managed secret every
|
||||
1 minute unless configured otherwise.
|
||||
</Info>
|
||||
</Tab>
|
||||
<Tab title="Managed ConfigMap">
|
||||
```bash
|
||||
# Verify managed config map is created
|
||||
kubectl get configmaps -n <namespace of managed config map>
|
||||
```
|
||||
<Info>
|
||||
The Infisical config map data will be synced and stored into the managed config map every
|
||||
1 minute unless configured otherwise.
|
||||
</Info>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
The Infisical secrets will be synced and stored into the managed secret every
|
||||
1 minutes.
|
||||
</Info>
|
||||
|
||||
## Using managed secret in your deployment
|
||||
|
||||
## Using Managed Secret In Your Deployment
|
||||
|
||||
To make use of the managed secret created by the operator into your deployment can be achieved through several methods.
|
||||
Here, we will highlight three of the most common ways to utilize it. Learn more about Kubernetes secrets [here](https://kubernetes.io/docs/concepts/configuration/secret/)
|
||||
@@ -755,7 +899,7 @@ spec:
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: managed-secret # managed secret name
|
||||
key: SOME_SECRET_KEY # The name of the key which exists in the managed secret
|
||||
key: SOME_SECRET_KEY # The name of the key which exists in the managed secret
|
||||
```
|
||||
|
||||
Example usage in a deployment
|
||||
@@ -764,28 +908,30 @@ Example usage in a deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers: - name: nginx
|
||||
image: nginx:1.14.2
|
||||
env: - name: STRIPE_API_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: managed-secret # <- name of managed secret
|
||||
key: STRIPE_API_SECRET
|
||||
ports: - containerPort: 80
|
||||
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
env:
|
||||
- name: STRIPE_API_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: managed-secret # <- name of managed secret
|
||||
key: STRIPE_API_SECRET
|
||||
ports:
|
||||
- containerPort: 80
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
@@ -861,12 +1007,12 @@ stringData:
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
### Auto redeployment
|
||||
### Automatic Redeployment
|
||||
|
||||
Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed.
|
||||
To address this, we added functionality to automatically redeploy your deployment when its managed secret updates.
|
||||
|
||||
#### Enabling auto redeploy
|
||||
#### Enabling Automatic Redeployment
|
||||
|
||||
To enable auto redeployment you simply have to add the following annotation to the deployment, statefulset, or daemonset that consumes a managed secret.
|
||||
|
||||
@@ -910,7 +1056,173 @@ spec:
|
||||
Then, for each deployment that has this annotation present, a rolling update will be triggered.
|
||||
</Info>
|
||||
|
||||
## Propagating labels & annotations
|
||||
## Using Managed ConfigMap In Your Deployment
|
||||
|
||||
To make use of the managed ConfigMap created by the operator into your deployment can be achieved through several methods.
|
||||
Here, we will highlight three of the most common ways to utilize it. Learn more about Kubernetes ConfigMaps [here](https://kubernetes.io/docs/concepts/configuration/configmap/)
|
||||
|
||||
<Tip>
|
||||
Automatic redeployment of deployments using managed ConfigMaps is not yet supported.
|
||||
</Tip>
|
||||
|
||||
|
||||
<Accordion title="envFrom">
|
||||
This will take all the secrets from your managed ConfigMap and expose them to your container
|
||||
|
||||
````yaml
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: managed-configmap # managed configmap name
|
||||
```
|
||||
|
||||
Example usage in a deployment
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: managed-configmap # <- name of managed configmap
|
||||
ports:
|
||||
- containerPort: 80
|
||||
````
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="env">
|
||||
This will allow you to select individual secrets by key name from your managed ConfigMap and expose them to your container
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: CONFIG_NAME # The environment variable's name which is made available in the container
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: managed-configmap # managed configmap name
|
||||
key: SOME_CONFIG_KEY # The name of the key which exists in the managed configmap
|
||||
```
|
||||
|
||||
Example usage in a deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
env:
|
||||
- name: STRIPE_API_SECRET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: managed-configmap # <- name of managed configmap
|
||||
key: STRIPE_API_SECRET
|
||||
ports:
|
||||
- containerPort: 80
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="volumes">
|
||||
This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret
|
||||
```yaml
|
||||
volumes:
|
||||
- name: configmaps-volume-name # The name of the volume under which configmaps will be stored
|
||||
configMap:
|
||||
name: managed-configmap # managed configmap name
|
||||
````
|
||||
|
||||
You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets
|
||||
|
||||
```yaml
|
||||
volumeMounts:
|
||||
- name: configmaps-volume-name
|
||||
mountPath: /etc/config
|
||||
readOnly: true
|
||||
```
|
||||
|
||||
Example usage in a deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
volumeMounts:
|
||||
- name: configmaps-volume-name
|
||||
mountPath: /etc/config
|
||||
readOnly: true
|
||||
ports:
|
||||
- containerPort: 80
|
||||
volumes:
|
||||
- name: configmaps-volume-name
|
||||
configMap:
|
||||
name: managed-configmap # <- managed configmap
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
The definition file of the Kubernetes secret for the CA certificate can be structured like the following:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: custom-ca-certificate
|
||||
type: Opaque
|
||||
stringData:
|
||||
ca.crt: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL
|
||||
...
|
||||
BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
## Propagating Labels & Annotations
|
||||
|
||||
The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created.
|
||||
Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example:
|
||||
@@ -949,5 +1261,4 @@ metadata:
|
||||
namespace: default
|
||||
type: Opaque
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
@@ -340,6 +340,7 @@
|
||||
"cli/commands/secrets",
|
||||
"cli/commands/dynamic-secrets",
|
||||
"cli/commands/ssh",
|
||||
"cli/commands/gateway",
|
||||
"cli/commands/export",
|
||||
"cli/commands/token",
|
||||
"cli/commands/service-token",
|
||||
|
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal 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
|
||||
}
|
34
flake.nix
Normal file
34
flake.nix
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
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
|
||||
|
||||
go
|
||||
python312Full
|
||||
nodejs_20
|
||||
nodePackages.prettier
|
||||
infisical
|
||||
];
|
||||
|
||||
env = {
|
||||
GOROOT = "${pkgs.go}/share/go";
|
||||
};
|
||||
|
||||
shellHook = ''
|
||||
export GOPATH="$(pwd)/.go"
|
||||
mkdir -p "$GOPATH"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
245
frontend/package-lock.json
generated
245
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -47,8 +48,10 @@
|
||||
"@tanstack/react-router": "^1.95.1",
|
||||
"@tanstack/virtual-file-routes": "^1.87.6",
|
||||
"@tanstack/zod-adapter": "^1.91.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"argon2-browser": "^1.18.0",
|
||||
"axios": "^1.7.9",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -507,6 +510,24 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
|
||||
@@ -3955,6 +3976,61 @@
|
||||
"@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": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -4382,6 +4458,64 @@
|
||||
"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": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
@@ -5456,6 +5590,12 @@
|
||||
"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": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
@@ -5808,6 +5948,111 @@
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -51,8 +52,10 @@
|
||||
"@tanstack/react-router": "^1.95.1",
|
||||
"@tanstack/virtual-file-routes": "^1.87.6",
|
||||
"@tanstack/zod-adapter": "^1.91.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"argon2-browser": "^1.18.0",
|
||||
"axios": "^1.7.9",
|
||||
"classnames": "^2.5.1",
|
||||
|
@@ -11,6 +11,7 @@ import { encodeBase64 } from "tweetnacl-util";
|
||||
import { initProjectHelper } from "@app/helpers/project";
|
||||
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { onRequestError } from "@app/hooks/api/reactQuery";
|
||||
|
||||
import InputField from "../basic/InputField";
|
||||
import checkPassword from "../utilities/checks/password/checkPassword";
|
||||
@@ -206,6 +207,7 @@ export default function UserInfoStep({
|
||||
|
||||
incrementStep();
|
||||
} catch (error) {
|
||||
onRequestError(error);
|
||||
setIsLoading(false);
|
||||
console.error(error);
|
||||
}
|
||||
|
@@ -8,10 +8,11 @@ import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useCreateOrg, useSelectOrganization } from "@app/hooks/api";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { GenericResourceNameSchema } from "@app/lib/schemas";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().nonempty({ message: "Name is required" })
|
||||
name: GenericResourceNameSchema.nonempty({ message: "Name is required" })
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -78,7 +79,7 @@ export const CreateOrgModal: FC<CreateOrgModalProps> = ({ isOpen, onClose }) =>
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}>
|
||||
<Modal modal={false} isOpen={isOpen}>
|
||||
<ModalContent
|
||||
title="Create Organization"
|
||||
subTitle="Looks like you're not part of any organizations. Create one to start using Infisical"
|
||||
|
211
frontend/src/components/permissions/AccessTree/AccessTree.tsx
Normal file
211
frontend/src/components/permissions/AccessTree/AccessTree.tsx
Normal file
@@ -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:support@infisical.com"
|
||||
>
|
||||
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
|
||||
};
|
||||
};
|
1
frontend/src/components/permissions/AccessTree/index.ts
Normal file
1
frontend/src/components/permissions/AccessTree/index.ts
Normal file
@@ -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 { OrgPermissionCan } from "./OrgPermissionCan";
|
||||
export { PermissionDeniedBanner } from "./PermissionDeniedBanner";
|
||||
|
@@ -50,7 +50,9 @@ export const PopoverContent = ({
|
||||
</IconButton>
|
||||
</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.Portal>
|
||||
);
|
||||
|
@@ -59,7 +59,9 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
||||
>
|
||||
<div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{props.icon && <FontAwesomeIcon icon={props.icon} />}
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
<div className="flex-1 truncate">
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectPrimitive.Icon className="ml-3">
|
||||
@@ -122,7 +124,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
<SelectPrimitive.Item
|
||||
{...props}
|
||||
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",
|
||||
isDisabled && "cursor-not-allowed text-gray-600 opacity-80 hover:!bg-transparent",
|
||||
className
|
||||
|
@@ -1,16 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { unpackRules } from "@casl/ability/extra";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
conditionsMatcher,
|
||||
fetchUserProjectPermissions,
|
||||
roleQueryKeys
|
||||
} from "@app/hooks/api/roles/queries";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
import { omit } from "@app/lib/fn/object";
|
||||
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
|
||||
import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
|
||||
|
||||
import { ProjectPermissionSet } from "./types";
|
||||
|
||||
@@ -31,33 +26,7 @@ export const useProjectPermission = () => {
|
||||
staleTime: Infinity,
|
||||
select: (data) => {
|
||||
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
|
||||
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);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const ability = evaluatePermissionsAbility(rule);
|
||||
return {
|
||||
permission: ability,
|
||||
membership: {
|
||||
|
39
frontend/src/helpers/permissions.ts
Normal file
39
frontend/src/helpers/permissions.ts
Normal 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;
|
||||
};
|
@@ -1,10 +1,10 @@
|
||||
export {
|
||||
useAdminDeleteUser,
|
||||
useAdminGrantServerAdminAccess,
|
||||
useCreateAdminUser,
|
||||
useUpdateAdminSlackConfig,
|
||||
useUpdateServerConfig,
|
||||
useUpdateServerEncryptionStrategy,
|
||||
useAdminGrantServerAdminAccess
|
||||
useUpdateServerEncryptionStrategy
|
||||
} from "./mutation";
|
||||
export {
|
||||
useAdminGetUsers,
|
||||
|
@@ -4,19 +4,24 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { User } from "../types";
|
||||
import {
|
||||
AdminGetIdentitiesFilters,
|
||||
AdminGetUsersFilters,
|
||||
AdminSlackConfig,
|
||||
TGetServerRootKmsEncryptionDetails,
|
||||
TServerConfig
|
||||
} from "./types";
|
||||
import { Identity } from "@app/hooks/api/identities/types";
|
||||
|
||||
export const adminStandaloneKeys = {
|
||||
getUsers: "get-users"
|
||||
getUsers: "get-users",
|
||||
getIdentities: "get-identities"
|
||||
};
|
||||
|
||||
export const adminQueryKeys = {
|
||||
serverConfig: () => ["server-config"] as const,
|
||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||
getAdminSlackConfig: () => ["admin-slack-config"] 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 = () => {
|
||||
return useQuery({
|
||||
queryKey: adminQueryKeys.getAdminSlackConfig(),
|
||||
|
@@ -53,6 +53,11 @@ export type AdminGetUsersFilters = {
|
||||
adminsOnly: boolean;
|
||||
};
|
||||
|
||||
export type AdminGetIdentitiesFilters = {
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export type AdminSlackConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
|
@@ -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 AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"];
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as TApiErrors;
|
||||
if (serverResponse?.error === ApiErrorTypes.ValidationError) {
|
||||
createNotification(
|
||||
{
|
||||
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",
|
||||
export const onRequestError = (error: unknown) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as TApiErrors;
|
||||
if (serverResponse?.error === ApiErrorTypes.ValidationError) {
|
||||
createNotification(
|
||||
{
|
||||
title: "Validation 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: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
@@ -287,9 +63,128 @@ export const queryClient = new QueryClient({
|
||||
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: {
|
||||
queries: {
|
||||
|
@@ -16,6 +16,7 @@ import {
|
||||
TDeleteFolderDTO,
|
||||
TGetFoldersByEnvDTO,
|
||||
TGetProjectFoldersDTO,
|
||||
TProjectEnvironmentsFolders,
|
||||
TSecretFolder,
|
||||
TUpdateFolderBatchDTO,
|
||||
TUpdateFolderDTO
|
||||
@@ -23,7 +24,9 @@ import {
|
||||
|
||||
export const folderQueryKeys = {
|
||||
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 = "/") => {
|
||||
@@ -37,6 +40,29 @@ const fetchProjectFolders = async (workspaceId: string, environment: string, pat
|
||||
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 = ({
|
||||
projectId,
|
||||
environment,
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||
|
||||
export enum ReservedFolders {
|
||||
SecretReplication = "__reserve_replication_"
|
||||
}
|
||||
@@ -6,6 +8,13 @@ export type TSecretFolder = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
export type TSecretFolderWithPath = TSecretFolder & { path: string };
|
||||
|
||||
export type TProjectEnvironmentsFolders = {
|
||||
[key: string]: WorkspaceEnv & { folders: TSecretFolderWithPath[] };
|
||||
};
|
||||
|
||||
export type TGetProjectFoldersDTO = {
|
||||
|
@@ -10,13 +10,16 @@ type FolderNameAndDescription = {
|
||||
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
|
||||
const folderNamesAndDescriptions = useMemo(() => {
|
||||
const namesAndDescriptions = new Map<string, FolderNameAndDescription>();
|
||||
|
||||
|
||||
folders?.forEach((folder) => {
|
||||
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());
|
||||
}, [folders]);
|
||||
|
||||
|
@@ -25,8 +25,8 @@ export const MenuIconButton = <T extends ElementType = "button">({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
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",
|
||||
isSelected && "bg-bunker-800 hover:bg-mineshaft-600 rounded-none",
|
||||
"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 && "rounded-none bg-bunker-800 hover:bg-mineshaft-600",
|
||||
isDisabled && "cursor-not-allowed hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
|
@@ -19,17 +19,17 @@ import { useGetOrgUsers } from "@app/hooks/api";
|
||||
|
||||
export const ServerAdminsPanel = () => {
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || "");
|
||||
|
||||
const adminUsers = orgUsers?.filter((orgUser) => {
|
||||
const isSuperAdmin = orgUser.user.superAdmin;
|
||||
const matchesSearch = debounedSearchTerm
|
||||
? orgUser.user.email?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.firstName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.lastName?.toLowerCase().includes(debounedSearchTerm.toLowerCase())
|
||||
const matchesSearch = debouncedSearchTerm
|
||||
? orgUser.user.email?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.firstName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.lastName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
|
||||
: true;
|
||||
return isSuperAdmin && matchesSearch;
|
||||
});
|
||||
|
@@ -1 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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"
|
||||
);
|
||||
|
@@ -10,6 +10,7 @@ import { NotFoundPage } from "./pages/public/NotFoundPage/NotFoundPage";
|
||||
// Import the generated route tree
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import "nprogress/nprogress.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
|
@@ -34,6 +34,7 @@ import { EncryptionPanel } from "./components/EncryptionPanel";
|
||||
import { IntegrationPanel } from "./components/IntegrationPanel";
|
||||
import { RateLimitPanel } from "./components/RateLimitPanel";
|
||||
import { UserPanel } from "./components/UserPanel";
|
||||
import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel";
|
||||
|
||||
enum TabSections {
|
||||
Settings = "settings",
|
||||
@@ -42,6 +43,7 @@ enum TabSections {
|
||||
RateLimit = "rate-limit",
|
||||
Integrations = "integrations",
|
||||
Users = "users",
|
||||
Identities = "identities",
|
||||
Kmip = "kmip"
|
||||
}
|
||||
|
||||
@@ -164,6 +166,7 @@ export const OverviewPage = () => {
|
||||
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
|
||||
<Tab value={TabSections.Integrations}>Integrations</Tab>
|
||||
<Tab value={TabSections.Users}>Users</Tab>
|
||||
<Tab value={TabSections.Identities}>Identities</Tab>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanel value={TabSections.Settings}>
|
||||
@@ -409,6 +412,9 @@ export const OverviewPage = () => {
|
||||
<TabPanel value={TabSections.Users}>
|
||||
<UserPanel />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Identities}>
|
||||
<IdentityPanel />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</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 { user } = useUser();
|
||||
const userId = user?.id || "";
|
||||
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
|
||||
limit: 20,
|
||||
searchTerm: debounedSearchTerm,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly
|
||||
});
|
||||
|
||||
|
@@ -28,7 +28,14 @@ type Props = {
|
||||
|
||||
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 const LogsTable = ({
|
||||
|
@@ -14,9 +14,10 @@ import {
|
||||
} from "@app/context";
|
||||
import { isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
|
||||
import { GenericResourceNameSchema } from "@app/lib/schemas";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().max(64, "Too long, maximum length is 64 characters"),
|
||||
name: GenericResourceNameSchema,
|
||||
slug: z
|
||||
.string()
|
||||
.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 (
|
||||
<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="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
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>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
|
@@ -39,8 +39,11 @@ export const IdentityManagementPermissionConditions = ({ position = 0, isDisable
|
||||
return (
|
||||
<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="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
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>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
|
@@ -1,10 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
|
||||
import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { AccessTree } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -13,6 +16,8 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/v2";
|
||||
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 { 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 (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<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>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isCustomRole && (
|
||||
<>
|
||||
{isDirty && (
|
||||
<Button
|
||||
className="mr-4 text-mineshaft-300"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
New policy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT)
|
||||
.sort((a, b) =>
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
a as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
b as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((subject) => (
|
||||
<DropdownMenuItem
|
||||
key={`permission-create-${subject}`}
|
||||
className="py-3"
|
||||
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
|
||||
>
|
||||
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<AccessTree permissions={formattedPermissions} />
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<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>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isCustomRole && (
|
||||
<>
|
||||
{isDirty && (
|
||||
<Button
|
||||
className="mr-4 text-mineshaft-300"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
New policy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT)
|
||||
.sort((a, b) =>
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
a as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
b as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((subject) => (
|
||||
<DropdownMenuItem
|
||||
key={`permission-create-${subject}`}
|
||||
className="py-3"
|
||||
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
|
||||
>
|
||||
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
{!isPending && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={subject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||
key={`project-permission-${subject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(subject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
))}
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
<div className="py-4">
|
||||
{!isPending && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={subject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||
key={`project-permission-${subject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(subject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
))}
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -43,8 +43,11 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
|
||||
return (
|
||||
<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="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
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>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
|
@@ -120,7 +120,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
|
||||
isError={Boolean(error)}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
@@ -155,7 +162,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
|
||||
errorText={error?.message}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
@@ -228,7 +228,8 @@ export const OverviewPage = () => {
|
||||
setPage
|
||||
});
|
||||
|
||||
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
|
||||
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } =
|
||||
useFolderOverview(folders);
|
||||
|
||||
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
|
||||
useDynamicSecretOverview(dynamicSecrets);
|
||||
@@ -251,7 +252,7 @@ export const OverviewPage = () => {
|
||||
"updateFolder"
|
||||
] as const);
|
||||
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
const promises = userAvailableEnvs.map((env) => {
|
||||
const environment = env.slug;
|
||||
return createFolder({
|
||||
@@ -1029,7 +1030,7 @@ export const OverviewPage = () => {
|
||||
)}
|
||||
{!isOverviewLoading && visibleEnvs.length > 0 && (
|
||||
<>
|
||||
{folderNamesAndDescriptions.map(({name: folderName, description}, index) => (
|
||||
{folderNamesAndDescriptions.map(({ name: folderName, description }, index) => (
|
||||
<SecretOverviewFolderRow
|
||||
folderName={folderName}
|
||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||
@@ -1161,7 +1162,9 @@ export const OverviewPage = () => {
|
||||
<FolderForm
|
||||
isEdit
|
||||
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}
|
||||
showDescriptionOverwriteWarning
|
||||
/>
|
||||
|
@@ -16,10 +16,10 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
const typeSchema = z
|
||||
.object({
|
||||
|
@@ -23,22 +23,26 @@ import { useWorkspace } from "@app/context";
|
||||
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
|
||||
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 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({
|
||||
provider: z.object({
|
||||
@@ -166,7 +170,7 @@ export const SqlDatabaseInputForm = ({
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: '-_.~!*'
|
||||
allowedSymbols: "-_.~!*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +209,7 @@ export const SqlDatabaseInputForm = ({
|
||||
setValue("provider.renewStatement", sqlStatment.renewStatement);
|
||||
setValue("provider.revocationStatement", sqlStatment.revocationStatement);
|
||||
setValue("provider.port", getDefaultPort(type));
|
||||
|
||||
|
||||
// Update password requirements based on provider
|
||||
const length = type === SqlProviders.Oracle ? 30 : 48;
|
||||
setValue("provider.passwordRequirements.length", length);
|
||||
@@ -424,7 +428,9 @@ export const SqlDatabaseInputForm = ({
|
||||
/>
|
||||
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>Creation, Revocation & Renew Statements (optional)</AccordionTrigger>
|
||||
<AccordionTrigger>
|
||||
Creation, Revocation & Renew Statements (optional)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Customize SQL statements for managing database user lifecycle
|
||||
@@ -508,10 +514,10 @@ export const SqlDatabaseInputForm = ({
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -519,17 +525,20 @@ export const SqlDatabaseInputForm = ({
|
||||
)}
|
||||
/>
|
||||
</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 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})` : ""}
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
@@ -546,9 +555,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -566,9 +575,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -586,9 +595,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -606,9 +615,9 @@ export const SqlDatabaseInputForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
|
@@ -15,7 +15,8 @@ type Props = {
|
||||
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({
|
||||
name: z
|
||||
@@ -25,9 +26,7 @@ const formSchema = z.object({
|
||||
/^[a-zA-Z0-9-_]+$/,
|
||||
"Folder name can only contain letters, numbers, dashes, and underscores"
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
description: z.string().optional()
|
||||
});
|
||||
type TFormData = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -59,7 +58,7 @@ export const FolderForm = ({
|
||||
if (textarea) {
|
||||
const lines = textarea.value.split("\n");
|
||||
const maxDescriptionLines = 10;
|
||||
|
||||
|
||||
if (lines.length > maxDescriptionLines) {
|
||||
textarea.value = lines.slice(0, maxDescriptionLines).join("\n");
|
||||
}
|
||||
@@ -90,30 +89,32 @@ export const FolderForm = ({
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Folder Description"
|
||||
isError={Boolean(error)}
|
||||
tooltipText={showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined}
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Folder description"
|
||||
{...field}
|
||||
rows={3}
|
||||
ref={descriptionRef}
|
||||
onInput={handleInput}
|
||||
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
control={control}
|
||||
name="description"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Folder Description"
|
||||
isError={Boolean(error)}
|
||||
tooltipText={
|
||||
showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined
|
||||
}
|
||||
isOptional
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Folder description"
|
||||
{...field}
|
||||
rows={3}
|
||||
ref={descriptionRef}
|
||||
onInput={handleInput}
|
||||
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
{isEdit ? "Save" : "Create"}
|
||||
|
@@ -23,22 +23,26 @@ import { useWorkspace } from "@app/context";
|
||||
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
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 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({
|
||||
inputs: z
|
||||
@@ -108,7 +112,7 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
digits: 1,
|
||||
symbols: 0
|
||||
},
|
||||
allowedSymbols: '-_.~!*'
|
||||
allowedSymbols: "-_.~!*"
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -124,8 +128,11 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
newName: dynamicSecret.name,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"]),
|
||||
passwordRequirements: (dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
|
||||
getDefaultPasswordRequirements((dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres)
|
||||
passwordRequirements:
|
||||
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
|
||||
getDefaultPasswordRequirements(
|
||||
(dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -381,7 +388,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
/>
|
||||
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>Creation, Revocation & Renew Statements (optional)</AccordionTrigger>
|
||||
<AccordionTrigger>
|
||||
Creation, Revocation & Renew Statements (optional)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mb-4 text-sm text-mineshaft-300">
|
||||
Customize SQL statements for managing database user lifecycle
|
||||
@@ -472,10 +481,10 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={250}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -483,17 +492,20 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
)}
|
||||
/>
|
||||
</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 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})` : ""}
|
||||
Total required characters: {total}{" "}
|
||||
{isError ? `(exceeds length of ${length})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
@@ -510,9 +522,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -530,9 +542,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase letters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -550,9 +562,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
@@ -570,9 +582,9 @@ export const EditDynamicSecretSqlProviderForm = ({
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
|
@@ -1,17 +1,17 @@
|
||||
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 { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
|
||||
|
||||
import { FolderForm } from "../ActionBar/FolderForm";
|
||||
|
||||
@@ -118,16 +118,15 @@ export const FolderListView = ({
|
||||
onClick={() => handleFolderClick(name)}
|
||||
>
|
||||
{name}
|
||||
{
|
||||
description &&
|
||||
{description && (
|
||||
<Tooltip
|
||||
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}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400 ml-1" />
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||
<ProjectPermissionCan
|
||||
|
@@ -62,7 +62,7 @@ export const PitDrawer = ({
|
||||
<div>
|
||||
{(() => {
|
||||
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>{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 { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
import { CreateReminderForm } from "./CreateReminderForm";
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
|
@@ -281,7 +281,7 @@ export const SecretItem = memo(
|
||||
isVisible={isVisible}
|
||||
isReadOnly={isReadOnly}
|
||||
{...field}
|
||||
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
containerClassName="py-1.5 rounded-md transition-all"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -301,19 +301,19 @@ export const SecretItem = memo(
|
||||
secretPath={secretPath}
|
||||
{...field}
|
||||
defaultValue={secretValueHidden ? "" : undefined}
|
||||
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
containerClassName="py-1.5 rounded-md transition-all"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div key="actions" className="flex h-8 flex-shrink-0 self-start transition-all">
|
||||
<div key="actions" className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2">
|
||||
<Tooltip content="Copy secret">
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
ariaLabel="copy-value"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5"
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
@@ -339,7 +339,7 @@ export const SecretItem = memo(
|
||||
<Modal>
|
||||
<ModalTrigger asChild>
|
||||
<IconButton
|
||||
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6"
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="reference-tree"
|
||||
@@ -390,7 +390,7 @@ export const SecretItem = memo(
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-5",
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
||||
hasTagsApplied && "w-5 text-primary"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
@@ -473,7 +473,7 @@ export const SecretItem = memo(
|
||||
size="sm"
|
||||
onClick={handleOverrideClick}
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5",
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||
isOverriden && "w-5 text-primary"
|
||||
)}
|
||||
>
|
||||
@@ -498,7 +498,7 @@ export const SecretItem = memo(
|
||||
<PopoverTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||
hasComment && "w-5 text-primary"
|
||||
)}
|
||||
variant="plain"
|
||||
@@ -518,7 +518,7 @@ export const SecretItem = memo(
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6"
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="share-secret"
|
||||
|
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -13,7 +14,6 @@ import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { useResetUserPasswordV2, useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type Errors = {
|
||||
tooShort?: string;
|
||||
|
@@ -13,9 +13,9 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: v0.8.12
|
||||
version: v0.8.13
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "v0.8.12"
|
||||
appVersion: "v0.8.13"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user