Compare commits

..

67 Commits

Author SHA1 Message Date
carlosmonastyrski
e53439d586 Improvements on dynamic secrets overview 2025-03-14 23:18:57 -03:00
carlosmonastyrski
6905029455 Change overview dynamic secret creation modal to set only one env 2025-03-14 14:59:19 -03:00
carlosmonastyrski
94d7d2b029 Fix call of onCompleted after all promises are resolved 2025-03-14 12:44:49 -03:00
carlosmonastyrski
e39d1a0530 Fix call of onCompleted after all promises are resolved 2025-03-14 12:26:20 -03:00
carlosmonastyrski
4c5f3859d6 Add dynamic secrets modal form on secrets overview page 2025-03-14 11:59:13 -03:00
Daniel Hougaard
32fa6866e4 Merge pull request #3238 from Infisical/feat/ENG-2320-echo-environment-being-used-in-cli
feat: confirm environment exists when running `run` command
2025-03-14 03:58:05 +04:00
Daniel Hougaard
b4faef797c fix: address comment 2025-03-14 03:47:25 +04:00
Mahyar Mirrashed
08732cab62 refactor(projects): move rest api call directly into run command module 2025-03-13 16:36:41 -07:00
Mahyar Mirrashed
81d5f639ae revert: "refactor: clean smelly code"
This reverts commit c04b97c689.
2025-03-13 16:33:26 -07:00
Mahyar Mirrashed
a500f00a49 fix(run): compare environment slug to environment slug 2025-03-13 13:21:12 -07:00
Mahyar Mirrashed
ad207786e2 refactor: clean up empty line 2025-03-13 12:18:54 -07:00
Mahyar Mirrashed
4c82408b51 fix(run): grap workspace id from workspace file if not defined on the cli 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
8146dcef16 refactor(run): call it project instead of workspace 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
2e90addbc5 refactor(run): do not report project id in error message 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
427201a634 refactor(run): set up variable before call 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
0b55ac141c refactor(projects): rename workspace to project 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
aecfa268ae fix(run): handle case where we require a login 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
fdfc020efc refactor: clean up more smelly code 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
62aa80a104 feat(run): ensure that the project has the requested environment 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
cf9d8035bd feat(run): add function to confirm project has the requested environment 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
d0c9f1ca53 feat(projects): add new module in util package for getting project details 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
2ecc7424d9 feat(models): add model for environments 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
c04b97c689 refactor: clean smelly code 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
7600a86dfc fix(nix): set gopath for usage by IDEs 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
8924eaf251 chore: ignore direnv folder 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
82e9504285 chore: ignore .idea and .go folders 2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
c4e10df754 fix(nix): set the goroot for tools like jetbrains
JetBrains needs to know the GOROOT environment variables. For the sake
of other tooling, we will just set these in the flake rather than only
in the `.envrc` file. It also keeps all environment configuration
localized to our project flake.
2025-03-13 11:43:00 -07:00
Mahyar Mirrashed
ce60e96008 chore(nix): add golang dependency 2025-03-13 11:43:00 -07:00
Akhil Mohan
de7e92ccfc Merge pull request #3236 from akhilmhdh/fix/renew-token
Resolved renew token not renewing
2025-03-13 20:12:26 +05:30
Akhil Mohan
522d81ae1a Merge pull request #3237 from akhilmhdh/feat/metadata-oidc
Resolved create and update failing for service token
2025-03-13 19:47:51 +05:30
=
02153ffb32 fix: resolved create and update failing for service token 2025-03-13 19:41:33 +05:30
Scott Wilson
d9d62384e7 Merge pull request #3196 from Infisical/org-name-constraint
Improvement: Add Organization Name Constraint
2025-03-12 19:02:38 -07:00
Scott Wilson
76f34501dc improvements: address feedback 2025-03-12 17:20:53 -07:00
Scott Wilson
7415bb93b8 Merge branch 'main' into org-name-constraint 2025-03-12 17:07:12 -07:00
Mahyar Mirrashed
7a1c08a7f2 Merge pull request #3224 from Infisical/feat/ENG-2352-view-machine-identities-in-admin-console
feat: add ability to view machine identities in admin console
2025-03-12 16:31:54 -07:00
Maidul Islam
84f9eb5f9f Merge pull request #3234 from Infisical/fix/ENG-2341-fix-ui-glitch-hovering-on-comment
fix: ui glitching on hover
2025-03-12 16:55:52 -04:00
=
87ac723fcb feat: resolved renew token not renewing 2025-03-13 01:45:49 +05:30
Akhil Mohan
a6dab47552 Merge pull request #3232 from akhilmhdh/fix/delete-secret-approval
Resolved approval rejecting on delete secret
2025-03-13 01:34:44 +05:30
Mahyar Mirrashed
08bac83bcc chore(nix): add comments linking to documentation 2025-03-12 12:23:12 -07:00
Mahyar Mirrashed
46c90f03f0 refactor: use flexbox gap instead of individual margin right 2025-03-12 12:04:28 -07:00
Mahyar Mirrashed
d7722f7587 fix: set pointer events to none for arrow part of popover 2025-03-12 12:04:12 -07:00
Scott Wilson
a42bcb3393 Merge pull request #3230 from Infisical/access-tree
Feature: Role Access Tree
2025-03-12 11:38:35 -07:00
Scott Wilson
192dba04a5 improvement: update conditions description 2025-03-12 11:34:22 -07:00
Scott Wilson
0cc3240956 improvements: final feedback 2025-03-12 11:28:38 -07:00
Scott Wilson
667580546b improvement: check env folders exists 2025-03-12 10:43:45 -07:00
Scott Wilson
9fd662b7f7 improvements: address feedback 2025-03-12 10:33:56 -07:00
=
a56cbbc02f feat: resolved approval rejecting on delete secret 2025-03-12 14:28:50 +05:30
Scott Wilson
dc30465afb chore: refactor to avoid dep cycle 2025-03-11 22:04:56 -07:00
Scott Wilson
f1caab2d00 chore: revert license fns 2025-03-11 22:00:50 -07:00
Scott Wilson
1d186b1950 feature: access tree 2025-03-11 22:00:25 -07:00
Maidul Islam
9cf5908cc1 Merge pull request #3229 from Infisical/daniel/secret-scanning-docs
docs(platform): secret scanning
2025-03-12 00:09:42 -04:00
Daniel Hougaard
f1b6c3764f Update secret-scanning.mdx 2025-03-12 08:07:20 +04:00
Daniel Hougaard
4e6c860c69 Update secret-scanning.mdx 2025-03-12 07:46:29 +04:00
Daniel Hougaard
eda9ed257e docs: secret scanning 2025-03-12 07:31:25 +04:00
Maidul Islam
38cf43176e add gateway diagram 2025-03-11 20:13:39 -04:00
Mahyar Mirrashed
de5a432745 fix(lint): appease the linter
There is a conflict between this and our Prettier configuration.
2025-03-11 14:54:03 -07:00
Mahyar Mirrashed
387780aa94 fix(lint): remove file extension from imports
JetBrains accidentally added these when I ran the auto-complete. Weird.
2025-03-11 14:44:22 -07:00
Mahyar Mirrashed
3887ce800b refactor(admin): fix spelling for variable 2025-03-11 14:34:14 -07:00
Mahyar Mirrashed
1a06b3e1f5 fix(admin): stop returning auth method on table 2025-03-11 14:30:04 -07:00
Mahyar Mirrashed
627e17b3ae fix(admin): return back auth method from schema too 2025-03-11 14:10:08 -07:00
Mahyar Mirrashed
39b7a4a111 chore(nix): add python312 to list of dependencies 2025-03-11 13:31:23 -07:00
Mahyar Mirrashed
e7c512999e feat(admin): add ability to view machine identities 2025-03-11 13:30:45 -07:00
Mahyar Mirrashed
c9da8477c8 chore(nix): add prettier to list of dependencies 2025-03-11 08:54:15 -07:00
Mahyar Mirrashed
5e4b478b74 refactor(nix): replace shell hook with infisical dependency 2025-03-11 08:17:07 -07:00
Mahyar Mirrashed
17cf602a65 style: remove blank line 2025-03-10 16:26:39 -07:00
Mahyar Mirrashed
23f6f5dfd4 chore(nix): add support for flakes 2025-03-10 16:26:18 -07:00
Scott Wilson
abc2ffca57 improvement: add organization name constraint 2025-03-06 15:41:27 -08:00
111 changed files with 3377 additions and 720 deletions

3
.envrc Normal file
View File

@@ -0,0 +1,3 @@
# Learn more at https://direnv.net
# We instruct direnv to use our Nix flake for a consistent development environment.
use flake

8
.gitignore vendored
View File

@@ -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

View File

@@ -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,

View File

@@ -21,3 +21,10 @@ export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInpu
message: `${field} field can only contain lowercase letters, numbers, and hyphens`
});
};
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");

View File

@@ -635,6 +635,7 @@ export const registerRoutes = async (
});
const superAdminService = superAdminServiceFactory({
userDAL,
identityDAL,
userAliasDAL,
authService: loginService,
serverCfgDAL: superAdminDAL,

View File

@@ -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",

View File

@@ -91,7 +91,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await projectRouter.register(registerProjectMembershipRouter);
await projectRouter.register(registerSecretTagRouter);
},
{ prefix: "/workspace" }
);

View File

@@ -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(),

View File

@@ -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;
}
});
};

View File

@@ -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({

View File

@@ -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()

View File

@@ -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) => {

View File

@@ -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 };
};

View 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}`;
};

View File

@@ -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
};
};

View File

@@ -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(

View File

@@ -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">;

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 == "" {

View File

@@ -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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -4,6 +4,8 @@ sidebarTitle: "Overview"
description: "How to access private network resources from Infisical"
---
![Alt text](/documentation/platform/gateways/images/gateway-highlevel-diagram.png)
The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment.
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.

View File

@@ -0,0 +1,68 @@
---
title: 'Secret Scanning'
description: "Scan and prevent secret leaks in your code repositories"
---
The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories.
To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes.
## Code Scanning
![Scanning Overview](/images/platform/secret-scanning/overview.png)
Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets.
If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it:
- **Date**: When the risk was first detected.
- **Secret Type**: Which type of secret was detected.
- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change.
Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret.
<Tip>
Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed.
</Tip>
![Exposed Secret](/images/platform/secret-scanning/exposed-secret.png)
## Responding to Exposed Secrets
After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible.
You can mark the risk as `Resolved` by changing the status to one of the following states:
- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner.
- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed.
- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk.
![Needs Attention](/images/platform/secret-scanning/needs-attention.png)
## Ignoring Known Secrets
If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below.
### infisical-scan:ignore
To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming.
```js example.js
function helloWorld() {
console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore
}
```
### .infisicalignore
An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root.
You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found.
By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans.
```.ignore .infisicalignore
bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147
bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5
1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -220,7 +220,8 @@
"documentation/platform/admin-panel/org-admin-console"
]
},
"documentation/platform/secret-sharing"
"documentation/platform/secret-sharing",
"documentation/platform/secret-scanning"
]
},
{

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1741445498,
"narHash": "sha256-F5Em0iv/CxkN5mZ9hRn3vPknpoWdcdCyR0e4WklHwiE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "52e3095f6d812b91b22fb7ad0bfc1ab416453634",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

34
flake.nix Normal file
View 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"
'';
};
};
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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"

View 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>
);
};

View File

@@ -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;
};

View File

@@ -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>;
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./AccessTreeContext";
export * from "./AccessTreeErrorBoundary";
export * from "./PermissionSimulation";

View File

@@ -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}
/>
);
};

View File

@@ -0,0 +1 @@
export * from "./BasePermissionEdge";

View File

@@ -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
};
};

View File

@@ -0,0 +1 @@
export * from "./AccessTree";

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export * from "./FolderNodeTooltipContent";

View File

@@ -0,0 +1 @@
export * from "./FolderNode";

View File

@@ -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}
/>
</>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./FolderNode/FolderNode";
export * from "./RoleNode";

View File

@@ -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"
}

View File

@@ -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 }
};
};

View File

@@ -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
};
};

View File

@@ -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
});

View File

@@ -0,0 +1,3 @@
import { camelCaseToSpaces } from "@app/lib/fn/string";
export const formatActionName = (action: string) => camelCaseToSpaces(action.replaceAll("-", " "));

View File

@@ -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;
};

View File

@@ -0,0 +1,6 @@
export * from "./createBaseEdge";
export * from "./createFolderNode";
export * from "./createRoleNode";
export * from "./formatActionName";
export * from "./getActionRuleMap";
export * from "./positionElements";

View File

@@ -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
};
};

View File

@@ -1,3 +1,4 @@
export * from "./AccessTree";
export { GlobPermissionInfo } from "./GlobPermissionInfo";
export { OrgPermissionCan } from "./OrgPermissionCan";
export { PermissionDeniedBanner } from "./PermissionDeniedBanner";

View File

@@ -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>
);

View File

@@ -59,7 +59,9 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
>
<div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
{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

View File

@@ -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: {

View File

@@ -0,0 +1,39 @@
import { createMongoAbility, MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { conditionsMatcher } from "@app/hooks/api/roles/queries";
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
export const evaluatePermissionsAbility = (
rule: RawRuleOf<MongoAbility<ProjectPermissionSet, MongoQuery>>[]
) => {
const negatedRules = groupBy(
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
// this allows in frontend to skip some rules using *
conditionsMatcher: (rules) => {
return (entity) => {
// skip validation if its negated rules
const isNegatedRule =
// eslint-disable-next-line no-underscore-dangle
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
if (isNegatedRule) {
const baseMatcher = conditionsMatcher(rules);
return baseMatcher(entity);
}
const rulesStrippedOfWildcard = omit(
rules,
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
);
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
return baseMatcher(entity);
};
}
});
return ability;
};

View File

@@ -1,10 +1,10 @@
export {
useAdminDeleteUser,
useAdminGrantServerAdminAccess,
useCreateAdminUser,
useUpdateAdminSlackConfig,
useUpdateServerConfig,
useUpdateServerEncryptionStrategy,
useAdminGrantServerAdminAccess
useUpdateServerEncryptionStrategy
} from "./mutation";
export {
useAdminGetUsers,

View File

@@ -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(),

View File

@@ -53,6 +53,11 @@ export type AdminGetUsersFilters = {
adminsOnly: boolean;
};
export type AdminGetIdentitiesFilters = {
limit: number;
searchTerm: string;
};
export type AdminSlackConfig = {
clientId: string;
clientSecret: string;

View File

@@ -18,268 +18,44 @@ export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"];
export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"];
export const 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: {

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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]);

View File

@@ -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
)}

View File

@@ -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;
});

View File

@@ -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"
);

View File

@@ -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";

View File

@@ -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>
)}

View File

@@ -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>
);

View File

@@ -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
});

View File

@@ -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 = ({

View File

@@ -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"),

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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>
);
};

View File

@@ -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) => {

View File

@@ -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>
)}
/>

View File

@@ -19,6 +19,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
@@ -49,8 +50,10 @@ import {
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
@@ -71,6 +74,7 @@ import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm";
import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
@@ -131,6 +135,7 @@ export const OverviewPage = () => {
const [searchFilter, setSearchFilter] = useState("");
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(searchFilter);
const secretPath = (routerSearch?.secretPath as string) || "/";
const { subscription } = useSubscription();
const [filter, setFilter] = useState<Filter>(DEFAULT_FILTER_STATE);
const [filterHistory, setFilterHistory] = useState<
@@ -178,6 +183,15 @@ export const OverviewPage = () => {
}, []);
const userAvailableEnvs = currentWorkspace?.environments || [];
const userAvailableDynamicSecretEnvs = userAvailableEnvs.filter((env) =>
permission.can(
ProjectPermissionDynamicSecretActions.CreateRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: env.slug,
secretPath
})
)
);
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
@@ -228,7 +242,8 @@ export const OverviewPage = () => {
setPage
});
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } =
useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
@@ -248,10 +263,12 @@ export const OverviewPage = () => {
"addSecretsInAllEnvs",
"addFolder",
"misc",
"updateFolder"
"updateFolder",
"addDynamicSecret",
"upgradePlan"
] 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({
@@ -850,20 +867,43 @@ export const OverviewPage = () => {
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
leftIcon={<FontAwesomeIcon icon={faFolderPlus} className="pr-2" />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10"
className="h-10 text-left"
isFullWidth
>
Add Folder
</Button>
)}
</ProjectPermissionCan>
<Tooltip
content={
userAvailableDynamicSecretEnvs.length === 0 ? "Access restricted" : ""
}
>
<Button
leftIcon={<FontAwesomeIcon icon={faFingerprint} className="pr-2" />}
onClick={() => {
if (subscription?.dynamicSecret) {
handlePopUpOpen("addDynamicSecret");
handlePopUpClose("misc");
return;
}
handlePopUpOpen("upgradePlan");
}}
isDisabled={userAvailableDynamicSecretEnvs.length === 0}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add Dynamic Secret
</Button>
</Tooltip>
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -1029,7 +1069,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,12 +1201,32 @@ 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
/>
</ModalContent>
</Modal>
<CreateDynamicSecretForm
isOpen={popUp.addDynamicSecret.isOpen}
onToggle={(isOpen) => handlePopUpToggle("addDynamicSecret", isOpen)}
projectSlug={projectSlug}
environments={userAvailableDynamicSecretEnvs}
secretPath={secretPath}
/>
{subscription && (
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={
subscription.slug === null
? "You can perform this action under an Enterprise license"
: "You can perform this action if you switch to Infisical's Team plan"
}
/>
)}
</div>
);
};

View File

@@ -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({

View File

@@ -655,8 +655,9 @@ export const ActionBar = ({
isOpen={popUp.addDynamicSecret.isOpen}
onToggle={(isOpen) => handlePopUpToggle("addDynamicSecret", isOpen)}
projectSlug={projectSlug}
environment={environment}
environments={[{ slug: environment, name: environment, id: "not-used" }]}
secretPath={secretPath}
isSingleEnvironmentMode
/>
<Modal
isOpen={popUp.addFolder.isOpen}

View File

@@ -11,6 +11,7 @@ import {
AccordionItem,
AccordionTrigger,
Button,
FilterableSelect,
FormControl,
Input,
SecretInput,
@@ -18,6 +19,7 @@ import {
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -50,7 +52,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -59,15 +62,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const AwsElastiCacheInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -87,13 +92,20 @@ export const AwsElastiCacheInputForm = ({
revocationStatement: `{
"UserId": "{{username}}"
}`
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -104,7 +116,7 @@ export const AwsElastiCacheInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -299,6 +311,28 @@ export const AwsElastiCacheInputForm = ({
</AccordionContent>
</AccordionItem>
</Accordion>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -5,9 +5,10 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { Button, FilterableSelect, FormControl, Input, TextArea } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -40,7 +41,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -49,29 +51,41 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const AwsIamInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
resolver: zodResolver(formSchema),
defaultValues: {
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AwsIam, inputs: provider },
@@ -80,7 +94,7 @@ export const AwsIamInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -286,6 +300,29 @@ export const AwsIamInputForm = ({
</FormControl>
)}
/>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -12,7 +12,7 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,6 +23,7 @@ import { Tooltip } from "@app/components/v2/Tooltip";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
selectedUsers: z.array(
@@ -60,7 +61,8 @@ const formSchema = z.object({
name: z
.string()
.min(1)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -69,15 +71,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const AzureEntraIdInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -85,7 +89,10 @@ export const AzureEntraIdInputForm = ({
watch,
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
resolver: zodResolver(formSchema),
defaultValues: {
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const tenantId = watch("provider.tenantId");
const applicationId = watch("provider.applicationId");
@@ -107,7 +114,8 @@ export const AzureEntraIdInputForm = ({
selectedUsers,
provider,
maxTTL,
defaultTTL
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
@@ -129,7 +137,7 @@ export const AzureEntraIdInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
});
onCompleted();
@@ -373,6 +381,29 @@ export const AzureEntraIdInputForm = ({
</div>
</div>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isLoading || isError}>

View File

@@ -11,6 +11,7 @@ import {
AccordionItem,
AccordionTrigger,
Button,
FilterableSelect,
FormControl,
Input,
SecretInput,
@@ -18,6 +19,7 @@ import {
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -52,7 +54,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -61,7 +64,8 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
const getSqlStatements = () => {
@@ -76,9 +80,10 @@ const getSqlStatements = () => {
export const CassandraInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -87,15 +92,23 @@ export const CassandraInputForm = ({
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: getSqlStatements()
provider: getSqlStatements(),
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Cassandra, inputs: provider },
@@ -104,7 +117,7 @@ export const CassandraInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -345,6 +358,29 @@ export const CassandraInputForm = ({
</AccordionContent>
</AccordionItem>
</Accordion>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
@@ -38,8 +39,9 @@ type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
secretPath: string;
isSingleEnvironmentMode?: boolean;
};
enum WizardSteps {
@@ -129,8 +131,9 @@ export const CreateDynamicSecretForm = ({
isOpen,
onToggle,
projectSlug,
environment,
secretPath
environments,
secretPath,
isSingleEnvironmentMode
}: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectProvider);
const [selectedProvider, setSelectedProvider] = useState<DynamicSecretProviders | null>(null);
@@ -197,7 +200,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -215,7 +219,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -233,7 +238,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -251,7 +257,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -269,7 +276,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -287,7 +295,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -305,7 +314,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -323,7 +333,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -341,7 +352,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -359,7 +371,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -377,7 +390,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -395,7 +409,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -413,7 +428,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -432,7 +448,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
@@ -450,7 +467,8 @@ export const CreateDynamicSecretForm = ({
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}

View File

@@ -9,6 +9,7 @@ import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
@@ -19,6 +20,7 @@ import {
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const authMethods = [
{
@@ -73,7 +75,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -82,15 +85,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const ElasticSearchInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -107,13 +112,20 @@ export const ElasticSearchInputForm = ({
},
roles: ["superuser"],
port: 443
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -124,7 +136,7 @@ export const ElasticSearchInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -406,6 +418,29 @@ export const ElasticSearchInputForm = ({
)}
/>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -7,9 +7,18 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Select, SelectItem, TextArea } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
Input,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
enum CredentialType {
Dynamic = "dynamic",
@@ -69,7 +78,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -79,7 +89,8 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const LdapInputForm = ({
@@ -87,7 +98,8 @@ export const LdapInputForm = ({
onCancel,
secretPath,
projectSlug,
environment
environments,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -107,7 +119,8 @@ export const LdapInputForm = ({
revocationLdif: "",
rollbackLdif: "",
credentialType: CredentialType.Dynamic
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
@@ -115,7 +128,13 @@ export const LdapInputForm = ({
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -126,7 +145,7 @@ export const LdapInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -369,6 +388,29 @@ export const LdapInputForm = ({
)}
/>
)}
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -13,6 +13,7 @@ import {
AccordionItem,
AccordionTrigger,
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
@@ -22,6 +23,7 @@ import {
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -63,7 +65,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -72,7 +75,8 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
const ATLAS_SCOPE_TYPES = [
@@ -93,9 +97,10 @@ const ATLAS_SCOPE_TYPES = [
export const MongoAtlasInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -108,7 +113,8 @@ export const MongoAtlasInputForm = ({
defaultValues: {
provider: {
roles: [{ databaseName: "", roleName: "" }]
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
@@ -124,7 +130,13 @@ export const MongoAtlasInputForm = ({
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -135,7 +147,7 @@ export const MongoAtlasInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -438,6 +450,29 @@ export const MongoAtlasInputForm = ({
</AccordionContent>
</AccordionItem>
</Accordion>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -7,9 +7,18 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, IconButton, Input, SecretInput } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
SecretInput
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -46,7 +55,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -55,15 +65,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const MongoDBDatabaseInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -76,7 +88,8 @@ export const MongoDBDatabaseInputForm = ({
defaultValues: {
provider: {
roles: [{ roleName: "readWrite" }]
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
@@ -87,7 +100,13 @@ export const MongoDBDatabaseInputForm = ({
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -105,7 +124,7 @@ export const MongoDBDatabaseInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -321,6 +340,29 @@ export const MongoDBDatabaseInputForm = ({
)}
/>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -7,9 +7,18 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, IconButton, Input, SecretInput } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
SecretInput
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -50,7 +59,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -59,15 +69,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const RabbitMqInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -89,13 +101,20 @@ export const RabbitMqInputForm = ({
}
},
tags: []
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -106,7 +125,7 @@ export const RabbitMqInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -405,6 +424,29 @@ export const RabbitMqInputForm = ({
)}
/>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import {
AccordionItem,
AccordionTrigger,
Button,
FilterableSelect,
FormControl,
Input,
SecretInput,
@@ -18,6 +19,7 @@ import {
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -50,7 +52,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -59,15 +62,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const RedisInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -81,13 +86,20 @@ export const RedisInputForm = ({
port: 6379,
creationStatement: "ACL SETUSER {{username}} on >{{password}} ~* &* +@all",
revocationStatement: "ACL DELUSER {{username}}"
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -98,7 +110,7 @@ export const RedisInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -313,6 +325,29 @@ export const RedisInputForm = ({
</AccordionContent>
</AccordionItem>
</Accordion>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

View File

@@ -11,12 +11,14 @@ import {
AccordionItem,
AccordionTrigger,
Button,
FilterableSelect,
FormControl,
Input,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
@@ -48,7 +50,8 @@ const formSchema = z.object({
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
@@ -57,15 +60,17 @@ type Props = {
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const SapAseInputForm = ({
onCompleted,
onCancel,
environment,
environments,
secretPath,
projectSlug
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
@@ -82,13 +87,20 @@ sp_adduser '{{username}}', '{{username}}', null;
sp_role 'grant', 'mon_role', '{{username}}';`,
revocationStatement: `sp_dropuser '{{username}}';
sp_droplogin '{{username}}';`
}
},
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
@@ -99,7 +111,7 @@ sp_droplogin '{{username}}';`
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
environmentSlug: environment.slug
});
onCompleted();
} catch {
@@ -291,6 +303,29 @@ sp_droplogin '{{username}}';`
</AccordionContent>
</AccordionItem>
</Accordion>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>

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