Compare commits

...

45 Commits

Author SHA1 Message Date
Scott Wilson
8bab6d87bb Merge pull request #2424 from scott-ray-wilson/secrets-pagination-fix
Fix: Account for secret import count in secrets offset
2024-09-13 07:37:42 -07:00
Scott Wilson
39a49f12f5 fix: account for secret import count in secrets offset 2024-09-13 07:27:52 -07:00
Meet Shah
cfd841ea08 Merge pull request #2419 from meetcshah19/meet/add-empty-value-log-gcp
chore: add log on empty value being pushed to gcp
2024-09-13 19:53:38 +05:30
Maidul Islam
4d67c03e3e Merge pull request #2423 from scott-ray-wilson/secrets-pagination
Feature: Secrets Overview Page Pagination/Optimizations
2024-09-13 09:56:48 -04:00
Scott Wilson
8826bc5d60 fix: include imports in secret pagination, and rectify tag/value search not working for secrets 2024-09-13 06:25:13 -07:00
Maidul Islam
03fdce67f1 Merge pull request #2417 from akhilmhdh/fix/saml-entra
fix: resolved entra failing
2024-09-13 09:08:07 -04:00
Sheen
72f3f7980e Merge pull request #2414 from Infisical/misc/address-minor-cert-lint-issues
misc: addressed minor cert lint issues
2024-09-13 20:57:40 +08:00
Meet
f1aa2fbd84 chore: better log string 2024-09-13 15:34:12 +05:30
=
217de6250f feat: pagination for main secret page 2024-09-13 14:12:53 +05:30
Scott Wilson
f742bd01d9 refactor to useCallback select instead of queryFn 2024-09-12 22:47:23 -07:00
Scott Wilson
3fe53d5183 remove unused import 2024-09-12 22:08:16 -07:00
Scott Wilson
a5f5f803df feature: secret overview page pagination/optimizations 2024-09-12 21:44:38 -07:00
Sheen Capadngan
c37e3ba635 misc: addressed comments 2024-09-13 12:44:12 +08:00
BlackMagiq
55279e5e41 Merge pull request #2422 from Infisical/pki-docs-improvement
Update README (Expand on PKI / New Features)
2024-09-12 20:16:41 -07:00
Tuan Dang
88fb37e8c6 Made changes as per review 2024-09-12 20:14:25 -07:00
Tuan Dang
6271dcc25d Fix mint.json openapi link back 2024-09-12 20:02:40 -07:00
Tuan Dang
0f7faa6bfe Update README to include newer features, expand on PKI, separate PKI endpoints into separate section in API reference 2024-09-12 19:58:55 -07:00
Tuan Dang
4ace339d5b Update README to include newer features, expand on PKI, separate PKI endpoints into separate section in API reference 2024-09-12 19:57:37 -07:00
=
e8c0d1ece9 fix: resolved entra failing 2024-09-13 07:18:49 +05:30
Maidul Islam
bb1977976c Merge pull request #2421 from Infisical/maidful-edwdwqdhwjq
revert PR #2412
2024-09-12 20:43:38 -04:00
Tuan Dang
bb3da75870 Minor text updates 2024-09-12 17:26:56 -07:00
Maidul Islam
088e888560 Merge pull request #2420 from scott-ray-wilson/identity-pagination-fix
Fix: Apply Project Identity Pagination Prior to Left Join of Roles
2024-09-12 20:23:03 -04:00
Maidul Islam
180241fdf0 revert PR #2412 2024-09-13 00:15:26 +00:00
Scott Wilson
93f27a7ee8 improvement: make limit conditional 2024-09-12 16:19:22 -07:00
Scott Wilson
ed3bc8dd27 fix: apply project identity offset/limit separate from left joins 2024-09-12 16:11:58 -07:00
Maidul Islam
8dc4809ec8 Merge pull request #2416 from akhilmhdh/ui/combobox
UI/combobox
2024-09-12 18:50:43 -04:00
Meet
a55d64e430 chore: add log on empty value being pushed to gcp 2024-09-13 03:52:09 +05:30
Scott Wilson
02d54da74a resolve change requests 2024-09-12 15:22:05 -07:00
=
d660168700 fix: org invite check only when needed 2024-09-13 00:35:48 +05:30
=
1c75fc84f0 feat: added a temporary combobox for identity addition to project 2024-09-13 00:35:48 +05:30
Sheen Capadngan
f63da87c7f Merge remote-tracking branch 'origin/main' into misc/address-minor-cert-lint-issues 2024-09-13 01:46:00 +08:00
Sheen
53b9fe2dec Merge pull request #2401 from Infisical/feat/add-key-usages-for-template-and-cert
feat: add support for configuring certificate key usage and extended key usage
2024-09-13 00:55:19 +08:00
Sheen Capadngan
87dc0eed7e fix: addressed tslint errors 2024-09-12 23:25:26 +08:00
Maidul Islam
f2dd6f94a4 Merge pull request #2409 from scott-ray-wilson/identity-pagination
Feature: Project and Org Identities Table Additions: Pagination, Search and Sort
2024-09-12 11:22:45 -04:00
Sheen Capadngan
ac26ae3893 misc: addressed minor cert lint issues 2024-09-12 23:16:49 +08:00
Scott Wilson
4c65e9910a resolve merge conflict 2024-09-12 08:03:10 -07:00
Sheen Capadngan
a79087670e misc: addressed comments and doc changes 2024-09-12 13:27:39 +08:00
Scott Wilson
ce9b66ef14 address feedback suggestions 2024-09-11 12:40:27 -07:00
Sheen Capadngan
bfa533e9d2 misc: api property description 2024-09-11 22:59:19 +08:00
Sheen Capadngan
a8759e7410 feat: added support for custom extended key usages 2024-09-11 22:38:36 +08:00
Scott Wilson
16182a9d1d feature: project and org identity pagination, search and sort 2024-09-11 07:22:08 -07:00
Sheen Capadngan
c1f61f2db4 feat: added custom key usages support for sign endpoint 2024-09-11 20:26:33 +08:00
Sheen Capadngan
4e6b289e1b misc: integrated custom key usages for issue-cert endpoint 2024-09-11 01:57:16 +08:00
Sheen Capadngan
6fab7d9507 Merge remote-tracking branch 'origin/main' into feat/add-key-usages-for-template-and-cert 2024-09-11 00:22:04 +08:00
Sheen Capadngan
1c749c84f2 misc: key usages setup 2024-09-10 21:42:41 +08:00
65 changed files with 2561 additions and 871 deletions

1
.gitignore vendored
View File

@@ -63,6 +63,7 @@ yarn-error.log*
# Editor specific # Editor specific
.vscode/* .vscode/*
.idea/*
frontend-build frontend-build

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,85 @@
import { Knex } from "knex";
import { CertKeyUsage } from "@app/services/certificate/certificate-types";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
// Certificate template
const hasKeyUsagesCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "keyUsages");
const hasExtendedKeyUsagesCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "extendedKeyUsages");
await knex.schema.alterTable(TableName.CertificateTemplate, (tb) => {
if (!hasKeyUsagesCol) {
tb.specificType("keyUsages", "text[]");
}
if (!hasExtendedKeyUsagesCol) {
tb.specificType("extendedKeyUsages", "text[]");
}
});
if (!hasKeyUsagesCol) {
await knex(TableName.CertificateTemplate).update({
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]
});
}
if (!hasExtendedKeyUsagesCol) {
await knex(TableName.CertificateTemplate).update({
extendedKeyUsages: []
});
}
// Certificate
const doesCertTableHaveKeyUsages = await knex.schema.hasColumn(TableName.Certificate, "keyUsages");
const doesCertTableHaveExtendedKeyUsages = await knex.schema.hasColumn(TableName.Certificate, "extendedKeyUsages");
await knex.schema.alterTable(TableName.Certificate, (tb) => {
if (!doesCertTableHaveKeyUsages) {
tb.specificType("keyUsages", "text[]");
}
if (!doesCertTableHaveExtendedKeyUsages) {
tb.specificType("extendedKeyUsages", "text[]");
}
});
if (!doesCertTableHaveKeyUsages) {
await knex(TableName.Certificate).update({
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]
});
}
if (!doesCertTableHaveExtendedKeyUsages) {
await knex(TableName.Certificate).update({
extendedKeyUsages: []
});
}
}
export async function down(knex: Knex): Promise<void> {
// Certificate Template
const hasKeyUsagesCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "keyUsages");
const hasExtendedKeyUsagesCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "extendedKeyUsages");
await knex.schema.alterTable(TableName.CertificateTemplate, (t) => {
if (hasKeyUsagesCol) {
t.dropColumn("keyUsages");
}
if (hasExtendedKeyUsagesCol) {
t.dropColumn("extendedKeyUsages");
}
});
// Certificate
const doesCertTableHaveKeyUsages = await knex.schema.hasColumn(TableName.Certificate, "keyUsages");
const doesCertTableHaveExtendedKeyUsages = await knex.schema.hasColumn(TableName.Certificate, "extendedKeyUsages");
await knex.schema.alterTable(TableName.Certificate, (t) => {
if (doesCertTableHaveKeyUsages) {
t.dropColumn("keyUsages");
}
if (doesCertTableHaveExtendedKeyUsages) {
t.dropColumn("extendedKeyUsages");
}
});
}

View File

@@ -16,7 +16,9 @@ export const CertificateTemplatesSchema = z.object({
subjectAlternativeName: z.string(), subjectAlternativeName: z.string(),
ttl: z.string(), ttl: z.string(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
keyUsages: z.string().array().nullable().optional(),
extendedKeyUsages: z.string().array().nullable().optional()
}); });
export type TCertificateTemplates = z.infer<typeof CertificateTemplatesSchema>; export type TCertificateTemplates = z.infer<typeof CertificateTemplatesSchema>;

View File

@@ -22,7 +22,9 @@ export const CertificatesSchema = z.object({
revocationReason: z.number().nullable().optional(), revocationReason: z.number().nullable().optional(),
altNames: z.string().default("").nullable().optional(), altNames: z.string().default("").nullable().optional(),
caCertId: z.string().uuid(), caCertId: z.string().uuid(),
certificateTemplateId: z.string().uuid().nullable().optional() certificateTemplateId: z.string().uuid().nullable().optional(),
keyUsages: z.string().array().nullable().optional(),
extendedKeyUsages: z.string().array().nullable().optional()
}); });
export type TCertificates = z.infer<typeof CertificatesSchema>; export type TCertificates = z.infer<typeof CertificatesSchema>;

View File

@@ -11,6 +11,30 @@ export const registerCaCrlRouter = async (server: FastifyZodProvider) => {
config: { config: {
rateLimit: readLimit rateLimit: readLimit
}, },
schema: {
description: "Get CRL in DER format (deprecated)",
params: z.object({
crlId: z.string().trim().describe(CA_CRLS.GET.crlId)
}),
response: {
200: z.instanceof(Buffer)
}
},
handler: async (req, res) => {
const { crl } = await server.services.certificateAuthorityCrl.getCrlById(req.params.crlId);
res.header("Content-Type", "application/pkix-crl");
return Buffer.from(crl);
}
});
server.route({
method: "GET",
url: "/:crlId/der",
config: {
rateLimit: readLimit
},
schema: { schema: {
description: "Get CRL in DER format", description: "Get CRL in DER format",
params: z.object({ params: z.object({

View File

@@ -100,9 +100,20 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
async (req, profile, cb) => { async (req, profile, cb) => {
try { try {
if (!profile) throw new BadRequestError({ message: "Missing profile" }); if (!profile) throw new BadRequestError({ message: "Missing profile" });
const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved const email =
profile?.email ??
// entra sends data in this format
(profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ??
(profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\
if (!email || !profile.firstName) { const firstName = (profile.firstName ??
// entra sends data in this format
profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string;
const lastName =
profile.lastName ?? profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastName"];
if (!email || !firstName) {
logger.info( logger.info(
{ {
err: new Error("Invalid saml request. Missing email or first name"), err: new Error("Invalid saml request. Missing email or first name"),
@@ -110,14 +121,13 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}, },
`email: ${email} firstName: ${profile.firstName as string}` `email: ${email} firstName: ${profile.firstName as string}`
); );
throw new BadRequestError({ message: "Invalid request. Missing email or first name" });
} }
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
externalId: profile.nameID, externalId: profile.nameID,
email, email,
firstName: profile.firstName as string, firstName,
lastName: profile.lastName as string, lastName: lastName as string,
relayState: (req.body as { RelayState?: string }).RelayState, relayState: (req.body as { RelayState?: string }).RelayState,
authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider as string, authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider as string,
orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string

View File

@@ -363,7 +363,12 @@ export const ORGANIZATIONS = {
membershipId: "The ID of the membership to delete." membershipId: "The ID of the membership to delete."
}, },
LIST_IDENTITY_MEMBERSHIPS: { LIST_IDENTITY_MEMBERSHIPS: {
orgId: "The ID of the organization to get identity memberships from." orgId: "The ID of the organization to get identity memberships from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.",
limit: "The number of identity memberships to return.",
orderBy: "The column to order identity memberships by.",
orderDirection: "The direction identity memberships will be sorted in.",
search: "The text string that identity membership names will be filtered by."
}, },
GET_PROJECTS: { GET_PROJECTS: {
organizationId: "The ID of the organization to get projects from." organizationId: "The ID of the organization to get projects from."
@@ -472,7 +477,12 @@ export const PROJECT_USERS = {
export const PROJECT_IDENTITIES = { export const PROJECT_IDENTITIES = {
LIST_IDENTITY_MEMBERSHIPS: { LIST_IDENTITY_MEMBERSHIPS: {
projectId: "The ID of the project to get identity memberships from." projectId: "The ID of the project to get identity memberships from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.",
limit: "The number of identity memberships to return.",
orderBy: "The column to order identity memberships by.",
orderDirection: "The direction identity memberships will be sorted in.",
search: "The text string that identity membership names will be filtered by."
}, },
GET_IDENTITY_MEMBERSHIP_BY_ID: { GET_IDENTITY_MEMBERSHIP_BY_ID: {
identityId: "The ID of the identity to get the membership for.", identityId: "The ID of the identity to get the membership for.",
@@ -1073,6 +1083,10 @@ export const CERTIFICATE_AUTHORITIES = {
certificateChain: "The certificate chain of the CA", certificateChain: "The certificate chain of the CA",
serialNumber: "The serial number of the CA certificate" serialNumber: "The serial number of the CA certificate"
}, },
GET_CERT_BY_ID: {
caId: "The ID of the CA to get the CA certificate from",
caCertId: "The ID of the CA certificate to get"
},
GET_CA_CERTS: { GET_CA_CERTS: {
caId: "The ID of the CA to get the CA certificates for", caId: "The ID of the CA to get the CA certificates for",
certificate: "The certificate body of the CA certificate", certificate: "The certificate body of the CA certificate",
@@ -1112,11 +1126,15 @@ export const CERTIFICATE_AUTHORITIES = {
issuingCaCertificate: "The certificate of the issuing CA", issuingCaCertificate: "The certificate of the issuing CA",
certificateChain: "The certificate chain of the issued certificate", certificateChain: "The certificate chain of the issued certificate",
privateKey: "The private key of the issued certificate", privateKey: "The private key of the issued certificate",
serialNumber: "The serial number of the issued certificate" serialNumber: "The serial number of the issued certificate",
keyUsages: "The key usage extension of the certificate",
extendedKeyUsages: "The extended key usage extension of the certificate"
}, },
SIGN_CERT: { SIGN_CERT: {
caId: "The ID of the CA to issue the certificate from", caId: "The ID of the CA to issue the certificate from",
pkiCollectionId: "The ID of the PKI collection to add the certificate to", pkiCollectionId: "The ID of the PKI collection to add the certificate to",
keyUsages: "The key usage extension of the certificate",
extendedKeyUsages: "The extended key usage extension of the certificate",
csr: "The pem-encoded CSR to sign with the CA to be used for certificate issuance", csr: "The pem-encoded CSR to sign with the CA to be used for certificate issuance",
friendlyName: "A friendly name for the certificate", friendlyName: "A friendly name for the certificate",
commonName: "The common name (CN) for the certificate", commonName: "The common name (CN) for the certificate",
@@ -1166,7 +1184,10 @@ export const CERTIFICATE_TEMPLATES = {
name: "The name of the template", name: "The name of the template",
commonName: "The regular expression string to use for validating common names", commonName: "The regular expression string to use for validating common names",
subjectAlternativeName: "The regular expression string to use for validating subject alternative names", subjectAlternativeName: "The regular expression string to use for validating subject alternative names",
ttl: "The max TTL for the template" ttl: "The max TTL for the template",
keyUsages: "The key usage constraint or default value for when template is used during certificate issuance",
extendedKeyUsages:
"The extended key usage constraint or default value for when template is used during certificate issuance"
}, },
GET: { GET: {
certificateTemplateId: "The ID of the certificate template to get" certificateTemplateId: "The ID of the certificate template to get"
@@ -1178,7 +1199,11 @@ export const CERTIFICATE_TEMPLATES = {
name: "The updated name of the template", name: "The updated name of the template",
commonName: "The updated regular expression string for validating common names", commonName: "The updated regular expression string for validating common names",
subjectAlternativeName: "The updated regular expression string for validating subject alternative names", subjectAlternativeName: "The updated regular expression string for validating subject alternative names",
ttl: "The updated max TTL for the template" ttl: "The updated max TTL for the template",
keyUsages:
"The updated key usage constraint or default value for when template is used during certificate issuance",
extendedKeyUsages:
"The updated extended key usage constraint or default value for when template is used during certificate issuance"
}, },
DELETE: { DELETE: {
certificateTemplateId: "The ID of the certificate template to delete" certificateTemplateId: "The ID of the certificate template to delete"

View File

@@ -52,3 +52,8 @@ export enum SecretSharingAccessType {
Anyone = "anyone", Anyone = "anyone",
Organization = "organization" Organization = "organization"
} }
export enum OrderByDirection {
ASC = "asc",
DESC = "desc"
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
@@ -7,7 +8,7 @@ import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import { CaRenewalType, CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; import { CaRenewalType, CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types";
import { import {
validateAltNamesField, validateAltNamesField,
@@ -139,6 +140,33 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
} }
}); });
// this endpoint will be used to serve the CA certificate when a client makes a request
// against the Authority Information Access CA Issuer URL
server.route({
method: "GET",
url: "/:caId/certificates/:caCertId/der",
config: {
rateLimit: readLimit
},
schema: {
description: "Get DER-encoded certificate of CA",
params: z.object({
caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT_BY_ID.caId),
caCertId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT_BY_ID.caCertId)
}),
response: {
200: z.instanceof(Buffer)
}
},
handler: async (req, res) => {
const caCert = await server.services.certificateAuthority.getCaCertById(req.params);
res.header("Content-Type", "application/pkix-cert");
return Buffer.from(caCert.rawData);
}
});
server.route({ server.route({
method: "PATCH", method: "PATCH",
url: "/:caId", url: "/:caId",
@@ -573,7 +601,9 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl),
notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore),
notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter) notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter),
keyUsages: z.nativeEnum(CertKeyUsage).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -653,7 +683,9 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl),
notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore),
notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter) notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter),
keyUsages: z.nativeEnum(CertKeyUsage).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional()
}) })
.refine( .refine(
(data) => { (data) => {

View File

@@ -7,7 +7,7 @@ import { CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { CrlReason } from "@app/services/certificate/certificate-types"; import { CertExtendedKeyUsage, CertKeyUsage, CrlReason } from "@app/services/certificate/certificate-types";
import { import {
validateAltNamesField, validateAltNamesField,
validateCaDateField validateCaDateField
@@ -86,7 +86,17 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl),
notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore),
notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter) notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.optional()
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.keyUsages),
extendedKeyUsages: z
.nativeEnum(CertExtendedKeyUsage)
.array()
.optional()
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.extendedKeyUsages)
}) })
.refine( .refine(
(data) => { (data) => {
@@ -177,7 +187,17 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl),
notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore),
notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter) notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.optional()
.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.keyUsages),
extendedKeyUsages: z
.nativeEnum(CertExtendedKeyUsage)
.array()
.optional()
.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.extendedKeyUsages)
}) })
.refine( .refine(
(data) => { (data) => {

View File

@@ -7,6 +7,7 @@ import { CERTIFICATE_TEMPLATES } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
@@ -74,7 +75,19 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
ttl: z ttl: z
.string() .string()
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_TEMPLATES.CREATE.ttl) .describe(CERTIFICATE_TEMPLATES.CREATE.ttl),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.optional()
.default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT])
.describe(CERTIFICATE_TEMPLATES.CREATE.keyUsages),
extendedKeyUsages: z
.nativeEnum(CertExtendedKeyUsage)
.array()
.optional()
.default([])
.describe(CERTIFICATE_TEMPLATES.CREATE.extendedKeyUsages)
}), }),
response: { response: {
200: sanitizedCertificateTemplate 200: sanitizedCertificateTemplate
@@ -130,7 +143,13 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
.string() .string()
.refine((val) => ms(val) > 0, "TTL must be a positive number") .refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional() .optional()
.describe(CERTIFICATE_TEMPLATES.UPDATE.ttl) .describe(CERTIFICATE_TEMPLATES.UPDATE.ttl),
keyUsages: z.nativeEnum(CertKeyUsage).array().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.keyUsages),
extendedKeyUsages: z
.nativeEnum(CertExtendedKeyUsage)
.array()
.optional()
.describe(CERTIFICATE_TEMPLATES.UPDATE.extendedKeyUsages)
}), }),
params: z.object({ params: z.object({
certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId)

View File

@@ -246,12 +246,13 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
description: true description: true
}).optional(), }).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}).array() }).array(),
totalCount: z.number()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identities = await server.services.identity.listOrgIdentities({ const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
@@ -259,7 +260,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
orgId: req.query.orgId orgId: req.query.orgId
}); });
return { identities }; return { identities: identityMemberships, totalCount };
} }
}); });

View File

@@ -2,9 +2,11 @@ import { z } from "zod";
import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas";
import { ORGANIZATIONS } from "@app/lib/api-docs"; import { ORGANIZATIONS } from "@app/lib/api-docs";
import { OrderByDirection } from "@app/lib/types";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { OrgIdentityOrderBy } from "@app/services/identity/identity-types";
export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -24,6 +26,27 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
params: z.object({ params: z.object({
orgId: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orgId) orgId: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orgId)
}), }),
querystring: z.object({
offset: z.coerce.number().min(0).default(0).describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.offset).optional(),
limit: z.coerce
.number()
.min(1)
.max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added
.default(100)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.limit)
.optional(),
orderBy: z
.nativeEnum(OrgIdentityOrderBy)
.default(OrgIdentityOrderBy.Name)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderDirection)
.optional(),
search: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.search).optional()
}),
response: { response: {
200: z.object({ 200: z.object({
identityMemberships: IdentityOrgMembershipsSchema.merge( identityMemberships: IdentityOrgMembershipsSchema.merge(
@@ -37,20 +60,26 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
}).optional(), }).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}) })
).array() ).array(),
totalCount: z.number()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identityMemberships = await server.services.identity.listOrgIdentities({ const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
orgId: req.params.orgId orgId: req.params.orgId,
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
orderDirection: req.query.orderDirection,
search: req.query.search
}); });
return { identityMemberships }; return { identityMemberships, totalCount };
} }
}); });
}; };

View File

@@ -7,11 +7,13 @@ import {
ProjectMembershipRole, ProjectMembershipRole,
ProjectUserMembershipRolesSchema ProjectUserMembershipRolesSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { PROJECT_IDENTITIES } from "@app/lib/api-docs"; import { ORGANIZATIONS, PROJECT_IDENTITIES } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-project-types";
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
import { SanitizedProjectSchema } from "../sanitizedSchemas"; import { SanitizedProjectSchema } from "../sanitizedSchemas";
@@ -214,6 +216,32 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
params: z.object({ params: z.object({
projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId) projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId)
}), }),
querystring: z.object({
offset: z.coerce
.number()
.min(0)
.default(0)
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.offset)
.optional(),
limit: z.coerce
.number()
.min(1)
.max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added
.default(100)
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.limit)
.optional(),
orderBy: z
.nativeEnum(ProjectIdentityOrderBy)
.default(ProjectIdentityOrderBy.Name)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderDirection)
.optional(),
search: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.search).optional()
}),
response: { response: {
200: z.object({ 200: z.object({
identityMemberships: z identityMemberships: z
@@ -239,19 +267,25 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }), identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }),
project: SanitizedProjectSchema.pick({ name: true, id: true }) project: SanitizedProjectSchema.pick({ name: true, id: true })
}) })
.array() .array(),
totalCount: z.number()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identityMemberships = await server.services.identityProject.listProjectIdentities({ const { identityMemberships, totalCount } = await server.services.identityProject.listProjectIdentities({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId: req.params.projectId projectId: req.params.projectId,
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
orderDirection: req.query.orderDirection,
search: req.query.search
}); });
return { identityMemberships }; return { identityMemberships, totalCount };
} }
}); });

View File

@@ -15,7 +15,7 @@ import {
/* eslint-disable no-bitwise */ /* eslint-disable no-bitwise */
export const createSerialNumber = () => { export const createSerialNumber = () => {
const randomBytes = crypto.randomBytes(32); const randomBytes = crypto.randomBytes(20);
randomBytes[0] &= 0x7f; // ensure the first bit is 0 randomBytes[0] &= 0x7f; // ensure the first bit is 0
return randomBytes.toString("hex"); return randomBytes.toString("hex");
}; };

View File

@@ -19,7 +19,13 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; import {
CertExtendedKeyUsage,
CertExtendedKeyUsageOIDToName,
CertKeyAlgorithm,
CertKeyUsage,
CertStatus
} from "../certificate/certificate-types";
import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal";
import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns"; import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns";
import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal";
@@ -762,6 +768,39 @@ export const certificateAuthorityServiceFactory = ({
}; };
}; };
/**
* Return CA certificate object by ID
*/
const getCaCertById = async ({ caId, caCertId }: { caId: string; caCertId: string }) => {
const caCert = await certificateAuthorityCertDAL.findOne({
caId,
id: caCertId
});
if (!caCert) {
throw new NotFoundError({ message: "CA certificate not found" });
}
const ca = await certificateAuthorityDAL.findById(caId);
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: keyId
});
const decryptedCaCert = await kmsDecryptor({
cipherTextBlob: caCert.encryptedCertificate
});
const caCertObj = new x509.X509Certificate(decryptedCaCert);
return caCertObj;
};
/** /**
* Issue certificate to be imported back in for intermediate CA * Issue certificate to be imported back in for intermediate CA
*/ */
@@ -776,6 +815,7 @@ export const certificateAuthorityServiceFactory = ({
notAfter, notAfter,
maxPathLength maxPathLength
}: TSignIntermediateDTO) => { }: TSignIntermediateDTO) => {
const appCfg = getConfig();
const ca = await certificateAuthorityDAL.findById(caId); const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" }); if (!ca) throw new BadRequestError({ message: "CA not found" });
@@ -850,7 +890,7 @@ export const certificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
} }
const { caPrivateKey } = await getCaCredentials({ const { caPrivateKey, caSecret } = await getCaCredentials({
caId: ca.id, caId: ca.id,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthoritySecretDAL, certificateAuthoritySecretDAL,
@@ -859,6 +899,11 @@ export const certificateAuthorityServiceFactory = ({
}); });
const serialNumber = createSerialNumber(); const serialNumber = createSerialNumber();
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
const intermediateCert = await x509.X509CertificateGenerator.create({ const intermediateCert = await x509.X509CertificateGenerator.create({
serialNumber, serialNumber,
subject: csrObj.subject, subject: csrObj.subject,
@@ -878,7 +923,11 @@ export const certificateAuthorityServiceFactory = ({
), ),
new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
})
] ]
}); });
@@ -1052,7 +1101,9 @@ export const certificateAuthorityServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
keyUsages,
extendedKeyUsages
}: TIssueCertFromCaDTO) => { }: TIssueCertFromCaDTO) => {
let ca: TCertificateAuthorities | undefined; let ca: TCertificateAuthorities | undefined;
let certificateTemplate: TCertificateTemplates | undefined; let certificateTemplate: TCertificateTemplates | undefined;
@@ -1168,16 +1219,70 @@ export const certificateAuthorityServiceFactory = ({
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
const appCfg = getConfig(); const appCfg = getConfig();
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}`; const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
const extensions: x509.Extension[] = [ const extensions: x509.Extension[] = [
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true),
new x509.BasicConstraintsExtension(false), new x509.BasicConstraintsExtension(false),
new x509.CRLDistributionPointsExtension([distributionPointUrl]), new x509.CRLDistributionPointsExtension([distributionPointUrl]),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
}),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
]; ];
// handle key usages
let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? [];
if (keyUsages === undefined && !certificateTemplate) {
selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
}
if (keyUsages === undefined && certificateTemplate) {
selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[];
}
if (keyUsages?.length && certificateTemplate) {
const validKeyUsages = certificateTemplate.keyUsages || [];
if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) {
throw new BadRequestError({
message: "Invalid key usage value based on template policy"
});
}
selectedKeyUsages = keyUsages;
}
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
if (keyUsagesBitValue) {
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
}
// handle extended key usages
let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? [];
if (extendedKeyUsages === undefined && certificateTemplate) {
selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[];
}
if (extendedKeyUsages?.length && certificateTemplate) {
const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || [];
if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) {
throw new BadRequestError({
message: "Invalid extended key usage value based on template policy"
});
}
selectedExtendedKeyUsages = extendedKeyUsages;
}
if (selectedExtendedKeyUsages.length) {
extensions.push(
new x509.ExtendedKeyUsageExtension(
selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]),
true
)
);
}
let altNamesArray: { let altNamesArray: {
type: "email" | "dns"; type: "email" | "dns";
value: string; value: string;
@@ -1259,7 +1364,9 @@ export const certificateAuthorityServiceFactory = ({
altNames, altNames,
serialNumber, serialNumber,
notBefore: notBeforeDate, notBefore: notBeforeDate,
notAfter: notAfterDate notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages
}, },
tx tx
); );
@@ -1308,6 +1415,7 @@ export const certificateAuthorityServiceFactory = ({
* Note: CSR is generated externally and submitted to Infisical. * Note: CSR is generated externally and submitted to Infisical.
*/ */
const signCertFromCa = async (dto: TSignCertFromCaDTO) => { const signCertFromCa = async (dto: TSignCertFromCaDTO) => {
const appCfg = getConfig();
let ca: TCertificateAuthorities | undefined; let ca: TCertificateAuthorities | undefined;
let certificateTemplate: TCertificateTemplates | undefined; let certificateTemplate: TCertificateTemplates | undefined;
@@ -1321,7 +1429,9 @@ export const certificateAuthorityServiceFactory = ({
altNames, altNames,
ttl, ttl,
notBefore, notBefore,
notAfter notAfter,
keyUsages,
extendedKeyUsages
} = dto; } = dto;
let collectionId = pkiCollectionId; let collectionId = pkiCollectionId;
@@ -1432,7 +1542,7 @@ export const certificateAuthorityServiceFactory = ({
message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" message: "A common name (CN) is required in the CSR or as a parameter to this endpoint"
}); });
const { caPrivateKey } = await getCaCredentials({ const { caPrivateKey, caSecret } = await getCaCredentials({
caId: ca.id, caId: ca.id,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthoritySecretDAL, certificateAuthoritySecretDAL,
@@ -1440,13 +1550,115 @@ export const certificateAuthorityServiceFactory = ({
kmsService kmsService
}); });
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
const extensions: x509.Extension[] = [ const extensions: x509.Extension[] = [
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true),
new x509.BasicConstraintsExtension(false), new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
}),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
]; ];
// handle key usages
const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension;
let csrKeyUsages: CertKeyUsage[] = [];
if (csrKeyUsageExtension) {
csrKeyUsages = Object.values(CertKeyUsage).filter(
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0
);
}
let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? [];
if (keyUsages === undefined && !certificateTemplate) {
if (csrKeyUsageExtension) {
selectedKeyUsages = csrKeyUsages;
} else {
selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
}
}
if (keyUsages === undefined && certificateTemplate) {
if (csrKeyUsageExtension) {
const validKeyUsages = certificateTemplate.keyUsages || [];
if (csrKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) {
throw new BadRequestError({
message: "Invalid key usage value based on template policy"
});
}
selectedKeyUsages = csrKeyUsages;
} else {
selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[];
}
}
if (keyUsages?.length && certificateTemplate) {
const validKeyUsages = certificateTemplate.keyUsages || [];
if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) {
throw new BadRequestError({
message: "Invalid key usage value based on template policy"
});
}
selectedKeyUsages = keyUsages;
}
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
if (keyUsagesBitValue) {
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
}
// handle extended key usages
const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension;
let csrExtendedKeyUsages: CertExtendedKeyUsage[] = [];
if (csrExtendedKeyUsageExtension) {
csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map(
(ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]
);
}
let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? [];
if (extendedKeyUsages === undefined && !certificateTemplate && csrExtendedKeyUsageExtension) {
selectedExtendedKeyUsages = csrExtendedKeyUsages;
}
if (extendedKeyUsages === undefined && certificateTemplate) {
if (csrExtendedKeyUsageExtension) {
const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || [];
if (csrExtendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) {
throw new BadRequestError({
message: "Invalid extended key usage value based on template policy"
});
}
selectedExtendedKeyUsages = csrExtendedKeyUsages;
} else {
selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[];
}
}
if (extendedKeyUsages?.length && certificateTemplate) {
const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || [];
if (extendedKeyUsages.some((keyUsage) => !validExtendedKeyUsages.includes(keyUsage))) {
throw new BadRequestError({
message: "Invalid extended key usage value based on template policy"
});
}
selectedExtendedKeyUsages = extendedKeyUsages;
}
if (selectedExtendedKeyUsages.length) {
extensions.push(
new x509.ExtendedKeyUsageExtension(
selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]),
true
)
);
}
let altNamesFromCsr: string = ""; let altNamesFromCsr: string = "";
let altNamesArray: { let altNamesArray: {
type: "email" | "dns"; type: "email" | "dns";
@@ -1542,7 +1754,9 @@ export const certificateAuthorityServiceFactory = ({
altNames: altNamesFromCsr || altNames, altNames: altNamesFromCsr || altNames,
serialNumber, serialNumber,
notBefore: notBeforeDate, notBefore: notBeforeDate,
notAfter: notAfterDate notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages
}, },
tx tx
); );
@@ -1628,6 +1842,7 @@ export const certificateAuthorityServiceFactory = ({
renewCaCert, renewCaCert,
getCaCerts, getCaCerts,
getCaCert, getCaCert,
getCaCertById,
signIntermediate, signIntermediate,
importCertToCa, importCertToCa,
issueCertFromCa, issueCertFromCa,

View File

@@ -4,7 +4,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { CertKeyAlgorithm } from "../certificate/certificate-types"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal";
@@ -97,6 +97,8 @@ export type TIssueCertFromCaDTO = {
ttl: string; ttl: string;
notBefore?: string; notBefore?: string;
notAfter?: string; notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO = export type TSignCertFromCaDTO =
@@ -112,6 +114,8 @@ export type TSignCertFromCaDTO =
ttl?: string; ttl?: string;
notBefore?: string; notBefore?: string;
notAfter?: string; notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} }
| ({ | ({
isInternal: false; isInternal: false;
@@ -125,6 +129,8 @@ export type TSignCertFromCaDTO =
ttl: string; ttl: string;
notBefore?: string; notBefore?: string;
notAfter?: string; notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">); } & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = { export type TGetCaCertificateTemplatesDTO = {

View File

@@ -9,7 +9,9 @@ export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({
commonName: true, commonName: true,
subjectAlternativeName: true, subjectAlternativeName: true,
pkiCollectionId: true, pkiCollectionId: true,
ttl: true ttl: true,
keyUsages: true,
extendedKeyUsages: true
}).merge( }).merge(
z.object({ z.object({
projectId: z.string(), projectId: z.string(),

View File

@@ -57,7 +57,9 @@ export const certificateTemplateServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
keyUsages,
extendedKeyUsages
}: TCreateCertTemplateDTO) => { }: TCreateCertTemplateDTO) => {
const ca = await certificateAuthorityDAL.findById(caId); const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) { if (!ca) {
@@ -86,7 +88,9 @@ export const certificateTemplateServiceFactory = ({
name, name,
commonName, commonName,
subjectAlternativeName, subjectAlternativeName,
ttl ttl,
keyUsages,
extendedKeyUsages
}, },
tx tx
); );
@@ -113,7 +117,9 @@ export const certificateTemplateServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
keyUsages,
extendedKeyUsages
}: TUpdateCertTemplateDTO) => { }: TUpdateCertTemplateDTO) => {
const certTemplate = await certificateTemplateDAL.getById(id); const certTemplate = await certificateTemplateDAL.getById(id);
if (!certTemplate) { if (!certTemplate) {
@@ -153,7 +159,9 @@ export const certificateTemplateServiceFactory = ({
commonName, commonName,
subjectAlternativeName, subjectAlternativeName,
name, name,
ttl ttl,
keyUsages,
extendedKeyUsages
}, },
tx tx
); );

View File

@@ -1,4 +1,5 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
export type TCreateCertTemplateDTO = { export type TCreateCertTemplateDTO = {
caId: string; caId: string;
@@ -7,6 +8,8 @@ export type TCreateCertTemplateDTO = {
commonName: string; commonName: string;
subjectAlternativeName: string; subjectAlternativeName: string;
ttl: string; ttl: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateCertTemplateDTO = { export type TUpdateCertTemplateDTO = {
@@ -17,6 +20,8 @@ export type TUpdateCertTemplateDTO = {
commonName?: string; commonName?: string;
subjectAlternativeName?: string; subjectAlternativeName?: string;
ttl?: string; ttl?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetCertTemplateDTO = { export type TGetCertTemplateDTO = {

View File

@@ -1,3 +1,5 @@
import * as x509 from "@peculiar/x509";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export enum CertStatus { export enum CertStatus {
@@ -12,6 +14,36 @@ export enum CertKeyAlgorithm {
ECDSA_P384 = "EC_secp384r1" ECDSA_P384 = "EC_secp384r1"
} }
export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature",
KEY_ENCIPHERMENT = "keyEncipherment",
NON_REPUDIATION = "nonRepudiation",
DATA_ENCIPHERMENT = "dataEncipherment",
KEY_AGREEMENT = "keyAgreement",
KEY_CERT_SIGN = "keyCertSign",
CRL_SIGN = "cRLSign",
ENCIPHER_ONLY = "encipherOnly",
DECIPHER_ONLY = "decipherOnly"
}
export enum CertExtendedKeyUsage {
CLIENT_AUTH = "clientAuth",
SERVER_AUTH = "serverAuth",
CODE_SIGNING = "codeSigning",
EMAIL_PROTECTION = "emailProtection",
TIMESTAMPING = "timeStamping",
OCSP_SIGNING = "ocspSigning"
}
export const CertExtendedKeyUsageOIDToName: Record<string, CertExtendedKeyUsage> = {
[x509.ExtendedKeyUsage.clientAuth]: CertExtendedKeyUsage.CLIENT_AUTH,
[x509.ExtendedKeyUsage.serverAuth]: CertExtendedKeyUsage.SERVER_AUTH,
[x509.ExtendedKeyUsage.codeSigning]: CertExtendedKeyUsage.CODE_SIGNING,
[x509.ExtendedKeyUsage.emailProtection]: CertExtendedKeyUsage.EMAIL_PROTECTION,
[x509.ExtendedKeyUsage.ocspSigning]: CertExtendedKeyUsage.OCSP_SIGNING,
[x509.ExtendedKeyUsage.timeStamping]: CertExtendedKeyUsage.TIMESTAMPING
};
export enum CrlReason { export enum CrlReason {
UNSPECIFIED = "UNSPECIFIED", UNSPECIFIED = "UNSPECIFIED",
KEY_COMPROMISE = "KEY_COMPROMISE", KEY_COMPROMISE = "KEY_COMPROMISE",

View File

@@ -1,9 +1,11 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName, TIdentities } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, sqlNestRelationships } from "@app/lib/knex"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { ProjectIdentityOrderBy, TListProjectIdentityDTO } from "@app/services/identity-project/identity-project-types";
export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>; export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>;
@@ -107,12 +109,45 @@ export const identityProjectDALFactory = (db: TDbClient) => {
} }
}; };
const findByProjectId = async (projectId: string, filter: { identityId?: string } = {}, tx?: Knex) => { const findByProjectId = async (
projectId: string,
filter: { identityId?: string } & Pick<
TListProjectIdentityDTO,
"limit" | "offset" | "search" | "orderBy" | "orderDirection"
> = {},
tx?: Knex
) => {
try { try {
const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership) // TODO: scott - optimize, there's redundancy here with project membership and the below query
const fetchIdentitySubquery = (tx || db.replicaNode())(TableName.Identity)
.where((qb) => {
if (filter.search) {
void qb.whereILike(`${TableName.Identity}.name`, `%${filter.search}%`);
}
})
.join(
TableName.IdentityProjectMembership,
`${TableName.IdentityProjectMembership}.identityId`,
`${TableName.Identity}.id`
)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.orderBy(
`${TableName.Identity}.${filter.orderBy ?? ProjectIdentityOrderBy.Name}`,
filter.orderDirection ?? OrderByDirection.ASC
)
.select(selectAllTableCols(TableName.Identity))
.as(TableName.Identity); // required for subqueries
if (filter.limit) {
void fetchIdentitySubquery.offset(filter.offset ?? 0).limit(filter.limit);
}
const query = (tx || db.replicaNode())(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) .join<TIdentities, TIdentities>(fetchIdentitySubquery, (bd) => {
bd.on(`${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`);
})
.where((qb) => { .where((qb) => {
if (filter.identityId) { if (filter.identityId) {
void qb.where("identityId", filter.identityId); void qb.where("identityId", filter.identityId);
@@ -154,6 +189,19 @@ export const identityProjectDALFactory = (db: TDbClient) => {
db.ref("name").as("projectName").withSchema(TableName.Project) db.ref("name").as("projectName").withSchema(TableName.Project)
); );
// TODO: scott - joins seem to reorder identities so need to order again, for the sake of urgency will optimize at a later point
if (filter.orderBy) {
switch (filter.orderBy) {
case "name":
void query.orderBy(`${TableName.Identity}.${filter.orderBy}`, filter.orderDirection);
break;
default:
// do nothing
}
}
const docs = await query;
const members = sqlNestRelationships({ const members = sqlNestRelationships({
data: docs, data: docs,
parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt, projectName }) => ({ parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt, projectName }) => ({
@@ -208,9 +256,37 @@ export const identityProjectDALFactory = (db: TDbClient) => {
} }
}; };
const getCountByProjectId = async (
projectId: string,
filter: { identityId?: string } & Pick<TListProjectIdentityDTO, "search"> = {},
tx?: Knex
) => {
try {
const identities = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
.where((qb) => {
if (filter.identityId) {
void qb.where("identityId", filter.identityId);
}
if (filter.search) {
void qb.whereILike(`${TableName.Identity}.name`, `%${filter.search}%`);
}
})
.count();
return Number(identities[0].count);
} catch (error) {
throw new DatabaseError({ error, name: "GetCountByProjectId" });
}
};
return { return {
...identityProjectOrm, ...identityProjectOrm,
findByIdentityId, findByIdentityId,
findByProjectId findByProjectId,
getCountByProjectId
}; };
}; };

View File

@@ -268,7 +268,12 @@ export const identityProjectServiceFactory = ({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId,
limit,
offset,
orderBy,
orderDirection,
search
}: TListProjectIdentityDTO) => { }: TListProjectIdentityDTO) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
@@ -279,8 +284,17 @@ export const identityProjectServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
const identityMemberships = await identityProjectDAL.findByProjectId(projectId); const identityMemberships = await identityProjectDAL.findByProjectId(projectId, {
return identityMemberships; limit,
offset,
orderBy,
orderDirection,
search
});
const totalCount = await identityProjectDAL.getCountByProjectId(projectId, { search });
return { identityMemberships, totalCount };
}; };
const getProjectIdentityByIdentityId = async ({ const getProjectIdentityByIdentityId = async ({

View File

@@ -1,4 +1,4 @@
import { TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
@@ -40,8 +40,18 @@ export type TDeleteProjectIdentityDTO = {
identityId: string; identityId: string;
} & TProjectPermission; } & TProjectPermission;
export type TListProjectIdentityDTO = TProjectPermission; export type TListProjectIdentityDTO = {
limit?: number;
offset?: number;
orderBy?: ProjectIdentityOrderBy;
orderDirection?: OrderByDirection;
search?: string;
} & TProjectPermission;
export type TGetProjectIdentityByIdentityIdDTO = { export type TGetProjectIdentityByIdentityIdDTO = {
identityId: string; identityId: string;
} & TProjectPermission; } & TProjectPermission;
export enum ProjectIdentityOrderBy {
Name = "name"
}

View File

@@ -4,6 +4,8 @@ import { TDbClient } from "@app/db";
import { TableName, TIdentityOrgMemberships } from "@app/db/schemas"; import { TableName, TIdentityOrgMemberships } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types";
export type TIdentityOrgDALFactory = ReturnType<typeof identityOrgDALFactory>; export type TIdentityOrgDALFactory = ReturnType<typeof identityOrgDALFactory>;
@@ -27,9 +29,20 @@ export const identityOrgDALFactory = (db: TDbClient) => {
} }
}; };
const find = async (filter: Partial<TIdentityOrgMemberships>, tx?: Knex) => { const find = async (
{
limit,
offset = 0,
orderBy,
orderDirection = OrderByDirection.ASC,
search,
...filter
}: Partial<TIdentityOrgMemberships> &
Pick<TListOrgIdentitiesByOrgIdDTO, "offset" | "limit" | "orderBy" | "orderDirection" | "search">,
tx?: Knex
) => {
try { try {
const docs = await (tx || db.replicaNode())(TableName.IdentityOrgMembership) const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter) .where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
@@ -44,6 +57,30 @@ export const identityOrgDALFactory = (db: TDbClient) => {
.select(db.ref("id").as("identityId").withSchema(TableName.Identity)) .select(db.ref("id").as("identityId").withSchema(TableName.Identity))
.select(db.ref("name").as("identityName").withSchema(TableName.Identity)) .select(db.ref("name").as("identityName").withSchema(TableName.Identity))
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); .select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity));
if (limit) {
void query.offset(offset).limit(limit);
}
if (orderBy) {
switch (orderBy) {
case "name":
void query.orderBy(`${TableName.Identity}.${orderBy}`, orderDirection);
break;
case "role":
void query.orderBy(`${TableName.IdentityOrgMembership}.${orderBy}`, orderDirection);
break;
default:
// do nothing
}
}
if (search?.length) {
void query.whereILike(`${TableName.Identity}.name`, `%${search}%`);
}
const docs = await query;
return docs.map( return docs.map(
({ ({
crId, crId,
@@ -79,5 +116,27 @@ export const identityOrgDALFactory = (db: TDbClient) => {
} }
}; };
return { ...identityOrgOrm, find, findOne }; const countAllOrgIdentities = async (
{ search, ...filter }: Partial<TIdentityOrgMemberships> & Pick<TListOrgIdentitiesByOrgIdDTO, "search">,
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`)
.count();
if (search?.length) {
void query.whereILike(`${TableName.Identity}.name`, `%${search}%`);
}
const identities = await query;
return Number(identities[0].count);
} catch (error) {
throw new DatabaseError({ error, name: "countAllOrgIdentities" });
}
};
return { ...identityOrgOrm, find, findOne, countAllOrgIdentities };
}; };

View File

@@ -6,7 +6,6 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TOrgPermission } from "@app/lib/types";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
@@ -16,6 +15,7 @@ import {
TCreateIdentityDTO, TCreateIdentityDTO,
TDeleteIdentityDTO, TDeleteIdentityDTO,
TGetIdentityByIdDTO, TGetIdentityByIdDTO,
TListOrgIdentitiesByOrgIdDTO,
TListProjectIdentitiesByIdentityIdDTO, TListProjectIdentitiesByIdentityIdDTO,
TUpdateIdentityDTO TUpdateIdentityDTO
} from "./identity-types"; } from "./identity-types";
@@ -195,14 +195,36 @@ export const identityServiceFactory = ({
return { ...deletedIdentity, orgId: identityOrgMembership.orgId }; return { ...deletedIdentity, orgId: identityOrgMembership.orgId };
}; };
const listOrgIdentities = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPermission) => { const listOrgIdentities = async ({
orgId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
limit,
offset,
orderBy,
orderDirection,
search
}: TListOrgIdentitiesByOrgIdDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
const identityMemberships = await identityOrgMembershipDAL.find({ const identityMemberships = await identityOrgMembershipDAL.find({
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId [`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId,
limit,
offset,
orderBy,
orderDirection,
search
}); });
return identityMemberships;
const totalCount = await identityOrgMembershipDAL.countAllOrgIdentities({
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId,
search
});
return { identityMemberships, totalCount };
}; };
const listProjectIdentitiesByIdentityId = async ({ const listProjectIdentitiesByIdentityId = async ({

View File

@@ -1,5 +1,5 @@
import { IPType } from "@app/lib/ip"; import { IPType } from "@app/lib/ip";
import { TOrgPermission } from "@app/lib/types"; import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = { export type TCreateIdentityDTO = {
role: string; role: string;
@@ -29,3 +29,16 @@ export interface TIdentityTrustedIp {
export type TListProjectIdentitiesByIdentityIdDTO = { export type TListProjectIdentitiesByIdentityIdDTO = {
identityId: string; identityId: string;
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type TListOrgIdentitiesByOrgIdDTO = {
limit?: number;
offset?: number;
orderBy?: OrgIdentityOrderBy;
orderDirection?: OrderByDirection;
search?: string;
} & TOrgPermission;
export enum OrgIdentityOrderBy {
Name = "name",
Role = "role"
}

View File

@@ -207,6 +207,12 @@ const syncSecretsGCPSecretManager = async ({
} }
); );
if (!secrets[key].value) {
logger.warn(
`syncSecretsGcpsecretManager: create secret value in gcp where [key=${key}] and integration appId [appId=${integration.appId}]`
);
}
await request.post( await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`, `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`,
{ {
@@ -237,6 +243,12 @@ const syncSecretsGCPSecretManager = async ({
} }
); );
} else if (secrets[key].value !== res[key]) { } else if (secrets[key].value !== res[key]) {
if (!secrets[key].value) {
logger.warn(
`syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and integration appId [appId=${integration.appId}]`
);
}
await request.post( await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`, `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`,
{ {

View File

@@ -458,7 +458,6 @@ export const orgServiceFactory = ({
const appCfg = getConfig(); const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
const org = await orgDAL.findOrgById(orgId); const org = await orgDAL.findOrgById(orgId);
@@ -583,6 +582,8 @@ export const orgServiceFactory = ({
// if there exist no org membership we set is as given by the request // if there exist no org membership we set is as given by the request
if (!inviteeMembership) { if (!inviteeMembership) {
// as its used by project invite also
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
let roleId; let roleId;
const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug; const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug;
if (isCustomOrgRole) { if (isCustomOrgRole) {

View File

@@ -60,6 +60,8 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.
- Common Name (CN): A regular expression used to validate the common name in certificate requests. - Common Name (CN): A regular expression used to validate the common name in certificate requests.
- Alternative Names (SANs): A regular expression used to validate subject alternative names in certificate requests. - Alternative Names (SANs): A regular expression used to validate subject alternative names in certificate requests.
- TTL: The maximum Time-to-Live (TTL) for certificates issued using this template. - TTL: The maximum Time-to-Live (TTL) for certificates issued using this template.
- Key Usage: The key usage constraint or default value for certificates issued using this template.
- Extended Key Usage: The extended key usage constraint or default value for certificates issued using this template.
</Step> </Step>
<Step title="Creating a certificate"> <Step title="Creating a certificate">
To create a certificate, head to your Project > Internal PKI > Certificates and press **Issue** under the Certificates section. To create a certificate, head to your Project > Internal PKI > Certificates and press **Issue** under the Certificates section.
@@ -76,13 +78,16 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.
- Common Name (CN): The (common) name for the certificate like `service.acme.com`. - Common Name (CN): The (common) name for the certificate like `service.acme.com`.
- Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses like `app1.acme.com, app2.acme.com`. - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses like `app1.acme.com, app2.acme.com`.
- TTL: The lifetime of the certificate in seconds. - TTL: The lifetime of the certificate in seconds.
- Key Usage: The key usage extension of the certificate.
- Extended Key Usage: The extended key usage extension of the certificate.
<Note> <Note>
Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None** Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None**
and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same. and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same.
That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates. That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates.
</Note> </Note>
</Step> </Step>
<Step title="Copying the certificate details"> <Step title="Copying the certificate details">
Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**.
@@ -105,7 +110,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.
With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like .*.acme.com or perhaps that the max TTL cannot be more than 1 year. With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like .*.acme.com or perhaps that the max TTL cannot be more than 1 year.
To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates/create) API endpoint, specifying the issuing CA. To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates/create) API endpoint, specifying the issuing CA.
### Sample request ### Sample request
```bash Request ```bash Request
@@ -132,6 +137,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.
ttl: "...", ttl: "...",
} }
``` ```
</Step> </Step>
<Step title="Creating a certificate"> <Step title="Creating a certificate">
To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-cert) API endpoint, To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-cert) API endpoint,
@@ -164,7 +170,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.
<Note> <Note>
Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None** Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None**
and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same. and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same.
That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates. That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates.
</Note> </Note>
@@ -197,6 +203,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.
serialNumber: "..." serialNumber: "..."
} }
``` ```
</Step> </Step>
</Steps> </Steps>
</Tab> </Tab>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 KiB

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -696,7 +696,12 @@
{ {
"group": "Audit Logs", "group": "Audit Logs",
"pages": ["api-reference/endpoints/audit-logs/export-audit-log"] "pages": ["api-reference/endpoints/audit-logs/export-audit-log"]
}, }
]
},
{
"group": "Infisical PKI",
"pages": [
{ {
"group": "Certificate Authorities", "group": "Certificate Authorities",
"pages": [ "pages": [

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { faCaretDown, faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Combobox, Transition } from "@headlessui/react";
import { ByComparator } from "@headlessui/react/dist/types";
import { twMerge } from "tailwind-merge";
type ComboBoxProps<T extends object> = {
value?: T;
className?: string;
items: {
value: T;
key: string;
label: string;
}[];
by: ByComparator<T>;
defaultValue?: T;
displayValue: (value: T) => string;
onSelectChange: (value: T) => void;
onFilter: (value: { value: T }, filterQuery: string) => boolean;
};
// TODO(akhilmhdh): This is a very temporary one due to limitation of present situation
// don't mind the api for now will be switched to aria later
export const ComboBox = <T extends object>({
onSelectChange,
onFilter,
displayValue,
by,
items,
...props
}: ComboBoxProps<T>) => {
const [query, setQuery] = useState("");
const filteredResult =
query === "" ? items.slice(0, 20) : items.filter((el) => onFilter(el, query)).slice(0, 20);
return (
<Combobox by={by} {...props} onChange={onSelectChange}>
<div className="relative">
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
displayValue={displayValue}
className=" inline-flex w-full items-center justify-between rounded-md bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200"
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon icon={faCaretDown} size="sm" aria-hidden="true" />
</Combobox.Button>
<Transition
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-mineshaft-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
{filteredResult.map(({ value, key, label }) => (
<Combobox.Option
key={key}
value={value}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 transition-all hover:bg-mineshaft-500 ${
active ? "text-primary" : "text-white"
}`
}
>
{({ selected }) => (
<>
{label}
{selected ? (
<div className={twMerge("absolute top-2 left-3 text-primary")}>
<FontAwesomeIcon icon={faCheck} />
</div>
) : null}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Transition>
</div>
</Combobox>
);
};

View File

@@ -0,0 +1 @@
export { ComboBox } from "./ComboBox";

View File

@@ -11,6 +11,7 @@ type Props = {
isDisabled?: boolean; isDisabled?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
autoCapitalization?: boolean; autoCapitalization?: boolean;
containerClassName?: string;
}; };
const inputVariants = cva( const inputVariants = cva(
@@ -71,6 +72,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
( (
{ {
className, className,
containerClassName,
isRounded = true, isRounded = true,
isFullWidth = true, isFullWidth = true,
isDisabled, isDisabled,
@@ -94,7 +96,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
}; };
return ( return (
<div className={inputParentContainerVariants({ isRounded, isError, isFullWidth, variant })}> <div
className={inputParentContainerVariants({
isRounded,
isError,
isFullWidth,
variant,
className: containerClassName
})}
>
{leftIcon && <span className="absolute left-0 ml-3 text-sm">{leftIcon}</span>} {leftIcon && <span className="absolute left-0 ml-3 text-sm">{leftIcon}</span>}
<input <input
{...props} {...props}

View File

@@ -11,13 +11,24 @@ export type ModalContentProps = DialogPrimitive.DialogContentProps & {
title?: ReactNode; title?: ReactNode;
subTitle?: ReactNode; subTitle?: ReactNode;
footerContent?: ReactNode; footerContent?: ReactNode;
bodyClassName?: string;
onClose?: () => void; onClose?: () => void;
overlayClassName?: string; overlayClassName?: string;
}; };
export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>( export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
( (
{ children, title, subTitle, className, overlayClassName, footerContent, onClose, ...props }, {
children,
title,
subTitle,
className,
overlayClassName,
footerContent,
bodyClassName,
onClose,
...props
},
forwardedRef forwardedRef
) => ( ) => (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
@@ -35,7 +46,10 @@ export const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
style={{ maxHeight: "90%" }} style={{ maxHeight: "90%" }}
> >
{title && <CardTitle subTitle={subTitle}>{title}</CardTitle>} {title && <CardTitle subTitle={subTitle}>{title}</CardTitle>}
<CardBody className="overflow-y-auto overflow-x-hidden" style={{ maxHeight: "90%" }}> <CardBody
className={twMerge("overflow-y-auto overflow-x-hidden", bodyClassName)}
style={{ maxHeight: "90%" }}
>
{children} {children}
</CardBody> </CardBody>
{footerContent && <CardFooter>{footerContent}</CardFooter>} {footerContent && <CardFooter>{footerContent}</CardFooter>}

View File

@@ -40,6 +40,8 @@ export const Pagination = ({
const upperLimit = Math.ceil(count / perPage); const upperLimit = Math.ceil(count / perPage);
const nextPageNumber = Math.min(upperLimit, page + 1); const nextPageNumber = Math.min(upperLimit, page + 1);
const canGoNext = page + 1 <= upperLimit; const canGoNext = page + 1 <= upperLimit;
const canGoFirst = page > 1;
const canGoLast = page < upperLimit;
return ( return (
<div <div
@@ -50,7 +52,7 @@ export const Pagination = ({
> >
<div className="mr-6 flex items-center space-x-2"> <div className="mr-6 flex items-center space-x-2">
<div className="text-xs"> <div className="text-xs">
{(page - 1) * perPage} - {Math.min((page - 1) * perPage + perPage, count)} of {count} {(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -73,6 +75,16 @@ export const Pagination = ({
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<IconButton
variant="plain"
ariaLabel="pagination-first"
className="relative"
onClick={() => onChangePage(1)}
isDisabled={!canGoFirst}
>
<FontAwesomeIcon className="absolute left-2.5 top-1 text-xs" icon={faChevronLeft} />
<FontAwesomeIcon className="text-xs" icon={faChevronLeft} />
</IconButton>
<IconButton <IconButton
variant="plain" variant="plain"
ariaLabel="pagination-prev" ariaLabel="pagination-prev"
@@ -89,6 +101,16 @@ export const Pagination = ({
> >
<FontAwesomeIcon className="text-xs" icon={faChevronRight} /> <FontAwesomeIcon className="text-xs" icon={faChevronRight} />
</IconButton> </IconButton>
<IconButton
variant="plain"
ariaLabel="pagination-last"
className="relative"
onClick={() => onChangePage(upperLimit)}
isDisabled={!canGoLast}
>
<FontAwesomeIcon className="absolute left-2.5 top-1 text-xs" icon={faChevronRight} />
<FontAwesomeIcon className="text-xs" icon={faChevronRight} />
</IconButton>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { CertKeyAlgorithm } from "../certificates/enums"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificates/enums";
import { CaRenewalType, CaStatus, CaType } from "./enums"; import { CaRenewalType, CaStatus, CaType } from "./enums";
export type TCertificateAuthority = { export type TCertificateAuthority = {
@@ -91,6 +91,8 @@ export type TCreateCertificateDTO = {
ttl: string; // string compatible with ms ttl: string; // string compatible with ms
notBefore?: string; notBefore?: string;
notAfter?: string; notAfter?: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
}; };
export type TCreateCertificateResponse = { export type TCreateCertificateResponse = {

View File

@@ -1,3 +1,5 @@
import { CertExtendedKeyUsage, CertKeyUsage } from "../certificates/enums";
export type TCertificateTemplate = { export type TCertificateTemplate = {
id: string; id: string;
caId: string; caId: string;
@@ -8,6 +10,8 @@ export type TCertificateTemplate = {
commonName: string; commonName: string;
subjectAlternativeName: string; subjectAlternativeName: string;
ttl: string; ttl: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
}; };
export type TCreateCertificateTemplateDTO = { export type TCreateCertificateTemplateDTO = {
@@ -18,6 +22,8 @@ export type TCreateCertificateTemplateDTO = {
subjectAlternativeName: string; subjectAlternativeName: string;
ttl: string; ttl: string;
projectId: string; projectId: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
}; };
export type TUpdateCertificateTemplateDTO = { export type TUpdateCertificateTemplateDTO = {
@@ -29,6 +35,8 @@ export type TUpdateCertificateTemplateDTO = {
subjectAlternativeName?: string; subjectAlternativeName?: string;
ttl?: string; ttl?: string;
projectId: string; projectId: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
}; };
export type TDeleteCertificateTemplateDTO = { export type TDeleteCertificateTemplateDTO = {

View File

@@ -1,4 +1,10 @@
import { CertKeyAlgorithm, CertStatus, CrlReason } from "./enums"; import {
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage,
CertStatus,
CrlReason
} from "./enums";
export const certStatusToNameMap: { [K in CertStatus]: string } = { export const certStatusToNameMap: { [K in CertStatus]: string } = {
[CertStatus.ACTIVE]: "Active", [CertStatus.ACTIVE]: "Active",
@@ -69,3 +75,24 @@ export const crlReasons = [
}, },
{ label: crlReasonToNameMap[CrlReason.A_A_COMPROMISE], value: CrlReason.A_A_COMPROMISE } { label: crlReasonToNameMap[CrlReason.A_A_COMPROMISE], value: CrlReason.A_A_COMPROMISE }
]; ];
export const KEY_USAGES_OPTIONS = [
{ value: CertKeyUsage.DIGITAL_SIGNATURE, label: "Digital Signature" },
{ value: CertKeyUsage.KEY_ENCIPHERMENT, label: "Key Encipherment" },
{ value: CertKeyUsage.NON_REPUDIATION, label: "Non Repudiation" },
{ value: CertKeyUsage.DATA_ENCIPHERMENT, label: "Data Encipherment" },
{ value: CertKeyUsage.KEY_AGREEMENT, label: "Key Agreement" },
{ value: CertKeyUsage.KEY_CERT_SIGN, label: "Certificate Sign" },
{ value: CertKeyUsage.CRL_SIGN, label: "CRL Sign" },
{ value: CertKeyUsage.ENCIPHER_ONLY, label: "Encipher Only" },
{ value: CertKeyUsage.DECIPHER_ONLY, label: "Decipher Only" }
] as const;
export const EXTENDED_KEY_USAGES_OPTIONS = [
{ value: CertExtendedKeyUsage.CLIENT_AUTH, label: "Client Auth" },
{ value: CertExtendedKeyUsage.SERVER_AUTH, label: "Server Auth" },
{ value: CertExtendedKeyUsage.EMAIL_PROTECTION, label: "Email Protection" },
{ value: CertExtendedKeyUsage.OCSP_SIGNING, label: "OCSP Signing" },
{ value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" },
{ value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" }
] as const;

View File

@@ -22,3 +22,24 @@ export enum CrlReason {
PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN",
A_A_COMPROMISE = "A_A_COMPROMISE" A_A_COMPROMISE = "A_A_COMPROMISE"
} }
export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature",
KEY_ENCIPHERMENT = "keyEncipherment",
NON_REPUDIATION = "nonRepudiation",
DATA_ENCIPHERMENT = "dataEncipherment",
KEY_AGREEMENT = "keyAgreement",
KEY_CERT_SIGN = "keyCertSign",
CRL_SIGN = "cRLSign",
ENCIPHER_ONLY = "encipherOnly",
DECIPHER_ONLY = "decipherOnly"
}
export enum CertExtendedKeyUsage {
CLIENT_AUTH = "clientAuth",
SERVER_AUTH = "serverAuth",
CODE_SIGNING = "codeSigning",
EMAIL_PROTECTION = "emailProtection",
TIMESTAMPING = "timeStamping",
OCSP_SIGNING = "ocspSigning"
}

View File

@@ -1,4 +1,4 @@
import { CertStatus } from "./enums"; import { CertExtendedKeyUsage, CertKeyUsage, CertStatus } from "./enums";
export type TCertificate = { export type TCertificate = {
id: string; id: string;
@@ -11,6 +11,8 @@ export type TCertificate = {
serialNumber: string; serialNumber: string;
notBefore: string; notBefore: string;
notAfter: string; notAfter: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
}; };
export type TDeleteCertDTO = { export type TDeleteCertDTO = {

View File

@@ -0,0 +1,4 @@
export enum OrderByDirection {
ASC = "asc",
DESC = "desc"
}

View File

@@ -469,3 +469,8 @@ export type RevokeTokenDTO = {
export type RevokeTokenRes = { export type RevokeTokenRes = {
message: string; message: string;
}; };
export type TProjectIdentitiesList = {
identityMemberships: IdentityMembership[];
totalCount: number;
};

View File

@@ -1,19 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TGroupOrgMembership } from "../groups/types"; import { TGroupOrgMembership } from "../groups/types";
import { IdentityMembershipOrg } from "../identities/types";
import { import {
BillingDetails, BillingDetails,
Invoice, Invoice,
License, License,
Organization, Organization,
OrgIdentityOrderBy,
OrgPlanTable, OrgPlanTable,
PlanBillingInfo, PlanBillingInfo,
PmtMethod, PmtMethod,
ProductsTable, ProductsTable,
TaxID, TaxID,
TListOrgIdentitiesDTO,
TOrgIdentitiesList,
UpdateOrgDTO UpdateOrgDTO
} from "./types"; } from "./types";
@@ -30,6 +33,12 @@ export const organizationKeys = {
getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const, getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const,
getOrgIdentityMemberships: (orgId: string) => getOrgIdentityMemberships: (orgId: string) =>
[{ orgId }, "organization-identity-memberships"] as const, [{ orgId }, "organization-identity-memberships"] as const,
// allows invalidation using above key without knowing params
getOrgIdentityMembershipsWithParams: ({
organizationId: orgId,
...params
}: TListOrgIdentitiesDTO) =>
[...organizationKeys.getOrgIdentityMemberships(orgId), params] as const,
getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const
}; };
@@ -360,19 +369,51 @@ export const useGetOrgLicenses = (organizationId: string) => {
}); });
}; };
export const useGetIdentityMembershipOrgs = (organizationId: string) => { export const useGetIdentityMembershipOrgs = (
{
organizationId,
offset = 0,
limit = 100,
orderBy = OrgIdentityOrderBy.Name,
orderDirection = OrderByDirection.ASC,
search = ""
}: TListOrgIdentitiesDTO,
options?: Omit<
UseQueryOptions<
TOrgIdentitiesList,
unknown,
TOrgIdentitiesList,
ReturnType<typeof organizationKeys.getOrgIdentityMembershipsWithParams>
>,
"queryKey" | "queryFn"
>
) => {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
orderBy: String(orderBy),
orderDirection: String(orderDirection),
search: String(search)
});
return useQuery({ return useQuery({
queryKey: organizationKeys.getOrgIdentityMemberships(organizationId), queryKey: organizationKeys.getOrgIdentityMembershipsWithParams({
organizationId,
offset,
limit,
orderBy,
orderDirection,
search
}),
queryFn: async () => { queryFn: async () => {
const { const { data } = await apiRequest.get<TOrgIdentitiesList>(
data: { identityMemberships } `/api/v2/organizations/${organizationId}/identity-memberships`,
} = await apiRequest.get<{ identityMemberships: IdentityMembershipOrg[] }>( { params }
`/api/v2/organizations/${organizationId}/identity-memberships`
); );
return identityMemberships; return data;
}, },
enabled: true enabled: true,
...options
}); });
}; };

View File

@@ -1,3 +1,6 @@
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { IdentityMembershipOrg } from "@app/hooks/api/identities/types";
export type Organization = { export type Organization = {
id: string; id: string;
name: string; name: string;
@@ -102,3 +105,22 @@ export type ProductsTable = {
head: ProductsTableHead[]; head: ProductsTableHead[];
rows: ProductsTableRow[]; rows: ProductsTableRow[];
}; };
export type TListOrgIdentitiesDTO = {
organizationId: string;
offset?: number;
limit?: number;
orderBy?: OrgIdentityOrderBy;
orderDirection?: OrderByDirection;
search?: string;
};
export type TOrgIdentitiesList = {
identityMemberships: IdentityMembershipOrg[];
totalCount: number;
};
export enum OrgIdentityOrderBy {
Name = "name",
Role = "role"
}

View File

@@ -163,27 +163,29 @@ export const useGetImportedSecretsAllEnvs = ({
queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []), queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []),
enabled: Boolean(projectId) && Boolean(env), enabled: Boolean(projectId) && Boolean(env),
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
select: (data: TImportedSecrets[]) => { select: useCallback(
return data.map((el) => ({ (data: Awaited<ReturnType<typeof fetchImportedSecrets>>) =>
environment: el.environment, data.map((el) => ({
secretPath: el.secretPath, environment: el.environment,
environmentInfo: el.environmentInfo, secretPath: el.secretPath,
folderId: el.folderId, environmentInfo: el.environmentInfo,
secrets: el.secrets.map((encSecret) => { folderId: el.folderId,
return { secrets: el.secrets.map((encSecret) => {
id: encSecret.id, return {
env: encSecret.environment, id: encSecret.id,
key: encSecret.secretKey, env: encSecret.environment,
value: encSecret.secretValue, key: encSecret.secretKey,
tags: encSecret.tags, value: encSecret.secretValue,
comment: encSecret.secretComment, tags: encSecret.tags,
createdAt: encSecret.createdAt, comment: encSecret.secretComment,
updatedAt: encSecret.updatedAt, createdAt: encSecret.createdAt,
version: encSecret.version updatedAt: encSecret.updatedAt,
}; version: encSecret.version
}) };
})); })
} })),
[]
)
})) }))
}); });

View File

@@ -108,7 +108,7 @@ export const useGetProjectSecrets = ({
// wait for all values to be available // wait for all values to be available
enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true), enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true),
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }),
queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }), queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
onError: (error) => { onError: (error) => {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string }; const serverResponse = error.response?.data as { message: string };
@@ -119,7 +119,10 @@ export const useGetProjectSecrets = ({
}); });
} }
}, },
select: ({ secrets }) => mergePersonalSecrets(secrets) select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecrets>>) => mergePersonalSecrets(data.secrets),
[]
)
}); });
export const useGetProjectSecretsAllEnv = ({ export const useGetProjectSecretsAllEnv = ({
@@ -131,7 +134,11 @@ export const useGetProjectSecretsAllEnv = ({
const secrets = useQueries({ const secrets = useQueries({
queries: envs.map((environment) => ({ queries: envs.map((environment) => ({
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), queryKey: secretKeys.getProjectSecret({
workspaceId,
environment,
secretPath
}),
enabled: Boolean(workspaceId && environment), enabled: Boolean(workspaceId && environment),
onError: (error: unknown) => { onError: (error: unknown) => {
if (axios.isAxiosError(error) && !isErrorHandled) { if (axios.isAxiosError(error) && !isErrorHandled) {
@@ -147,12 +154,17 @@ export const useGetProjectSecretsAllEnv = ({
setIsErrorHandled.on(); setIsErrorHandled.on();
} }
}, },
queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }), queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
select: (el: SecretV3RawResponse) => staleTime: 60 * 1000,
mergePersonalSecrets(el.secrets).reduce<Record<string, SecretV3RawSanitized>>( // eslint-disable-next-line react-hooks/rules-of-hooks
(prev, curr) => ({ ...prev, [curr.key]: curr }), select: useCallback(
{} (data: Awaited<ReturnType<typeof fetchProjectSecrets>>) =>
) mergePersonalSecrets(data.secrets).reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
),
[]
)
})) }))
}); });

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { CaStatus } from "../ca/enums"; import { CaStatus } from "../ca/enums";
import { TCertificateAuthority } from "../ca/types"; import { TCertificateAuthority } from "../ca/types";
@@ -8,7 +9,7 @@ import { TCertificate } from "../certificates/types";
import { TCertificateTemplate } from "../certificateTemplates/types"; import { TCertificateTemplate } from "../certificateTemplates/types";
import { TGroupMembership } from "../groups/types"; import { TGroupMembership } from "../groups/types";
import { identitiesKeys } from "../identities/queries"; import { identitiesKeys } from "../identities/queries";
import { IdentityMembership } from "../identities/types"; import { TProjectIdentitiesList } from "../identities/types";
import { IntegrationAuth } from "../integrationAuth/types"; import { IntegrationAuth } from "../integrationAuth/types";
import { TIntegration } from "../integrations/types"; import { TIntegration } from "../integrations/types";
import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiAlert } from "../pkiAlerts/types";
@@ -24,8 +25,10 @@ import {
DeleteEnvironmentDTO, DeleteEnvironmentDTO,
DeleteWorkspaceDTO, DeleteWorkspaceDTO,
NameWorkspaceSecretsDTO, NameWorkspaceSecretsDTO,
ProjectIdentityOrderBy,
RenameWorkspaceDTO, RenameWorkspaceDTO,
TGetUpgradeProjectStatusDTO, TGetUpgradeProjectStatusDTO,
TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO, ToggleAutoCapitalizationDTO,
TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO, TUpdateWorkspaceUserRoleDTO,
@@ -484,18 +487,51 @@ export const useDeleteIdentityFromWorkspace = () => {
}); });
}; };
export const useGetWorkspaceIdentityMemberships = (workspaceId: string) => { export const useGetWorkspaceIdentityMemberships = (
{
workspaceId,
offset = 0,
limit = 100,
orderBy = ProjectIdentityOrderBy.Name,
orderDirection = OrderByDirection.ASC,
search = ""
}: TListProjectIdentitiesDTO,
options?: Omit<
UseQueryOptions<
TProjectIdentitiesList,
unknown,
TProjectIdentitiesList,
ReturnType<typeof workspaceKeys.getWorkspaceIdentityMembershipsWithParams>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({ return useQuery({
queryKey: workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), queryKey: workspaceKeys.getWorkspaceIdentityMembershipsWithParams({
workspaceId,
offset,
limit,
orderBy,
orderDirection,
search
}),
queryFn: async () => { queryFn: async () => {
const { const params = new URLSearchParams({
data: { identityMemberships } offset: String(offset),
} = await apiRequest.get<{ identityMemberships: IdentityMembership[] }>( limit: String(limit),
`/api/v2/workspace/${workspaceId}/identity-memberships` orderBy: String(orderBy),
orderDirection: String(orderDirection),
search: String(search)
});
const { data } = await apiRequest.get<TProjectIdentitiesList>(
`/api/v2/workspace/${workspaceId}/identity-memberships`,
{ params }
); );
return identityMemberships; return data;
}, },
enabled: true enabled: true,
...options
}); });
}; };

View File

@@ -1,3 +1,5 @@
import { TListProjectIdentitiesDTO } from "@app/hooks/api/workspace/types";
import type { CaStatus } from "../ca"; import type { CaStatus } from "../ca";
export const workspaceKeys = { export const workspaceKeys = {
@@ -15,6 +17,12 @@ export const workspaceKeys = {
getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) => getWorkspaceIdentityMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-identity-memberships"] as const, [{ workspaceId }, "workspace-identity-memberships"] as const,
// allows invalidation using above key without knowing params
getWorkspaceIdentityMembershipsWithParams: ({
workspaceId,
...params
}: TListProjectIdentitiesDTO) =>
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
getWorkspaceGroupMemberships: (workspaceId: string) => getWorkspaceGroupMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-groups"] as const, [{ workspaceId }, "workspace-groups"] as const,
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>

View File

@@ -1,3 +1,5 @@
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TProjectRole } from "../roles/types"; import { TProjectRole } from "../roles/types";
export enum ProjectVersion { export enum ProjectVersion {
@@ -141,3 +143,16 @@ export type TUpdateWorkspaceGroupRoleDTO = {
} }
)[]; )[];
}; };
export type TListProjectIdentitiesDTO = {
workspaceId: string;
offset?: number;
limit?: number;
orderBy?: ProjectIdentityOrderBy;
orderDirection?: OrderByDirection;
search?: string;
};
export enum ProjectIdentityOrderBy {
Name = "name"
}

View File

@@ -43,11 +43,6 @@ export const useNavigateToSelectOrganization = () => {
await navigateUserToOrg(router, config.defaultAuthOrgId); await navigateUserToOrg(router, config.defaultAuthOrgId);
} }
let localOrgId = localStorage.getItem("orgData.id")
if(!cliCallbackPort && localOrgId != null){
await navigateUserToOrg(router, localOrgId);
}
queryClient.invalidateQueries(userKeys.getUser); queryClient.invalidateQueries(userKeys.getUser);
let redirectTo = "/login/select-organization"; let redirectTo = "/login/select-organization";

View File

@@ -1,5 +1,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faEllipsis, faServer } from "@fortawesome/free-solid-svg-icons"; import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -11,8 +18,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton,
Input,
Pagination,
Select, Select,
SelectItem, SelectItem,
Spinner,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -23,7 +34,10 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDebounce } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -36,22 +50,60 @@ type Props = {
) => void; ) => void;
}; };
const INIT_PER_PAGE = 10;
export const IdentityTable = ({ handlePopUpOpen }: Props) => { export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter(); const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState(OrgIdentityOrderBy.Name);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { data, isLoading } = useGetIdentityMembershipOrgs(orgId);
const { data: roles } = useGetOrgRoles(orgId); const offset = (page - 1) * perPage;
const { data, isLoading, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit: perPage,
orderDirection,
orderBy,
search: debouncedSearch
},
{ keepPreviousData: true }
);
useEffect(() => {
// reset page if no longer valid
if (data && data.totalCount < offset) setPage(1);
}, [data?.totalCount]);
const { data: roles } = useGetOrgRoles(organizationId);
const handleSort = (column: OrgIdentityOrderBy) => {
if (column === orderBy) {
setOrderDirection((prev) =>
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => { const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try { try {
await updateMutateAsync({ await updateMutateAsync({
identityId, identityId,
role, role,
organizationId: orgId organizationId
}); });
createNotification({ createNotification({
@@ -71,124 +123,180 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}; };
return ( return (
<TableContainer> <div>
<Table> <Input
<THead> containerClassName="mb-4"
<Tr> value={search}
<Th>Name</Th> onChange={(e) => setSearch(e.target.value)}
<Th>Role</Th> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th className="w-5" /> placeholder="Search identities by name..."
</Tr> />
</THead> <TableContainer>
<TBody> <Table>
{isLoading && <TableSkeleton columns={4} innerKey="org-identities" />} <THead>
{!isLoading && <Tr className="h-14">
data?.map(({ identity: { id, name }, role, customRole }) => { <Th className="w-1/2">
return ( <div className="flex items-center">
<Tr Name
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" <IconButton
key={`identity-${id}`} variant="plain"
onClick={() => router.push(`/org/${orgId}/identities/${id}`)} className={`ml-2 ${orderBy === OrgIdentityOrderBy.Name ? "" : "opacity-30"}`}
> ariaLabel="sort"
<Td>{name}</Td> onClick={() => handleSort(OrgIdentityOrderBy.Name)}
<Td> >
<OrgPermissionCan <FontAwesomeIcon
I={OrgPermissionActions.Edit} icon={
a={OrgPermissionSubjects.Identity} orderDirection === OrderByDirection.DESC &&
> orderBy === OrgIdentityOrderBy.Name
{(isAllowed) => { ? faArrowUp
return ( : faArrowDown
<Select }
value={role === "custom" ? (customRole?.slug as string) : role} />
isDisabled={!isAllowed} </IconButton>
className="w-40 bg-mineshaft-600" </div>
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800" </Th>
onValueChange={(selectedRole) => <Th>
handleChangeRole({ <div className="flex items-center">
identityId: id, Role
role: selectedRole <IconButton
}) variant="plain"
} className={`ml-2 ${orderBy === OrgIdentityOrderBy.Role ? "" : "opacity-30"}`}
> ariaLabel="sort"
{(roles || []).map(({ slug, name: roleName }) => ( onClick={() => handleSort(OrgIdentityOrderBy.Role)}
<SelectItem value={slug} key={`owner-option-${slug}`}> >
{roleName} <FontAwesomeIcon
</SelectItem> icon={
))} orderDirection === OrderByDirection.DESC &&
</Select> orderBy === OrgIdentityOrderBy.Role
); ? faArrowUp
}} : faArrowDown
</OrgPermissionCan> }
</Td> />
<Td> </IconButton>
<DropdownMenu> </div>
<DropdownMenuTrigger asChild className="rounded-lg"> </Th>
<div className="hover:text-primary-400 data-[state=open]:text-primary-400"> <Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/identities/${id}`);
}}
disabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState
title="No identities have been created in this organization"
icon={faServer}
/>
</Td>
</Tr> </Tr>
)} </THead>
</TBody> <TBody>
</Table> {isLoading && <TableSkeleton columns={3} innerKey="org-identities" />}
</TableContainer> {!isLoading &&
data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-${id}`}
onClick={() => router.push(`/org/${organizationId}/identities/${id}`)}
>
<Td>{name}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
identityId: id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="flex justify-center hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${organizationId}/identities/${id}`);
}}
disabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Identity
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}
icon={faServer}
/>
)}
</TableContainer>
</div>
); );
}; };

View File

@@ -7,7 +7,12 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button, Button,
Checkbox,
FormControl, FormControl,
FormLabel, FormLabel,
Input, Input,
@@ -28,6 +33,11 @@ import {
useListWorkspacePkiCollections useListWorkspacePkiCollections
} from "@app/hooks/api"; } from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent"; import { CertificateContent } from "./CertificateContent";
@@ -39,7 +49,26 @@ const schema = z.object({
friendlyName: z.string(), friendlyName: z.string(),
commonName: z.string().trim().min(1), commonName: z.string().trim().min(1),
altNames: z.string(), altNames: z.string(),
ttl: z.string().trim() ttl: z.string().trim(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
}),
extendedKeyUsages: z.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
}); });
export type FormData = z.infer<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -88,7 +117,14 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
setValue, setValue,
watch watch
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema) resolver: zodResolver(schema),
defaultValues: {
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
}
}); });
const selectedCertTemplateId = watch("certificateTemplateId"); const selectedCertTemplateId = watch("certificateTemplateId");
@@ -107,7 +143,11 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
commonName: cert.commonName, commonName: cert.commonName,
altNames: cert.altNames, altNames: cert.altNames,
certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
ttl: "" ttl: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(cert.extendedKeyUsages || []).map((name) => [name, true])
)
}); });
} else { } else {
reset({ reset({
@@ -116,7 +156,12 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
commonName: "", commonName: "",
altNames: "", altNames: "",
ttl: "", ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
}); });
} }
}, [cert]); }, [cert]);
@@ -124,6 +169,14 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
useEffect(() => { useEffect(() => {
if (!cert && selectedCertTemplate) { if (!cert && selectedCertTemplate) {
setValue("ttl", selectedCertTemplate.ttl); setValue("ttl", selectedCertTemplate.ttl);
setValue(
"keyUsages",
Object.fromEntries(selectedCertTemplate.keyUsages.map((name) => [name, true]))
);
setValue(
"extendedKeyUsages",
Object.fromEntries(selectedCertTemplate.extendedKeyUsages.map((name) => [name, true]))
);
} }
}, [selectedCertTemplate, cert]); }, [selectedCertTemplate, cert]);
@@ -133,7 +186,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
collectionId, collectionId,
commonName, commonName,
altNames, altNames,
ttl ttl,
keyUsages,
extendedKeyUsages
}: FormData) => { }: FormData) => {
try { try {
if (!currentWorkspace?.slug) return; if (!currentWorkspace?.slug) return;
@@ -146,7 +201,13 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
friendlyName, friendlyName,
commonName, commonName,
altNames, altNames,
ttl ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
}); });
reset(); reset();
@@ -363,8 +424,87 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mt-2 mb-7 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isDisabled={Boolean(cert)}
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mt-2 mb-7 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isDisabled={Boolean(cert)}
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
{!cert && ( {!cert && (
<div className="flex items-center"> <div className="mt-4 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
size="sm" size="sm"

View File

@@ -7,7 +7,12 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button, Button,
Checkbox,
FormControl, FormControl,
FormLabel, FormLabel,
Input, Input,
@@ -25,8 +30,14 @@ import {
useGetCertTemplate, useGetCertTemplate,
useListWorkspaceCas, useListWorkspaceCas,
useListWorkspacePkiCollections, useListWorkspacePkiCollections,
useUpdateCertTemplate} from "@app/hooks/api"; useUpdateCertTemplate
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const validateTemplateRegexField = z const validateTemplateRegexField = z
@@ -45,7 +56,26 @@ const schema = z.object({
name: z.string().min(1), name: z.string().min(1),
commonName: validateTemplateRegexField, commonName: validateTemplateRegexField,
subjectAlternativeName: validateTemplateRegexField, subjectAlternativeName: validateTemplateRegexField,
ttl: z.string().trim().min(1) ttl: z.string().trim().min(1),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
}),
extendedKeyUsages: z.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
}); });
export type FormData = z.infer<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -61,9 +91,9 @@ type Props = {
export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Props) => { export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data: ca } = useGetCaById(caId); const { data: ca } = useGetCaById(caId);
const { data: certTemplate } = useGetCertTemplate( const { data: certTemplate } = useGetCertTemplate(
(popUp?.certificateTemplate?.data as { id: string })?.id || "" (popUp?.certificateTemplate?.data as { id: string })?.id || ""
); );
@@ -86,7 +116,13 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro
reset, reset,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema) resolver: zodResolver(schema),
defaultValues: {
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
}
}
}); });
useEffect(() => { useEffect(() => {
@@ -97,14 +133,23 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro
commonName: certTemplate.commonName, commonName: certTemplate.commonName,
subjectAlternativeName: certTemplate.subjectAlternativeName, subjectAlternativeName: certTemplate.subjectAlternativeName,
collectionId: certTemplate.pkiCollectionId ?? undefined, collectionId: certTemplate.pkiCollectionId ?? undefined,
ttl: certTemplate.ttl ttl: certTemplate.ttl,
keyUsages: Object.fromEntries(certTemplate.keyUsages.map((name) => [name, true]) ?? []),
extendedKeyUsages: Object.fromEntries(
certTemplate.extendedKeyUsages.map((name) => [name, true]) ?? []
)
}); });
} else { } else {
reset({ reset({
caId, caId,
name: "", name: "",
commonName: "", commonName: "",
ttl: "" ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
}); });
} }
}, [certTemplate, ca]); }, [certTemplate, ca]);
@@ -114,7 +159,9 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro
name, name,
commonName, commonName,
subjectAlternativeName, subjectAlternativeName,
ttl ttl,
keyUsages,
extendedKeyUsages
}: FormData) => { }: FormData) => {
if (!currentWorkspace?.id) { if (!currentWorkspace?.id) {
return; return;
@@ -130,7 +177,13 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro
name, name,
commonName, commonName,
subjectAlternativeName, subjectAlternativeName,
ttl ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
}); });
createNotification({ createNotification({
@@ -145,7 +198,13 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro
name, name,
commonName, commonName,
subjectAlternativeName, subjectAlternativeName,
ttl ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
}); });
createNotification({ createNotification({
@@ -332,7 +391,84 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro
</FormControl> </FormControl>
)} )}
/> />
<div className="flex items-center"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mt-2 mb-7 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mt-2 mb-7 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-4 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
size="sm" size="sm"

View File

@@ -1,11 +1,15 @@
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
faArrowUpRightFromSquare, faArrowDown,
faClock, faArrowUp,
faEdit, faArrowUpRightFromSquare,
faPlus, faClock,
faServer, faEdit,
faXmark faMagnifyingGlass,
faPlus,
faServer,
faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -15,337 +19,418 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
IconButton, IconButton,
Modal, Input,
ModalContent, Modal,
Table, ModalContent,
TableContainer, Pagination,
TableSkeleton, Spinner,
Tag, Table,
TBody, TableContainer,
Td, TableSkeleton,
Th, Tag,
THead, TBody,
Tooltip, Td,
Tr Th,
THead,
Tooltip,
Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { useDebounce } from "@app/hooks";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { IdentityMembership } from "@app/hooks/api/identities/types"; import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectIdentityOrderBy } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityModal } from "./components/IdentityModal"; import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoleForm } from "./components/IdentityRoleForm"; import { IdentityRoleForm } from "./components/IdentityRoleForm";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const INIT_PER_PAGE = 10;
const formatRoleName = (role: string, customRoleName?: string) => { const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName; if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer"; if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access"; if (role === ProjectMembershipRole.NoAccess) return "No access";
return role; return role;
}; };
export const IdentityTab = withProjectPermission( export const IdentityTab = withProjectPermission(
() => { () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? ""; const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState(ProjectIdentityOrderBy.Name);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search);
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); const workspaceId = currentWorkspace?.id ?? "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const offset = (page - 1) * perPage;
"identity", const { data, isLoading, isFetching } = useGetWorkspaceIdentityMemberships(
"deleteIdentity", {
"upgradePlan", workspaceId: currentWorkspace?.id || "",
"updateRole" offset,
] as const); limit: perPage,
orderDirection,
orderBy,
search: debouncedSearch
},
{ keepPreviousData: true }
);
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const onRemoveIdentitySubmit = async (identityId: string) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
try { "identity",
await deleteMutateAsync({ "deleteIdentity",
identityId, "upgradePlan",
workspaceId "updateRole"
}); ] as const);
createNotification({ const onRemoveIdentitySubmit = async (identityId: string) => {
text: "Successfully removed identity from project", try {
type: "success" await deleteMutateAsync({
}); identityId,
workspaceId
});
handlePopUpClose("deleteIdentity"); createNotification({
} catch (err) { text: "Successfully removed identity from project",
console.error(err); type: "success"
const error = err as any; });
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({ handlePopUpClose("deleteIdentity");
text, } catch (err) {
type: "error" console.error(err);
}); const error = err as any;
} const text = error?.response?.data?.message ?? "Failed to remove identity from project";
};
return ( createNotification({
<motion.div text,
key="identity-role-panel" type: "error"
transition={{ duration: 0.15 }} });
initial={{ opacity: 0, translateX: 30 }} }
animate={{ opacity: 1, translateX: 0 }} };
exit={{ opacity: 0, translateX: 30 }}
useEffect(() => {
// reset page if no longer valid
if (data && data.totalCount < offset) setPage(1);
}, [data?.totalCount]);
const handleSort = (column: ProjectIdentityOrderBy) => {
if (column === orderBy) {
setOrderDirection((prev) =>
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
return (
<motion.div
key="identity-role-panel"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
> >
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> {(isAllowed) => (
<div className="mb-4 flex items-center justify-between"> <Button
<p className="text-xl font-semibold text-mineshaft-100">Identities</p> colorSchema="primary"
<div className="flex w-full justify-end pr-4"> type="submit"
<Link href="https://infisical.com/docs/documentation/platform/identities/overview"> leftIcon={<FontAwesomeIcon icon={faPlus} />}
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"> onClick={() => handlePopUpOpen("identity")}
Documentation{" "} isDisabled={!isAllowed}
<FontAwesomeIcon >
icon={faArrowUpRightFromSquare} Add identity
className="mb-[0.06rem] ml-1 text-xs" </Button>
/> )}
</span> </ProjectPermissionCan>
</Link> </div>
</div> <Input
<ProjectPermissionCan containerClassName="mb-4"
I={ProjectPermissionActions.Create} value={search}
a={ProjectPermissionSub.Identity} onChange={(e) => setSearch(e.target.value)}
> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
{(isAllowed) => ( placeholder="Search identities by name..."
<Button />
colorSchema="primary" <TableContainer>
type="submit" <Table>
leftIcon={<FontAwesomeIcon icon={faPlus} />} <THead>
onClick={() => handlePopUpOpen("identity")} <Tr className="h-14">
isDisabled={!isAllowed} <Th className="w-1/3">
> <div className="flex items-center">
Add identity Name
</Button> <IconButton
)} variant="plain"
</ProjectPermissionCan> className={`ml-2 ${
orderBy === ProjectIdentityOrderBy.Name ? "" : "opacity-30"
}`}
ariaLabel="sort"
onClick={() => handleSort(ProjectIdentityOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === ProjectIdentityOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div> </div>
<TableContainer> </Th>
<Table> <Th className="w-1/3">Role</Th>
<THead> <Th>Added on</Th>
<Tr> <Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
<Th>Name</Th> </Tr>
<Th>Role</Th> </THead>
<Th>Added on</Th> <TBody>
<Th className="w-5" /> {isLoading && <TableSkeleton columns={4} innerKey="project-identities" />}
</Tr> {!isLoading &&
</THead> data &&
<TBody> data.identityMemberships.length > 0 &&
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />} data.identityMemberships.map((identityMember, index) => {
{!isLoading && const {
data && identity: { id, name },
data.length > 0 && roles,
data.map((identityMember, index) => { createdAt
const { } = identityMember;
identity: { id, name }, return (
roles, <Tr className="h-10" key={`st-v3-${id}`}>
createdAt <Td>{name}</Td>
} = identityMember;
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td> <Td>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{roles {roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map( .map(
({ ({
role, role,
customRoleName, customRoleName,
id: roleId, id: roleId,
isTemporary, isTemporary,
temporaryAccessEndTime temporaryAccessEndTime
}) => { }) => {
const isExpired = const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string)); new Date() > new Date(temporaryAccessEndTime || ("" as string));
return ( return (
<Tag key={roleId}> <Tag key={roleId}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="capitalize"> <div className="capitalize">
{formatRoleName(role, customRoleName)} {formatRoleName(role, customRoleName)}
</div> </div>
{isTemporary && ( {isTemporary && (
<div> <div>
<Tooltip <Tooltip
content={ content={
isExpired isExpired
? "Timed role expired" ? "Timed role expired"
: "Timed role access" : "Timed role access"
} }
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faClock} icon={faClock}
className={twMerge(isExpired && "text-red-600")} className={twMerge(isExpired && "text-red-600")}
/> />
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> </div>
</Tag> </Tag>
); );
} }
)} )}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard> <HoverCard>
<HoverCardTrigger> <HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag> <Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4"> <HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles {roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map( .map(
({ ({
role, role,
customRoleName, customRoleName,
id: roleId, id: roleId,
isTemporary, isTemporary,
temporaryAccessEndTime temporaryAccessEndTime
}) => { }) => {
const isExpired = const isExpired =
new Date() > new Date() >
new Date(temporaryAccessEndTime || ("" as string)); new Date(temporaryAccessEndTime || ("" as string));
return ( return (
<Tag key={roleId} className="capitalize"> <Tag key={roleId} className="capitalize">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div> <div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && ( {isTemporary && (
<div> <div>
<Tooltip <Tooltip
content={ content={
isExpired isExpired
? "Access expired" ? "Access expired"
: "Temporary access" : "Temporary access"
} }
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faClock} icon={faClock}
className={twMerge( className={twMerge(
new Date() > new Date() >
new Date( new Date(
temporaryAccessEndTime as string temporaryAccessEndTime as string
) && "text-red-600" ) && "text-red-600"
)} )}
/> />
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> </div>
</Tag> </Tag>
);
}
)}
</HoverCardContent>
</HoverCard>
)}
<Tooltip content="Edit permission">
<IconButton
size="sm"
variant="plain"
ariaLabel="update-role"
onClick={() =>
handlePopUpOpen("updateRole", { ...identityMember, index })
}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
); );
})} }
{!isLoading && data && data?.length === 0 && ( )}
<Tr> </HoverCardContent>
<Td colSpan={7}> </HoverCard>
<EmptyState )}
title="No identities have been added to this project" <Tooltip content="Edit permission">
icon={faServer} <IconButton
/> size="sm"
</Td> variant="plain"
</Tr> ariaLabel="update-role"
)} onClick={() =>
</TBody> handlePopUpOpen("updateRole", { ...identityMember, index })
</Table> }
</TableContainer> >
<Modal <FontAwesomeIcon icon={faEdit} />
isOpen={popUp.updateRole.isOpen} </IconButton>
onOpenChange={(state) => handlePopUpToggle("updateRole", state)} </Tooltip>
> </div>
<ModalContent </Td>
className="max-w-3xl" <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
title={`Manage Access for ${(popUp.updateRole.data as IdentityMembership)?.identity?.name <Td className="flex justify-end">
}`} <ProjectPermissionCan
subTitle={` I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No identities match search filter"
: "No identities have been added to this project"
}
icon={faServer}
/>
)}
</TableContainer>
<Modal
isOpen={popUp.updateRole.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
>
<ModalContent
className="max-w-3xl"
title={`Manage Access for ${
(popUp.updateRole.data as IdentityMembership)?.identity?.name
}`}
subTitle={`
Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role. Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role.
`} `}
> >
<IdentityRoleForm <IdentityRoleForm
onOpenUpgradeModal={(description) => onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description }) handlePopUpOpen("upgradePlan", { description })
} }
identityProjectMember={ identityProjectMember={
data?.[ data?.identityMemberships[
(popUp.updateRole?.data as IdentityMembership & { index: number })?.index (popUp.updateRole?.data as IdentityMembership & { index: number })?.index
] as IdentityMembership ] as IdentityMembership
} }
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen} isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${(popUp?.deleteIdentity?.data as { name: string })?.name || "" title={`Are you sure want to remove ${
} from the project?`} (popUp?.deleteIdentity?.data as { name: string })?.name || ""
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)} } from the project?`}
deleteKey="confirm" onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
onDeleteApproved={() => deleteKey="confirm"
onRemoveIdentitySubmit( onDeleteApproved={() =>
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId onRemoveIdentitySubmit(
) (popUp?.deleteIdentity?.data as { identityId: string })?.identityId
} )
/> }
</div> />
</motion.div> </div>
); </motion.div>
}, );
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
); );

View File

@@ -1,11 +1,12 @@
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import Link from "next/link"; import Link from "next/link";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { ComboBox } from "@app/components/v2/ComboBox";
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { import {
useAddIdentityToWorkspace, useAddIdentityToWorkspace,
@@ -17,8 +18,14 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup const schema = yup
.object({ .object({
identityId: yup.string().required("Identity id is required"), identity: yup.object({
role: yup.string() id: yup.string().required("Identity id is required"),
name: yup.string().required("Identity name is required")
}),
role: yup.object({
slug: yup.string().required("role slug is required"),
name: yup.string().required("role name is required")
})
}) })
.required(); .required();
@@ -33,14 +40,26 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?.id || ""; const organizationId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || ""; const projectSlug = currentWorkspace?.slug || "";
const { data: identityMembershipOrgs } = useGetIdentityMembershipOrgs(orgId); const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({
const { data: identityMemberships } = useGetWorkspaceIdentityMemberships(workspaceId); organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will replace with combobox in separate PR
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
workspaceId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will optimize in PR referenced above
});
const identityMemberships = identityMembershipsData?.identityMemberships;
const { data: roles } = useGetProjectRoles(projectSlug); const {
data: roles,
isLoading: isRolesLoading,
isFetched: isRolesFetched
} = useGetProjectRoles(projectSlug);
const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace();
@@ -58,17 +77,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
control, control,
handleSubmit, handleSubmit,
reset, reset,
setValue,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: yupResolver(schema) resolver: yupResolver(schema)
}); });
const onFormSubmit = async ({ identityId, role }: FormData) => { useEffect(() => {
if (!isRolesFetched || !roles) return;
setValue("role", { name: roles[0]?.name, slug: roles[0]?.slug });
}, [isRolesFetched, roles]);
const onFormSubmit = async ({ identity, role }: FormData) => {
try { try {
await addIdentityToWorkspaceMutateAsync({ await addIdentityToWorkspaceMutateAsync({
workspaceId, workspaceId,
identityId, identityId: identity.id,
role: role || undefined role: role.slug || undefined
}); });
createNotification({ createNotification({
@@ -76,7 +102,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
type: "success" type: "success"
}); });
reset(); const nextAvailableMembership = filteredIdentityMembershipOrgs.filter(
(membership) => membership.identity.id !== identity.id
)[0];
// prevents combobox from displaying previously added identity
reset({
identity: {
name: nextAvailableMembership?.identity.name,
id: nextAvailableMembership?.identity.id
}
});
handlePopUpToggle("identity", false); handlePopUpToggle("identity", false);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -98,34 +134,41 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset(); reset();
}} }}
> >
<ModalContent title="Add Identity to Project"> <ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
{filteredIdentityMembershipOrgs.length ? ( {filteredIdentityMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller <Controller
control={control} control={control}
name="identityId" name="identity"
defaultValue={filteredIdentityMembershipOrgs?.[0]?.id} defaultValue={{
id: filteredIdentityMembershipOrgs?.[0]?.identity?.id,
name: filteredIdentityMembershipOrgs?.[0]?.identity?.name
}}
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}> <FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<Select <ComboBox
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
> by="id"
{filteredIdentityMembershipOrgs.map(({ identity }) => ( value={{ id: field.value.id, name: field.value.name }}
<SelectItem value={identity.id} key={`org-identity-${identity.id}`}> defaultValue={{ id: field.value.id, name: field.value.name }}
{identity.name} onSelectChange={(value) => onChange({ id: value.id, name: value.name })}
</SelectItem> displayValue={(el) => el.name}
))} onFilter={({ value }, filterQuery) =>
</Select> value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={filteredIdentityMembershipOrgs.map(({ identity }) => ({
key: identity.id,
value: { id: identity.id, name: identity.name },
label: identity.name
}))}
/>
</FormControl> </FormControl>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="role" name="role"
defaultValue="" defaultValue={{ name: "", slug: "" }}
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Role" label="Role"
@@ -133,18 +176,22 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)} isError={Boolean(error)}
className="mt-4" className="mt-4"
> >
<Select <ComboBox
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
> by="slug"
{(roles || []).map(({ name, slug }) => ( value={{ slug: field.value.slug, name: field.value.name }}
<SelectItem value={slug} key={`st-role-${slug}`}> defaultValue={{ slug: field.value.slug, name: field.value.name }}
{name} onSelectChange={(value) => onChange({ slug: value.slug, name: value.name })}
</SelectItem> displayValue={(el) => el.name}
))} onFilter={({ value }, filterQuery) =>
</Select> value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={(roles || []).map(({ slug, name }) => ({
key: slug,
value: { slug, name },
label: name
}))}
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -158,9 +205,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
> >
{popUp?.identity?.data ? "Update" : "Create"} {popUp?.identity?.data ? "Update" : "Create"}
</Button> </Button>
<Button colorSchema="secondary" variant="plain"> <ModalClose asChild>
Cancel <Button colorSchema="secondary" variant="plain">
</Button> Cancel
</Button>
</ModalClose>
</div> </div>
</form> </form>
) : ( ) : (
@@ -169,7 +218,9 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
All identities in your organization have already been added to this project. All identities in your organization have already been added to this project.
</div> </div>
<Link href={`/org/${currentWorkspace?.orgId}/members`}> <Link href={`/org/${currentWorkspace?.orgId}/members`}>
<Button variant="outline_bg">Create a new identity</Button> <Button isDisabled={isRolesLoading} isLoading={isRolesLoading} variant="outline_bg">
Create a new identity
</Button>
</Link> </Link>
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
@@ -8,14 +8,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions"; import { PermissionDeniedBanner } from "@app/components/permissions";
import { ContentLoader } from "@app/components/v2"; import { ContentLoader, Pagination } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
import { import {
useGetDynamicSecrets, useGetDynamicSecrets,
useGetImportedSecretsSingleEnv, useGetImportedSecretsSingleEnv,
@@ -39,7 +39,7 @@ import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView } from "./components/SecretListView"; import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView"; import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store"; import { StoreProvider } from "./SecretMainPage.store";
import { Filter, GroupBy, SortDir } from "./SecretMainPage.types"; import { Filter, SortDir } from "./SecretMainPage.types";
const LOADER_TEXT = [ const LOADER_TEXT = [
"Retrieving your encrypted secrets...", "Retrieving your encrypted secrets...",
@@ -47,6 +47,7 @@ const LOADER_TEXT = [
"Getting secret import links..." "Getting secret import links..."
]; ];
const INIT_PER_PAGE = 10;
export const SecretMainPage = () => { export const SecretMainPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
@@ -59,6 +60,10 @@ export const SecretMainPage = () => {
tags: {}, tags: {},
searchFilter: (router.query.searchFilter as string) || "" searchFilter: (router.query.searchFilter as string) || ""
}); });
const debouncedSearchFilter = useDebounce(filter.searchFilter);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const paginationOffset = (page - 1) * perPage;
const [snapshotId, setSnapshotId] = useState<string | null>(null); const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId); const isRollbackMode = Boolean(snapshotId);
@@ -185,11 +190,6 @@ export const SecretMainPage = () => {
}); });
}; };
const handleGroupByChange = useCallback(
(groupBy?: GroupBy) => setFilter((state) => ({ ...state, groupBy })),
[]
);
const handleTagToggle = useCallback( const handleTagToggle = useCallback(
(tagId: string) => (tagId: string) =>
setFilter((state) => { setFilter((state) => {
@@ -223,6 +223,113 @@ export const SecretMainPage = () => {
const loadingOnAccess = const loadingOnAccess =
canReadSecret && canReadSecret &&
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading); (isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading);
const rows = useMemo(() => {
const filteredSecrets =
secrets
?.filter(({ key, tags: secretTags, value }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
return (
(!isTagFilterActive || secretTags?.some(({ id }) => filter.tags?.[id])) &&
(key.toUpperCase().includes(debouncedSearchFilter.toUpperCase()) ||
value?.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
);
})
.sort((a, b) =>
sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key)
) ?? [];
const filteredFolders =
folders
?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) =>
sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
) ?? [];
const filteredDynamicSecrets =
dynamicSecrets
?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) =>
sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
) ?? [];
const filteredSecretImports =
secretImports
?.filter(({ importPath }) =>
importPath.toLowerCase().includes(debouncedSearchFilter.toLowerCase())
)
.sort((a, b) =>
sortDir === "asc"
? a.importPath.localeCompare(b.importPath)
: b.importPath.localeCompare(a.importPath)
) ?? [];
const totalRows =
filteredSecretImports.length +
filteredFolders.length +
filteredDynamicSecrets.length +
filteredSecrets.length;
const paginatedImports = filteredSecretImports.slice(
paginationOffset,
paginationOffset + perPage
);
let remainingRows = perPage - paginatedImports.length;
const foldersStartIndex = Math.max(0, paginationOffset - filteredSecretImports.length);
const paginatedFolders =
remainingRows > 0
? filteredFolders.slice(foldersStartIndex, foldersStartIndex + remainingRows)
: [];
remainingRows -= paginatedFolders.length;
const dynamicSecretStartIndex = Math.max(
0,
paginationOffset - filteredSecretImports.length - filteredFolders.length
);
const paginatiedDynamicSecrets =
remainingRows > 0
? filteredDynamicSecrets.slice(
dynamicSecretStartIndex,
dynamicSecretStartIndex + remainingRows
)
: [];
remainingRows -= paginatiedDynamicSecrets.length;
const secretStartIndex = Math.max(
0,
paginationOffset -
filteredSecretImports.length -
filteredFolders.length -
filteredDynamicSecrets.length
);
const paginatiedSecrets =
remainingRows > 0
? filteredSecrets.slice(secretStartIndex, secretStartIndex + remainingRows)
: [];
return {
imports: paginatedImports,
folders: paginatedFolders,
secrets: paginatiedSecrets,
dynamicSecrets: paginatiedDynamicSecrets,
totalRows
};
}, [
sortDir,
debouncedSearchFilter,
folders,
secrets,
dynamicSecrets,
paginationOffset,
perPage,
filter.tags,
importedSecrets
]);
useEffect(() => {
// reset page if no longer valid
if (rows.totalRows < paginationOffset) setPage(1);
}, [rows.totalRows]);
// loading screen when you don't have permission but as folder's is viewable need to wait for that // loading screen when you don't have permission but as folder's is viewable need to wait for that
const loadingOnDenied = !canReadSecret && isFoldersLoading; const loadingOnDenied = !canReadSecret && isFoldersLoading;
if (loadingOnAccess || loadingOnDenied) { if (loadingOnAccess || loadingOnDenied) {
@@ -258,7 +365,6 @@ export const SecretMainPage = () => {
filter={filter} filter={filter}
tags={tags} tags={tags}
onVisiblilityToggle={handleToggleVisibility} onVisiblilityToggle={handleToggleVisibility}
onGroupByChange={handleGroupByChange}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle} onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0} snapshotCount={snapshotCount || 0}
@@ -291,7 +397,7 @@ export const SecretMainPage = () => {
{canReadSecret && ( {canReadSecret && (
<SecretImportListView <SecretImportListView
searchTerm={filter.searchFilter} searchTerm={filter.searchFilter}
secretImports={secretImports} secretImports={rows.imports}
isFetching={isSecretImportsLoading || isSecretImportsFetching} isFetching={isSecretImportsLoading || isSecretImportsFetching}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
@@ -301,7 +407,7 @@ export const SecretMainPage = () => {
/> />
)} )}
<FolderListView <FolderListView
folders={folders} folders={rows.folders}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
@@ -314,15 +420,13 @@ export const SecretMainPage = () => {
environment={environment} environment={environment}
projectSlug={projectSlug} projectSlug={projectSlug}
secretPath={secretPath} secretPath={secretPath}
dynamicSecrets={dynamicSecrets || []} dynamicSecrets={rows.dynamicSecrets || []}
/> />
)} )}
{canReadSecret && ( {canReadSecret && (
<SecretListView <SecretListView
secrets={secrets} secrets={rows.secrets}
tags={tags} tags={tags}
filter={filter}
sortDir={sortDir}
isVisible={isVisible} isVisible={isVisible}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
@@ -331,6 +435,16 @@ export const SecretMainPage = () => {
/> />
)} )}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />} {!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
{!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && (
<Pagination
className="border-t border-solid border-t-mineshaft-600"
count={rows.totalRows}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</div> </div>
</div> </div>
<CreateSecretForm <CreateSecretForm

View File

@@ -1,7 +1,6 @@
export type Filter = { export type Filter = {
tags: Record<string, boolean>; tags: Record<string, boolean>;
searchFilter: string; searchFilter: string;
groupBy?: GroupBy | null;
}; };
export enum SortDir { export enum SortDir {
@@ -9,6 +8,8 @@ export enum SortDir {
DESC = "desc" DESC = "desc"
} }
export enum GroupBy { export enum RowType {
PREFIX = "prefix" Folder = "folder",
DynamicSecret = "dynamic",
Secret = "Secret"
} }

View File

@@ -62,7 +62,7 @@ import {
useSelectedSecretActions, useSelectedSecretActions,
useSelectedSecrets useSelectedSecrets
} from "../../SecretMainPage.store"; } from "../../SecretMainPage.store";
import { Filter, GroupBy } from "../../SecretMainPage.types"; import { Filter } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm"; import { FolderForm } from "./FolderForm";
@@ -81,7 +81,6 @@ type Props = {
isVisible?: boolean; isVisible?: boolean;
snapshotCount: number; snapshotCount: number;
isSnapshotCountLoading?: boolean; isSnapshotCountLoading?: boolean;
onGroupByChange: (opt?: GroupBy) => void;
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void; onToggleTagFilter: (tagId: string) => void;
onVisiblilityToggle: () => void; onVisiblilityToggle: () => void;
@@ -101,7 +100,6 @@ export const ActionBar = ({
isSnapshotCountLoading, isSnapshotCountLoading,
onSearchChange, onSearchChange,
onToggleTagFilter, onToggleTagFilter,
onGroupByChange,
onVisiblilityToggle, onVisiblilityToggle,
onClickRollbackMode onClickRollbackMode
}: Props) => { }: Props) => {
@@ -307,16 +305,6 @@ export const ActionBar = ({
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0"> <DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Group By</DropdownMenuGroup>
<DropdownMenuItem
iconPos="right"
icon={
filter?.groupBy === GroupBy.PREFIX && <FontAwesomeIcon icon={faCheckCircle} />
}
onClick={() => onGroupByChange(!filter.groupBy ? GroupBy.PREFIX : undefined)}
>
Prefix
</DropdownMenuItem>
<DropdownMenuGroup>Filter By</DropdownMenuGroup> <DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownSubMenu> <DropdownSubMenu>
<DropdownSubMenuTrigger <DropdownSubMenuTrigger

View File

@@ -123,7 +123,7 @@ export const SecretImportListView = ({
if (!isFetching) { if (!isFetching) {
setItems(secretImports); setItems(secretImports);
} }
}, [isFetching]); }, [isFetching, secretImports]);
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
const { mutate: updateSecretImport } = useUpdateSecretImport(); const { mutate: updateSecretImport } = useUpdateSecretImport();

View File

@@ -1,7 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { CreateTagModal } from "@app/components/tags/CreateTagModal"; import { CreateTagModal } from "@app/components/tags/CreateTagModal";
@@ -16,7 +15,7 @@ import { WsTag } from "@app/hooks/api/types";
import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal"; import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types"; import { Filter } from "../../SecretMainPage.types";
import { SecretDetailSidebar } from "./SecretDetaiSidebar"; import { SecretDetailSidebar } from "./SecretDetaiSidebar";
import { SecretItem } from "./SecretItem"; import { SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils"; import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
@@ -26,53 +25,11 @@ type Props = {
environment: string; environment: string;
workspaceId: string; workspaceId: string;
secretPath?: string; secretPath?: string;
filter: Filter;
sortDir?: SortDir;
tags?: WsTag[]; tags?: WsTag[];
isVisible?: boolean; isVisible?: boolean;
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
}; };
const reorderSecretGroupByUnderscore = (secrets: SecretV3RawSanitized[], sortDir: SortDir) => {
const groupedSecrets: Record<string, SecretV3RawSanitized[]> = {};
secrets.forEach((secret) => {
const lastSeperatorIndex = secret.key.lastIndexOf("_");
const namespace =
lastSeperatorIndex !== -1 ? secret.key.substring(0, lastSeperatorIndex) : "misc";
if (!groupedSecrets?.[namespace]) groupedSecrets[namespace] = [];
groupedSecrets[namespace].push(secret);
});
return Object.keys(groupedSecrets)
.sort((a, b) =>
sortDir === SortDir.ASC
? a.toLowerCase().localeCompare(b.toLowerCase())
: b.toLowerCase().localeCompare(a.toLowerCase())
)
.map((namespace) => ({ namespace, secrets: groupedSecrets[namespace] }));
};
const reorderSecret = (
secrets: SecretV3RawSanitized[],
sortDir: SortDir,
filter?: GroupBy | null
) => {
if (filter === GroupBy.PREFIX) {
return reorderSecretGroupByUnderscore(secrets, sortDir);
}
return [
{
namespace: "",
secrets: secrets?.sort((a, b) =>
sortDir === SortDir.ASC
? a.key.toLowerCase().localeCompare(b.key.toLowerCase())
: b.key.toLowerCase().localeCompare(a.key.toLowerCase())
)
}
];
};
export const filterSecrets = (secrets: SecretV3RawSanitized[], filter: Filter) => export const filterSecrets = (secrets: SecretV3RawSanitized[], filter: Filter) =>
secrets.filter(({ key, value, tags }) => { secrets.filter(({ key, value, tags }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length); const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
@@ -88,8 +45,6 @@ export const SecretListView = ({
environment, environment,
workspaceId, workspaceId,
secretPath = "/", secretPath = "/",
filter,
sortDir = SortDir.ASC,
tags: wsTags = [], tags: wsTags = [],
isVisible, isVisible,
isProtectedBranch = false isProtectedBranch = false
@@ -331,52 +286,30 @@ export const SecretListView = ({
return ( return (
<> <>
{reorderSecret(secrets, sortDir, filter.groupBy).map( {FontAwesomeSpriteSymbols.map(({ icon, symbol }) => (
({ namespace, secrets: groupedSecrets }) => { <FontAwesomeIcon icon={icon} symbol={symbol} key={`font-awesome-svg-spritie-${symbol}`} />
const filteredSecrets = filterSecrets(groupedSecrets, filter); ))}
return ( {secrets.map((secret) => (
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}> <SecretItem
<div environment={environment}
className={twMerge( secretPath={secretPath}
"text-md h-0 bg-bunker-600 capitalize transition-all", tags={wsTags}
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 " isSelected={selectedSecrets?.[secret.id]}
)} onToggleSecretSelect={toggleSelectedSecret}
key={namespace} isVisible={isVisible}
> secret={secret}
{namespace} key={secret.id}
</div> onSaveSecret={handleSaveSecret}
{FontAwesomeSpriteSymbols.map(({ icon, symbol }) => ( onDeleteSecret={onDeleteSecret}
<FontAwesomeIcon onDetailViewSecret={onDetailViewSecret}
icon={icon} onCreateTag={onCreateTag}
symbol={symbol} handleSecretShare={() =>
key={`font-awesome-svg-spritie-${symbol}`} handlePopUpOpen("createSharedSecret", {
/> value: secret.valueOverride ?? secret.value
))} })
{filteredSecrets.map((secret) => ( }
<SecretItem />
environment={environment} ))}
secretPath={secretPath}
tags={wsTags}
isSelected={selectedSecrets?.[secret.id]}
onToggleSecretSelect={toggleSelectedSecret}
isVisible={isVisible}
secret={secret}
key={secret.id}
onSaveSecret={handleSaveSecret}
onDeleteSecret={onDeleteSecret}
onDetailViewSecret={onDetailViewSecret}
onCreateTag={onCreateTag}
handleSecretShare={() =>
handlePopUpOpen("createSharedSecret", {
value: secret.valueOverride ?? secret.value
})
}
/>
))}
</div>
);
}
)}
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteSecret.isOpen} isOpen={popUp.deleteSecret.isOpen}
deleteKey={(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key} deleteKey={(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -31,6 +31,7 @@ import {
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -49,7 +50,7 @@ import {
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
import { import {
useCreateFolder, useCreateFolder,
useCreateSecretV3, useCreateSecretV3,
@@ -78,6 +79,14 @@ export enum EntryType {
SECRET = "secret" SECRET = "secret"
} }
enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "Secret"
}
const INIT_PER_PAGE = 10;
export const SecretOverviewPage = () => { export const SecretOverviewPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -101,6 +110,7 @@ export const SecretOverviewPage = () => {
const workspaceId = currentWorkspace?.id as string; const workspaceId = currentWorkspace?.id as string;
const projectSlug = currentWorkspace?.slug as string; const projectSlug = currentWorkspace?.slug as string;
const [searchFilter, setSearchFilter] = useState(""); const [searchFilter, setSearchFilter] = useState("");
const debouncedSearchFilter = useDebounce(searchFilter);
const secretPath = (router.query?.secretPath as string) || "/"; const secretPath = (router.query?.secretPath as string) || "/";
const [selectedEntries, setSelectedEntries] = useState<{ const [selectedEntries, setSelectedEntries] = useState<{
@@ -111,6 +121,9 @@ export const SecretOverviewPage = () => {
[EntryType.SECRET]: {} [EntryType.SECRET]: {}
}); });
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const toggleSelectedEntry = useCallback( const toggleSelectedEntry = useCallback(
(type: EntryType, key: string) => { (type: EntryType, key: string) => {
const isChecked = Boolean(selectedEntries[type]?.[key]); const isChecked = Boolean(selectedEntries[type]?.[key]);
@@ -439,7 +452,40 @@ export const SecretOverviewPage = () => {
} }
}; };
if (isWorkspaceLoading) { const rows = useMemo(() => {
const filteredSecretNames =
secKeys
?.filter((name) => name.toUpperCase().includes(debouncedSearchFilter.toUpperCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
const filteredFolderNames =
folderNames
?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
const filteredDynamicSecrets =
dynamicSecretNames
?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
return [
...filteredFolderNames.map((name) => ({ name, type: RowType.Folder })),
...filteredDynamicSecrets.map((name) => ({ name, type: RowType.DynamicSecret })),
...filteredSecretNames.map((name) => ({ name, type: RowType.Secret }))
];
}, [sortDir, debouncedSearchFilter, secKeys, folderNames, dynamicSecretNames]);
const paginationOffset = (page - 1) * perPage;
useEffect(() => {
// reset page if no longer valid
if (rows.length < paginationOffset) setPage(1);
}, [rows.length]);
const isTableLoading =
folders?.some(({ isLoading }) => isLoading) ||
secrets?.some(({ isLoading }) => isLoading) ||
dynamicSecrets?.some(({ isLoading }) => isLoading);
if (isWorkspaceLoading || isTableLoading) {
return ( return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img <img
@@ -454,32 +500,16 @@ export const SecretOverviewPage = () => {
); );
} }
const isTableLoading = !(
folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading)
);
const canViewOverviewPage = Boolean(userAvailableEnvs.length); const canViewOverviewPage = Boolean(userAvailableEnvs.length);
// This is needed to also show imports from other paths right now those are missing. // This is needed to also show imports from other paths right now those are missing.
// const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()];
const filteredSecretNames = secKeys
?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
const filteredFolderNames = folderNames
?.filter((name) => name.toLowerCase().includes(searchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
const filteredDynamicSecrets = dynamicSecretNames
?.filter((name) => name.toLowerCase().includes(searchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
const isTableEmpty = const isTableEmpty =
!( !(
folders?.every(({ isLoading }) => isLoading) && folders?.every(({ isLoading }) => isLoading) &&
secrets?.every(({ isLoading }) => isLoading) && secrets?.every(({ isLoading }) => isLoading) &&
dynamicSecrets?.every(({ isLoading }) => isLoading) dynamicSecrets?.every(({ isLoading }) => isLoading)
) && ) && rows.length === 0;
filteredSecretNames?.length === 0 &&
filteredFolderNames?.length === 0 &&
filteredDynamicSecrets?.length === 0;
return ( return (
<> <>
@@ -656,7 +686,7 @@ export const SecretOverviewPage = () => {
resetSelectedEntries={resetSelectedEntries} resetSelectedEntries={resetSelectedEntries}
/> />
<div className="thin-scrollbar mt-4" ref={parentTableRef}> <div className="thin-scrollbar mt-4" ref={parentTableRef}>
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto"> <TableContainer>
<Table> <Table>
<THead> <THead>
<Tr className="sticky top-0 z-20 border-0"> <Tr className="sticky top-0 z-20 border-0">
@@ -753,7 +783,7 @@ export const SecretOverviewPage = () => {
<Td colSpan={visibleEnvs.length + 1}> <Td colSpan={visibleEnvs.length + 1}>
<EmptyState <EmptyState
title={ title={
searchFilter debouncedSearchFilter
? "No secret found for your search, add one now" ? "No secret found for your search, add one now"
: "Let's add some secrets" : "Let's add some secrets"
} }
@@ -774,48 +804,59 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
)} )}
{!isTableLoading && {!isTableLoading &&
filteredFolderNames.map((folderName, index) => ( rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => {
<SecretOverviewFolderRow switch (row.type) {
folderName={folderName} case RowType.Secret:
isFolderPresentInEnv={isFolderPresentInEnv} if (visibleEnvs?.length === 0) return null;
isSelected={selectedEntries.folder[folderName]} return (
onToggleFolderSelect={() => toggleSelectedEntry(EntryType.FOLDER, folderName)} <SecretOverviewTableRow
environments={visibleEnvs} isSelected={selectedEntries.secret[row.name]}
key={`overview-${folderName}-${index + 1}`} onToggleSecretSelect={() =>
onClick={handleFolderClick} toggleSelectedEntry(EntryType.SECRET, row.name)
onToggleFolderEdit={(name: string) => }
handlePopUpOpen("updateFolder", { name }) secretPath={secretPath}
} getImportedSecretByKey={getImportedSecretByKey}
/> isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
))} onSecretCreate={handleSecretCreate}
{!isTableLoading && onSecretDelete={handleSecretDelete}
filteredDynamicSecrets.map((dynamicSecretName, index) => ( onSecretUpdate={handleSecretUpdate}
<SecretOverviewDynamicSecretRow key={`overview-${row.name}-${index + 1}`}
dynamicSecretName={dynamicSecretName} environments={visibleEnvs}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv} secretKey={row.name}
environments={visibleEnvs} getSecretByKey={getSecretByKey}
key={`overview-${dynamicSecretName}-${index + 1}`} expandableColWidth={expandableTableWidth}
/> />
))} );
{!isTableLoading && case RowType.DynamicSecret:
visibleEnvs?.length > 0 && return (
filteredSecretNames.map((key, index) => ( <SecretOverviewDynamicSecretRow
<SecretOverviewTableRow dynamicSecretName={row.name}
isSelected={selectedEntries.secret[key]} isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)} environments={visibleEnvs}
secretPath={secretPath} key={`overview-${row.name}-${index + 1}`}
getImportedSecretByKey={getImportedSecretByKey} />
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} );
onSecretCreate={handleSecretCreate} case RowType.Folder:
onSecretDelete={handleSecretDelete} return (
onSecretUpdate={handleSecretUpdate} <SecretOverviewFolderRow
key={`overview-${key}-${index + 1}`} folderName={row.name}
environments={visibleEnvs} isFolderPresentInEnv={isFolderPresentInEnv}
secretKey={key} isSelected={selectedEntries.folder[row.name]}
getSecretByKey={getSecretByKey} onToggleFolderSelect={() =>
expandableColWidth={expandableTableWidth} toggleSelectedEntry(EntryType.FOLDER, row.name)
/> }
))} environments={visibleEnvs}
key={`overview-${row.name}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
);
default:
return null;
}
})}
</TBody> </TBody>
<TFoot> <TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800"> <Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
@@ -842,6 +883,16 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
</TFoot> </TFoot>
</Table> </Table>
{!isTableLoading && rows.length > INIT_PER_PAGE && (
<Pagination
className="border-t border-solid border-t-mineshaft-600"
count={rows.length}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</TableContainer> </TableContainer>
</div> </div>
</div> </div>