diff --git a/.infisicalignore b/.infisicalignore index 441031b338..46e38aa23f 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -58,4 +58,5 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139 docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62 docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61 backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246 -backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248 \ No newline at end of file +backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248 +docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:142 \ No newline at end of file diff --git a/Makefile b/Makefile index 2352e51348..a5a87dfbc7 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ up-dev-metrics: docker compose -f docker-compose.dev.yml --profile metrics up --build up-prod: - docker-compose -f docker-compose.prod.yml up --build + docker compose -f docker-compose.prod.yml up --build down: docker compose -f docker-compose.dev.yml down diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 80f6fed6cb..d02eabe817 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -192,3 +192,28 @@ Feature: Challenge And the value response with jq ".status" should be equal to 400 And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badCSR" And the value response with jq ".detail" should be equal to "Invalid CSR: Common name + SANs mismatch with order identifiers" + + Scenario: Get certificate without passing challenge when skip DNS ownership verification is enabled + Given I create an ACME profile with config as "acme_profile" + """ + { + "skipDnsOwnershipVerification": true + } + """ + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.body with jq ".status" should be equal to "ready" + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq ".subject.common_name" should be equal to "localhost" diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index c0b2fee8f0..1ce839638f 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -266,6 +266,46 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str): ) +@given( + 'I create an ACME profile with config as "{profile_var}"' +) +def step_impl(context: Context, profile_var: str): + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + acme_config = replace_vars(json.loads(context.text), context.vars) + response = context.http_client.post( + "/api/v1/cert-manager/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": context.vars["CERT_CA_ID"], + "certificateTemplateId": context.vars["CERT_TEMPLATE_ID"], + "acmeConfig": acme_config, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) + + @given('I have an ACME cert profile with external ACME CA as "{profile_var}"') def step_impl(context: Context, profile_var: str): profile_id = context.vars.get("PROFILE_ID") diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 1301000d58..67c8adfa93 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -170,6 +170,9 @@ import { TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, + TIdentityGroupMembership, + TIdentityGroupMembershipInsert, + TIdentityGroupMembershipUpdate, TIdentityJwtAuths, TIdentityJwtAuthsInsert, TIdentityJwtAuthsUpdate, @@ -857,6 +860,11 @@ declare module "knex/types/tables" { TUserGroupMembershipInsert, TUserGroupMembershipUpdate >; + [TableName.IdentityGroupMembership]: KnexOriginal.CompositeTableType< + TIdentityGroupMembership, + TIdentityGroupMembershipInsert, + TIdentityGroupMembershipUpdate + >; [TableName.GroupProjectMembership]: KnexOriginal.CompositeTableType< TGroupProjectMemberships, TGroupProjectMembershipsInsert, diff --git a/backend/src/db/migrations/20251202162715_add-identity-group-membership.ts b/backend/src/db/migrations/20251202162715_add-identity-group-membership.ts new file mode 100644 index 0000000000..ff67f9a9f1 --- /dev/null +++ b/backend/src/db/migrations/20251202162715_add-identity-group-membership.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityGroupMembership))) { + await knex.schema.createTable(TableName.IdentityGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("identityId").notNullable(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.unique(["identityId", "groupId"]); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityGroupMembership); +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityGroupMembership)) { + await knex.schema.dropTable(TableName.IdentityGroupMembership); + await dropOnUpdateTrigger(knex, TableName.IdentityGroupMembership); + } +} diff --git a/backend/src/db/migrations/20251209191101_add-acme-order-id-for-cert-requests.ts b/backend/src/db/migrations/20251209191101_add-acme-order-id-for-cert-requests.ts new file mode 100644 index 0000000000..3a87e75a40 --- /dev/null +++ b/backend/src/db/migrations/20251209191101_add-acme-order-id-for-cert-requests.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { dropConstraintIfExists } from "./utils/dropConstraintIfExists"; + +const FOREIGN_KEY_CONSTRAINT_NAME = "certificate_requests_acme_order_id_fkey"; +const INDEX_NAME = "certificate_requests_acme_order_id_idx"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateRequests)) { + const hasAcmeOrderId = await knex.schema.hasColumn(TableName.CertificateRequests, "acmeOrderId"); + + if (!hasAcmeOrderId) { + await knex.schema.alterTable(TableName.CertificateRequests, (t) => { + t.uuid("acmeOrderId").nullable(); + t.foreign("acmeOrderId", FOREIGN_KEY_CONSTRAINT_NAME) + .references("id") + .inTable(TableName.PkiAcmeOrder) + .onDelete("SET NULL"); + t.index("acmeOrderId", INDEX_NAME); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateRequests)) { + const hasAcmeOrderId = await knex.schema.hasColumn(TableName.CertificateRequests, "acmeOrderId"); + + if (hasAcmeOrderId) { + await dropConstraintIfExists(TableName.CertificateRequests, FOREIGN_KEY_CONSTRAINT_NAME, knex); + await knex.schema.alterTable(TableName.CertificateRequests, (t) => { + t.dropIndex("acmeOrderId", INDEX_NAME); + t.dropColumn("acmeOrderId"); + }); + } + } +} diff --git a/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts b/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts new file mode 100644 index 0000000000..6ae5b40397 --- /dev/null +++ b/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + if (!(await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification"))) { + await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.boolean("skipDnsOwnershipVerification").defaultTo(false).notNullable(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + if (await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification")) { + await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.dropColumn("skipDnsOwnershipVerification"); + }); + } + } +} diff --git a/backend/src/db/schemas/certificate-requests.ts b/backend/src/db/schemas/certificate-requests.ts index e01e08bbd1..4013ea5bbc 100644 --- a/backend/src/db/schemas/certificate-requests.ts +++ b/backend/src/db/schemas/certificate-requests.ts @@ -26,7 +26,8 @@ export const CertificateRequestsSchema = z.object({ keyAlgorithm: z.string().nullable().optional(), signatureAlgorithm: z.string().nullable().optional(), errorMessage: z.string().nullable().optional(), - metadata: z.string().nullable().optional() + metadata: z.string().nullable().optional(), + acmeOrderId: z.string().uuid().nullable().optional() }); export type TCertificateRequests = z.infer; diff --git a/backend/src/db/schemas/identity-group-membership.ts b/backend/src/db/schemas/identity-group-membership.ts new file mode 100644 index 0000000000..7dcd65c545 --- /dev/null +++ b/backend/src/db/schemas/identity-group-membership.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityGroupMembershipSchema = z.object({ + id: z.string().uuid(), + identityId: z.string().uuid(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityGroupMembership = z.infer; +export type TIdentityGroupMembershipInsert = Omit, TImmutableDBKeys>; +export type TIdentityGroupMembershipUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 528582c592..3517bd6cd0 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -55,6 +55,7 @@ export * from "./identity-alicloud-auths"; export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; +export * from "./identity-group-membership"; export * from "./identity-jwt-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-metadata"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index fe38a9c9ba..b18d21163c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -42,6 +42,7 @@ export enum TableName { GroupProjectMembershipRole = "group_project_membership_roles", ExternalGroupOrgRoleMapping = "external_group_org_role_mappings", UserGroupMembership = "user_group_membership", + IdentityGroupMembership = "identity_group_membership", UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", AuthTokens = "auth_tokens", diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts index f0592319bf..4f4460986f 100644 --- a/backend/src/db/schemas/pki-acme-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -13,7 +13,8 @@ export const PkiAcmeEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), encryptedEabSecret: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + skipDnsOwnershipVerification: z.boolean().default(false) }); export type TPkiAcmeEnrollmentConfigs = z.infer; diff --git a/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts b/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts index 47b4947f2b..2ae94155b9 100644 --- a/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts +++ b/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts @@ -11,6 +11,7 @@ import { } from "@app/ee/services/external-kms/providers/model"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; +import { deterministicStringify } from "@app/lib/fn/object"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -88,9 +89,11 @@ export const registerExternalKmsEndpoints = < ...rest } = externalKms; + const credentialsToHash = deterministicStringify(configuration.credential); + const credentialsHash = crypto.nativeCrypto .createHash("sha256") - .update(externalKmsData.encryptedProviderInputs) + .update(Buffer.from(credentialsToHash)) .digest("hex"); return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } }; } @@ -153,9 +156,12 @@ export const registerExternalKmsEndpoints = < external: { providerInput: externalKmsConfiguration, ...externalKmsData }, ...rest } = externalKms; + + const credentialsToHash = deterministicStringify(externalKmsConfiguration.credential); + const credentialsHash = crypto.nativeCrypto .createHash("sha256") - .update(externalKmsData.encryptedProviderInputs) + .update(Buffer.from(credentialsToHash)) .digest("hex"); return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } }; } @@ -222,9 +228,12 @@ export const registerExternalKmsEndpoints = < external: { providerInput: externalKmsConfiguration, ...externalKmsData }, ...rest } = externalKms; + + const credentialsToHash = deterministicStringify(externalKmsConfiguration.credential); + const credentialsHash = crypto.nativeCrypto .createHash("sha256") - .update(externalKmsData.encryptedProviderInputs) + .update(Buffer.from(credentialsToHash)) .digest("hex"); return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } }; } @@ -277,9 +286,12 @@ export const registerExternalKmsEndpoints = < external: { providerInput: configuration, ...externalKmsData }, ...rest } = externalKms; + + const credentialsToHash = deterministicStringify(configuration.credential); + const credentialsHash = crypto.nativeCrypto .createHash("sha256") - .update(externalKmsData.encryptedProviderInputs) + .update(Buffer.from(credentialsToHash)) .digest("hex"); return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } }; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 4696bef26d..d5d2455a7b 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,18 +1,27 @@ import { z } from "zod"; -import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas"; +import { GroupsSchema, IdentitiesSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas"; import { - EFilterReturnedProjects, - EFilterReturnedUsers, - EGroupProjectsOrderBy + FilterMemberType, + FilterReturnedMachineIdentities, + FilterReturnedProjects, + FilterReturnedUsers, + GroupMembersOrderBy, + GroupProjectsOrderBy } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS } from "@app/lib/api-docs"; import { OrderByDirection } from "@app/lib/types"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const GroupIdentityResponseSchema = IdentitiesSchema.pick({ + id: true, + name: true +}); + export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -190,8 +199,15 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), - filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -202,12 +218,10 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { lastName: true, id: true }) - .merge( - z.object({ - isPartOfGroup: z.boolean(), - joinedGroupAt: z.date().nullable() - }) - ) + .extend({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) .array(), totalCount: z.number() }) @@ -227,6 +241,134 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:id/machine-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.LIST_MACHINE_IDENTITIES.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MACHINE_IDENTITIES.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MACHINE_IDENTITIES.limit), + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_MACHINE_IDENTITIES.search), + filter: z + .nativeEnum(FilterReturnedMachineIdentities) + .optional() + .describe(GROUPS.LIST_MACHINE_IDENTITIES.filterMachineIdentities) + }), + response: { + 200: z.object({ + machineIdentities: GroupIdentityResponseSchema.extend({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { machineIdentities, totalCount } = await server.services.group.listGroupMachineIdentities({ + id: req.params.id, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { machineIdentities, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id/members", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.LIST_MEMBERS.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MEMBERS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MEMBERS.limit), + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_MEMBERS.search), + orderBy: z + .nativeEnum(GroupMembersOrderBy) + .default(GroupMembersOrderBy.Name) + .optional() + .describe(GROUPS.LIST_MEMBERS.orderBy), + orderDirection: z.nativeEnum(OrderByDirection).optional().describe(GROUPS.LIST_MEMBERS.orderDirection), + memberTypeFilter: z + .union([z.nativeEnum(FilterMemberType), z.array(z.nativeEnum(FilterMemberType))]) + .optional() + .describe(GROUPS.LIST_MEMBERS.memberTypeFilter) + .transform((val) => { + if (!val) return undefined; + return Array.isArray(val) ? val : [val]; + }) + }), + response: { + 200: z.object({ + members: z + .discriminatedUnion("type", [ + z.object({ + id: z.string(), + joinedGroupAt: z.date().nullable(), + type: z.literal("user"), + user: UsersSchema.pick({ id: true, firstName: true, lastName: true, email: true, username: true }) + }), + z.object({ + id: z.string(), + joinedGroupAt: z.date().nullable(), + type: z.literal("machineIdentity"), + machineIdentity: GroupIdentityResponseSchema + }) + ]) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { members, totalCount } = await server.services.group.listGroupMembers({ + id: req.params.id, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { members, totalCount }; + } + }); + server.route({ method: "GET", url: "/:id/projects", @@ -243,11 +385,18 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { querystring: z.object({ offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_PROJECTS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit), - search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search), - filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects), + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_PROJECTS.search), + filter: z.nativeEnum(FilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects), orderBy: z - .nativeEnum(EGroupProjectsOrderBy) - .default(EGroupProjectsOrderBy.Name) + .nativeEnum(GroupProjectsOrderBy) + .default(GroupProjectsOrderBy.Name) .describe(GROUPS.LIST_PROJECTS.orderBy), orderDirection: z .nativeEnum(OrderByDirection) @@ -263,11 +412,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { description: true, type: true }) - .merge( - z.object({ - joinedGroupAt: z.date().nullable() - }) - ) + .extend({ + joinedGroupAt: z.date().nullable() + }) .array(), totalCount: z.number() }) @@ -325,6 +472,40 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/:id/machine-identities/:machineIdentityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.id), + machineIdentityId: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.machineIdentityId) + }), + response: { + 200: z.object({ + id: z.string() + }) + } + }, + handler: async (req) => { + const machineIdentity = await server.services.group.addMachineIdentityToGroup({ + id: req.params.id, + identityId: req.params.machineIdentityId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return machineIdentity; + } + }); + server.route({ method: "DELETE", url: "/:id/users/:username", @@ -362,4 +543,38 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { return user; } }); + + server.route({ + method: "DELETE", + url: "/:id/machine-identities/:machineIdentityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.id), + machineIdentityId: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.machineIdentityId) + }), + response: { + 200: z.object({ + id: z.string() + }) + } + }, + handler: async (req) => { + const machineIdentity = await server.services.group.removeMachineIdentityFromGroup({ + id: req.params.id, + identityId: req.params.machineIdentityId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return machineIdentity; + } + }); }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index 6b6a4cbed6..9c7cf161d6 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -3,6 +3,11 @@ import { SanitizedAwsIamAccountWithResourceSchema, UpdateAwsIamAccountSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + CreateKubernetesAccountSchema, + SanitizedKubernetesAccountWithResourceSchema, + UpdateKubernetesAccountSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLAccountSchema, SanitizedMySQLAccountWithResourceSchema, @@ -50,6 +55,15 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + accountResponseSchema: SanitizedKubernetesAccountWithResourceSchema, + createAccountSchema: CreateKubernetesAccountSchema, + updateAccountSchema: UpdateKubernetesAccountSchema + }); + }, [PamResource.AwsIam]: async (server: FastifyZodProvider) => { registerPamResourceEndpoints({ server, diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 0c78b64491..802d430935 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -4,6 +4,7 @@ import { PamFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums"; import { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { SanitizedKubernetesAccountWithResourceSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas"; @@ -21,10 +22,17 @@ const SanitizedAccountSchema = z.union([ SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, SanitizedMySQLAccountWithResourceSchema, + SanitizedKubernetesAccountWithResourceSchema, SanitizedAwsIamAccountWithResourceSchema ]); -type TSanitizedAccount = z.infer; +const ListPamAccountsResponseSchema = z.object({ + accounts: SanitizedAccountSchema.array(), + folders: PamFoldersSchema.array(), + totalCount: z.number().default(0), + folderId: z.string().optional(), + folderPaths: z.record(z.string(), z.string()) +}); export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,13 +63,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { .optional() }), response: { - 200: z.object({ - accounts: SanitizedAccountSchema.array(), - folders: PamFoldersSchema.array(), - totalCount: z.number().default(0), - folderId: z.string().optional(), - folderPaths: z.record(z.string(), z.string()) - }) + 200: ListPamAccountsResponseSchema } }, onRequest: verifyAuth([AuthMode.JWT]), @@ -98,7 +100,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { } }); - return { accounts: accounts as TSanitizedAccount[], folders, totalCount, folderId, folderPaths }; + return { accounts, folders, totalCount, folderId, folderPaths } as z.infer; } }); @@ -135,6 +137,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Postgres) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.MySQL) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Kubernetes) }), // AWS IAM (no gateway, returns console URL) z.object({ sessionId: z.string(), diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index fcd9840b4a..e3c9cf60c5 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -3,6 +3,11 @@ import { SanitizedAwsIamResourceSchema, UpdateAwsIamResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + CreateKubernetesResourceSchema, + SanitizedKubernetesResourceSchema, + UpdateKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLResourceSchema, MySQLResourceSchema, @@ -50,6 +55,15 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + resourceResponseSchema: SanitizedKubernetesResourceSchema, + createResourceSchema: CreateKubernetesResourceSchema, + updateResourceSchema: UpdateKubernetesResourceSchema + }); + }, [PamResource.AwsIam]: async (server: FastifyZodProvider) => { registerPamResourceEndpoints({ server, diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index b6a7532edd..8e4326f3f1 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -5,6 +5,10 @@ import { AwsIamResourceListItemSchema, SanitizedAwsIamResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + KubernetesResourceListItemSchema, + SanitizedKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema @@ -27,6 +31,7 @@ const SanitizedResourceSchema = z.union([ SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema, SanitizedSSHResourceSchema, + SanitizedKubernetesResourceSchema, SanitizedAwsIamResourceSchema ]); @@ -34,6 +39,7 @@ const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, MySQLResourceListItemSchema, SSHResourceListItemSchema, + KubernetesResourceListItemSchema, AwsIamResourceListItemSchema ]); diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 3c39a9516e..574b8b7c3d 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { KubernetesSessionCredentialsSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { + HttpEventSchema, PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema @@ -17,7 +19,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SessionCredentialsSchema = z.union([ SSHSessionCredentialsSchema, PostgresSessionCredentialsSchema, - MySQLSessionCredentialsSchema + MySQLSessionCredentialsSchema, + KubernetesSessionCredentialsSchema ]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { @@ -89,7 +92,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { sessionId: z.string().uuid() }), body: z.object({ - logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema, HttpEventSchema])) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index 926b222319..debaa12cfe 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -142,6 +142,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr data: { ...req.body, ...req.body.type, + name: req.body.slug, permissions: req.body.permissions ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 6d97e3b9eb..7882eaab7d 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -56,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = { TAccessApprovalRequestReviewerDALFactory, "create" | "find" | "findOne" | "transaction" | "delete" >; - groupDAL: Pick; + groupDAL: Pick; smtpService: Pick; userDAL: Pick< TUserDALFactory, @@ -182,7 +182,7 @@ export const accessApprovalRequestServiceFactory = ({ await Promise.all( approverGroupIds.map((groupApproverId) => groupDAL - .findAllGroupPossibleMembers({ + .findAllGroupPossibleUsers({ orgId: actorOrgId, groupId: groupApproverId }) diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index b89137ec6e..64cd7c9360 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -49,6 +49,7 @@ import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { KmipPermission } from "../kmip/kmip-enum"; +import { AcmeChallengeType, AcmeIdentifierType } from "../pki-acme/pki-acme-schemas"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; export type TListProjectAuditLogDTO = { @@ -78,7 +79,9 @@ export type TCreateAuditLogDTO = { | ScimClientActor | PlatformActor | UnknownUserActor - | KmipClientActor; + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -574,7 +577,18 @@ export enum EventType { APPROVAL_REQUEST_CANCEL = "approval-request-cancel", APPROVAL_REQUEST_GRANT_LIST = "approval-request-grant-list", APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get", - APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke" + APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke", + + // PKI ACME + CREATE_ACME_ACCOUNT = "create-acme-account", + RETRIEVE_ACME_ACCOUNT = "retrieve-acme-account", + CREATE_ACME_ORDER = "create-acme-order", + FINALIZE_ACME_ORDER = "finalize-acme-order", + DOWNLOAD_ACME_CERTIFICATE = "download-acme-certificate", + RESPOND_TO_ACME_CHALLENGE = "respond-to-acme-challenge", + PASS_ACME_CHALLENGE = "pass-acme-challenge", + ATTEMPT_ACME_CHALLENGE = "attempt-acme-challenge", + FAIL_ACME_CHALLENGE = "fail-acme-challenge" } export const filterableSecretEvents: EventType[] = [ @@ -615,6 +629,15 @@ interface KmipClientActorMetadata { name: string; } +interface AcmeProfileActorMetadata { + profileId: string; +} + +interface AcmeAccountActorMetadata { + profileId: string; + accountId: string; +} + interface UnknownUserActorMetadata {} export interface UserActor { @@ -652,7 +675,25 @@ export interface ScimClientActor { metadata: ScimClientActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor | KmipClientActor; +export interface AcmeProfileActor { + type: ActorType.ACME_PROFILE; + metadata: AcmeProfileActorMetadata; +} + +export interface AcmeAccountActor { + type: ActorType.ACME_ACCOUNT; + metadata: AcmeAccountActorMetadata; +} + +export type Actor = + | UserActor + | ServiceActor + | IdentityActor + | ScimClientActor + | PlatformActor + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -4368,6 +4409,84 @@ interface ApprovalRequestGrantRevokeEvent { }; } +interface CreateAcmeAccountEvent { + type: EventType.CREATE_ACME_ACCOUNT; + metadata: { + accountId: string; + publicKeyThumbprint: string; + emails?: string[]; + }; +} + +interface RetrieveAcmeAccountEvent { + type: EventType.RETRIEVE_ACME_ACCOUNT; + metadata: { + accountId: string; + publicKeyThumbprint: string; + }; +} + +interface CreateAcmeOrderEvent { + type: EventType.CREATE_ACME_ORDER; + metadata: { + orderId: string; + identifiers: Array<{ + type: AcmeIdentifierType; + value: string; + }>; + }; +} + +interface FinalizeAcmeOrderEvent { + type: EventType.FINALIZE_ACME_ORDER; + metadata: { + orderId: string; + csr: string; + }; +} + +interface DownloadAcmeCertificateEvent { + type: EventType.DOWNLOAD_ACME_CERTIFICATE; + metadata: { + orderId: string; + }; +} + +interface RespondToAcmeChallengeEvent { + type: EventType.RESPOND_TO_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + }; +} +interface PassedAcmeChallengeEvent { + type: EventType.PASS_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + }; +} + +interface AttemptAcmeChallengeEvent { + type: EventType.ATTEMPT_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + retryCount: number; + errorMessage: string; + }; +} + +interface FailAcmeChallengeEvent { + type: EventType.FAIL_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + retryCount: number; + errorMessage: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -4768,4 +4887,13 @@ export type Event = | ApprovalRequestCancelEvent | ApprovalRequestGrantListEvent | ApprovalRequestGrantGetEvent - | ApprovalRequestGrantRevokeEvent; + | ApprovalRequestGrantRevokeEvent + | CreateAcmeAccountEvent + | RetrieveAcmeAccountEvent + | CreateAcmeOrderEvent + | FinalizeAcmeOrderEvent + | DownloadAcmeCertificateEvent + | RespondToAcmeChallengeEvent + | PassedAcmeChallengeEvent + | AttemptAcmeChallengeEvent + | FailAcmeChallengeEvent; diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index eb595ee020..af246eacdc 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -380,6 +380,7 @@ export const externalKmsServiceFactory = ({ const findById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id: kmsId }: TGetExternalKmsByIdDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new NotFoundError({ message: `Could not find KMS with ID '${kmsId}'` }); const { permission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor, diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index ced8410b76..eccad82a66 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -6,7 +6,14 @@ import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; -import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types"; +import { + FilterMemberType, + FilterReturnedMachineIdentities, + FilterReturnedProjects, + FilterReturnedUsers, + GroupMembersOrderBy, + GroupProjectsOrderBy +} from "./group-types"; export type TGroupDALFactory = ReturnType; @@ -70,7 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { }; // special query - const findAllGroupPossibleMembers = async ({ + const findAllGroupPossibleUsers = async ({ orgId, groupId, offset = 0, @@ -85,7 +92,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; }) => { try { const query = db @@ -127,11 +134,11 @@ export const groupDALFactory = (db: TDbClient) => { } switch (filter) { - case EFilterReturnedUsers.EXISTING_MEMBERS: - void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + case FilterReturnedUsers.EXISTING_MEMBERS: + void query.whereNotNull(`${TableName.UserGroupMembership}.createdAt`); break; - case EFilterReturnedUsers.NON_MEMBERS: - void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + case FilterReturnedUsers.NON_MEMBERS: + void query.whereNull(`${TableName.UserGroupMembership}.createdAt`); break; default: break; @@ -155,7 +162,7 @@ export const groupDALFactory = (db: TDbClient) => { username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId, + isPartOfGroup: Boolean(memberGroupId), joinedGroupAt }) ), @@ -167,6 +174,256 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findAllGroupPossibleMachineIdentities = async ({ + orgId, + groupId, + offset = 0, + limit, + search, + filter + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + search?: string; + filter?: FilterReturnedMachineIdentities; + }) => { + try { + const query = db + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .whereNull(`${TableName.Identity}.projectId`) + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.IdentityGroupMembership, (bd) => { + bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn( + `${TableName.IdentityGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.Membership), + db.ref("groupId").withSchema(TableName.IdentityGroupMembership), + db.ref("createdAt").withSchema(TableName.IdentityGroupMembership).as("joinedGroupAt"), + db.ref("name").withSchema(TableName.Identity), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.raw(`count(*) OVER() as total_count`) + ) + .offset(offset) + .orderBy("name", "asc"); + + if (limit) { + void query.limit(limit); + } + + if (search) { + void query.andWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, `%${search}%`); + } + + switch (filter) { + case FilterReturnedMachineIdentities.ASSIGNED_MACHINE_IDENTITIES: + void query.whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`); + break; + case FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES: + void query.whereNull(`${TableName.IdentityGroupMembership}.createdAt`); + break; + default: + break; + } + + const machineIdentities = await query; + + return { + machineIdentities: machineIdentities.map(({ name, identityId, joinedGroupAt, groupId: identityGroupId }) => ({ + id: identityId, + name, + isPartOfGroup: Boolean(identityGroupId), + joinedGroupAt + })), + // @ts-expect-error col select is raw and not strongly typed + totalCount: Number(machineIdentities?.[0]?.total_count ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find all group identities" }); + } + }; + + const findAllGroupPossibleMembers = async ({ + orgId, + groupId, + offset = 0, + limit, + search, + orderBy = GroupMembersOrderBy.Name, + orderDirection = OrderByDirection.ASC, + memberTypeFilter + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + search?: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; + }) => { + try { + const includeUsers = + !memberTypeFilter || memberTypeFilter.length === 0 || memberTypeFilter.includes(FilterMemberType.USERS); + const includeMachineIdentities = + !memberTypeFilter || + memberTypeFilter.length === 0 || + memberTypeFilter.includes(FilterMemberType.MACHINE_IDENTITIES); + + const query = db + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .leftJoin(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .leftJoin(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.UserGroupMembership, (bd) => { + bd.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .leftJoin(TableName.IdentityGroupMembership, (bd) => { + bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn( + `${TableName.IdentityGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .where((qb) => { + void qb + .where((innerQb) => { + void innerQb + .whereNotNull(`${TableName.Membership}.actorUserId`) + .whereNotNull(`${TableName.UserGroupMembership}.createdAt`) + .where(`${TableName.Users}.isGhost`, false); + }) + .orWhere((innerQb) => { + void innerQb + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`) + .whereNull(`${TableName.Identity}.projectId`); + }); + }) + .select( + db.raw( + `CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN "${TableName.UserGroupMembership}"."createdAt" ELSE "${TableName.IdentityGroupMembership}"."createdAt" END as "joinedGroupAt"` + ), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.raw(`"${TableName.Users}"."id"::text as "userId"`), + db.raw(`"${TableName.Identity}"."id"::text as "identityId"`), + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.raw( + `CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN 'user' ELSE 'machineIdentity' END as "member_type"` + ), + db.raw(`count(*) OVER() as total_count`) + ); + + void query.andWhere((qb) => { + if (includeUsers) { + void qb.whereNotNull(`${TableName.Membership}.actorUserId`); + } + + if (includeMachineIdentities) { + void qb[includeUsers ? "orWhere" : "where"]((innerQb) => { + void innerQb.whereNotNull(`${TableName.Membership}.actorIdentityId`); + }); + } + + if (!includeUsers && !includeMachineIdentities) { + void qb.whereRaw("FALSE"); + } + }); + + if (search) { + void query.andWhere((qb) => { + void qb + .whereRaw( + `CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName", lower("${TableName.Users}"."username")) ilike ?`, + [`%${search}%`] + ) + .orWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, [`%${search}%`]); + }); + } + + if (orderBy === GroupMembersOrderBy.Name) { + const orderDirectionClause = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"; + + // This order by clause is used to sort the members by name. + // It first checks if the full name (first name and last name) is not empty, then the username, then the email, then the identity name. If all of these are empty, it returns null. + void query.orderByRaw( + `LOWER(COALESCE(NULLIF(TRIM(CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName")), ''), "${TableName.Users}"."username", "${TableName.Users}"."email", "${TableName.Identity}"."name")) ${orderDirectionClause}` + ); + } + + if (offset) { + void query.offset(offset); + } + if (limit) { + void query.limit(limit); + } + + const results = (await query) as unknown as { + email: string; + username: string; + firstName: string; + lastName: string; + userId: string; + identityId: string; + identityName: string; + member_type: "user" | "machineIdentity"; + joinedGroupAt: Date; + total_count: string; + }[]; + + const members = results.map( + ({ email, username, firstName, lastName, userId, identityId, identityName, member_type, joinedGroupAt }) => { + if (member_type === "user") { + return { + id: userId, + joinedGroupAt, + type: "user" as const, + user: { + id: userId, + email, + username, + firstName, + lastName + } + }; + } + return { + id: identityId, + joinedGroupAt, + type: "machineIdentity" as const, + machineIdentity: { + id: identityId, + name: identityName + } + }; + } + ); + + return { + members, + totalCount: Number(results?.[0]?.total_count ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find all group possible members" }); + } + }; + const findAllGroupProjects = async ({ orgId, groupId, @@ -182,8 +439,8 @@ export const groupDALFactory = (db: TDbClient) => { offset?: number; limit?: number; search?: string; - filter?: EFilterReturnedProjects; - orderBy?: EGroupProjectsOrderBy; + filter?: FilterReturnedProjects; + orderBy?: GroupProjectsOrderBy; orderDirection?: OrderByDirection; }) => { try { @@ -225,10 +482,10 @@ export const groupDALFactory = (db: TDbClient) => { } switch (filter) { - case EFilterReturnedProjects.ASSIGNED_PROJECTS: + case FilterReturnedProjects.ASSIGNED_PROJECTS: void query.whereNotNull(`${TableName.Membership}.id`); break; - case EFilterReturnedProjects.UNASSIGNED_PROJECTS: + case FilterReturnedProjects.UNASSIGNED_PROJECTS: void query.whereNull(`${TableName.Membership}.id`); break; default: @@ -313,6 +570,8 @@ export const groupDALFactory = (db: TDbClient) => { ...groupOrm, findGroups, findByOrgId, + findAllGroupPossibleUsers, + findAllGroupPossibleMachineIdentities, findAllGroupPossibleMembers, findAllGroupProjects, findGroupsByProjectId, diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index c4384cc4e2..e42a86261e 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -5,9 +5,11 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError, ScimRequestError } from "@app/lib/errors"; import { + TAddIdentitiesToGroup, TAddUsersToGroup, TAddUsersToGroupByUserIds, TConvertPendingGroupAdditionsToGroupMemberships, + TRemoveIdentitiesFromGroup, TRemoveUsersFromGroupByUserIds } from "./group-types"; @@ -285,6 +287,70 @@ export const addUsersToGroupByUserIds = async ({ }); }; +/** + * Add identities with identity ids [identityIds] to group [group]. + * @param {group} group - group to add identity(s) to + * @param {string[]} identityIds - id(s) of organization scoped identity(s) to add to group + * @returns {Promise<{ id: string }[]>} - id(s) of added identity(s) + */ +export const addIdentitiesToGroup = async ({ + group, + identityIds, + identityDAL, + identityGroupMembershipDAL, + membershipDAL +}: TAddIdentitiesToGroup) => { + const identityIdsSet = new Set(identityIds); + const identityIdsArray = Array.from(identityIdsSet); + + // ensure all identities exist and belong to the org via org scoped membership + const foundIdentitiesMemberships = await membershipDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + $in: { + actorIdentityId: identityIdsArray + } + }); + + const existingIdentityOrgMembershipsIdentityIdsSet = new Set( + foundIdentitiesMemberships.map((u) => u.actorIdentityId as string) + ); + + identityIdsArray.forEach((identityId) => { + if (!existingIdentityOrgMembershipsIdentityIdsSet.has(identityId)) { + throw new ForbiddenRequestError({ + message: `Identity with id ${identityId} is not part of the organization` + }); + } + }); + + // check if identity group membership already exists + const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({ + groupId: group.id, + $in: { + identityId: identityIdsArray + } + }); + + if (existingIdentityGroupMemberships.length) { + throw new BadRequestError({ + message: `${identityIdsArray.length > 1 ? `Identities are` : `Identity is`} already part of the group ${group.slug}` + }); + } + + return identityDAL.transaction(async (tx) => { + await identityGroupMembershipDAL.insertMany( + foundIdentitiesMemberships.map((membership) => ({ + identityId: membership.actorIdentityId as string, + groupId: group.id + })), + tx + ); + + return identityIdsArray.map((identityId) => ({ id: identityId })); + }); +}; + /** * Remove users with user ids [userIds] from group [group]. * - Users may be part of the group (non-pending + pending); @@ -421,6 +487,75 @@ export const removeUsersFromGroupByUserIds = async ({ }); }; +/** + * Remove identities with identity ids [identityIds] from group [group]. + * @param {group} group - group to remove identity(s) from + * @param {string[]} identityIds - id(s) of identity(s) to remove from group + * @returns {Promise<{ id: string }[]>} - id(s) of removed identity(s) + */ +export const removeIdentitiesFromGroup = async ({ + group, + identityIds, + identityDAL, + membershipDAL, + identityGroupMembershipDAL +}: TRemoveIdentitiesFromGroup) => { + const identityIdsSet = new Set(identityIds); + const identityIdsArray = Array.from(identityIdsSet); + + // ensure all identities exist and belong to the org via org scoped membership + const foundIdentitiesMemberships = await membershipDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + $in: { + actorIdentityId: identityIdsArray + } + }); + + const foundIdentitiesMembershipsIdentityIdsSet = new Set( + foundIdentitiesMemberships.map((u) => u.actorIdentityId as string) + ); + + if (foundIdentitiesMembershipsIdentityIdsSet.size !== identityIdsArray.length) { + throw new NotFoundError({ + message: `Machine identities not found` + }); + } + + // check if identity group membership already exists + const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({ + groupId: group.id, + $in: { + identityId: identityIdsArray + } + }); + + const existingIdentityGroupMembershipsIdentityIdsSet = new Set( + existingIdentityGroupMemberships.map((u) => u.identityId) + ); + + identityIdsArray.forEach((identityId) => { + if (!existingIdentityGroupMembershipsIdentityIdsSet.has(identityId)) { + throw new ForbiddenRequestError({ + message: `Machine identities are not part of the group ${group.slug}` + }); + } + }); + return identityDAL.transaction(async (tx) => { + await identityGroupMembershipDAL.delete( + { + groupId: group.id, + $in: { + identityId: identityIdsArray + } + }, + tx + ); + + return identityIdsArray.map((identityId) => ({ id: identityId })); + }); +}; + /** * Convert pending group additions for users with ids [userIds] to group memberships. * @param {string[]} userIds - id(s) of user(s) to try to convert pending group additions to group memberships diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 1a6a046a6b..30d047a776 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -5,6 +5,8 @@ import { AccessScope, OrganizationActionScope, OrgMembershipRole, TRoles } from import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -18,33 +20,48 @@ import { OrgPermissionGroupActions, OrgPermissionSubjects } from "../permission/ import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGroupDALFactory } from "./group-dal"; -import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns"; import { + addIdentitiesToGroup, + addUsersToGroupByUserIds, + removeIdentitiesFromGroup, + removeUsersFromGroupByUserIds +} from "./group-fns"; +import { + TAddMachineIdentityToGroupDTO, TAddUserToGroupDTO, TCreateGroupDTO, TDeleteGroupDTO, TGetGroupByIdDTO, + TListGroupMachineIdentitiesDTO, + TListGroupMembersDTO, TListGroupProjectsDTO, TListGroupUsersDTO, + TRemoveMachineIdentityFromGroupDTO, TRemoveUserFromGroupDTO, TUpdateGroupDTO } from "./group-types"; +import { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { userDAL: Pick; + identityDAL: Pick; + identityGroupMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, | "create" | "findOne" | "update" | "delete" + | "findAllGroupPossibleUsers" + | "findAllGroupPossibleMachineIdentities" | "findAllGroupPossibleMembers" | "findById" | "transaction" | "findAllGroupProjects" >; membershipGroupDAL: Pick; + membershipDAL: Pick; membershipRoleDAL: Pick; orgDAL: Pick; userGroupMembershipDAL: Pick< @@ -65,6 +82,9 @@ type TGroupServiceFactoryDep = { export type TGroupServiceFactory = ReturnType; export const groupServiceFactory = ({ + identityDAL, + membershipDAL, + identityGroupMembershipDAL, userDAL, groupDAL, orgDAL, @@ -362,7 +382,7 @@ export const groupServiceFactory = ({ message: `Failed to find group with ID ${id}` }); - const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({ orgId: group.orgId, groupId: group.id, offset, @@ -375,6 +395,100 @@ export const groupServiceFactory = ({ return { users: members, totalCount }; }; + const listGroupMachineIdentities = async ({ + id, + offset, + limit, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search, + filter + }: TListGroupMachineIdentitiesDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const { machineIdentities, totalCount } = await groupDAL.findAllGroupPossibleMachineIdentities({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + search, + filter + }); + + return { machineIdentities, totalCount }; + }; + + const listGroupMembers = async ({ + id, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListGroupMembersDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter + }); + + return { members, totalCount }; + }; + const listGroupProjects = async ({ id, offset, @@ -504,6 +618,81 @@ export const groupServiceFactory = ({ return users[0]; }; + const addMachineIdentityToGroup = async ({ + id, + identityId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TAddMachineIdentityToGroupDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + + // check if user has broader or equal to privileges than group + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.AddIdentities, + OrgPermissionSubjects.Groups, + permission, + rolePermissionDetails.permission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to add identity to more privileged group", + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.AddIdentities, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const identityMembership = await membershipDAL.findOne({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + actorIdentityId: identityId + }); + + if (!identityMembership) { + throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` }); + } + + const identities = await addIdentitiesToGroup({ + group, + identityIds: [identityId], + identityDAL, + membershipDAL, + identityGroupMembershipDAL + }); + + return identities[0]; + }; + const removeUserFromGroup = async ({ id, username, @@ -587,14 +776,91 @@ export const groupServiceFactory = ({ return users[0]; }; + const removeMachineIdentityFromGroup = async ({ + id, + identityId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRemoveMachineIdentityFromGroupDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + + // check if user has broader or equal to privileges than group + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.RemoveIdentities, + OrgPermissionSubjects.Groups, + permission, + rolePermissionDetails.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to remove identity from more privileged group", + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.RemoveIdentities, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const identityMembership = await membershipDAL.findOne({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + actorIdentityId: identityId + }); + + if (!identityMembership) { + throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` }); + } + + const identities = await removeIdentitiesFromGroup({ + group, + identityIds: [identityId], + identityDAL, + membershipDAL, + identityGroupMembershipDAL + }); + + return identities[0]; + }; + return { createGroup, updateGroup, deleteGroup, listGroupUsers, + listGroupMachineIdentities, + listGroupMembers, listGroupProjects, addUserToGroup, + addMachineIdentityToGroup, removeUserFromGroup, + removeMachineIdentityFromGroup, getGroupById }; }; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 335b6d72bb..044dc4bca1 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -3,6 +3,8 @@ import { Knex } from "knex"; import { TGroups } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { OrderByDirection, TGenericPermission } from "@app/lib/types"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -10,6 +12,8 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal"; + export type TCreateGroupDTO = { name: string; slug?: string; @@ -39,7 +43,25 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; +} & TGenericPermission; + +export type TListGroupMachineIdentitiesDTO = { + id: string; + offset: number; + limit: number; + search?: string; + filter?: FilterReturnedMachineIdentities; +} & TGenericPermission; + +export type TListGroupMembersDTO = { + id: string; + offset: number; + limit: number; + search?: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; } & TGenericPermission; export type TListGroupProjectsDTO = { @@ -47,8 +69,8 @@ export type TListGroupProjectsDTO = { offset: number; limit: number; search?: string; - filter?: EFilterReturnedProjects; - orderBy?: EGroupProjectsOrderBy; + filter?: FilterReturnedProjects; + orderBy?: GroupProjectsOrderBy; orderDirection?: OrderByDirection; } & TGenericPermission; @@ -61,11 +83,21 @@ export type TAddUserToGroupDTO = { username: string; } & TGenericPermission; +export type TAddMachineIdentityToGroupDTO = { + id: string; + identityId: string; +} & TGenericPermission; + export type TRemoveUserFromGroupDTO = { id: string; username: string; } & TGenericPermission; +export type TRemoveMachineIdentityFromGroupDTO = { + id: string; + identityId: string; +} & TGenericPermission; + // group fns types export type TAddUsersToGroup = { @@ -93,6 +125,14 @@ export type TAddUsersToGroupByUserIds = { tx?: Knex; }; +export type TAddIdentitiesToGroup = { + group: TGroups; + identityIds: string[]; + identityDAL: Pick; + identityGroupMembershipDAL: Pick; + membershipDAL: Pick; +}; + export type TRemoveUsersFromGroupByUserIds = { group: TGroups; userIds: string[]; @@ -103,6 +143,14 @@ export type TRemoveUsersFromGroupByUserIds = { tx?: Knex; }; +export type TRemoveIdentitiesFromGroup = { + group: TGroups; + identityIds: string[]; + identityDAL: Pick; + membershipDAL: Pick; + identityGroupMembershipDAL: Pick; +}; + export type TConvertPendingGroupAdditionsToGroupMemberships = { userIds: string[]; userDAL: Pick; @@ -117,16 +165,30 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { tx?: Knex; }; -export enum EFilterReturnedUsers { +export enum FilterReturnedUsers { EXISTING_MEMBERS = "existingMembers", NON_MEMBERS = "nonMembers" } -export enum EFilterReturnedProjects { +export enum FilterReturnedMachineIdentities { + ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities", + NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities" +} + +export enum FilterReturnedProjects { ASSIGNED_PROJECTS = "assignedProjects", UNASSIGNED_PROJECTS = "unassignedProjects" } -export enum EGroupProjectsOrderBy { +export enum GroupProjectsOrderBy { Name = "name" } + +export enum GroupMembersOrderBy { + Name = "name" +} + +export enum FilterMemberType { + USERS = "users", + MACHINE_IDENTITIES = "machineIdentities" +} diff --git a/backend/src/ee/services/group/identity-group-membership-dal.ts b/backend/src/ee/services/group/identity-group-membership-dal.ts new file mode 100644 index 0000000000..7b6d67244d --- /dev/null +++ b/backend/src/ee/services/group/identity-group-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityGroupMembershipDALFactory = ReturnType; + +export const identityGroupMembershipDALFactory = (db: TDbClient) => { + const identityGroupMembershipOrm = ormify(db, TableName.IdentityGroupMembership); + + return { + ...identityGroupMembershipOrm + }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index c3baeff0c4..ec8b37c365 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -689,13 +689,30 @@ export const pamAccountServiceFactory = ({ throw new BadRequestError({ message: "Gateway ID is required for this resource type" }); } + const { host, port } = + resourceType !== PamResource.Kubernetes + ? connectionDetails + : (() => { + const url = new URL(connectionDetails.url); + let portNumber: number | undefined; + if (url.port) { + portNumber = Number(url.port); + } else { + portNumber = url.protocol === "https:" ? 443 : 80; + } + return { + host: url.hostname, + port: portNumber + }; + })(); + const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ gatewayId, duration, sessionId: session.id, resourceType: resource.resourceType as PamResource, - host: (connectionDetails as TSqlResourceConnectionDetails).host, - port: (connectionDetails as TSqlResourceConnectionDetails).port, + host, + port, actorMetadata: { id: actor.id, type: actor.type, @@ -746,6 +763,13 @@ export const pamAccountServiceFactory = ({ }; } break; + case PamResource.Kubernetes: + metadata = { + resourceName: resource.name, + accountName: account.name, + accountPath + }; + break; default: break; } diff --git a/backend/src/ee/services/pam-folder/pam-folder-dal.ts b/backend/src/ee/services/pam-folder/pam-folder-dal.ts index 0b8aa8f60c..a21c401ffb 100644 --- a/backend/src/ee/services/pam-folder/pam-folder-dal.ts +++ b/backend/src/ee/services/pam-folder/pam-folder-dal.ts @@ -71,23 +71,29 @@ export const pamFolderDALFactory = (db: TDbClient) => { const findByPath = async (projectId: string, path: string, tx?: Knex) => { try { const dbInstance = tx || db.replicaNode(); + + const folders = await dbInstance(TableName.PamFolder) + .where(`${TableName.PamFolder}.projectId`, projectId) + .select(selectAllTableCols(TableName.PamFolder)); + const pathSegments = path.split("/").filter(Boolean); + if (pathSegments.length === 0) { + return undefined; + } + + const foldersByParentId = new Map(); + for (const folder of folders) { + const children = foldersByParentId.get(folder.parentId ?? null) ?? []; + children.push(folder); + foldersByParentId.set(folder.parentId ?? null, children); + } let parentId: string | null = null; - let currentFolder: Awaited> | undefined; + let currentFolder: (typeof folders)[0] | undefined; - for await (const segment of pathSegments) { - const query = dbInstance(TableName.PamFolder) - .where(`${TableName.PamFolder}.projectId`, projectId) - .where(`${TableName.PamFolder}.name`, segment); - - if (parentId) { - void query.where(`${TableName.PamFolder}.parentId`, parentId); - } else { - void query.whereNull(`${TableName.PamFolder}.parentId`); - } - - currentFolder = await query.first(); + for (const segment of pathSegments) { + const childFolders: typeof folders = foldersByParentId.get(parentId) || []; + currentFolder = childFolders.find((folder) => folder.name === segment); if (!currentFolder) { return undefined; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts new file mode 100644 index 0000000000..21d7da806c --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts @@ -0,0 +1,3 @@ +export enum KubernetesAuthMethod { + ServiceAccountToken = "service-account-token" +} diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts new file mode 100644 index 0000000000..dddeb37baf --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts @@ -0,0 +1,225 @@ +import axios, { AxiosError } from "axios"; +import https from "https"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway/types"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { logger } from "@app/lib/logger"; + +import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { PamResource } from "../pam-resource-enums"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../pam-resource-types"; +import { KubernetesAuthMethod } from "./kubernetes-resource-enums"; +import { TKubernetesAccountCredentials, TKubernetesResourceConnectionDetails } from "./kubernetes-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +export const executeWithGateway = async ( + config: { + connectionDetails: TKubernetesResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + }, + gatewayV2Service: Pick, + operation: (baseUrl: string, httpsAgent: https.Agent) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const url = new URL(connectionDetails.url); + const [targetHost] = await verifyHostInputValidity(url.hostname, true); + + let targetPort: number; + if (url.port) { + targetPort = Number(url.port); + } else if (url.protocol === "https:") { + targetPort = 443; + } else { + targetPort = 80; + } + + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort + }); + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + const httpsAgent = new https.Agent({ + ca: connectionDetails.sslCertificate, + rejectUnauthorized: connectionDetails.sslRejectUnauthorized, + servername: targetHost + }); + return withGatewayV2Proxy( + async (proxyPort) => { + const protocol = url.protocol === "https:" ? "https" : "http"; + const baseUrl = `${protocol}://localhost:${proxyPort}`; + return operation(baseUrl, httpsAgent); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay, + httpsAgent + } + ); +}; + +export const kubernetesResourceFactory: TPamResourceFactory< + TKubernetesResourceConnectionDetails, + TKubernetesAccountCredentials +> = (resourceType, connectionDetails, gatewayId, gatewayV2Service) => { + const validateConnection = async () => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + // Validate connection by checking API server version + try { + await axios.get(`${baseUrl}/version`, { + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } catch (error) { + if (error instanceof AxiosError) { + // If we get a 401/403, it means we reached the API server but need auth - that's fine for connection validation + if (error.response?.status === 401 || error.response?.status === 403) { + logger.info( + { status: error.response.status }, + "[Kubernetes Resource Factory] Kubernetes connection validation succeeded (auth required)" + ); + return connectionDetails; + } + throw new BadRequestError({ + message: `Unable to connect to Kubernetes API server: ${error.response?.statusText || error.message}` + }); + } + throw error; + } + + logger.info("[Kubernetes Resource Factory] Kubernetes connection validation succeeded"); + return connectionDetails; + } + ); + return connectionDetails; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials< + TKubernetesAccountCredentials + > = async (credentials) => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + const { authMethod } = credentials; + if (authMethod === KubernetesAuthMethod.ServiceAccountToken) { + // Validate service account token using SelfSubjectReview API (whoami) + // This endpoint doesn't require any special permissions from the service account + try { + await axios.post( + `${baseUrl}/apis/authentication.k8s.io/v1/selfsubjectreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "SelfSubjectReview" + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.serviceAccountToken}` + }, + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + logger.info("[Kubernetes Resource Factory] Kubernetes service account token authentication successful"); + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 401 || error.response?.status === 403) { + throw new BadRequestError({ + message: + "Account credentials invalid. Service account token is not valid or does not have required permissions." + }); + } + throw new BadRequestError({ + message: `Unable to validate account credentials: ${error.response?.statusText || error.message}` + }); + } + throw error; + } + } else { + throw new BadRequestError({ + message: `Unsupported Kubernetes auth method: ${authMethod as string}` + }); + } + } + ); + return credentials; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials< + TKubernetesAccountCredentials + > = async () => { + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: not implemented` + }); + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TKubernetesAccountCredentials, + currentCredentials: TKubernetesAccountCredentials + ) => { + if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) { + return updatedAccountCredentials; + } + + if ( + updatedAccountCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken && + currentCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken + ) { + if (updatedAccountCredentials.serviceAccountToken === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + serviceAccountToken: currentCredentials.serviceAccountToken + }; + } + } + + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts new file mode 100644 index 0000000000..b7d3546c5c --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts @@ -0,0 +1,8 @@ +import { KubernetesResourceListItemSchema } from "./kubernetes-resource-schemas"; + +export const getKubernetesResourceListItem = () => { + return { + name: KubernetesResourceListItemSchema.shape.name.value, + resource: KubernetesResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts new file mode 100644 index 0000000000..2a0f06d0e5 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreateGatewayPamResourceSchema, + BaseCreatePamAccountSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema +} from "../pam-resource-schemas"; +import { KubernetesAuthMethod } from "./kubernetes-resource-enums"; + +export const BaseKubernetesResourceSchema = BasePamResourceSchema.extend({ + resourceType: z.literal(PamResource.Kubernetes) +}); + +export const KubernetesResourceListItemSchema = z.object({ + name: z.literal("Kubernetes"), + resource: z.literal(PamResource.Kubernetes) +}); + +export const KubernetesResourceConnectionDetailsSchema = z.object({ + url: z.string().url().trim().max(500), + sslRejectUnauthorized: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +export const KubernetesServiceAccountTokenCredentialsSchema = z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken), + serviceAccountToken: z.string().trim().max(10000) +}); + +export const KubernetesAccountCredentialsSchema = z.discriminatedUnion("authMethod", [ + KubernetesServiceAccountTokenCredentialsSchema +]); + +export const KubernetesResourceSchema = BaseKubernetesResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedKubernetesResourceSchema = BaseKubernetesResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: z + .discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken) + }) + ]) + .nullable() + .optional() +}); + +export const CreateKubernetesResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateKubernetesResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const KubernetesAccountSchema = BasePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema +}); + +export const CreateKubernetesAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema +}); + +export const UpdateKubernetesAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema.optional() +}); + +export const SanitizedKubernetesAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken) + }) + ]) +}); + +// Sessions +export const KubernetesSessionCredentialsSchema = KubernetesResourceConnectionDetailsSchema.and( + KubernetesAccountCredentialsSchema +); diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts new file mode 100644 index 0000000000..d23163d267 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + KubernetesAccountCredentialsSchema, + KubernetesAccountSchema, + KubernetesResourceConnectionDetailsSchema, + KubernetesResourceSchema +} from "./kubernetes-resource-schemas"; + +// Resources +export type TKubernetesResource = z.infer; +export type TKubernetesResourceConnectionDetails = z.infer; + +// Accounts +export type TKubernetesAccount = z.infer; +export type TKubernetesAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index bea1667fbe..c8c57b03b2 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -2,6 +2,7 @@ export enum PamResource { Postgres = "postgres", MySQL = "mysql", SSH = "ssh", + Kubernetes = "kubernetes", AwsIam = "aws-iam" } diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 1d1a84f339..bf8d13d664 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,4 +1,5 @@ import { awsIamResourceFactory } from "./aws-iam/aws-iam-resource-factory"; +import { kubernetesResourceFactory } from "./kubernetes/kubernetes-resource-factory"; import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; @@ -10,5 +11,6 @@ export const PAM_RESOURCE_FACTORY_MAP: Record { - return [getPostgresResourceListItem(), getMySQLResourceListItem(), getAwsIamResourceListItem()].sort((a, b) => - a.name.localeCompare(b.name) - ); + return [ + getPostgresResourceListItem(), + getMySQLResourceListItem(), + getAwsIamResourceListItem(), + getKubernetesResourceListItem() + ].sort((a, b) => a.name.localeCompare(b.name)); }; // Resource diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 2a27fb76e2..5291e044ac 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -7,6 +7,12 @@ import { TAwsIamResource, TAwsIamResourceConnectionDetails } from "./aws-iam/aws-iam-resource-types"; +import { + TKubernetesAccount, + TKubernetesAccountCredentials, + TKubernetesResource, + TKubernetesResourceConnectionDetails +} from "./kubernetes/kubernetes-resource-types"; import { TMySQLAccount, TMySQLAccountCredentials, @@ -28,21 +34,23 @@ import { } from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource | TKubernetesResource; export type TPamResourceConnectionDetails = | TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails | TSSHResourceConnectionDetails + | TKubernetesResourceConnectionDetails | TAwsIamResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount | TKubernetesAccount; export type TPamAccountCredentials = | TPostgresAccountCredentials // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents | TMySQLAccountCredentials | TSSHAccountCredentials + | TKubernetesAccountCredentials | TAwsIamAccountCredentials; // Resource DTOs diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts index db24931966..b336d15c80 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -11,6 +11,8 @@ export const PamSessionCommandLogSchema = z.object({ // SSH Terminal Event schemas export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); +export const HttpEventTypeSchema = z.enum(["request", "response"]); + export const TerminalEventSchema = z.object({ timestamp: z.coerce.date(), eventType: TerminalEventTypeSchema, @@ -18,8 +20,29 @@ export const TerminalEventSchema = z.object({ elapsedTime: z.number() // Seconds since session start (for replay) }); +export const HttpBaseEventSchema = z.object({ + timestamp: z.coerce.date(), + requestId: z.string(), + eventType: TerminalEventTypeSchema, + headers: z.record(z.string(), z.array(z.string())), + body: z.string().optional() +}); + +export const HttpRequestEventSchema = HttpBaseEventSchema.extend({ + eventType: z.literal(HttpEventTypeSchema.Values.request), + method: z.string(), + url: z.string() +}); + +export const HttpResponseEventSchema = HttpBaseEventSchema.extend({ + eventType: z.literal(HttpEventTypeSchema.Values.response), + status: z.string() +}); + +export const HttpEventSchema = z.discriminatedUnion("eventType", [HttpRequestEventSchema, HttpResponseEventSchema]); + export const SanitizedSessionSchema = PamSessionsSchema.omit({ encryptedLogsBlob: true }).extend({ - logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) + logs: z.array(z.union([PamSessionCommandLogSchema, HttpEventSchema, TerminalEventSchema])) }); diff --git a/backend/src/ee/services/pam-session/pam-session-types.ts b/backend/src/ee/services/pam-session/pam-session-types.ts index 893f930e51..8f202b6723 100644 --- a/backend/src/ee/services/pam-session/pam-session-types.ts +++ b/backend/src/ee/services/pam-session/pam-session-types.ts @@ -1,13 +1,19 @@ import { z } from "zod"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas"; +import { + HttpEventSchema, + PamSessionCommandLogSchema, + SanitizedSessionSchema, + TerminalEventSchema +} from "./pam-session-schemas"; export type TPamSessionCommandLog = z.infer; export type TTerminalEvent = z.infer; +export type THttpEvent = z.infer; export type TPamSanitizedSession = z.infer; // DTOs export type TUpdateSessionLogsDTO = { sessionId: string; - logs: (TPamSessionCommandLog | TTerminalEvent)[]; + logs: (TPamSessionCommandLog | TTerminalEvent | THttpEvent)[]; }; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 743dcd63fb..0c00c8b408 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -88,8 +88,10 @@ export enum OrgPermissionGroupActions { Edit = "edit", Delete = "delete", GrantPrivileges = "grant-privileges", + AddIdentities = "add-identities", AddMembers = "add-members", - RemoveMembers = "remove-members" + RemoveMembers = "remove-members", + RemoveIdentities = "remove-identities" } export enum OrgPermissionBillingActions { @@ -381,8 +383,10 @@ const buildAdminPermission = () => { can(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.AddIdentities, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.RemoveIdentities, OrgPermissionSubjects.Groups); can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index efe17edad4..c58bec1561 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -178,6 +178,16 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { .where(`${TableName.UserGroupMembership}.userId`, actorId) .select(db.ref("id").withSchema(TableName.Groups)); + const identityGroupSubquery = (tx || db)(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, scopeData.orgId) + .where(`${TableName.IdentityGroupMembership}.identityId`, actorId) + .select(db.ref("id").withSchema(TableName.Groups)); + const docs = await (tx || db) .replicaNode()(TableName.Membership) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) @@ -214,7 +224,9 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { .where(`${TableName.Membership}.actorUserId`, actorId) .orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery); } else if (actorType === ActorType.IDENTITY) { - void qb.where(`${TableName.Membership}.actorIdentityId`, actorId); + void qb + .where(`${TableName.Membership}.actorIdentityId`, actorId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery); } }) .where((qb) => { @@ -653,6 +665,15 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId: string ) => { try { + const identityGroupSubquery = db(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, orgId) + .select(db.ref("id").withSchema(TableName.Groups)); + const docs = await db .replicaNode()(TableName.Membership) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) @@ -668,7 +689,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .where(`${TableName.Membership}.scopeOrgId`, orgId) - .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where((qb) => { + void qb + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery); + }) .where(`${TableName.Membership}.scope`, AccessScope.Project) .where(`${TableName.Membership}.scopeProjectId`, projectId) .select(selectAllTableCols(TableName.MembershipRole)) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 74cbd14660..abc3a654b5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -122,6 +122,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { const result = await (tx || db)(TableName.PkiAcmeChallenge) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) .join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`) + .join( + TableName.PkiCertificateProfile, + `${TableName.PkiAcmeAccount}.profileId`, + `${TableName.PkiCertificateProfile}.id` + ) .select( selectAllTableCols(TableName.PkiAcmeChallenge), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), @@ -131,7 +136,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"), db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"), db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"), - db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint") + db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint"), + db.ref("profileId").withSchema(TableName.PkiAcmeAccount).as("profileId"), + db.ref("projectId").withSchema(TableName.PkiCertificateProfile).as("projectId") ) // For all challenges, acquire update lock on the auth to avoid race conditions .forUpdate(TableName.PkiAcmeAuth) @@ -149,6 +156,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { authExpiresAt, accountId, accountPublicKeyThumbprint, + profileId, + projectId, ...challenge } = result; return { @@ -161,7 +170,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { expiresAt: authExpiresAt, account: { id: accountId, - publicKeyThumbprint: accountPublicKeyThumbprint + publicKeyThumbprint: accountPublicKeyThumbprint, + project: { + id: projectId + }, + profileId } } }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 7379d7ece2..06ad692365 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -5,7 +5,9 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeConnectionError, @@ -25,10 +27,12 @@ type TPkiAcmeChallengeServiceFactoryDep = { | "markAsInvalidCascadeById" | "updateById" >; + auditLogService: Pick; }; export const pkiAcmeChallengeServiceFactory = ({ - acmeChallengeDAL + acmeChallengeDAL, + auditLogService }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); const markChallengeAsReady = async (challengeId: string): Promise => { @@ -113,7 +117,25 @@ export const pkiAcmeChallengeServiceFactory = ({ } logger.info({ challengeId }, "ACME challenge response is correct, marking challenge as valid"); await acmeChallengeDAL.markAsValidCascadeById(challengeId); + await auditLogService.createAuditLog({ + projectId: challenge.auth.account.project.id, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: challenge.auth.account.profileId, + accountId: challenge.auth.account.id + } + }, + event: { + type: EventType.PASS_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType + } + } + }); } catch (exp) { + let finalAttempt = false; if (retryCount >= 2) { logger.error( exp, @@ -121,35 +143,59 @@ export const pkiAcmeChallengeServiceFactory = ({ ); // This is the last attempt to validate the challenge response, if it fails, we mark the challenge as invalid await acmeChallengeDAL.markAsInvalidCascadeById(challengeId); + finalAttempt = true; } - // Properly type and inspect the error - if (axios.isAxiosError(exp)) { - const axiosError = exp as AxiosError; - const errorCode = axiosError.code; - const errorMessage = axiosError.message; + try { + // Properly type and inspect the error + if (axios.isAxiosError(exp)) { + const axiosError = exp as AxiosError; + const errorCode = axiosError.code; + const errorMessage = axiosError.message; - if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { - throw new AcmeConnectionError({ message: "Connection refused" }); + if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + throw new AcmeConnectionError({ message: "Connection refused" }); + } + if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { + throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } + if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) { + throw new AcmeConnectionError({ message: "Connection reset by peer" }); + } + if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) { + logger.error(exp, "Connection timed out while validating ACME challenge response"); + throw new AcmeConnectionError({ message: "Connection timed out" }); + } + logger.error(exp, "Unknown error validating ACME challenge response"); + throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } - if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { - throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); - } - if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) { - throw new AcmeConnectionError({ message: "Connection reset by peer" }); - } - if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) { - logger.error(exp, "Connection timed out while validating ACME challenge response"); - throw new AcmeConnectionError({ message: "Connection timed out" }); + if (exp instanceof Error) { + logger.error(exp, "Error validating ACME challenge response"); + throw exp; } logger.error(exp, "Unknown error validating ACME challenge response"); throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } catch (outterExp) { + await auditLogService.createAuditLog({ + projectId: challenge.auth.account.project.id, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: challenge.auth.account.profileId, + accountId: challenge.auth.account.id + } + }, + event: { + type: finalAttempt ? EventType.FAIL_ACME_CHALLENGE : EventType.ATTEMPT_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType, + retryCount, + errorMessage: exp instanceof Error ? exp.message : "Unknown error" + } + } + }); + throw outterExp; } - if (exp instanceof Error) { - logger.error(exp, "Error validating ACME challenge response"); - throw exp; - } - logger.error(exp, "Unknown error validating ACME challenge response"); - throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts index 5aab0be631..cf7d96e876 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; export type TPkiAcmeOrderDALFactory = ReturnType; @@ -19,6 +20,43 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { } }; + const findWithCertificateRequestForSync = async (id: string, tx?: Knex) => { + try { + const order = await (tx || db)(TableName.PkiAcmeOrder) + .leftJoin( + TableName.CertificateRequests, + `${TableName.PkiAcmeOrder}.id`, + `${TableName.CertificateRequests}.acmeOrderId` + ) + .select( + selectAllTableCols(TableName.PkiAcmeOrder), + db.ref("id").withSchema(TableName.CertificateRequests).as("certificateRequestId"), + db.ref("status").withSchema(TableName.CertificateRequests).as("certificateRequestStatus"), + db.ref("certificateId").withSchema(TableName.CertificateRequests).as("certificateId") + ) + .forUpdate(TableName.PkiAcmeOrder) + .where(`${TableName.PkiAcmeOrder}.id`, id) + .first(); + if (!order) { + return null; + } + const { certificateRequestId, certificateRequestStatus, certificateId, ...details } = order; + return { + ...details, + certificateRequest: + certificateRequestId && certificateRequestStatus + ? { + id: certificateRequestId, + status: certificateRequestStatus as CertificateRequestStatus, + certificateId + } + : undefined + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id with certificate request" }); + } + }; + const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) @@ -72,6 +110,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { return { ...pkiAcmeOrderOrm, findByIdForFinalization, + findWithCertificateRequestForSync, findByAccountAndOrderIdWithAuthorizations, listByAccountId }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 23b86d172c..ebd17c26a8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -6,8 +6,8 @@ export enum AcmeIdentifierType { export enum AcmeOrderStatus { Pending = "pending", - Processing = "processing", Ready = "ready", + Processing = "processing", Valid = "valid", Invalid = "invalid" } diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index d9654e50b4..705cd6c392 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -7,8 +7,10 @@ import { importJWK, JWSHeaderParameters } from "jose"; +import { Knex } from "knex"; import { z, ZodError } from "zod"; +import { TPkiAcmeOrders } from "@app/db/schemas"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; @@ -17,20 +19,15 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; import { logger } from "@app/lib/logger"; -import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { ActorType } from "@app/services/auth/auth-type"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; -import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; -import { - CertExtendedKeyUsage, - CertKeyUsage, - CertSubjectAlternativeNameType -} from "@app/services/certificate/certificate-types"; -import { orderCertificate } from "@app/services/certificate-authority/acme/acme-certificate-authority-fns"; +import { CertSubjectAlternativeNameType } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; -import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal"; +import { + TCertificateIssuanceQueueFactory, + TIssueCertificateFromProfileJobData +} from "@app/services/certificate-authority/certificate-issuance-queue"; import { extractAlgorithmsFromCSR, extractCertificateRequestFromCSR @@ -40,6 +37,8 @@ import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { TCertificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; @@ -47,6 +46,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; @@ -99,13 +99,9 @@ import { type TPkiAcmeServiceFactoryDep = { projectDAL: Pick; - appConnectionDAL: Pick; - certificateDAL: Pick; certificateAuthorityDAL: Pick; - externalCertificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateBodyDAL: Pick; - certificateSecretDAL: Pick; certificateTemplateV2DAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, @@ -113,11 +109,13 @@ type TPkiAcmeServiceFactoryDep = { >; acmeOrderDAL: Pick< TPkiAcmeOrderDALFactory, + | "findById" | "create" | "transaction" | "updateById" | "findByAccountAndOrderIdWithAuthorizations" | "findByIdForFinalization" + | "findWithCertificateRequestForSync" | "listByAccountId" >; acmeAuthDAL: Pick; @@ -134,19 +132,18 @@ type TPkiAcmeServiceFactoryDep = { licenseService: Pick; certificateV3Service: Pick; certificateTemplateV2Service: Pick; + certificateRequestService: Pick; + certificateIssuanceQueue: Pick; acmeChallengeService: Pick; pkiAcmeQueueService: Pick; + auditLogService: Pick; }; export const pkiAcmeServiceFactory = ({ projectDAL, - appConnectionDAL, - certificateDAL, certificateAuthorityDAL, - externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, - certificateSecretDAL, certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, @@ -158,8 +155,11 @@ export const pkiAcmeServiceFactory = ({ licenseService, certificateV3Service, certificateTemplateV2Service, + certificateRequestService, + certificateIssuanceQueue, acmeChallengeService, - pkiAcmeQueueService + pkiAcmeQueueService, + auditLogService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); @@ -364,6 +364,52 @@ export const pkiAcmeServiceFactory = ({ }; }; + const checkAndSyncAcmeOrderStatus = async ({ orderId }: { orderId: string }): Promise => { + const order = await acmeOrderDAL.findById(orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } + if (order.status !== AcmeOrderStatus.Processing) { + // We only care about processing orders, as they are the ones that have async certificate requests + return order; + } + return acmeOrderDAL.transaction(async (tx) => { + // Lock the order for syncing with async cert request + const orderWithCertificateRequest = await acmeOrderDAL.findWithCertificateRequestForSync(orderId, tx); + if (!orderWithCertificateRequest) { + throw new NotFoundError({ message: "ACME order not found" }); + } + // Check the status again after we have acquired the lock, as things may have changed since we last checked + if ( + orderWithCertificateRequest.status !== AcmeOrderStatus.Processing || + !orderWithCertificateRequest.certificateRequest + ) { + return orderWithCertificateRequest; + } + let newStatus: AcmeOrderStatus | undefined; + let newCertificateId: string | undefined; + switch (orderWithCertificateRequest.certificateRequest.status) { + case CertificateRequestStatus.PENDING: + break; + case CertificateRequestStatus.ISSUED: + newStatus = AcmeOrderStatus.Valid; + newCertificateId = orderWithCertificateRequest.certificateRequest.certificateId ?? undefined; + break; + case CertificateRequestStatus.FAILED: + newStatus = AcmeOrderStatus.Invalid; + break; + default: + throw new AcmeServerInternalError({ + message: `Invalid certificate request status: ${orderWithCertificateRequest.certificateRequest.status as string}` + }); + } + if (newStatus) { + return acmeOrderDAL.updateById(orderId, { status: newStatus, certificateId: newCertificateId }, tx); + } + return orderWithCertificateRequest; + }); + }; + const getAcmeDirectory = async (profileId: string): Promise => { const profile = await validateAcmeProfile(profileId); return { @@ -446,6 +492,23 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" }); } if (existingAccount) { + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_PROFILE, + metadata: { + profileId: profile.id + } + }, + event: { + type: EventType.RETRIEVE_ACME_ACCOUNT, + metadata: { + accountId: existingAccount.id, + publicKeyThumbprint + } + } + }); + return { status: 200, body: { @@ -518,7 +581,25 @@ export const pkiAcmeServiceFactory = ({ publicKeyThumbprint, emails: contact ?? [] }); - // TODO: create audit log here + + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_PROFILE, + metadata: { + profileId: profile.id + } + }, + event: { + type: EventType.CREATE_ACME_ACCOUNT, + metadata: { + accountId: newAccount.id, + publicKeyThumbprint: newAccount.publicKeyThumbprint, + emails: newAccount.emails + } + } + }); + return { status: 201, body: { @@ -567,6 +648,8 @@ export const pkiAcmeServiceFactory = ({ accountId: string; payload: TCreateAcmeOrderPayload; }): Promise> => { + const profile = await validateAcmeProfile(profileId); + const skipDnsOwnershipVerification = profile.acmeConfig?.skipDnsOwnershipVerification ?? false; // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order // TODO: check the identifiers and see if are they even allowed for this profile. @@ -592,7 +675,7 @@ export const pkiAcmeServiceFactory = ({ const createdOrder = await acmeOrderDAL.create( { accountId: account.id, - status: AcmeOrderStatus.Pending, + status: skipDnsOwnershipVerification ? AcmeOrderStatus.Ready : AcmeOrderStatus.Pending, notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined, notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined, // TODO: read config from the profile to get the expiration time instead @@ -611,7 +694,7 @@ export const pkiAcmeServiceFactory = ({ const auth = await acmeAuthDAL.create( { accountId: account.id, - status: AcmeAuthStatus.Pending, + status: skipDnsOwnershipVerification ? AcmeAuthStatus.Valid : AcmeAuthStatus.Pending, identifierType: identifier.type, identifierValue: identifier.value, // RFC 8555 suggests a token with at least 128 bits of entropy @@ -623,15 +706,17 @@ export const pkiAcmeServiceFactory = ({ }, tx ); - // TODO: support other challenge types here. Currently only HTTP-01 is supported. - await acmeChallengeDAL.create( - { - authId: auth.id, - status: AcmeChallengeStatus.Pending, - type: AcmeChallengeType.HTTP_01 - }, - tx - ); + if (!skipDnsOwnershipVerification) { + // TODO: support other challenge types here. Currently only HTTP-01 is supported. + await acmeChallengeDAL.create( + { + authId: auth.id, + status: AcmeChallengeStatus.Pending, + type: AcmeChallengeType.HTTP_01 + }, + tx + ); + } return auth; }) ); @@ -643,7 +728,26 @@ export const pkiAcmeServiceFactory = ({ })), tx ); - // TODO: create audit log here + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: account.profileId, + accountId: account.id + } + }, + event: { + type: EventType.CREATE_ACME_ORDER, + metadata: { + orderId: createdOrder.id, + identifiers: authorizations.map((auth) => ({ + type: auth.identifierType as AcmeIdentifierType, + value: auth.identifierValue + })) + } + } + }); return { ...createdOrder, authorizations, account }; }); @@ -673,9 +777,12 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } + // Sync order first in case if there is a certificate request that needs to be processed + await checkAndSyncAcmeOrderStatus({ orderId }); + const updatedOrder = (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId))!; return { status: 200, - body: buildAcmeOrderResource({ profileId, order }), + body: buildAcmeOrderResource({ profileId, order: updatedOrder }), headers: { Location: buildUrl(profileId, `/orders/${orderId}`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` @@ -683,6 +790,129 @@ export const pkiAcmeServiceFactory = ({ }; }; + const processCertificateIssuanceForOrder = async ({ + caType, + accountId, + actorOrgId, + profileId, + orderId, + csr, + finalizingOrder, + certificateRequest, + profile, + ca, + tx + }: { + caType: CaType; + accountId: string; + actorOrgId: string; + profileId: string; + orderId: string; + csr: string; + finalizingOrder: { + notBefore?: Date | null; + notAfter?: Date | null; + }; + certificateRequest: ReturnType; + profile: TCertificateProfileWithConfigs; + ca: Awaited>; + tx?: Knex; + }): Promise<{ certificateId?: string; certIssuanceJobData?: TIssueCertificateFromProfileJobData }> => { + if (caType === CaType.INTERNAL) { + const result = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, + notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, + validity: !finalizingOrder.notAfter + ? { + // 47 days, the default TTL comes with Let's Encrypt + // TODO: read config from the profile to get the expiration time instead + ttl: `${47}d` + } + : // ttl is not used if notAfter is provided + ({ ttl: "0d" } as const), + enrollmentType: EnrollmentType.ACME + }); + return { + certificateId: result.certificateId + }; + } + + const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = + extractAlgorithmsFromCSR(csr); + const updatedCertificateRequest = { + ...certificateRequest, + keyAlgorithm: extractedKeyAlgorithm, + signatureAlgorithm: extractedSignatureAlgorithm, + validity: finalizingOrder.notAfter + ? (() => { + const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date(); + const notAfter = new Date(finalizingOrder.notAfter); + const diffMs = notAfter.getTime() - notBefore.getTime(); + const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + return { ttl: `${diffDays}d` }; + })() + : certificateRequest.validity + }; + + const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + template.id, + updatedCertificateRequest + ); + if (!validationResult.isValid) { + throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` }); + } + + const certRequest = await certificateRequestService.createCertificateRequest({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + projectId: profile.projectId, + caId: ca.id, + profileId: profile.id, + commonName: updatedCertificateRequest.commonName ?? "", + keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [], + extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [], + keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "", + signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "", + altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","), + notBefore: updatedCertificateRequest.notBefore, + notAfter: updatedCertificateRequest.notAfter, + status: CertificateRequestStatus.PENDING, + acmeOrderId: orderId, + csr, + tx + }); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const csrPem = csrObj.toString("pem"); + return { + certIssuanceJobData: { + certificateId: orderId, + profileId: profile.id, + caId: profile.caId || "", + ttl: updatedCertificateRequest.validity?.ttl || "1y", + signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "", + keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "", + commonName: updatedCertificateRequest.commonName || "", + altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value) || [], + keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [], + extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [], + certificateRequestId: certRequest.id, + csr: csrPem + } + }; + }; + const finalizeAcmeOrder = async ({ profileId, accountId, @@ -707,7 +937,11 @@ export const pkiAcmeServiceFactory = ({ throw new NotFoundError({ message: "ACME order not found" }); } if (order.status === AcmeOrderStatus.Ready) { - const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { + const { + order: updatedOrder, + error, + certIssuanceJobData + } = await acmeOrderDAL.transaction(async (tx) => { const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead? const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; @@ -754,94 +988,31 @@ export const pkiAcmeServiceFactory = ({ } const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; let errorToReturn: Error | undefined; + let certIssuanceJobDataToReturn: TIssueCertificateFromProfileJobData | undefined; try { - const { certificateId } = await (async () => { - if (caType === CaType.INTERNAL) { - const result = await certificateV3Service.signCertificateFromProfile({ - actor: ActorType.ACME_ACCOUNT, - actorId: accountId, - actorAuthMethod: null, - actorOrgId, - profileId, - csr, - notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, - notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, - validity: !finalizingOrder.notAfter - ? { - // 47 days, the default TTL comes with Let's Encrypt - // TODO: read config from the profile to get the expiration time instead - ttl: `${47}d` - } - : // ttl is not used if notAfter is provided - ({ ttl: "0d" } as const), - enrollmentType: EnrollmentType.ACME - }); - return { certificateId: result.certificateId }; - } - const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!; - const csrObj = new x509.Pkcs10CertificateRequest(csr); - const csrPem = csrObj.toString("pem"); - - const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = - extractAlgorithmsFromCSR(csr); - - certificateRequest.keyAlgorithm = extractedKeyAlgorithm; - certificateRequest.signatureAlgorithm = extractedSignatureAlgorithm; - if (finalizingOrder.notAfter) { - const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date(); - const notAfter = new Date(finalizingOrder.notAfter); - const diffMs = notAfter.getTime() - notBefore.getTime(); - const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); - certificateRequest.validity = { ttl: `${diffDays}d` }; - } - - const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId); - if (!template) { - throw new NotFoundError({ message: "Certificate template not found" }); - } - const validationResult = await certificateTemplateV2Service.validateCertificateRequest( - template.id, - certificateRequest - ); - if (!validationResult.isValid) { - throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` }); - } - // TODO: this is pretty slow, and we are holding the transaction open for a long time, - // we should queue the certificate issuance to a background job instead - const cert = await orderCertificate( - { - caId: certificateAuthority!.id, - // It is possible that the CSR does not have a common name, in which case we use an empty string - // (more likely than not for a CSR from a modern ACME client like certbot, cert-manager, etc.) - commonName: certificateRequest.commonName ?? "", - altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value), - csr: Buffer.from(csrPem), - // TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now - keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT], - extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH] - }, - { - appConnectionDAL, - certificateAuthorityDAL, - externalCertificateAuthorityDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - kmsService, - projectDAL - } - ); - return { certificateId: cert.id }; - })(); + const result = await processCertificateIssuanceForOrder({ + caType, + accountId, + actorOrgId, + profileId, + orderId, + csr, + finalizingOrder, + certificateRequest, + profile, + ca, + tx + }); await acmeOrderDAL.updateById( orderId, { - status: AcmeOrderStatus.Valid, + status: result.certificateId ? AcmeOrderStatus.Valid : AcmeOrderStatus.Processing, csr, - certificateId + certificateId: result.certificateId }, tx ); + certIssuanceJobDataToReturn = result.certIssuanceJobData; } catch (exp) { await acmeOrderDAL.updateById( orderId, @@ -859,18 +1030,43 @@ export const pkiAcmeServiceFactory = ({ } else if (exp instanceof AcmeError) { errorToReturn = exp; } else { - errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); + errorToReturn = new AcmeServerInternalError({ + message: "Failed to sign certificate with internal error" + }); } } return { order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!, - error: errorToReturn + error: errorToReturn, + certIssuanceJobData: certIssuanceJobDataToReturn }; }); if (error) { throw error; } + if (certIssuanceJobData) { + // TODO: ideally, this should be done inside the transaction, but the pg-boss queue doesn't support external transactions + // as it seems to be. we need to commit the transaction before queuing the job, otherwise the job will fail (not found error). + await certificateIssuanceQueue.queueCertificateIssuance(certIssuanceJobData); + } order = updatedOrder; + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.FINALIZE_ACME_ORDER, + metadata: { + orderId: updatedOrder.id, + csr: updatedOrder.csr! + } + } + }); } else if (order.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); } @@ -898,14 +1094,16 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } - if (order.status !== AcmeOrderStatus.Valid) { + // Sync order first in case if there is a certificate request that needs to be processed + const syncedOrder = await checkAndSyncAcmeOrderStatus({ orderId }); + if (syncedOrder.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" }); } - if (!order.certificateId) { + if (!syncedOrder.certificateId) { throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" }); } - const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId }); + const certBody = await certificateBodyDAL.findOne({ certId: syncedOrder.certificateId }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: profile.projectId, projectDAL, @@ -926,6 +1124,24 @@ export const pkiAcmeServiceFactory = ({ const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n"); const certChain = certificateChain.trim().replace("\n", "\r\n"); + + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.DOWNLOAD_ACME_CERTIFICATE, + metadata: { + orderId + } + } + }); + return { status: 200, body: @@ -1008,6 +1224,7 @@ export const pkiAcmeServiceFactory = ({ authzId: string; challengeId: string; }): Promise> => { + const profile = await validateAcmeProfile(profileId); const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId); if (!result) { throw new NotFoundError({ message: "ACME challenge not found" }); @@ -1015,6 +1232,23 @@ export const pkiAcmeServiceFactory = ({ await acmeChallengeService.markChallengeAsReady(challengeId); await pkiAcmeQueueService.queueChallengeValidation(challengeId); const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!; + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.RESPOND_TO_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType + } + } + }); return { status: 200, body: { diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 465ed3ee58..9433c46277 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -72,7 +72,7 @@ type TScimServiceFactoryDep = { TGroupDALFactory, | "create" | "findOne" - | "findAllGroupPossibleMembers" + | "findAllGroupPossibleUsers" | "delete" | "findGroups" | "transaction" @@ -952,7 +952,7 @@ export const scimServiceFactory = ({ } const users = await groupDAL - .findAllGroupPossibleMembers({ + .findAllGroupPossibleUsers({ orgId: group.orgId, groupId: group.id }) diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index cf236b56f5..1718e09fd0 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -214,7 +214,10 @@ export const secretRotationV2DALFactory = ( tx?: Knex ) => { try { - const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options }) + const { limit, offset = 0, sort, ...queryOptions } = options || {}; + const baseOptions = { ...queryOptions }; + + const subquery = baseSecretRotationV2Query({ filter, db, tx, options: baseOptions }) .join( TableName.SecretRotationV2SecretMapping, `${TableName.SecretRotationV2SecretMapping}.rotationId`, @@ -233,6 +236,7 @@ export const secretRotationV2DALFactory = ( ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .select( + selectAllTableCols(TableName.SecretRotationV2), db.ref("id").withSchema(TableName.SecretV2).as("secretId"), db.ref("key").withSchema(TableName.SecretV2).as("secretKey"), db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"), @@ -252,18 +256,31 @@ export const secretRotationV2DALFactory = ( db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue"), + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.SecretRotationV2}."createdAt" DESC) as rank`) ); if (search) { - void extendedQuery.where((query) => { - void query + void subquery.where((qb) => { + void qb .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); }); } - const secretRotations = await extendedQuery; + let secretRotations: Awaited; + if (limit !== undefined) { + const rankOffset = offset + 1; + const queryWithLimit = (tx || db) + .with("inner", subquery) + .select("*") + .from("inner") + .where("inner.rank", ">=", rankOffset) + .andWhere("inner.rank", "<", rankOffset + limit); + secretRotations = (await queryWithLimit) as unknown as Awaited; + } else { + secretRotations = await subquery; + } if (!secretRotations.length) return []; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1b70a8181d..e7ec5868af 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -106,6 +106,25 @@ export const GROUPS = { filterUsers: "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, + LIST_MACHINE_IDENTITIES: { + id: "The ID of the group to list identities for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + search: "The text string that machine identity name will be filtered by.", + filterMachineIdentities: + "Whether to filter the list of returned identities. 'assignedMachineIdentities' will only return identities assigned to the group, 'nonAssignedMachineIdentities' will only return identities not assigned to the group, undefined will return all identities in the organization." + }, + LIST_MEMBERS: { + id: "The ID of the group to list members for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th member.", + limit: "The number of members to return.", + search: + "The text string that member email(in case of users) or name(in case of machine identities) will be filtered by.", + orderBy: "The column to order members by.", + orderDirection: "The direction to order members in.", + memberTypeFilter: + "Filter members by type. Can be a single value ('users' or 'machineIdentities') or an array of values. If not specified, both users and machine identities will be returned." + }, LIST_PROJECTS: { id: "The ID of the group to list projects for.", offset: "The offset to start from. If you enter 10, it will start from the 10th project.", @@ -120,12 +139,20 @@ export const GROUPS = { id: "The ID of the group to add the user to.", username: "The username of the user to add to the group." }, + ADD_MACHINE_IDENTITY: { + id: "The ID of the group to add the machine identity to.", + machineIdentityId: "The ID of the machine identity to add to the group." + }, GET_BY_ID: { id: "The ID of the group to fetch." }, DELETE_USER: { id: "The ID of the group to remove the user from.", username: "The username of the user to remove from the group." + }, + DELETE_MACHINE_IDENTITY: { + id: "The ID of the group to remove the machine identity from.", + machineIdentityId: "The ID of the machine identity to remove from the group." } } as const; diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 6ff7278aae..c437e484aa 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -103,3 +103,34 @@ export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: st return deepEqual(filtered1, filtered2); }; + +export const deterministicStringify = (value: unknown): string => { + if (value === null || value === undefined) { + return JSON.stringify(value); + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + const items = value.map((item) => deterministicStringify(item)); + return `[${items.join(",")}]`; + } + + if (typeof value === "object") { + const sortedKeys = Object.keys(value).sort(); + const sortedObj: Record = {}; + for (const key of sortedKeys) { + const val = (value as Record)[key]; + if (typeof val === "object" && val !== null) { + sortedObj[key] = JSON.parse(deterministicStringify(val)); + } else { + sortedObj[key] = val; + } + } + return JSON.stringify(sortedObj); + } + + return JSON.stringify(value); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6611f4a594..640c4e9a49 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -46,6 +46,7 @@ import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; +import { identityGroupMembershipDALFactory } from "@app/ee/services/group/identity-group-membership-dal"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; @@ -470,6 +471,7 @@ export const registerRoutes = async ( const identityMetadataDAL = identityMetadataDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); + const identityGroupMembershipDAL = identityGroupMembershipDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db); @@ -754,6 +756,9 @@ export const registerRoutes = async ( membershipGroupDAL }); const groupService = groupServiceFactory({ + identityDAL, + membershipDAL, + identityGroupMembershipDAL, userDAL, groupDAL, orgDAL, @@ -2303,7 +2308,8 @@ export const registerRoutes = async ( }); const acmeChallengeService = pkiAcmeChallengeServiceFactory({ - acmeChallengeDAL + acmeChallengeDAL, + auditLogService }); const pkiAcmeQueueService = await pkiAcmeQueueServiceFactory({ @@ -2313,13 +2319,9 @@ export const registerRoutes = async ( const pkiAcmeService = pkiAcmeServiceFactory({ projectDAL, - appConnectionDAL, - certificateDAL, certificateAuthorityDAL, - externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, - certificateSecretDAL, certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, @@ -2331,8 +2333,11 @@ export const registerRoutes = async ( licenseService, certificateV3Service, certificateTemplateV2Service, + certificateRequestService, + certificateIssuanceQueue, acmeChallengeService, - pkiAcmeQueueService + pkiAcmeQueueService, + auditLogService }); const pkiSubscriberService = pkiSubscriberServiceFactory({ diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index ff770326d6..a3402bd858 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -47,7 +47,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: ExternalConfigUnionSchema }) .refine( @@ -245,7 +249,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid acmeConfig: z .object({ id: z.string(), - directoryUrl: z.string() + directoryUrl: z.string(), + skipDnsOwnershipVerification: z.boolean().optional() }) .optional(), externalConfigs: ExternalConfigUnionSchema @@ -434,6 +439,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: ExternalConfigUnionSchema }) .refine( diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 3970851710..25beb710cd 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -316,13 +316,11 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { params: z.object({ requestId: z.string().uuid() }), - query: z.object({ - projectId: z.string().uuid() - }), response: { 200: z.object({ status: z.nativeEnum(CertificateRequestStatus), certificate: z.string().nullable(), + certificateId: z.string().nullable(), privateKey: z.string().nullable(), serialNumber: z.string().nullable(), errorMessage: z.string().nullable(), @@ -333,18 +331,17 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const data = await server.services.certificateRequest.getCertificateFromRequest({ + const { certificateRequest, projectId } = await server.services.certificateRequest.getCertificateFromRequest({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: (req.query as { projectId: string }).projectId, certificateRequestId: req.params.requestId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: (req.query as { projectId: string }).projectId, + projectId, event: { type: EventType.GET_CERTIFICATE_REQUEST, metadata: { @@ -352,7 +349,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { } } }); - return data; + return certificateRequest; } }); diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 8cf9604a43..7dc763730f 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -624,7 +624,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SanitizedTagSchema.array().optional() + tags: SanitizedTagSchema.array().optional(), + reminder: RemindersSchema.extend({ + recipients: z.string().array() + }).nullable() }) .nullable() .array() @@ -743,6 +746,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ReturnType >[number]["secrets"][number] & { isEmpty: boolean; + reminder: Awaited>[string] | null; } > | null)[]; })[] @@ -847,27 +851,38 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { - secretRotations = ( - await server.services.secretRotationV2.getDashboardSecretRotations( - { - projectId, - search, - orderBy, - orderDirection, - environments: [environment], - secretPath, - limit: remainingLimit, - offset: adjustedOffset - }, - req.permission - ) - ).map((rotation) => ({ + const rawSecretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + const allRotationSecretIds = rawSecretRotations + .flatMap((rotation) => rotation.secrets) + .filter((secret) => Boolean(secret)) + .map((secret) => secret.id); + + const rotationReminders = + allRotationSecretIds.length > 0 + ? await server.services.reminder.getRemindersForDashboard(allRotationSecretIds) + : {}; + + secretRotations = rawSecretRotations.map((rotation) => ({ ...rotation, secrets: rotation.secrets.map((secret) => secret ? { ...secret, - isEmpty: !secret.secretValue + isEmpty: !secret.secretValue, + reminder: rotationReminders[secret.id] ?? null } : secret ) @@ -948,7 +963,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { @@ -970,7 +986,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { offset: adjustedOffset, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }) ).secrets; diff --git a/backend/src/server/routes/v1/group-project-router.ts b/backend/src/server/routes/v1/group-project-router.ts index 93caf50351..146891c80e 100644 --- a/backend/src/server/routes/v1/group-project-router.ts +++ b/backend/src/server/routes/v1/group-project-router.ts @@ -9,7 +9,7 @@ import { TemporaryPermissionMode, UsersSchema } from "@app/db/schemas"; -import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; +import { FilterReturnedUsers } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { isUuidV4 } from "@app/lib/validator"; @@ -355,9 +355,10 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => rateLimit: readLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.ProjectGroups], - description: "Return project group users", + description: "Return project group users (Deprecated: Use /api/v1/groups/{id}/users instead)", params: z.object({ projectId: z.string().trim().describe(GROUPS.LIST_USERS.projectId), groupId: z.string().trim().describe(GROUPS.LIST_USERS.id) @@ -367,7 +368,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), - filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/deprecated-group-project-router.ts b/backend/src/server/routes/v2/deprecated-group-project-router.ts index 5be7df8387..acfd72a59f 100644 --- a/backend/src/server/routes/v2/deprecated-group-project-router.ts +++ b/backend/src/server/routes/v2/deprecated-group-project-router.ts @@ -9,7 +9,7 @@ import { TemporaryPermissionMode, UsersSchema } from "@app/db/schemas"; -import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; +import { FilterReturnedUsers } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { isUuidV4 } from "@app/lib/validator"; @@ -367,7 +367,7 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), - filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 9e692dcd37..f93f511dd1 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ... IDENTITY = "identity", Machine = "machine", SCIM_CLIENT = "scimClient", + ACME_PROFILE = "acmeProfile", ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 3a8f99f1ae..1572746830 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -168,7 +168,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), - db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret") + db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret"), + db + .ref("skipDnsOwnershipVerification") + .withSchema(TableName.PkiAcmeEnrollmentConfig) + .as("acmeConfigSkipDnsOwnershipVerification") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -198,7 +202,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const acmeConfig = result.acmeConfigId ? ({ id: result.acmeConfigId, - encryptedEabSecret: result.acmeConfigEncryptedEabSecret + encryptedEabSecret: result.acmeConfigEncryptedEabSecret, + skipDnsOwnershipVerification: result.acmeConfigSkipDnsOwnershipVerification ?? false } as TCertificateProfileWithConfigs["acmeConfig"]) : undefined; @@ -356,7 +361,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), - db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId"), + db + .ref("skipDnsOwnershipVerification") + .withSchema(TableName.PkiAcmeEnrollmentConfig) + .as("acmeSkipDnsOwnershipVerification") ); if (processedRules) { @@ -393,7 +402,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const acmeConfig = result.acmeId ? { - id: result.acmeId as string + id: result.acmeId as string, + skipDnsOwnershipVerification: !!result.acmeSkipDnsOwnershipVerification } : undefined; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index e6b574dea5..3ac4def572 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -30,7 +30,11 @@ export const createCertificateProfileSchema = z renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional() }) .refine( (data) => { @@ -155,6 +159,11 @@ export const updateCertificateProfileSchema = z autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) + .optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) .optional() }) .refine( diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 59e3afbd99..4cb60a5229 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -403,7 +403,13 @@ export const certificateProfileServiceFactory = ({ apiConfigId = apiConfig.id; } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); - const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx); + const acmeConfig = await acmeEnrollmentConfigDAL.create( + { + skipDnsOwnershipVerification: data.acmeConfig.skipDnsOwnershipVerification ?? false, + encryptedEabSecret + }, + tx + ); acmeConfigId = acmeConfig.id; } @@ -505,7 +511,7 @@ export const certificateProfileServiceFactory = ({ const updatedData = finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; - const { estConfig, apiConfig, ...profileUpdateData } = updatedData; + const { estConfig, apiConfig, acmeConfig, ...profileUpdateData } = updatedData; const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { @@ -547,6 +553,16 @@ export const certificateProfileServiceFactory = ({ ); } + if (acmeConfig && existingProfile.acmeConfigId) { + await acmeEnrollmentConfigDAL.updateById( + existingProfile.acmeConfigId, + { + skipDnsOwnershipVerification: acmeConfig.skipDnsOwnershipVerification ?? false + }, + tx + ); + } + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); return profileResult; }); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 3eca249cd0..5b1d62387f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -46,7 +46,9 @@ export type TCertificateProfileUpdate = Omit< autoRenew?: boolean; renewBeforeDays?: number; }; - acmeConfig?: unknown; + acmeConfig?: { + skipDnsOwnershipVerification?: boolean; + }; }; export type TCertificateProfileWithConfigs = TCertificateProfile & { @@ -83,6 +85,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { id: string; directoryUrl: string; encryptedEabSecret?: Buffer; + skipDnsOwnershipVerification?: boolean; }; }; diff --git a/backend/src/services/certificate-request/certificate-request-service.test.ts b/backend/src/services/certificate-request/certificate-request-service.test.ts index 10c8b73cf6..812d11e739 100644 --- a/backend/src/services/certificate-request/certificate-request-service.test.ts +++ b/backend/src/services/certificate-request/certificate-request-service.test.ts @@ -258,7 +258,7 @@ describe("CertificateRequestService", () => { (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); (mockCertificateService.getCertPrivateKey as any).mockResolvedValue(mockPrivateKey); - const result = await service.getCertificateFromRequest(mockGetData); + const { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( "550e8400-e29b-41d4-a716-446655440005" @@ -277,8 +277,9 @@ describe("CertificateRequestService", () => { actorAuthMethod: AuthMethod.EMAIL, actorOrgId: "550e8400-e29b-41d4-a716-446655440002" }); - expect(result).toEqual({ + expect(certificateRequest).toEqual({ status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440006", certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----", serialNumber: "123456", @@ -286,6 +287,7 @@ describe("CertificateRequestService", () => { createdAt: mockRequestWithCert.createdAt, updatedAt: mockRequestWithCert.updatedAt }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); }); it("should get certificate from request successfully when no certificate is attached", async () => { @@ -310,10 +312,11 @@ describe("CertificateRequestService", () => { (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithoutCert); - const result = await service.getCertificateFromRequest(mockGetData); + const { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); - expect(result).toEqual({ + expect(certificateRequest).toEqual({ status: CertificateRequestStatus.PENDING, + certificateId: null, certificate: null, privateKey: null, serialNumber: null, @@ -321,6 +324,7 @@ describe("CertificateRequestService", () => { createdAt: mockRequestWithoutCert.createdAt, updatedAt: mockRequestWithoutCert.updatedAt }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); }); it("should get certificate from request successfully when user lacks private key permission", async () => { @@ -354,7 +358,7 @@ describe("CertificateRequestService", () => { (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert); (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); - const result = await service.getCertificateFromRequest(mockGetData); + const { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( "550e8400-e29b-41d4-a716-446655440005" @@ -367,8 +371,9 @@ describe("CertificateRequestService", () => { actorOrgId: "550e8400-e29b-41d4-a716-446655440002" }); expect(mockCertificateService.getCertPrivateKey).not.toHaveBeenCalled(); - expect(result).toEqual({ + expect(certificateRequest).toEqual({ status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440008", certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", privateKey: null, serialNumber: "123456", @@ -376,6 +381,7 @@ describe("CertificateRequestService", () => { createdAt: mockRequestWithCert.createdAt, updatedAt: mockRequestWithCert.updatedAt }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); }); it("should get certificate from request successfully when user has private key permission but key retrieval fails", async () => { @@ -414,7 +420,7 @@ describe("CertificateRequestService", () => { (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); (mockCertificateService.getCertPrivateKey as any).mockRejectedValue(new Error("Private key not found")); - const result = await service.getCertificateFromRequest(mockGetData); + const { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( "550e8400-e29b-41d4-a716-446655440005" @@ -433,8 +439,9 @@ describe("CertificateRequestService", () => { actorAuthMethod: AuthMethod.EMAIL, actorOrgId: "550e8400-e29b-41d4-a716-446655440002" }); - expect(result).toEqual({ + expect(certificateRequest).toEqual({ status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440009", certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", privateKey: null, serialNumber: "123456", @@ -442,6 +449,7 @@ describe("CertificateRequestService", () => { createdAt: mockRequestWithCert.createdAt, updatedAt: mockRequestWithCert.updatedAt }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); }); it("should get certificate from request with error message when failed", async () => { @@ -466,17 +474,19 @@ describe("CertificateRequestService", () => { (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockFailedRequest); - const result = await service.getCertificateFromRequest(mockGetData); + const { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); - expect(result).toEqual({ + expect(certificateRequest).toEqual({ status: CertificateRequestStatus.FAILED, certificate: null, + certificateId: null, privateKey: null, serialNumber: null, errorMessage: "Certificate issuance failed", createdAt: mockFailedRequest.createdAt, updatedAt: mockFailedRequest.updatedAt }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); }); it("should throw NotFoundError when certificate request does not exist", async () => { diff --git a/backend/src/services/certificate-request/certificate-request-service.ts b/backend/src/services/certificate-request/certificate-request-service.ts index 46cd494760..78bde276b2 100644 --- a/backend/src/services/certificate-request/certificate-request-service.ts +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -91,6 +91,7 @@ export const certificateRequestServiceFactory = ({ permissionService }: TCertificateRequestServiceFactoryDep) => { const createCertificateRequest = async ({ + acmeOrderId, actor, actorId, actorAuthMethod, @@ -123,6 +124,7 @@ export const certificateRequestServiceFactory = ({ { status, projectId, + acmeOrderId, ...validatedData }, tx @@ -170,13 +172,17 @@ export const certificateRequestServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - projectId, certificateRequestId }: TGetCertificateFromRequestDTO) => { + const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId, + projectId: certificateRequest.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -187,25 +193,20 @@ export const certificateRequestServiceFactory = ({ ProjectPermissionSub.Certificates ); - const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId); - if (!certificateRequest) { - throw new NotFoundError({ message: "Certificate request not found" }); - } - - if (certificateRequest.projectId !== projectId) { - throw new NotFoundError({ message: "Certificate request not found" }); - } - // If no certificate is attached, return basic info if (!certificateRequest.certificate) { return { - status: certificateRequest.status as CertificateRequestStatus, - certificate: null, - privateKey: null, - serialNumber: null, - errorMessage: certificateRequest.errorMessage || null, - createdAt: certificateRequest.createdAt, - updatedAt: certificateRequest.updatedAt + certificateRequest: { + status: certificateRequest.status as CertificateRequestStatus, + certificate: null, + certificateId: null, + privateKey: null, + serialNumber: null, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }, + projectId: certificateRequest.projectId }; } @@ -240,13 +241,17 @@ export const certificateRequestServiceFactory = ({ } return { - status: certificateRequest.status as CertificateRequestStatus, - certificate: certBody.certificate, - privateKey, - serialNumber: certificateRequest.certificate.serialNumber, - errorMessage: certificateRequest.errorMessage || null, - createdAt: certificateRequest.createdAt, - updatedAt: certificateRequest.updatedAt + certificateRequest: { + status: certificateRequest.status as CertificateRequestStatus, + certificate: certBody.certificate, + certificateId: certificateRequest.certificate.id, + privateKey, + serialNumber: certificateRequest.certificate.serialNumber, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }, + projectId: certificateRequest.projectId }; }; diff --git a/backend/src/services/certificate-request/certificate-request-types.ts b/backend/src/services/certificate-request/certificate-request-types.ts index c8a00de7e4..9ccf6fbaef 100644 --- a/backend/src/services/certificate-request/certificate-request-types.ts +++ b/backend/src/services/certificate-request/certificate-request-types.ts @@ -21,13 +21,14 @@ export type TCreateCertificateRequestDTO = TProjectPermission & { metadata?: string; status: CertificateRequestStatus; certificateId?: string; + acmeOrderId?: string; }; export type TGetCertificateRequestDTO = TProjectPermission & { certificateRequestId: string; }; -export type TGetCertificateFromRequestDTO = TProjectPermission & { +export type TGetCertificateFromRequestDTO = Omit & { certificateRequestId: string; }; diff --git a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts index afa8f17ef5..758c08491d 100644 --- a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts +++ b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts @@ -1,61 +1,13 @@ -import { Knex } from "knex"; - import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; -import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types"; - export type TAcmeEnrollmentConfigDALFactory = ReturnType; export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); - const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*"); - const [acmeConfig] = result; - - if (!acmeConfig) { - throw new Error("Failed to create ACME enrollment config"); - } - - return acmeConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Create ACME enrollment config" }); - } - }; - - const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*"); - const [acmeConfig] = result; - - if (!acmeConfig) { - return null; - } - - return acmeConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Update ACME enrollment config" }); - } - }; - - const findById = async (id: string, tx?: Knex) => { - try { - const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first(); - - return acmeConfig || null; - } catch (error) { - throw new DatabaseError({ error, name: "Find ACME enrollment config by id" }); - } - }; - return { - ...acmeEnrollmentConfigOrm, - create, - updateById, - findById + ...acmeEnrollmentConfigOrm }; }; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 7fe5a475d0..2ea68faa7f 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -37,4 +37,6 @@ export interface TApiConfigData { renewBeforeDays?: number; } -export interface TAcmeConfigData {} +export interface TAcmeConfigData { + skipDnsOwnershipVerification?: boolean; +} diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 4a4a614967..77497a26cd 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -10,7 +10,7 @@ import { TGroupDALFactory } from "../../ee/services/group/group-dal"; import { TProjectDALFactory } from "../project/project-dal"; type TGroupProjectServiceFactoryDep = { - groupDAL: Pick; + groupDAL: Pick; projectDAL: Pick; permissionService: Pick; }; @@ -51,7 +51,7 @@ export const groupProjectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); - const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({ orgId: project.orgId, groupId: id, offset, diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 2a67866d3a..29b12ceb3d 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -32,7 +32,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { const removeExpiredTokens = async (tx?: Knex) => { logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token started`); - const BATCH_SIZE = 10000; + const BATCH_SIZE = 5000; const MAX_RETRY_ON_FAILURE = 3; const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes const MAX_TTL = 315_360_000; // Maximum TTL value in seconds (10 years) @@ -101,7 +101,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { } finally { // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => { - setTimeout(resolve, 10); // time to breathe for db + setTimeout(resolve, 500); // time to breathe for db }); } isRetrying = numberOfRetryOnFailure > 0; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 11d4239db0..a25329aee4 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -25,12 +25,27 @@ export const projectDALFactory = (db: TDbClient) => { const findIdentityProjects = async (identityId: string, orgId: string, projectType?: ProjectType) => { try { + const identityGroupSubquery = db + .replicaNode()(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, orgId) + .where(`${TableName.IdentityGroupMembership}.identityId`, identityId) + .select(db.ref("id").withSchema(TableName.Groups)); + const workspaces = await db .replicaNode()(TableName.Membership) .where(`${TableName.Membership}.scope`, AccessScope.Project) - .where(`${TableName.Membership}.actorIdentityId`, identityId) .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + void qb + .where(`${TableName.Membership}.actorIdentityId`, identityId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery); + }) .andWhere((qb) => { if (projectType) { void qb.where(`${TableName.Project}.type`, projectType); @@ -347,11 +362,25 @@ export const projectDALFactory = (db: TDbClient) => { .where(`${TableName.Groups}.orgId`, dto.orgId) .where(`${TableName.UserGroupMembership}.userId`, dto.actorId) .select(db.ref("id").withSchema(TableName.Groups)); + + const identityGroupMembershipSubquery = db + .replicaNode()(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, dto.orgId) + .where(`${TableName.IdentityGroupMembership}.identityId`, dto.actorId) + .select(db.ref("id").withSchema(TableName.Groups)); + const membershipSubQuery = db(TableName.Membership) .where(`${TableName.Membership}.scope`, AccessScope.Project) .where((qb) => { if (dto.actor === ActorType.IDENTITY) { - void qb.where(`${TableName.Membership}.actorIdentityId`, dto.actorId); + void qb + .where(`${TableName.Membership}.actorIdentityId`, dto.actorId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupMembershipSubquery); } else { void qb .where(`${TableName.Membership}.actorUserId`, dto.actorId) diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index cf1771abad..a48daefdc2 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1986,7 +1986,7 @@ export const projectServiceFactory = ({ if (project.type === ProjectType.SecretManager) { projectTypeUrl = "secret-management"; } else if (project.type === ProjectType.CertificateManager) { - projectTypeUrl = "cert-management"; + projectTypeUrl = "cert-manager"; } const callbackPath = `/organizations/${project.orgId}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index d8220e4f54..0753f9640c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -416,6 +416,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { tagSlugs?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } ) => { try { @@ -481,6 +482,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { ); } + if (filters?.excludeRotatedSecrets) { + void query.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + const secrets = await query; // @ts-expect-error not inferred by knex @@ -594,6 +599,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { void bd.whereIn(`${TableName.SecretTag}.slug`, slugs); } }) + .where((bd) => { + if (filters?.excludeRotatedSecrets) { + void bd.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + }) .orderBy( filters?.orderBy === SecretsOrderBy.Name ? "key" : "id", filters?.orderDirection ?? OrderByDirection.ASC diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index d42a26effc..d8d06bd462 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -483,8 +483,8 @@ export const secretV2BridgeServiceFactory = ({ }); if (!sharedSecretToModify) throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` }); - if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue)) - throw new BadRequestError({ message: "Cannot update rotated secret name or value" }); + if (sharedSecretToModify.isRotatedSecret && inputSecret.newSecretName) + throw new BadRequestError({ message: "Cannot update rotated secret name" }); secretId = sharedSecretToModify.id; secret = sharedSecretToModify; } @@ -888,6 +888,7 @@ export const secretV2BridgeServiceFactory = ({ | "tagSlugs" | "environment" | "search" + | "excludeRotatedSecrets" >) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -1934,8 +1935,14 @@ export const secretV2BridgeServiceFactory = ({ if (el.isRotatedSecret) { const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key); - if (input && (input.newSecretName || input.secretValue)) - throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` }); + if (input) { + if (input.newSecretName) { + delete input.newSecretName; + } + if (input.secretValue !== undefined) { + delete input.secretValue; + } + } } }); @@ -2061,8 +2068,11 @@ export const secretV2BridgeServiceFactory = ({ commitChanges, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; + const shouldUpdateValue = !originalSecret.isRotatedSecret && typeof el.secretValue !== "undefined"; + const shouldUpdateName = !originalSecret.isRotatedSecret && el.newSecretName; + const encryptedValue = - typeof el.secretValue !== "undefined" + shouldUpdateValue && el.secretValue !== undefined ? { encryptedValue: secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob, references: secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences @@ -2077,7 +2087,7 @@ export const secretV2BridgeServiceFactory = ({ (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob ), skipMultilineEncoding: el.skipMultilineEncoding, - key: el.newSecretName || el.secretKey, + key: shouldUpdateName ? el.newSecretName : el.secretKey, tags: el.tagIds, secretMetadata: el.secretMetadata, ...encryptedValue diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 5e2ffc1a0f..f8613f57a3 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -50,6 +50,7 @@ export type TGetSecretsDTO = { limit?: number; search?: string; keys?: string[]; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretsMissingReadValuePermissionDTO = Omit< @@ -362,6 +363,7 @@ export type TFindSecretsByFolderIdsFilter = { includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; keys?: string[]; + excludeRotatedSecrets?: boolean; }; export type TGetSecretsRawByFolderMappingsDTO = { diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index a7f0eb5655..413ae5a912 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -200,6 +200,11 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.identityId`, + `${TableName.Identity}.id` + ) .leftJoin(TableName.Membership, (qb) => { void qb .on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project])) @@ -208,7 +213,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { void sqb .on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) .orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`) - .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`); + .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`) + .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.IdentityGroupMembership}.groupId`); }); }) .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 9ba1df47d7..27690249db 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1154,6 +1154,7 @@ export const secretServiceFactory = ({ | "search" | "includeTagsInSearch" | "includeMetadataInSearch" + | "excludeRotatedSecrets" >) => { const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index d8c778d7e3..be2c8b2149 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -214,6 +214,7 @@ export type TGetSecretsRawDTO = { keys?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretAccessListDTO = { diff --git a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx index f7a89f4e14..66df944393 100644 --- a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx @@ -61,9 +61,7 @@ export const PkiExpirationAlertTemplate = ({
- - View Certificate Alerts - + View Certificate Alerts
); diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index d2e977605b..560520f25f 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -1,4 +1,6 @@ import { + AcmeAccountActor, + AcmeProfileActor, IdentityActor, KmipClientActor, PlatformActor, @@ -60,6 +62,8 @@ export type TSecretModifiedEvent = { | ScimClientActor | PlatformActor | UnknownUserActor + | AcmeAccountActor + | AcmeProfileActor | KmipClientActor; }; }; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5c3661cb7a..623ed5dc22 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,7 +9,7 @@ services: condition: service_healthy redis: condition: service_started - image: infisical/infisical:latest-postgres + image: infisical/infisical:latest # PIN THIS TO A SPECIFIC TAG pull_policy: always env_file: .env ports: diff --git a/docs/api-reference/endpoints/certificates/certificate-request.mdx b/docs/api-reference/endpoints/certificates/certificate-request.mdx new file mode 100644 index 0000000000..fcf601f5ed --- /dev/null +++ b/docs/api-reference/endpoints/certificates/certificate-request.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Certificate Request" +openapi: "GET /api/v1/cert-manager/certificates/certificate-requests/{requestId}" +--- diff --git a/docs/api-reference/endpoints/certificates/create-certificate.mdx b/docs/api-reference/endpoints/certificates/create-certificate.mdx new file mode 100644 index 0000000000..54b70c4641 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/create-certificate.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Certificate" +openapi: "POST /api/v1/cert-manager/certificates" +--- diff --git a/docs/api-reference/endpoints/groups/add-group-machine-identity.mdx b/docs/api-reference/endpoints/groups/add-group-machine-identity.mdx new file mode 100644 index 0000000000..a997cddc7b --- /dev/null +++ b/docs/api-reference/endpoints/groups/add-group-machine-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Machine Identity to Group" +openapi: "POST /api/v1/groups/{id}/machine-identities/{machineIdentityId}" +--- diff --git a/docs/api-reference/endpoints/groups/list-group-machine-identities.mdx b/docs/api-reference/endpoints/groups/list-group-machine-identities.mdx new file mode 100644 index 0000000000..ebe0417132 --- /dev/null +++ b/docs/api-reference/endpoints/groups/list-group-machine-identities.mdx @@ -0,0 +1,4 @@ +--- +title: "List Group Machine Identities" +openapi: "GET /api/v1/groups/{id}/machine-identities" +--- diff --git a/docs/api-reference/endpoints/groups/list-group-members.mdx b/docs/api-reference/endpoints/groups/list-group-members.mdx new file mode 100644 index 0000000000..cf9f39a6ea --- /dev/null +++ b/docs/api-reference/endpoints/groups/list-group-members.mdx @@ -0,0 +1,5 @@ +--- +title: "List Group Members" +openapi: "GET /api/v1/groups/{id}/members" +--- + diff --git a/docs/api-reference/endpoints/groups/list-group-projects.mdx b/docs/api-reference/endpoints/groups/list-group-projects.mdx new file mode 100644 index 0000000000..6dc36a6cfd --- /dev/null +++ b/docs/api-reference/endpoints/groups/list-group-projects.mdx @@ -0,0 +1,5 @@ +--- +title: "List Group Projects" +openapi: "GET /api/v1/groups/{id}/projects" +--- + diff --git a/docs/api-reference/endpoints/groups/remove-group-identity.mdx b/docs/api-reference/endpoints/groups/remove-group-identity.mdx new file mode 100644 index 0000000000..b2ec485579 --- /dev/null +++ b/docs/api-reference/endpoints/groups/remove-group-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Machine Identity from Group" +openapi: "DELETE /api/v1/groups/{id}/machine-identities/{machineIdentityId}" +--- diff --git a/docs/docs.json b/docs/docs.json index f2f63c026a..6c8dcf38e1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -664,7 +664,16 @@ "group": "Concepts", "pages": [ "documentation/platform/pki/concepts/certificate-mgmt", - "documentation/platform/pki/concepts/certificate-lifecycle" + "documentation/platform/pki/concepts/certificate-lifecycle", + "documentation/platform/pki/concepts/certificate-components" + ] + }, + { + "group": "Guides", + "pages": [ + "documentation/platform/pki/guides/request-cert-agent", + "documentation/platform/pki/guides/request-cert-api", + "documentation/platform/pki/guides/request-cert-acme" ] } ] @@ -704,6 +713,7 @@ { "group": "Infrastructure Integrations", "pages": [ + "integrations/platforms/certificate-agent", "documentation/platform/pki/k8s-cert-manager", "documentation/platform/pki/integration-guides/gloo-mesh", "documentation/platform/pki/integration-guides/windows-server-acme", @@ -878,7 +888,12 @@ "api-reference/endpoints/groups/get-by-id", "api-reference/endpoints/groups/add-group-user", "api-reference/endpoints/groups/remove-group-user", - "api-reference/endpoints/groups/list-group-users" + "api-reference/endpoints/groups/list-group-users", + "api-reference/endpoints/groups/add-group-machine-identity", + "api-reference/endpoints/groups/remove-group-machine-identity", + "api-reference/endpoints/groups/list-group-machine-identities", + "api-reference/endpoints/groups/list-group-projects", + "api-reference/endpoints/groups/list-group-members" ] }, { @@ -2508,7 +2523,7 @@ ] }, { - "group": "Infisical PKI", + "group": "Certificate Management", "pages": [ { "group": "Certificate Authorities", @@ -2547,6 +2562,8 @@ "pages": [ "api-reference/endpoints/certificates/list", "api-reference/endpoints/certificates/read", + "api-reference/endpoints/certificates/certificate-request", + "api-reference/endpoints/certificates/create-certificate", "api-reference/endpoints/certificates/renew", "api-reference/endpoints/certificates/update-config", "api-reference/endpoints/certificates/revoke", @@ -3096,6 +3113,186 @@ { "source": "/documentation/platform/pki/est", "destination": "/documentation/platform/pki/enrollment-methods/est" + }, + { + "source": "/api-reference/endpoints/integrations/create-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/create", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/delete-auth-by-id", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/delete-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/delete", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/find-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/list-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/list-project-integrations", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/update", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/overview/examples/integration", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/documentation/platform/integrations", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/circleci", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/codefresh", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/octopus-deploy", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/rundeck", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/travisci", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/aws-parameter-store", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/aws-secret-manager", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/azure-app-configuration", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/azure-devops", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/azure-key-vault", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/checkly", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/cloud-66", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/cloudflare-pages", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/cloudflare-workers", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/databricks", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/digital-ocean-app-platform", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/flyio", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/gcp-secret-manager", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/hashicorp-vault", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/hasura-cloud", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/heroku", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/laravel-forge", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/netlify", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/northflank", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/qovery", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/railway", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/render", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/supabase", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/terraform-cloud", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/vercel", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/windmill", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/overview", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/aws-amplify", + "destination": "/integrations/cicd/aws-amplify" + }, + { + "source": "/integrations/cloud/teamcity", + "destination": "/integrations/secret-syncs/teamcity" } ] } diff --git a/docs/documentation/platform/groups.mdx b/docs/documentation/platform/groups.mdx index df4f114366..f7a49ef31c 100644 --- a/docs/documentation/platform/groups.mdx +++ b/docs/documentation/platform/groups.mdx @@ -1,29 +1,29 @@ --- -title: "User Groups" -description: "Manage user groups in Infisical." +title: "Groups" +description: "Manage groups containing users and machine identities in Infisical." --- - User Groups is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact team@infisical.com to purchase an enterprise license to use it. + Groups is a paid feature. If you're using Infisical Cloud, then it is + available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license + to use it. ## Concept -A (user) group is a collection of users that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple users together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization. +A group is a collection of identities (users and/or machine identities) that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple identities together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization, or a group called `CI/CD Identities` containing all the machine identities used in your CI/CD pipelines. -User groups have the following properties: +Groups have the following properties: -- If a group is added to a project under specific role(s), all users in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all users in the group will lose access to the project. -- If a user is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if a user is removed from a group, they will lose access to project(s) that the group has access to. -- If a user was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the user will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the user will not lose access to the project as they were previously added to the project separately. -- A user can be part of multiple groups. If a user is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of. +- If a group is added to a project under specific role(s), all identities in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all identities in the group will lose access to the project. +- If an identity is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if an identity is removed from a group, they will lose access to project(s) that the group has access to. +- If an identity was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the identity will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the identity will not lose access to the project as they were previously added to the project separately. +- An identity can be part of multiple groups. If an identity is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of. ## Workflow -In the following steps, we explore how to create and use user groups to provision user access to projects in Infisical. +In the following steps, we explore how to create and use groups to provision access to projects in Infisical. Groups can contain both users and machine identities, and the workflow is the same for both types of identities. @@ -32,36 +32,38 @@ In the following steps, we explore how to create and use user groups to provisio ![groups org](/images/platform/groups/groups-org.png) When creating a group, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - + ![groups org create](/images/platform/groups/groups-org-create.png) - + Now input a few details for your new group. Here’s some guidance for each field: - Name (required): A friendly name for the group like `Engineering`. - Slug (required): A unique identifier for the group like `engineering`. - Role (required): A role from the Organization Roles tab for the group to assume. The organization role assigned will determine what organization level resources this group can have access to. + - - Next, you'll want to assign users to the group. To do this, press on the users icon on the group and start assigning users to the group. + + Next, you'll want to assign identities (users and/or machine identities) to the group. To do this, click on the group row to open the group details page and click on the **+** button. - ![groups org users](/images/platform/groups/groups-org-users.png) + ![groups org users details](/images/platform/groups/group-details.png) - In this example, we're assigning **Alan Turing** and **Ada Lovelace** to the group **Engineering**. + In this example, we're assigning **Alan Turing** and **Ada Lovelace** (users) to the group **Engineering**. You can similarly add machine identities to the group by selecting them from the **Machine Identities** tab in the modal. ![groups org assign users](/images/platform/groups/groups-org-users-assign.png) To enable the group to access project-level resources such as secrets within a specific project, you should add it to that project. - To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add group**. - + To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add Group to Project**. + ![groups project](/images/platform/groups/groups-project.png) - + Next, select the group you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this group can have access to. - + ![groups project add](/images/platform/groups/groups-project-create.png) - + That's it! - - The users of the group now have access to the project under the role you assigned to the group. + + All identities of the group now have access to the project under the role you assigned to the group. + - \ No newline at end of file + diff --git a/docs/documentation/platform/pam/resources/aws-iam.mdx b/docs/documentation/platform/pam/resources/aws-iam.mdx new file mode 100644 index 0000000000..662d76b5e4 --- /dev/null +++ b/docs/documentation/platform/pam/resources/aws-iam.mdx @@ -0,0 +1,258 @@ +--- +title: "AWS IAM" +sidebarTitle: "AWS IAM" +description: "Learn how to configure AWS Management Console access through Infisical PAM for secure, audited, and just-in-time access to AWS." +--- + +Infisical PAM supports secure, just-in-time access to the **AWS Management Console** through federated sign-in. This allows your team to access AWS without sharing long-lived credentials, while maintaining a complete audit trail of who accessed what and when. + +## How It Works + +Unlike database or SSH resources that require a Gateway for network connectivity, AWS Console access works differently. Infisical uses AWS STS (Security Token Service) to assume roles on your behalf and generates temporary federated sign-in URLs. + +```mermaid +sequenceDiagram + participant User + participant Infisical + participant Resource Role as Resource Role
(Your AWS Account) + participant Target Role as Target Role
(Your AWS Account) + participant Console as AWS Console + + User->>Infisical: Request AWS Console access + Infisical->>Resource Role: AssumeRole (with ExternalId) + Resource Role-->>Infisical: Temporary credentials + Infisical->>Target Role: AssumeRole (role chaining) + Target Role-->>Infisical: Session credentials + Infisical->>Console: Generate federation URL + Console-->>Infisical: Signed console URL + Infisical-->>User: Return console URL + User->>Console: Open AWS Console (federated) +``` + +### Key Concepts + +1. **Resource Role**: An IAM role in your AWS account that trusts Infisical. This is the "bridge" role that Infisical assumes first. + +2. **Target Role**: The IAM role that end users will actually use in the AWS Console. The Resource Role assumes this role on behalf of the user. + +3. **Role Chaining**: Infisical uses AWS role chaining - it first assumes the Resource Role, then uses those credentials to assume the Target Role. This provides an additional layer of security and audit capability. + +4. **External ID**: A unique identifier (your Infisical Project ID) used in the trust policy to prevent [confused deputy attacks](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + +## Session Behavior + +### Session Duration + +The session duration is set when creating the account and applies to all access requests. You can specify the duration using human-readable formats like `15m`, `30m`, or `1h`. Due to AWS role chaining limitations: + +- **Minimum**: 15 minutes (`15m`) +- **Maximum**: 1 hour (`1h`) + +### Session Tracking + +Infisical tracks: +- When the session was created +- Who accessed which role +- When the session expires + + + **Important**: AWS Console sessions cannot be terminated early. Once a federated URL is generated, the session remains valid until the configured duration expires. However, you can [revoke active sessions](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html) by modifying the role's trust policy. + + +### CloudTrail Integration + +All actions performed in the AWS Console are logged in [AWS CloudTrail](https://console.aws.amazon.com/cloudtrail). The session is identified by the `RoleSessionName`, which includes the user's email address for attribution: + +``` +arn:aws:sts::123456789012:assumed-role/pam-readonly/user@example.com +``` + +This allows you to correlate Infisical PAM sessions with CloudTrail logs for complete audit visibility. + +## Prerequisites + +Before configuring AWS Console access in Infisical PAM, you need to set up two IAM roles in your AWS account: + +1. **Resource Role** - Trusted by Infisical, can assume target roles +2. **Target Role(s)** - The actual roles users will use in the console + + + **No Gateway Required**: Unlike database or SSH resources, AWS Console access does not require an Infisical Gateway. Infisical communicates directly with AWS APIs. + + +## Create the PAM Resource + +The PAM Resource represents the connection between Infisical and your AWS account. It contains the Resource Role that Infisical will assume. + + + + First, create an IAM policy that allows the Resource Role to assume your target roles. For simplicity, you can use a wildcard to allow assuming any role in your account: + + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam:::role/*" + }] + } + ``` + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/resource-role-policy.png) + + + **For more granular control**: If you want to restrict which roles the Resource Role can assume, replace the wildcard (`/*`) with a more specific pattern. For example: + - `arn:aws:iam:::role/pam-*` to only allow roles with the `pam-` prefix + - `arn:aws:iam:::role/infisical-*` to only allow roles with the `infisical-` prefix + + This allows you to limit the blast radius of the Resource Role's permissions. + + + + + Create an IAM role (e.g., `InfisicalResourceRole`) with: + - The permissions policy from the previous step attached + - The following trust policy: + + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::root" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "" + } + } + }] + } + ``` + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/resource-role-trust-policy.png) + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/resource-role-attach-policy.png) + + + **Security Best Practice**: Always use the External ID condition. This prevents confused deputy attacks where another Infisical customer could potentially trick Infisical into assuming your role. + + + **Infisical AWS Account IDs:** + | Region | Account ID | + |--------|------------| + | US | `381492033652` | + | EU | `345594589636` | + + + **For Dedicated Instances**: Your AWS account ID differs from the ones listed above. Please contact Infisical support to obtain your dedicated AWS account ID. + + + + **For Self-Hosted Instances**: Use the AWS account ID where your Infisical instance is deployed. This is the account that hosts your Infisical infrastructure and will be assuming the Resource Role. + + + + + 1. Navigate to your PAM project and go to the **Resources** tab + 2. Click **Add Resource** and select **AWS IAM** + 3. Enter a name for the resource (e.g., `production-aws`) + 4. Enter the **Resource Role ARN** - the ARN of the role you created in the previous step + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/create-resource.png) + + Clicking **Create Resource** will validate that Infisical can assume the Resource Role. If the connection fails, verify: + - The trust policy has the correct Infisical AWS account ID + - The External ID matches your project ID + - The role ARN is correct + + + +## Create PAM Accounts + +A PAM Account represents a specific Target Role that users can request access to. You can create multiple accounts per resource, each pointing to a different target role with different permission levels. + + + + Each target role needs a trust policy that allows your Resource Role to assume it: + + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::role/InfisicalResourceRole" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "" + } + } + }] + } + ``` + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/target-role-trust-policy.png) + + + + 1. Navigate to the **Accounts** tab in your PAM project + 2. Click **Add Account** and select the AWS IAM resource you created + 3. Fill in the account details: + + ![Create AWS IAM Account](/images/pam/resources/aws-iam/create-account.png) + + + A friendly name for this account (e.g., `readonly`, `admin`, `developer`) + + + + Optional description of what this account is used for + + + + The ARN of the IAM role users will assume (e.g., `arn:aws:iam::123456789012:role/pam-readonly`) + + + + Session duration using human-readable format (e.g., `15m`, `30m`, `1h`). Minimum 15 minutes, maximum 1 hour. + + + Due to AWS role chaining limitations, the maximum session duration is **1 hour**, regardless of the target role's configured maximum session duration. + + + + + +## Access the AWS Console + +Once your resource and accounts are configured, users can request access through Infisical: + +![Create AWS IAM Resource](/images/pam/resources/aws-iam/access-account.png) + + + + Go to the **Accounts** tab in your PAM project. + + + + Find the AWS Console account you want to access. + + + + Click the **Access** button. + + Infisical will: + 1. Assume the Resource Role using your project's External ID + 2. Assume the Target Role using role chaining + 3. Generate a federated sign-in URL + 4. Open the AWS Console in a new browser tab + + The user will be signed into the AWS Console with the permissions of the Target Role. + + \ No newline at end of file diff --git a/docs/documentation/platform/pam/resources/kubernetes.mdx b/docs/documentation/platform/pam/resources/kubernetes.mdx new file mode 100644 index 0000000000..a92ec51c7d --- /dev/null +++ b/docs/documentation/platform/pam/resources/kubernetes.mdx @@ -0,0 +1,224 @@ +--- +title: "Kubernetes" +sidebarTitle: "Kubernetes" +description: "Learn how to configure Kubernetes cluster access through Infisical PAM for secure, audited, and just-in-time access to your Kubernetes clusters." +--- + +Infisical PAM supports secure, just-in-time access to Kubernetes clusters through service account token authentication. This allows your team to access Kubernetes clusters without sharing long-lived credentials, while maintaining a complete audit trail of who accessed what and when. + +## How It Works + +Kubernetes access in Infisical PAM uses an Infisical Gateway to securely proxy connections to your Kubernetes API server. When a user requests access, Infisical generates a temporary kubeconfig that routes traffic through the Gateway, enabling secure access without exposing your cluster directly. + +```mermaid +sequenceDiagram + participant User + participant CLI as Infisical CLI + participant Infisical + participant Gateway as Infisical Gateway + participant K8s as Kubernetes API Server + + User->>CLI: Request Kubernetes access + CLI->>Infisical: Authenticate & request session + Infisical-->>CLI: Session credentials & Gateway info + CLI->>CLI: Start local proxy + CLI->>Gateway: Establish secure tunnel + User->>CLI: kubectl commands + CLI->>Gateway: Proxy kubectl requests + Gateway->>K8s: Forward with SA token + K8s-->>Gateway: Response + Gateway-->>CLI: Return response + CLI-->>User: kubectl output +``` + +### Key Concepts + +1. **Gateway**: An Infisical Gateway deployed in your network that can reach the Kubernetes API server. The Gateway handles secure communication between users and your cluster. + +2. **Service Account Token**: A Kubernetes service account token that grants access to the cluster. This token is stored securely in Infisical and used by the Gateway to authenticate with the Kubernetes API. + +3. **Local Proxy**: The Infisical CLI starts a local proxy on your machine that intercepts kubectl commands and routes them securely through the Gateway to your cluster. + +4. **Session Tracking**: All access sessions are logged, including when the session was created, who accessed the cluster, session duration, and when it ended. + +### Session Tracking + +Infisical tracks: +- When the session was created +- Who accessed which cluster +- Session duration +- All kubectl commands executed during the session +- When the session ended + + + **Session Logs**: After ending a session (by stopping the proxy), you can view detailed session logs in the Sessions page, including all commands executed during the session. + + +## Prerequisites + +Before configuring Kubernetes access in Infisical PAM, you need: + +1. **Infisical Gateway** - A Gateway deployed in your network with access to the Kubernetes API server +2. **Service Account** - A Kubernetes service account with appropriate RBAC permissions +3. **Infisical CLI** - The Infisical CLI installed on user machines + + + **Gateway Required**: Unlike AWS Console access, Kubernetes access requires an Infisical Gateway to be deployed and registered with your Infisical instance. The Gateway must have network connectivity to your Kubernetes API server. + + +## Create the PAM Resource + +The PAM Resource represents the connection between Infisical and your Kubernetes cluster. + + + + Before creating the resource, ensure you have an Infisical Gateway running and registered with your Infisical instance. The Gateway must have network access to your Kubernetes API server. + + + + 1. Navigate to your PAM project and go to the **Resources** tab + 2. Click **Add Resource** and select **Kubernetes** + 3. Enter a name for the resource (e.g., `production-k8s`, `staging-cluster`) + 4. Enter the **Kubernetes API Server URL** - the URL to your Kubernetes API endpoint (e.g.`https://kubernetes.example.com:6443`) + 5. Select the **Gateway** that has access to this cluster + 6. Configure SSL verification options if needed + + + **SSL Verification**: You may need to disable SSL verification if your Kubernetes API server uses a self-signed certificate or if the certificate's hostname doesn't match the URL you're using to access it. + + + + +## Create a Service Account + +Infisical PAM currently supports service account token authentication for Kubernetes. You'll need to create a service account with appropriate permissions in your cluster. + + + + Create a file named `sa.yaml` with the following content: + + ```yaml sa.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-pam-sa + namespace: kube-system + --- + # Bind the ServiceAccount to the desired ClusterRole + # This example uses cluster-admin - adjust based on your needs + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-pam-binding + subjects: + - kind: ServiceAccount + name: infisical-pam-sa + namespace: kube-system + roleRef: + kind: ClusterRole + name: cluster-admin # Change this to a more restrictive role as needed + apiGroup: rbac.authorization.k8s.io + --- + # Create a static, non-expiring token for the ServiceAccount + apiVersion: v1 + kind: Secret + metadata: + name: infisical-pam-sa-token + namespace: kube-system + annotations: + kubernetes.io/service-account.name: infisical-pam-sa + type: kubernetes.io/service-account-token + ``` + + + **Security Best Practice**: The example above uses `cluster-admin` for simplicity. In production environments, you should create custom ClusterRoles or Roles with the minimum permissions required for each use case. + + + + + Apply the configuration to your cluster: + + ```bash + kubectl apply -f sa.yaml + ``` + + This creates: + - A ServiceAccount named `infisical-pam-sa` in the `kube-system` namespace + - A ClusterRoleBinding that grants the service account its permissions + - A Secret containing a static, non-expiring token for the service account + + + + Get the service account token that you'll use when creating the PAM account: + + ```bash + kubectl -n kube-system get secret infisical-pam-sa-token -o jsonpath='{.data.token}' | base64 -d + ``` + + Copy this token - you'll need it in the next step. + + + +## Create PAM Accounts + +Once you have configured the PAM resource, you'll need to configure a PAM account for your Kubernetes resource. +A PAM Account represents a specific service account that users can request access to. You can create multiple accounts per resource, each with different permission levels. + + + + Go to the **Accounts** tab in your PAM project. + + + + Click **Add Account** and select the Kubernetes resource you created. + + + + Fill in the account details and paste the service account token you retrieved earlier. + + + +## Access Kubernetes Cluster + +Once your resource and accounts are configured, users can request access through the Infisical CLI: + + + + 1. Navigate to the **Accounts** tab in your PAM project + 2. Find the Kubernetes account you want to access + 3. Click the **Access** button + 4. Copy the provided CLI command + + + + + Run the copied command in your terminal. + + The CLI will: + 1. Authenticate with Infisical + 2. Establish a secure connection through the Gateway + 3. Start a local proxy on your machine + 4. Configure kubectl to use the proxy + + + + Once the proxy is running, you can use `kubectl` commands as normal: + + ```bash + kubectl get pods + kubectl get namespaces + kubectl describe deployment my-app + ``` + + All commands are routed securely through the Infisical Gateway to your cluster. + + + + When you're done, stop the proxy by pressing `Ctrl+C` in the terminal where it's running. This will: + - Close the secure tunnel + - End the session + - Log the session details to Infisical + + You can view session logs in the **Sessions** page of your PAM project. + + diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx deleted file mode 100644 index da73de37df..0000000000 --- a/docs/documentation/platform/pki/certificates.mdx +++ /dev/null @@ -1,401 +0,0 @@ ---- -title: "Certificates" -sidebarTitle: "Certificates" -description: "Learn how to issue X.509 certificates with Infisical." ---- - -## Concept - -Assuming that you've created a Private CA hierarchy with a root CA and an intermediate CA, you can now issue/revoke X.509 certificates using the intermediate CA. - -
- -```mermaid -graph TD - A[Root CA] - A --> B[Intermediate CA] - A --> C[Intermediate CA] - B --> D[Leaf Certificate] - C --> E[Leaf Certificate] -``` - -
- -## Workflow - -The typical workflow for managing certificates consists of the following steps: - -1. Issuing a certificate under an intermediate CA with details like name and validity period. As part of certificate issuance, you can either issue a certificate directly from a CA or do it via a certificate template. -2. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, - you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed - data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked. - - - Note that this workflow can be executed via the Infisical UI or manually such - as via API. - - -## Guide to Issuing Certificates - -In the following steps, we explore how to issue a X.509 certificate under a CA. - - - - - - - A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA and can also be bound to a certificate collection for alerting such that any certificate issued under the template is automatically added to the collection. - - 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. - - Head to your Project > Certificate Authorities > Your Issuing CA and create a certificate template. - - ![pki certificate template modal](/images/platform/pki/certificate/cert-template-modal.png) - - Here's some guidance on each field: - - - Template Name: A name for the certificate template. - - Issuing CA: The Certificate Authority (CA) that will issue certificates based on this template. - - Certificate Collection (Optional): The certificate collection that certificates should be added to when issued under the template. - - 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. - - 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. - - - To create a certificate, head to your Project > Internal PKI > Certificates and press **Issue** under the Certificates section. - - ![pki issue certificate](/images/platform/pki/certificate/cert-issue.png) - - Here, set the **Certificate Template** to the template from step 1 and fill out the rest of the details for the certificate to be issued. - - ![pki issue certificate modal](/images/platform/pki/certificate/cert-issue-modal.png) - - Here's some guidance on each field: - - - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. - - 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 hostnames or email addresses like `app1.acme.com, app2.acme.com`. - - 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 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. - - That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates. - - - - - 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**. - - ![pki certificate body](/images/platform/pki/certificate/cert-body.png) - - - Make sure to download and store the **Private Key** in a secure location as it will only be displayed once at the time of certificate issuance. - The **Certificate Body** and **Certificate Chain** will remain accessible and can be copied at any time. - - - - - - - - - A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA and can also be bound to a certificate collection for alerting such that any certificate issued under the template is automatically added to the collection. - - 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-v2/create) API endpoint, specifying the issuing CA. - - ### Sample request - - ```bash Request - curl --request POST \ - --url https://us.infisical.com/api/v2/certificate-templates \ - --header 'Content-Type: application/json' \ - --data '{ - "projectId": "", - "name": "", - "description": "", - "subject": [ - { - "type": "common_name", - "allowed": [ - "*.infisical.com" - ] - } - ], - "sans": [ - { - "type": "dns_name", - "allowed": [ - "*.sample.com" - ] - } - ], - "keyUsages": { - "allowed": [ - "digital_signature" - ] - }, - "extendedKeyUsages": { - "allowed": [ - "client_auth" - ] - }, - "algorithms": { - "signature": [ - "SHA256-RSA" - ], - "keyAlgorithm": [ - "RSA-2048" - ] - }, - "validity": { - "max": "365d" - } - }' - ``` - - ### Sample response - - ```bash Response - { - "certificateTemplate": { - "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "name": "", - "description": "", - "subject": [ - { - "type": "common_name", - "allowed": [ - "*.infisical.com" - ] - } - ], - "sans": [ - { - "type": "dns_name", - "allowed": [ - "*.sample.com" - ] - } - ], - "keyUsages": { - "allowed": [ - "digital_signature" - ] - }, - "extendedKeyUsages": { - "allowed": [ - "client_auth" - ] - }, - "algorithms": { - "signature": [ - "SHA256-RSA" - ], - "keyAlgorithm": [ - "RSA-2048" - ] - }, - "validity": { - "max": "365d" - }, - "createdAt": "2023-11-07T05:31:56Z", - "updatedAt": "2023-11-07T05:31:56Z" - } - } - ``` - - - - To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint, - specifying the issuing CA. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "profileId": "", - "commonName": "service.acme.com", - "ttl": "1y", - "signatureAlgorithm": "RSA-SHA256", - "keyAlgorithm": "RSA_2048" - }' - ``` - - ### Sample response - - ```bash Response - { - certificate: "...", - certificateChain: "...", - issuingCaCertificate: "...", - privateKey: "...", - serialNumber: "..." - } - ``` - - - 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. - - That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates. - - - - Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. - - - If you have an external private key, you can also create a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint, specifying the issuing CA. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "certificateTemplateId": "", - "csr": "...", - "ttl": "1y", - }' - ``` - - ### Sample response - - ```bash Response - { - certificate: "...", - certificateChain: "...", - issuingCaCertificate: "...", - privateKey: "...", - serialNumber: "..." - } - ``` - - - - - - -## Guide to Revoking Certificates - -In the following steps, we explore how to revoke a X.509 certificate under a CA and obtain a Certificate Revocation List (CRL) for a CA. - - - - - - Assuming that you've issued a certificate under a CA, you can revoke it by - selecting the **Revoke Certificate** option for it and specifying the reason - for revocation. - - ![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png) - - ![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png) - - - - In order to check the revocation status of a certificate, you can check it - against the CRL of a CA by heading to its Issuing CA and downloading the CRL. - - ![pki view crl](/images/platform/pki/ca/ca-crl.png) - - To verify a certificate against the - downloaded CRL with OpenSSL, you can use the following command: - -```bash -openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem -``` - -Note that you can also obtain the CRL from the certificate itself by -referencing the CRL distribution point extension on the certificate. - -To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: - -```bash -openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem -``` - - - - - - - - Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint, - specifying the serial number of the certificate and the reason for revocation. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates//revoke' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "revocationReason": "UNSPECIFIED" - }' - ``` - - ### Sample response - - ```bash Response - { - message: "Successfully revoked certificate", - serialNumber: "...", - revokedAt: "..." - } - ``` - - - In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. - To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint. - - ### Sample request - - ```bash Request - curl --location --request GET 'https://app.infisical.com/api/v1/cert-manager/ca/internal//crls' \ - --header 'Authorization: Bearer ' - ``` - - ### Sample response - - ```bash Response - [ - { - id: "...", - crl: "..." - }, - ... - ] - ``` - - To verify a certificate against the CRL with OpenSSL, you can use the following command: - - ```bash - openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem - ``` - - - - - - -## FAQ - - - - To renew a certificate, you have to issue a new certificate from the same CA - with the same common name as the old certificate. The original certificate - will continue to be valid through its original TTL unless explicitly - revoked. - - diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx index eab06fe435..1f2a5b7f2b 100644 --- a/docs/documentation/platform/pki/certificates/certificates.mdx +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -29,13 +29,13 @@ Refer to the documentation for each [enrollment method](/documentation/platform/ ## Guide to Renewing Certificates To [renew a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), you can either request a new certificate from a certificate profile or have the platform -automatically request a new one for you. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate +automatically request a new one for you to be delivered downstream to a target destination. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate profile as well as your infrastructure use-case. ### Client-Driven Certificate Renewal Client-driven certificate renewal is when renewal is initiated client-side by the end-entity consuming the certificate. -This is the most common approach to certificate renewal and is suitable for most use-cases. +More specifically, the client (e.g. [Infisical Agent](/integrations/platforms/certificate-agent), [ACME client](https://letsencrypt.org/docs/client-options/), etc.) monitors the certificate and makes a request for Infisical to issue a new certificate back to it when the existing certificate is nearing expiration. This is the most common approach to certificate renewal and is suitable for most use-cases. ### Server-Driven Certificate Renewal diff --git a/docs/documentation/platform/pki/concepts/certificate-components.mdx b/docs/documentation/platform/pki/concepts/certificate-components.mdx new file mode 100644 index 0000000000..cfda3813c1 --- /dev/null +++ b/docs/documentation/platform/pki/concepts/certificate-components.mdx @@ -0,0 +1,30 @@ +--- +title: "Certificate Components" +description: "Learn the main components for managing certificates with Infisical." +--- + +## Core Components + +The following resources define how certificates are issued, shaped, and governed in Infisical: + +- [Certificate Authority (CA)](/documentation/platform/pki/ca/overview): The trusted entity that issues X.509 certificates. This can be an [Internal CA](/documentation/platform/pki/ca/private-ca) or an [External CA](/documentation/platform/pki/ca/external-ca) in Infisical. + The former represents a fully managed CA hierarchy within Infisical, while the latter represents an external CA (e.g. [DigiCert](/documentation/platform/pki/ca/digicert), [Let's Encrypt](/documentation/platform/pki/ca/lets-encrypt), [Microsoft AD CS](/documentation/platform/pki/ca/azure-adcs), etc.) that can be integrated with Infisical. + +- [Certificate Template](/documentation/platform/pki/certificates/templates): A policy structure specifying permitted attributes for requested certificates. This includes constraints around subject naming conventions, SAN fields, key usages, and extended key usages. + +- [Certificate Profile](/documentation/platform/pki/certificates/profiles): A configuration set specifying how leaf certificates should be issued for a group of end-entities including the issuing CA, a certificate template, and the enrollment method (e.g. [ACME](/documentation/platform/pki/enrollment-methods/acme), [EST](/documentation/platform/pki/enrollment-methods/est), [API](/documentation/platform/pki/enrollment-methods/api), etc.) used to enroll certificates. + +- [Certificate](/documentation/platform/pki/certificates/certificates): The actual X.509 certificate issued for a profile. Once created, it is tracked in Infisical’s certificate inventory for management, renewal, and lifecycle operations. + +## Access Control + +Access control defines who (or what) can manage certificate resources and who can issue certificates within a project. Without clear boundaries, [certificate authorities](/documentation/platform/pki/ca/overview) and issuance workflows can be misconfigured or misused. + +To manage access to certificates, you assign role-based permissions at the project level. These permissions determine which certificate authorities, certificate templates, certificate profiles, and other related resources a user or machine identity can act on. For example, +you may want to: + +- Have specific teams(s) manage your internal CA hierarchy or external CA integration configuration and have separate team(s) configure certificate profiles for requested certificates. +- Limit which teams can manage policies defined on certificate templates. +- Have specific end-entities (e.g. servers, devices, users) request certificates from specific certificate profiles. + +This model follows the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege) so that each user or machine identity can manage or issue only the certificate resources it is responsible for and nothing more. diff --git a/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx b/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx index efdb8a072c..05c70a7805 100644 --- a/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx +++ b/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx @@ -9,7 +9,7 @@ A (digital) _certificate_ is a file that is tied to a cryptographic key pair and For example, when you visit a website over HTTPS, your browser checks the TLS certificate deployed on the web server or load balancer to make sure it’s really the site it claims to be. If the certificate is valid, your browser establishes an encrypted connection with the server. -Certificates contain information about the subject (who it identifies), the public key, and a digital signature from the CA that issued the certificate. They also include additional fields such as key usages, validity periods, and extensions that define how and where the certificate can be used. When a certificate expires, the service presenting it is no longer trusted, and clients won't be able to establish a secure connection to the service. +Certificates contain information about the subject (who it identifies), the public key, and a digital signature from the Certificate Authority (CA) that issued the certificate. They also include additional fields such as key usages, validity periods, and extensions that define how and where the certificate can be used. When a certificate expires, the service presenting it is no longer trusted, and clients won't be able to establish a secure connection to the service. ## What is Certificate Management? diff --git a/docs/documentation/platform/pki/enrollment-methods/acme.mdx b/docs/documentation/platform/pki/enrollment-methods/acme.mdx index 3c12a5040d..8567e48a74 100644 --- a/docs/documentation/platform/pki/enrollment-methods/acme.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/acme.mdx @@ -6,7 +6,9 @@ sidebarTitle: "ACME" ## Concept The ACME enrollment method allows Infisical to act as an ACME server. It lets you request and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment). -This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management. + +This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management; +it can also be used with [cert-manager](https://cert-manager.io/) to issue and renew certificates for Kubernetes workloads through the [ACME issuer type](https://cert-manager.io/docs/configuration/acme/). Infisical's ACME enrollment method is based on [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555/). @@ -26,6 +28,17 @@ In the following steps, we explore how to issue a X.509 certificate using the AC ![pki acme config](/images/platform/pki/enrollment-methods/acme/acme-config.png) + + + By default, when the ACME client requests a certificate against the certificate profile for a particular domain, Infisical will verify domain ownership using the [HTTP-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) method prior to issuing a certificate back to the client. + + If you want Infisical to skip domain ownership validation entirely, you can enable the **Skip DNS Ownership Validation** checkbox. + + Note that skipping domain ownership validation for the ACME enrollment method is **not the same** as skipping validation for an [External ACME CA integration](/documentation/platform/pki/ca/acme-ca). + + When using the ACME enrollment, the domain ownership check occurring between the ACME client and Infisical can be skipped. In contrast, External ACME CA integrations always require domain ownership validation, as Infisical must complete a DNS-01 challenge with the upstream ACME-compatible CA. + + Once you've created the certificate profile, you can obtain its ACME configuration details by clicking the **Reveal ACME EAB** option on the profile. diff --git a/docs/documentation/platform/pki/enrollment-methods/api.mdx b/docs/documentation/platform/pki/enrollment-methods/api.mdx index bfbac7f2e1..c7dff2a271 100644 --- a/docs/documentation/platform/pki/enrollment-methods/api.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/api.mdx @@ -100,32 +100,34 @@ Here, select the certificate profile from step 1 that will be used to issue the - To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint. + To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ "profileId": "", - "commonName": "service.acme.com", - "ttl": "1y", - "signatureAlgorithm": "RSA-SHA256", - "keyAlgorithm": "RSA_2048", - "keyUsages": ["digital_signature", "key_encipherment"], - "extendedKeyUsages": ["server_auth"], - "altNames": [ - { - "type": "DNS", - "value": "service.acme.com" - }, - { - "type": "DNS", - "value": "www.service.acme.com" - } - ] + "attributes": { + "commonName": "service.acme.com", + "ttl": "1y", + "signatureAlgorithm": "RSA-SHA256", + "keyAlgorithm": "RSA_2048", + "keyUsages": ["digital_signature", "key_encipherment"], + "extendedKeyUsages": ["server_auth"], + "altNames": [ + { + "type": "DNS", + "value": "service.acme.com" + }, + { + "type": "DNS", + "value": "www.service.acme.com" + } + ] + } }' ``` @@ -133,31 +135,36 @@ Here, select the certificate profile from step 1 that will be used to issue the ```bash Response { - "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----", - "serialNumber": "123456789012345678", - "certificateId": "880h3456-e29b-41d4-a716-446655440003" + "certificate": { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----", + "serialNumber": "123456789012345678", + "certificateId": "880h3456-e29b-41d4-a716-446655440003" + }, + "certificateRequestId": "..." } ``` - Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + Note: If the certificate is available to be issued immediately, the `certificate` field in the response will contain the certificate data. If issuance is delayed (for example, due to pending approval or additional processing), the `certificate` field will be `null` and you can use the `certificateRequestId` to poll for status or retrieve the certificate when it is ready using the [Get Certificate Request](/api-reference/endpoints/certificates/certificate-request) API endpoint. - If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint. + If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the same [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ "profileId": "", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBE9oaW8...\n-----END CERTIFICATE REQUEST-----", - "ttl": "1y" + "attributes": { + "ttl": "1y" + } }' ``` @@ -165,11 +172,14 @@ Here, select the certificate profile from step 1 that will be used to issue the ```bash Response { - "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "serialNumber": "123456789012345679", - "certificateId": "990i4567-e29b-41d4-a716-446655440004" + "certificate": { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "serialNumber": "123456789012345679", + "certificateId": "990i4567-e29b-41d4-a716-446655440004" + }, + "certificateRequestId": "..." } ``` diff --git a/docs/documentation/platform/pki/guides/request-cert-acme.mdx b/docs/documentation/platform/pki/guides/request-cert-acme.mdx new file mode 100644 index 0000000000..f0b545ba23 --- /dev/null +++ b/docs/documentation/platform/pki/guides/request-cert-acme.mdx @@ -0,0 +1,108 @@ +--- +title: "Obtain a Certificate via ACME" +--- + +import RequestCertSetup from "/snippets/documentation/platform/pki/guides/request-cert-setup.mdx"; + +The [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) lets any [ACME client](https://letsencrypt.org/docs/client-options/) obtain TLS certificates from Infisical using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment). +This includes ACME clients like [Certbot](https://certbot.eff.org/), [cert-manager](https://cert-manager.io/) in Kubernetes using the [ACME issuer type](https://cert-manager.io/docs/configuration/acme/), and more. + +Infisical currently supports the [HTTP-01 challenge type](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) for domain validation as part of the ACME enrollment method. + +## Diagram + +The following sequence diagram illustrates the certificate enrollment workflow for requesting a certificate via ACME from Infisical. + +```mermaid +sequenceDiagram + autonumber + participant ACME as ACME Client + participant Infis as Infisical ACME Server + participant Authz as HTTP-01 Challenge
Validation Endpoint + participant CA as CA
(Internal or External) + + Note over ACME: ACME Client discovers
Infisical ACME Directory URL + + ACME->>Infis: GET /directory + Infis-->>ACME: Directory + nonce + endpoints + + ACME->>Infis: HEAD /new-nonce + Infis-->>ACME: Return nonce in Replay-Nonce header + + ACME->>Infis: POST /new-account
(contact, ToS agreed) + Infis-->>ACME: Return account object + + Note over ACME,Infis: Requesting a certificate + + ACME->>Infis: POST /new-order
(identifiers: DNS names) + Infis-->>ACME: Return order
with authorization URLs + + loop For each authorization (one per DNS name) + ACME->>Infis: POST /authorizations/:authzId + Infis-->>ACME: Return HTTP-01 challenge
(URL + token + keyAuth) + + Note over ACME: Client must prove control
over the domain via HTTP + + ACME->>Authz: Provision challenge response
at
/.well-known/acme-challenge/ + + ACME->>Infis: POST /authorizations/:authzId/challenges/:challengeId
(trigger validation) + + Infis->>Authz: HTTP GET /.well-known/acme-challenge/ + Authz-->>Infis: Return keyAuth + + Infis-->>ACME: Authorization = valid + end + + Note over Infis: All authorizations valid → ready to finalize + + ACME->>ACME: Generate keypair locally
and create CSR + ACME->>Infis: POST /orders/:orderId/finalize
(CSR) + + Infis->>CA: Request certificate issuance
(CSR) + CA-->>Infis: Signed certificate (+ chain) + + Infis-->>ACME: Return order with certificate URL
(status: valid) + + ACME->>Infis: POST /orders/:orderId/certificate + Infis-->>ACME: Return certificate
and certificate chain +``` + +## Guide + +In the following steps, we explore an end-to-end workflow for obtaining a certificate via ACME with Infisical. + + + + + Next, follow the guide [here](/documentation/platform/pki/certificates/profiles#guide-to-creating-a-certificate-profile) to create a [certificate profile](/documentation/platform/pki/certificates/profiles) + that will be referenced when requesting a certificate. + + The certificate profile specifies which certificate template and issuing CA should be used to validate an incoming certificate request and issue a certificate; + it also specifies the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) for how certificates can be requested against this profile + to begin with. + + You should specify the certificate template from Step 2, the issuing CA from Step 1, and the **ACME** option in the **Enrollment Method** dropdown when creating the certificate profile. + + + + Finally, follow the guide [here](/documentation/platform/pki/enrollment-methods/acme#guide-to-certificate-enrollment-via-acme) to request a certificate against the certificate profile + using an [ACME client](https://letsencrypt.org/docs/client-options/). + + The ACME client will connect to Infisical's ACME server at the **ACME Directory URL** and authenticate using the **EAB Key Identifier (KID)** and **EAB Secret** credentials as part of the ACME protocol. + + The typical ACME workflow looks likes this: + + - The ACME client creates (or reuses) an ACME account with Infisical using EAB credentials. + - The ACME client creates an order for one or more DNS names. + - For each DNS name, the ACME client receives an `HTTP-01` challenge and provisions the corresponding token response at `/.well-known/acme-challenge/<token>`. + - Once all authorizations are valid, the ACME client finalizes the order by sending a CSR to Infisical. + - Infisical issues the certificate from the issuing CA on the certificate profile and returns it (plus the chain) back to the ACME client. + + ACME clients typically handle renewal by tracking certificate expiration and completing the lifecycle once again to request a new certificate. + + + We recommend reading more about the ACME protocol [here](https://letsencrypt.org/how-it-works/). + + + + diff --git a/docs/documentation/platform/pki/guides/request-cert-agent.mdx b/docs/documentation/platform/pki/guides/request-cert-agent.mdx new file mode 100644 index 0000000000..698ea25471 --- /dev/null +++ b/docs/documentation/platform/pki/guides/request-cert-agent.mdx @@ -0,0 +1,95 @@ +--- +title: "Request a Certificate via the Infisical Agent" +--- + +import RequestCertSetup from "/snippets/documentation/platform/pki/guides/request-cert-setup.mdx"; + +The [Infisical Agent](/integrations/platforms/certificate-agent) is an installable client daemon that can request TLS and other X.509 certificates from Infisical using the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles), persist it to a specified path on the filesystem, and automatically monitor and renew it before expiration. + +Instead of [manually requesting](/documentation/platform/pki/guides/request-cert-api) and renewing a certificate via the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint, you can install and launch the Infisical Agent to have it perform these steps for you automatically. + +## Diagram + +The following sequence diagram illustrates the certificate enrollment workflow for requesting a certificate using the Infisical Agent from Infisical. + +```mermaid +sequenceDiagram + autonumber + participant Agent as Infisical Agent + participant Infis as Infisical + participant CA as CA
(Internal or External) + + Agent->>Infis: Request certificate
(profileId, conditional subject/SANs, ttl,
key usages, conditional CSR, etc.) + + Infis->>Infis: Look up certificate profile
(by profileId) + Infis->>Infis: Validate request
against profile constraints
(CN/SAN rules, key usages, max TTL, etc.) + + alt Issuer Type = Self-Signed + Infis->>Infis: Generate keypair
and self-sign certificate + else Issuer Type = Internal CA + Infis->>CA: Request certificate issuance + CA-->>Infis: Signed certificate
(+ chain) + end + + Infis-->>Agent: Return certificate, certificate chain,
(and private key if server-generated) + + Note over Agent: Persist certificate and begin lifecycle monitoring + + loop Periodic certificate status check + Agent->>Agent: Check certificate expiration
against renew-before-expiry threshold + + alt Renewal not required + Agent-->>Agent: Continue monitoring + else Renewal required + Agent->>Infis: Request new certificate
(same profile and constraints) + + Infis->>Infis: Validate renewal request
against profile constraints + + alt Issuer Type = Self-Signed + Infis->>Infis: Generate keypair
and self-sign certificate + else Issuer Type = Internal CA + Infis->>CA: Request certificate issuance + CA-->>Infis: Signed certificate
(+ chain) + end + + Infis-->>Agent: Return renewed certificate, certificate chain, and private key + end + end +``` + +## Guide + +In the following steps, we explore an end-to-end workflow for requesting and continuously renewing a certificate using the Infisical Agent. + + + + + Next, follow the guide [here](/documentation/platform/pki/certificates/profiles#guide-to-creating-a-certificate-profile) to create a [certificate profile](/documentation/platform/pki/certificates/profiles) + that will be referenced when requesting a certificate. + + The certificate profile specifies which certificate template and issuing CA should be used to validate an incoming certificate request and issue a certificate; + it also specifies the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) for how certificates can be requested against this profile + to begin with. + + You should specify the certificate template from Step 2, the issuing CA from Step 1, and the **API** option in the **Enrollment Method** dropdown when creating the certificate profile. + + + Note that if you're looking to issue self-signed certificates, you should select the **Self-Signed** option in the **Issuer Type** dropdown when creating the certificate profile. + + + + + Next, [install the Infisical CLI](/cli/overview) on the target machine you wish to request the certificate on and follow the documentation [here](/integrations/platforms/certificate-agent#operating-the-agent) to set up the Infisical Agent on it. + + As part of the setup, you must create an [agent configuration file](/integrations/platforms/certificate-agent#agent-configuration) that specifies how the agent should authenticate with Infisical using a [machine identity](/documentation/platform/identities/machine-identities), the certificate profile it should request against (from Step 3), what kind of certificate to request, where to persist the certificate, and how it should be managed in terms of auto-renewal. + + Finally, start the agent with that configuration file so it can start requesting and continuously renewing the certificate on your behalf using the command below: + + ```bash + infisical cert-manager agent --config /path/to/your/agent-config.yaml + ``` + + The certificate, certificate chain, and private key will be persisted to the filesystem at the paths specified in the `file-output` section of the agent configuration file. + + + diff --git a/docs/documentation/platform/pki/guides/request-cert-api.mdx b/docs/documentation/platform/pki/guides/request-cert-api.mdx new file mode 100644 index 0000000000..e44771d73d --- /dev/null +++ b/docs/documentation/platform/pki/guides/request-cert-api.mdx @@ -0,0 +1,79 @@ +--- +title: "Request a Certificate via API" +--- + +import RequestCertSetup from "/snippets/documentation/platform/pki/guides/request-cert-setup.mdx"; + +The [API enrollment method](/documentation/platform/pki/enrollment-methods/api) lets you programmatically request TLS and other X.509 certificates from Infisical. + +This is the most flexible way to request certificates from Infisical but requires you to implement certificate request and renewal logic on your own. +For a more automated way to request certificates, we highly recommend you check out the guide for requesting certificates using the [Infisical Agent](/integrations/platforms/certificate-agent) [here](/documentation/platform/pki/guides/request-cert-agent). + +## Diagram + +The following sequence diagram illustrates the certificate issuance workflow for requesting a certificate via API from Infisical. + +```mermaid +sequenceDiagram + autonumber + participant Client as Client + participant Infis as Infisical + participant CA as CA
(Internal or External) + + Client->>Infis: POST /certificate
(profileId, conditional subject/SANs, ttl,
key usages, conditional CSR, etc.) + + Infis->>Infis: Look up certificate profile
(by profileId) + Infis->>Infis: Validate request or CSR
against profile constraints
(CN/SAN rules, key usages, max TTL, etc.) + + alt Issuer Type = Self-Signed + Infis->>Infis: Generate keypair
and self-sign certificate + else Issuer Type = CA + Infis->>CA: Request certificate issuance
(CSR) + CA-->>Infis: Signed certificate
(+ chain) + end + + Infis-->>Client: Return certificate, certificate chain,
issuing CA certificate, serial number,
certificate ID
(and private key if server-generated)
OR certificate request ID if async +``` + +## Guide + +In the following steps, we explore an end-to-end workflow for requesting a certificate via API from Infisical. + + + + + Next, follow the guide [here](/documentation/platform/pki/certificates/profiles#guide-to-creating-a-certificate-profile) to create a [certificate profile](/documentation/platform/pki/certificates/profiles) + that will be referenced when requesting a certificate. + + The certificate profile specifies which certificate template and issuing CA should be used to validate an incoming certificate request and issue a certificate; + it also specifies the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) for how certificates can be requested against this profile + to begin with. + + You should specify the certificate template from Step 2, the issuing CA from Step 1, and the **API** option in the **Enrollment Method** dropdown when creating the certificate profile. + + + Note that if you're looking to issue self-signed certificates, you should select the **Self-Signed** option in the **Issuer Type** dropdown when creating the certificate profile. + + + + + Finally, follow the guide [here](/documentation/platform/pki/enrollment-methods/api#guide-to-certificate-enrollment-via-api) to request a certificate against the certificate profile + over the Web UI or by making an API request the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint with or without a certificate signing request (CSR). + + To renew a certificate on the client-side, you have two options: + + - Make a request to issue a new certificate against the same [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint. + - Make a request to the [Renew Certificate](/api-reference/endpoints/certificates/renew) API endpoint with the ID of the certificate you wish to renew. Note that this endpoint only works if the original certificate was issued through the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint without a CSR. + + + We recommend reading the guide [here](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates) to learn more about all the ways to renew a certificate + with Infisical including [server-driven certificate renewal](/documentation/platform/pki/certificates/certificates#server-driven-certificate-renewal). + + + + + +Note that depending on your environment and infrastructure use-case, you may wish to use a different [enrollment method](/documentation/platform/pki/enrollment-methods/overview) to request certificates. + +For more automated certificate management, you may wish to request certificates using a client that can monitor expiring certificates and request renewals for you. +For example, you can install the Infisical Agent on a VM and have it request and renew certificates for you or use an [ACME client](https://letsencrypt.org/docs/client-options/) paired with Infisical's [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). diff --git a/docs/documentation/platform/pki/k8s-cert-manager.mdx b/docs/documentation/platform/pki/k8s-cert-manager.mdx index b0f696ba96..2479a72c9c 100644 --- a/docs/documentation/platform/pki/k8s-cert-manager.mdx +++ b/docs/documentation/platform/pki/k8s-cert-manager.mdx @@ -139,7 +139,7 @@ The following steps show how to install cert-manager (using `kubectl`) and obtai ``` - - Currently, the Infisical ACME server only supports the HTTP-01 challenge and requires successful challenge completion before issuing certificates. Support for optional challenges and DNS-01 is planned for a future release. + - Currently, the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) only supports the [HTTP-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) method. Support for the [DNS-01 challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) method is planned for a future release. If domain ownership validation is not desired, you can disable it by enabling the **Skip DNS ownership validation** option in your ACME certificate profile configuration. - An `Issuer` is namespace-scoped. Certificates can only be issued using an `Issuer` that exists in the same namespace as the `Certificate` resource. - If you need to issue certificates across multiple namespaces with a single resource, create a `ClusterIssuer` instead. The configuration is identical except `kind: ClusterIssuer` and no `metadata.namespace`. - More details: https://cert-manager.io/docs/configuration/acme/ diff --git a/docs/images/pam/resources/aws-iam/access-account.png b/docs/images/pam/resources/aws-iam/access-account.png new file mode 100644 index 0000000000..6e6a57e1ef Binary files /dev/null and b/docs/images/pam/resources/aws-iam/access-account.png differ diff --git a/docs/images/pam/resources/aws-iam/create-account.png b/docs/images/pam/resources/aws-iam/create-account.png new file mode 100644 index 0000000000..4d2203667c Binary files /dev/null and b/docs/images/pam/resources/aws-iam/create-account.png differ diff --git a/docs/images/pam/resources/aws-iam/create-resource.png b/docs/images/pam/resources/aws-iam/create-resource.png new file mode 100644 index 0000000000..766b86ffc5 Binary files /dev/null and b/docs/images/pam/resources/aws-iam/create-resource.png differ diff --git a/docs/images/pam/resources/aws-iam/resource-role-attach-policy.png b/docs/images/pam/resources/aws-iam/resource-role-attach-policy.png new file mode 100644 index 0000000000..39201c7cdd Binary files /dev/null and b/docs/images/pam/resources/aws-iam/resource-role-attach-policy.png differ diff --git a/docs/images/pam/resources/aws-iam/resource-role-policy.png b/docs/images/pam/resources/aws-iam/resource-role-policy.png new file mode 100644 index 0000000000..1c52aaebba Binary files /dev/null and b/docs/images/pam/resources/aws-iam/resource-role-policy.png differ diff --git a/docs/images/pam/resources/aws-iam/resource-role-trust-policy.png b/docs/images/pam/resources/aws-iam/resource-role-trust-policy.png new file mode 100644 index 0000000000..7c018f985e Binary files /dev/null and b/docs/images/pam/resources/aws-iam/resource-role-trust-policy.png differ diff --git a/docs/images/pam/resources/aws-iam/target-role-trust-policy.png b/docs/images/pam/resources/aws-iam/target-role-trust-policy.png new file mode 100644 index 0000000000..b91dd00308 Binary files /dev/null and b/docs/images/pam/resources/aws-iam/target-role-trust-policy.png differ diff --git a/docs/images/platform/groups/group-details.png b/docs/images/platform/groups/group-details.png new file mode 100644 index 0000000000..3cd94241db Binary files /dev/null and b/docs/images/platform/groups/group-details.png differ diff --git a/docs/images/platform/groups/groups-org-create.png b/docs/images/platform/groups/groups-org-create.png index a8a1e677cb..25af815e1b 100644 Binary files a/docs/images/platform/groups/groups-org-create.png and b/docs/images/platform/groups/groups-org-create.png differ diff --git a/docs/images/platform/groups/groups-org-users-assign.png b/docs/images/platform/groups/groups-org-users-assign.png index b5f629c2ec..b1e6cbf87a 100644 Binary files a/docs/images/platform/groups/groups-org-users-assign.png and b/docs/images/platform/groups/groups-org-users-assign.png differ diff --git a/docs/images/platform/groups/groups-org-users.png b/docs/images/platform/groups/groups-org-users.png deleted file mode 100644 index 383425e776..0000000000 Binary files a/docs/images/platform/groups/groups-org-users.png and /dev/null differ diff --git a/docs/images/platform/groups/groups-org.png b/docs/images/platform/groups/groups-org.png index 13b2edc445..db9ddb2d1b 100644 Binary files a/docs/images/platform/groups/groups-org.png and b/docs/images/platform/groups/groups-org.png differ diff --git a/docs/images/platform/groups/groups-project-create.png b/docs/images/platform/groups/groups-project-create.png index 9232aa042f..87e4fdf6b4 100644 Binary files a/docs/images/platform/groups/groups-project-create.png and b/docs/images/platform/groups/groups-project-create.png differ diff --git a/docs/images/platform/groups/groups-project.png b/docs/images/platform/groups/groups-project.png index 83e384861a..cd5ec63bc6 100644 Binary files a/docs/images/platform/groups/groups-project.png and b/docs/images/platform/groups/groups-project.png differ diff --git a/docs/images/platform/pki/enrollment-methods/acme/acme-config.png b/docs/images/platform/pki/enrollment-methods/acme/acme-config.png index 11ea8b075b..0b5581fe0f 100644 Binary files a/docs/images/platform/pki/enrollment-methods/acme/acme-config.png and b/docs/images/platform/pki/enrollment-methods/acme/acme-config.png differ diff --git a/docs/integrations/cicd/teamcity.mdx b/docs/integrations/cicd/teamcity.mdx deleted file mode 100644 index 0b58f1b807..0000000000 --- a/docs/integrations/cicd/teamcity.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "TeamCity" -description: "How to sync secrets from Infisical to TeamCity" ---- - - - The TeamCity Native Integration will be deprecated in 2026. Please migrate to our new [TeamCity Sync](../secret-syncs/teamcity). - diff --git a/docs/integrations/platforms/certificate-agent.mdx b/docs/integrations/platforms/certificate-agent.mdx new file mode 100644 index 0000000000..90bddeacb2 --- /dev/null +++ b/docs/integrations/platforms/certificate-agent.mdx @@ -0,0 +1,552 @@ +--- +title: "Infisical Agent" +sidebarTitle: "Infisical Agent" +description: "Learn how to use Infisical CLI Agent to manage certificates automatically." +--- + +## Concept + +The Infisical Agent is a client daemon that is packaged into the [Infisical CLI](/cli/overview). +It can be used to request a certificate from Infisical using the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles), persist it to a specified path on the filesystem, and automatically monitor and renew it before expiration. + +The Infisical Agent is notable: + +- Automating certificate management: The agent can request, persist, monitor, and renew certificates from Infisical automatically without manual intervention. It also supports post-event hooks to execute custom commands after certificate issuance, renewal, or failure events. +- Leveraging workload identity: The agent can authenticate with Infisical as a [machine identity](/documentation/platform/identities/machine-identities) using an infrastructure-native authentication method such as [AWS Auth](/docs/documentation/platform/identities/aws-auth), [Azure Auth](/docs/documentation/platform/identities/azure-auth), [GCP Auth](/docs/documentation/platform/identities/gcp-auth), [Kubernetes Auth](/docs/documentation/platform/identities/kubernetes-auth), etc. + +The typical workflow for using the agent involves installing the Infisical CLI on the target machine, creating a configuration file defining the certificate to request and how it should be managed, and then starting the agent with that configuration so it can request, persist, monitor, and renew the certificate before it expires. +This follows a [client-driven approach](/documentation/platform/pki/certificates/certificates#client-driven-certificate-renewal) to certificate renewal. + +## Workflow + +A typical workflow for using the Infisical Agent to request certificates from Infisical consists of the following steps: + +1. Create a [certificate profile](/documentation/platform/pki/certificates/profiles) in Infisical with the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on it. +2. Install the [Infisical CLI](/cli/overview) on the target machine. +3. Create an agent [configuration file](/integrations/platforms/certificate-agent#agent-configuration) containing details about the certificate to request and how it should be managed such as renewal thresholds, post-event hooks, etc. +4. Start the agent with that configuration so it can request, persist, monitor, and going forward automatically renew the certificate before it expires on the target machine. + +## Operating the Agent + +This section describes how to use the Infisical Agent to request certificates from Infisical. It covers how the agent authenticates with Infisical, +and how to configure it to start requesting certificates from Infisical. + +### Authentication + +The Infisical Agent can authenticate with Infisical as a [machine identity](/documentation/platform/identities/machine-identities) using one of its supported authentication methods. + +Upon successful authentication, the agent receives a short-lived access token that it uses to make subsequent authenticated requests to obtain and renew certificates from Infisical; +the agent automatically handles token renewal as documented [here](/integrations/platforms/infisical-agent#token-renewal). + + + + The Universal Auth method uses a client ID and secret for authentication. + + + + To create a universal auth machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/universal-auth). + + + Update the agent configuration file with the auth method and credentials: + + ```yaml + auth: + type: "universal-auth" + config: + client-id: "./client-id" # Path to file containing client ID + client-secret: "./client-secret" # Path to file containing client secret + remove-client-secret-on-read: false # Optional: remove secret file after reading + ``` + + You can also provide credentials directly: + + ```yaml + auth: + type: "universal-auth" + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + ``` + + + + + + The Kubernetes Auth method is used when running the agent in a Kubernetes environment. + + + + To create a Kubernetes machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/kubernetes-auth). + + + Configure the agent to use Kubernetes service account authentication: + + ```yaml + auth: + type: "kubernetes-auth" + config: + identity-id: "your-kubernetes-identity-id" + service-account-token-path: "/var/run/secrets/kubernetes.io/serviceaccount/token" + ``` + + + + + + The Azure Auth method is used when running the agent in an Azure environment. + + + + To create an Azure machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/azure-auth). + + + Configure the agent to use Azure managed identity authentication: + + ```yaml + auth: + type: "azure-auth" + config: + identity-id: "your-azure-identity-id" + ``` + + + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Update the agent configuration file with the specified auth method and identity ID: + + ```yaml + auth: + type: "gcp-id-token" + config: + identity-id: "your-gcp-identity-id" + ``` + + + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Update the agent configuration file with the specified auth method, identity ID, and service account key: + + ```yaml + auth: + type: "gcp-iam" + config: + identity-id: "your-gcp-identity-id" + service-account-key: "/path/to/service-account-key.json" + ``` + + + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment. + + + + To create an AWS machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/aws-auth). + + + Update the agent configuration file with the specified auth method and identity ID: + + ```yaml + auth: + type: "aws-iam" + config: + identity-id: "your-aws-identity-id" + ``` + + + + + + +### Agent Configuration + +The Infisical Agent relies on a YAML configuration file to define its behavior, including how it should authenticate with Infisical, the certificate it should request, and how that certificate should be managed including auto-renewal. + +The code snippet below shows an example configuration file that instructs the agent to request and continuously renew a certificate from Infisical. + +Note that not all configuration options in this file are required but this example includes all of the available options. + +```yaml example-cert-agent-config.yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + attributes: + common-name: "api.example.com" + alt-names: ["api.example.com", "api-v2.example.com"] + ttl: "90d" + key-algorithm: "RSA_2048" + signature-algorithm: "RSA-SHA256" + key-usages: + - "digital_signature" + - "key_encipherment" + extended-key-usages: + - "server_auth" + + # Enable automatic certificate renewal + lifecycle: + renew-before-expiry: "30d" + status-check-interval: "6h" + + # Configure where to store the issued certificate and its associated private key and certificate chain + file-output: + private-key: + path: "/etc/ssl/private/web.key" + permission: "0600" # Read/write for owner only + certificate: + path: "/etc/ssl/certs/web.crt" + permission: "0644" # Read for all, write for owner + chain: + path: "/etc/ssl/certs/web-chain.crt" + permission: "0644" # Read for all, write for owner + omit-root: true # Exclude the root CA certificate in chain + + # Configure custom commands to execute after certificate issuance, renewal, or failure events + post-hooks: + on-issuance: + command: | + echo "Certificate issued for ${CERT_COMMON_NAME}" + systemctl reload nginx + timeout: 30 + + on-renewal: + command: | + echo "Certificate renewed for ${CERT_COMMON_NAME}" + systemctl reload nginx + timeout: 30 + + on-failure: + command: | + echo "Certificate operation failed: ${ERROR_MESSAGE}" + mail -s "Certificate Alert" admin@company.com < /dev/null + timeout: 30 +``` + +To be more specific, the configuration file instructs the agent to: + +- Authenticate with Infisical using the [Universal Auth](/integrations/platforms/certificate-agent#universal-auth) authentication method. +- Request a 90-day certificate against the [certificate profile](/documentation/platform/pki/certificates/profiles) named `prof-web-server-12345` with the common name `web.company.com` and the subject alternative names `web.company.com` and `www.company.com`. +- Automatically renew the certificate 30 days before expiration by checking the certificate status every 6 hours and retrying up to 3 times with a base delay of 200ms and a maximum delay of 5s if the certificate status check fails. +- Store the certificate and its associated private key and certificate chain (excluding the root CA certificate) in the filesystem at the specified paths with the specified permissions. +- Execute custom commands after certificate issuance, renewal, or failure events such as reloading an `nginx` service or sending an email notification. + +### Agent Execution + +After creating the configuration file, you can run the command below with the `--config` flag pointing to the path where the agent configuration file is located. + +```bash +infisical cert-manager agent --config /path/to/your/agent-config.yaml +``` + +This will start the agent as a daemon process, continuously monitoring and managing certificates according to your configuration. You can also run it in the foreground for debugging: + +```bash +infisical cert-manager agent --config /path/to/your/agent-config.yaml --verbose +``` + +For production deployments, you may consider running the agent as a system service to ensure it starts automatically and runs continuously. + +### Agent Certificate Configuration Parameters + +The table below provides a complete list of parameters that can be configured in the **certificate configuration** section of the agent configuration file: + +| Parameter | Required | Description | +| ------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `profile-name` | Yes | The name of the [certificate profile](/documentation/platform/pki/certificates/profiles) to request a certificate against (e.g., `web-server-12345`) | +| `project-slug` | Yes | The slug of the project to request a certificate against (e.g., `my-project-slug`) | +| `common-name` | Optional | The common name for the certificate (e.g. `www.example.com`) | +| `alt-names` | Optional | The list of subject alternative names for the certificate (e.g., `["www.example.com", "api.example.com"]`) | +| `ttl` | Optional (uses profile default if not specified) | The time-to-live duration for the certificate, specified as a duration string (e.g. `72h`, `90d`, `1y`, etc.) | +| `key-algorithm` | Optional | The algorithm for the certificate key pair. One of: `RSA_2048`, `RSA_3072`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`, `EC_secp521r1`. | +| `signature-algorithm` | Optional | The algorithm used to sign the certificate. One of: `RSA-SHA256`, `RSA-SHA384`, `RSA-SHA512`, `ECDSA-SHA256`, `ECDSA-SHA384`, `ECDSA-SHA512`. | +| `key-usages` | Optional | The list of key usage values for the certificate. One or more of: `digital_signature`, `key_encipherment`, `non_repudiation`, `data_encipherment`, `key_agreement`, `key_cert_sign`, `crl_sign`, `encipher_only`, `decipher_only`. | +| `extended-key-usages` | Optional | The list of extended key usage values for the certificate. One or more of: `server_auth`, `client_auth`, `code_signing`, `email_protection`, `timestamping`, `ocsp_signing`. | +| `csr-path` | Conditional | The path to a certificate signing request (CSR) file (e.g., `./csr/webserver.csr`, `/etc/ssl/csr.pem`). This is required if using a pre-generated CSR. | +| `file-output.private-key.path` | Optional (required if the `csr-path` is not specified) | The path to store the private key (required if not using a CSR) | +| `file-output.private-key.permission` | Optional (defaults to `0600`) | The octal file permissions for the private key file (e.g. `0600`) | +| `file-output.certificate.path` | Yes | The path to store the issued certificate in the filesystem | +| `file-output.certificate.permission` | Optional (defaults to `0600`) | The octal file permissions for the certificate file (e.g. `0644`) | +| `file-output.chain.path` | Optional | The path to store the certificate chain in the filesystem. | +| `file-output.chain.permission` | Optional (defaults to `0600`) | The octal permissions for the chain file (e.g. `0644`) | +| `file-output.chain.omit-root` | Optional (defaults to `true`) | Whether to exclude the root CA certificate from the returned certificate chain | +| `lifecycle.renew-before-expiry` | Optional (auto-renewal is disabled if not set) | Duration before certificate expiration when renewal checks should begin, specified as a duration string (e.g. `72h`, `90d`, `1y`, etc.) | +| `lifecycle.status-check-interval` | Optional (defaults to `10s`) | How frequently the agent checks certificate status and renewal needs, specified as a duration string (e.g. `10s`, `30m`, `1d`, etc.) | +| `post-hooks.on-issuance.command` | Optional | The shell command to execute after a certificate is successfully issued for the first time (e.g., `systemctl reload nginx`, `/usr/local/bin/reload-service.sh`) | +| `post-hooks.on-issuance.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-issuance post-hook command before it is terminated (e.g., `30`, `60`, `120`) | +| `post-hooks.on-renewal.command` | Optional | The shell command to execute after a certificate is successfully renewed (e.g., `systemctl reload nginx`, `docker restart web-server`) | +| `post-hooks.on-renewal.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-renewal post-hook command before it is terminated (e.g., `30`, `60`, `120`) | +| `post-hooks.on-failure.command` | Optional | The shell command to execute when certificate issuance or renewal fails (e.g., `logger 'Certificate renewal failed'`, `/usr/local/bin/alert.sh`) | +| `post-hooks.on-failure.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-failure post-hook command before it is terminated (e.g., `10`, `30`, `60`) | + +### Post-Event Hooks + +The Infisical Agent supports running custom commands in response to certificate lifecycle events such as issuance, renewal, and failure through the `post-hooks` configuration +in the agent configuration file. + + + + Runs when a new certificate is successfully issued: + + ```yaml + post-hooks: + on-issuance: + command: | + echo "New certificate issued for ${CERT_COMMON_NAME}" + chown nginx:nginx ${CERT_FILE_PATH} + chmod 644 ${CERT_FILE_PATH} + systemctl reload nginx + timeout: 30 + ``` + + + + + Runs when a certificate is successfully renewed: + + ```yaml + post-hooks: + on-renewal: + command: | + echo "Certificate renewed for ${CERT_COMMON_NAME}" + # Reload services that use the certificate + systemctl reload nginx + systemctl reload haproxy + + # Send notification + curl -X POST https://hooks.slack.com/... \ + -d "{'text': 'Certificate for ${CERT_COMMON_NAME} renewed successfully'}" + timeout: 60 + ``` + + + + + Runs when certificate operations fail: + + ```yaml + post-hooks: + on-failure: + command: | + echo "Certificate operation failed for ${CERT_COMMON_NAME}: ${ERROR_MESSAGE}" + # Send alert + mail -s "Certificate Failure Alert" admin@company.com < /dev/null + # Log to syslog + logger -p daemon.error "Certificate agent failure: ${ERROR_MESSAGE}" + timeout: 30 + ``` + + + + +### Retrying mechanism + +The Infisical Agent will automatically attempt to retry any failed API requests including authentication, certificate issuance, and renewal operations. +By default, the agent will retry up to 3 times with a base delay of 200ms and a maximum delay of 5s. + +You can configure the retrying mechanism through the agent configuration file: + +```yaml +infisical: + address: "https://app.infisical.com" + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" +# ... rest of the agent configuration file +``` + +## Example Agent Configuration Files + +Since there are several ways you might want to use the Infisical Agent to request certificates from Infisical, +we provide a few example configuration files for common use cases below to help you get started. + +### One-Time Certificate Issuance + +The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical +once without performing any subsequent auto-renewal. + +```yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + attributes: + common-name: "api.example.com" + alt-names: + - "api.example.com" + - "api-v2.example.com" + key-algorithm: "RSA_2048" + signature-algorithm: "RSA-SHA256" + key-usages: + - "digital_signature" + - "key_encipherment" + extended-key-usages: + - "server_auth" + ttl: "30d" + file-output: + private-key: + path: "/etc/ssl/private/api.example.com.key" + permission: "0600" + certificate: + path: "/etc/ssl/certs/api.example.com.crt" + permission: "0644" + chain: + path: "/etc/ssl/certs/api.example.com.chain.crt" + permission: "0644" + omit-root: true +``` + +### One-Time Certificate Issuance using a Pre-Generated CSR + +The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical +once using a pre-generated CSR. + +Note that when `csr-path` is specified: + +- The `private-key` is omitted from the configuration file because we assume that it is pre-generated and managed externally, with only the CSR being submitted to Infisical for signing. +- The agent will not be able to perform any auto-renewal operations, as it is assumed to not have access to the private key required to generate a new CSR. + +```yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + csr-path: "/etc/ssl/requests/api.csr" + file-output: + certificate: + path: "/etc/ssl/certs/api.example.com.crt" + permission: "0644" + chain: + path: "/etc/ssl/certs/api.example.com.chain.crt" + permission: "0644" + omit-root: true +``` + +### Certificate Issuance with Automatic Renewal + +The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical and continuously renew it 14 days before expiration, checking the certificate status every 6 hours. + +```yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + attributes: + common-name: "api.example.com" + alt-names: + - "api.example.com" + - "api-v2.example.com" + key-algorithm: "RSA_2048" + signature-algorithm: "RSA-SHA256" + key-usages: + - "digital_signature" + - "key_encipherment" + extended-key-usages: + - "server_auth" + ttl: "30d" + lifecycle: + renew-before-expiry: "14d" # Renew 14 days before expiration + status-check-interval: "6h" # Check certificate status every 6 hours + file-output: + private-key: + path: "/etc/ssl/private/api.example.com.key" + permission: "0600" + certificate: + path: "/etc/ssl/certs/api.example.com.crt" + permission: "0644" + chain: + path: "/etc/ssl/certs/api.example.com.chain.crt" + permission: "0644" + post-hooks: + on-issuance: + command: "systemctl reload nginx" + timeout: 30 + on-renewal: + command: "systemctl reload nginx && logger 'Certificate renewed'" + timeout: 30 +``` diff --git a/docs/self-hosting/guides/automated-bootstrapping.mdx b/docs/self-hosting/guides/automated-bootstrapping.mdx index 3c2186eb9e..6c3671df90 100644 --- a/docs/self-hosting/guides/automated-bootstrapping.mdx +++ b/docs/self-hosting/guides/automated-bootstrapping.mdx @@ -247,9 +247,9 @@ curl -X POST \ "projectDescription": "A project created via API", "slug": "new-project-slug", "template": "default", - "type": "SECRET_MANAGER" + "type": "secret-manager" }' \ - https://your-infisical-instance.com/api/v2/projects + https://your-infisical-instance.com/api/v1/projects ``` ## Important Notes diff --git a/docs/snippets/documentation/platform/pki/guides/request-cert-setup.mdx b/docs/snippets/documentation/platform/pki/guides/request-cert-setup.mdx new file mode 100644 index 0000000000..c703fd63d2 --- /dev/null +++ b/docs/snippets/documentation/platform/pki/guides/request-cert-setup.mdx @@ -0,0 +1,27 @@ + + Before you can issue any certificate, you must first configure a [Certificate Authority (CA)](/documentation/platform/pki/ca/overview). + + The CA you configure will be used to issue the certificate back to your client; it can be either Internal or External: + + - [Internal CA](/documentation/platform/pki/ca/private-ca): If you're building your own PKI and wish to issue certificates for internal use, you should + follow the guide [here](/documentation/platform/pki/ca/private-ca#guide-to-creating-a-ca-hierarchy) to create at minimum a root CA and an intermediate/issuing CA + within Infisical. + + - [External CA](/documentation/platform/pki/ca/external-ca): If you have existing PKI infrastructure or wish to connect to a public CA (e.g. [Let's Encrypt](/documentation/platform/pki/ca/lets-encrypt), [DigiCert](/documentation/platform/pki/ca/digicert), etc.) to issue TLS certificates, + you should follow the documentation [here](/documentation/platform/pki/ca/external-ca) to configure an External CA. + + + Note that if you're looking to issue self-signed certificates, you can skip this step and proceed to Step 3. + + + + + Next, follow the guide [here](/documentation/platform/pki/certificates/templates#guide-to-creating-a-certificate-template) to create a [certificate template](/documentation/platform/pki/certificates/templates). + + The certificate template will constrain what attributes may or may not be allowed in the request to issue a certificate. + For example, you can specify that the requested common name must adhere to a specific format like `*.acme.com` and + that the maximum TTL cannot exceed 1 year. + + If you're looking to issue TLS server certificates, you should select the **TLS Server Certificate** option under the **Template Preset** dropdown. + + diff --git a/frontend/.storybook/decorators/RouterDecorator.tsx b/frontend/.storybook/decorators/RouterDecorator.tsx index a559c5cd1e..c44ee6b125 100644 --- a/frontend/.storybook/decorators/RouterDecorator.tsx +++ b/frontend/.storybook/decorators/RouterDecorator.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import type { Decorator } from "@storybook/react-vite"; import { createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router"; -export const RouterDecorator: Decorator = (Story) => { +export const RouterDecorator: Decorator = (Story, params) => { const router = useMemo(() => { const routeTree = createRootRoute({ component: Story @@ -11,7 +11,7 @@ export const RouterDecorator: Decorator = (Story) => { return createRouter({ routeTree }); - }, [Story]); + }, [Story, params]); return ; }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8fdda0965..bbb72bfa7c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", @@ -136,6 +137,7 @@ "prettier": "3.4.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", "vite": "^6.2.0", @@ -3353,6 +3355,85 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -14777,6 +14858,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8009c2118a..075807caa8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,6 +51,7 @@ "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", @@ -145,6 +146,7 @@ "prettier": "3.4.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", "vite": "^6.2.0", diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index d9887a929e..bfd4a19346 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -66,7 +66,7 @@ const PROJECT_TYPE_MENU_ITEMS = [ value: ProjectType.SecretManager }, { - label: "Certificates Management", + label: "Certificate Manager", value: ProjectType.CertificateManager }, { diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 7ce5bdb75b..743f45a5ec 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -9,7 +9,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormControl, Input, Select, SelectItem, Switch, Tooltip } from "@app/components/v2"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { SecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; +import { + SecretSync, + SecretSyncInitialSyncBehavior, + useSecretSyncOption +} from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; import { AwsParameterStoreSyncOptionsFields } from "./AwsParameterStoreSyncOptionsFields"; @@ -140,13 +144,25 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { )} /> - {!syncOption?.canImportSecrets && ( + {!syncOption?.canImportSecrets ? (

{destinationName} only supports overwriting destination secrets.{" "} {!currentSyncOption.disableSecretDeletion && - "Secrets not present in Infisical will be removed from the destination."} + `Secrets not present in Infisical will be removed from the destination. Consider adding a key schema or disabling secret deletion if you do not want existing secrets to be removed from ${destinationName}.`}

+ ) : ( + currentSyncOption.initialSyncBehavior === + SecretSyncInitialSyncBehavior.OverwriteDestination && + !currentSyncOption.disableSecretDeletion && ( +

+ + Secrets not present in Infisical will be removed from the destination. If you have + secrets in {destinationName} that you do not want deleted, consider setting initial + sync behavior to import destination secrets. Alternatively, configure a key schema + or disable secret deletion below to have Infisical ignore these secrets. +

+ ) )} )} @@ -184,26 +200,26 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { className="max-w-md" content={ - We highly recommend using a{" "} + We highly recommend configuring a{" "} - Key Schema + key schema {" "} - to ensure that Infisical only manages the specific keys you intend, keeping - everything else untouched. + to ensure that Infisical only manages secrets in {destinationName} that match + the key pattern.

Destination secrets that do not match the schema will not be deleted or updated.
} > -
- Infisical strongly advises setting a Key Schema{" "} - +
+ Infisical strongly advises configuring a key schema{" "} +
} diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 5a2ba2e123..ffe4c9daee 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P

{children}

-
{description}
+
{description}
); diff --git a/frontend/src/components/v3/generic/Accordion/Accordion.tsx b/frontend/src/components/v3/generic/Accordion/Accordion.tsx new file mode 100644 index 0000000000..dcf4d5559d --- /dev/null +++ b/frontend/src/components/v3/generic/Accordion/Accordion.tsx @@ -0,0 +1,79 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "../../utils"; + +function UnstableAccordion({ ...props }: React.ComponentProps) { + return ( + + ); +} + +function UnstableAccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function UnstableAccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + "cursor-pointer hover:bg-foreground/5", + "data-[state=open]:bg-foreground/5", + className + )} + {...props} + > + + {children} + + + ); +} + +function UnstableAccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { + UnstableAccordion, + UnstableAccordionContent, + UnstableAccordionItem, + UnstableAccordionTrigger +}; diff --git a/frontend/src/components/v3/generic/Accordion/index.ts b/frontend/src/components/v3/generic/Accordion/index.ts new file mode 100644 index 0000000000..16e0243c2a --- /dev/null +++ b/frontend/src/components/v3/generic/Accordion/index.ts @@ -0,0 +1 @@ +export * from "./Accordion"; diff --git a/frontend/src/components/v3/generic/Alert/Alert.tsx b/frontend/src/components/v3/generic/Alert/Alert.tsx new file mode 100644 index 0000000000..2e8190df15 --- /dev/null +++ b/frontend/src/components/v3/generic/Alert/Alert.tsx @@ -0,0 +1,63 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import { cva, type VariantProps } from "cva"; + +import { cn } from "../../utils"; + +const alertVariants = cva( + "relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-container text-card-foreground", + info: "bg-info/5 text-info border-info/20", + org: "bg-org/5 text-org border-org/20", + "sub-org": "bg-sub-org/5 text-sub-org border-sub-org/20" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +function UnstableAlert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function UnstableAlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableAlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle }; diff --git a/frontend/src/components/v3/generic/Alert/index.ts b/frontend/src/components/v3/generic/Alert/index.ts new file mode 100644 index 0000000000..b8e17a03c9 --- /dev/null +++ b/frontend/src/components/v3/generic/Alert/index.ts @@ -0,0 +1 @@ +export * from "./Alert"; diff --git a/frontend/src/components/v3/generic/Badge/Badge.stories.tsx b/frontend/src/components/v3/generic/Badge/Badge.stories.tsx index 4cbf955f93..244bd81087 100644 --- a/frontend/src/components/v3/generic/Badge/Badge.stories.tsx +++ b/frontend/src/components/v3/generic/Badge/Badge.stories.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react"; import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform"; +import { UnstableButtonGroup } from "../ButtonGroup"; import { Badge } from "./Badge"; /** @@ -33,13 +34,34 @@ const meta = { argTypes: { variant: { control: "select", - options: ["neutral", "success", "info", "warning", "danger", "project", "org", "sub-org"] + options: [ + "default", + "outline", + "neutral", + "success", + "info", + "warning", + "danger", + "project", + "org", + "sub-org" + ] }, isTruncatable: { table: { disable: true } }, + isFullWidth: { + table: { + disable: true + } + }, + isSquare: { + table: { + disable: true + } + }, asChild: { table: { disable: true @@ -57,6 +79,38 @@ const meta = { export default meta; type Story = StoryObj; +export const Default: Story = { + name: "Variant: Default", + args: { + variant: "default", + children: <>Default + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other badge variants are not applicable or as the key when displaying key-value pairs with ButtonGroup." + } + } + } +}; + +export const Outline: Story = { + name: "Variant: Outline", + args: { + variant: "outline", + children: <>Outline + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other badge variants are not applicable or as the value when displaying key-value pairs with ButtonGroup." + } + } + } +}; + export const Neutral: Story = { name: "Variant: Neutral", args: { @@ -71,8 +125,7 @@ export const Neutral: Story = { parameters: { docs: { description: { - story: - "Use this variant when indicating neutral or disabled states or when linking to external documents." + story: "Use this variant when indicating neutral or disabled states." } } } @@ -133,7 +186,8 @@ export const Info: Story = { parameters: { docs: { description: { - story: "Use this variant when indicating informational states." + story: + "Use this variant when indicating informational states or when linking to external documentation." } } } @@ -374,3 +428,22 @@ export const IsFullWidth: Story = {
) }; + +export const KeyValuePair: Story = { + name: "Example: Key-Value Pair", + args: {}, + parameters: { + docs: { + description: { + story: + "Use a default and outline badge in conjunction with the `` component to display key-value pairs." + } + } + }, + decorators: () => ( + + Key + Value + + ) +}; diff --git a/frontend/src/components/v3/generic/Badge/Badge.tsx b/frontend/src/components/v3/generic/Badge/Badge.tsx index f94bff5f2e..a31f279f2e 100644 --- a/frontend/src/components/v3/generic/Badge/Badge.tsx +++ b/frontend/src/components/v3/generic/Badge/Badge.tsx @@ -6,7 +6,7 @@ import { cn } from "@app/components/v3/utils"; const badgeVariants = cva( [ - "select-none items-center align-middle rounded-sm h-4.5 px-1.5 text-xs", + "select-none border items-center align-middle rounded-sm h-4.5 px-1.5 text-xs", "gap-x-1 [a&,button&]:cursor-pointer inline-flex font-normal", "[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:stroke-[2.25] [&_svg:not([class*='size-'])]:size-3", "transition duration-200 ease-in-out" @@ -24,19 +24,22 @@ const badgeVariants = cva( true: "w-4.5 justify-center px-0.5" }, variant: { - ghost: "text-mineshaft-200 gap-x-2", - neutral: "bg-neutral/25 text-neutral [a&,button&]:hover:bg-neutral/35", - success: "bg-success/25 text-success [a&,button&]:hover:bg-success/35", - info: "bg-info/25 text-info [a&,button&]:hover:bg-info/35", - warning: "bg-warning/25 text-warning [a&,button&]:hover:bg-warning/35", - danger: "bg-danger/25 text-danger [a&,button&]:hover:bg-danger/35", - project: "bg-project/25 text-project [a&,button&]:hover:bg-project/35", - org: "bg-org/25 text-org [a&,button&]:hover:bg-org/35", - "sub-org": "bg-sub-org/25 text-sub-org [a&,button&]:hover:bg-sub-org/35" + ghost: "text-foreground border-none", + default: "bg-label text-background border-label [a&,button&]:hover:bg-primary/35", + outline: "text-label border-label border", + neutral: "bg-neutral/15 border-neutral/10 text-neutral [a&,button&]:hover:bg-neutral/35", + success: "bg-success/15 border-success/10 text-success [a&,button&]:hover:bg-success/35", + info: "bg-info/15 border-info/10 border text-info [a&,button&]:hover:bg-info/35", + warning: "bg-warning/15 border-warning/10 text-warning [a&,button&]:hover:bg-warning/35", + danger: "bg-danger/15 border-danger/10 text-danger border [a&,button&]:hover:bg-danger/35", + project: + "bg-project/15 text-project border-project/10 border [a&,button&]:hover:bg-project/35", + org: "bg-org/15 border border-org/10 text-org [a&,button&]:hover:bg-org/35", + "sub-org": "bg-sub-org/15 border-sub-org/10 text-sub-org [a&,button&]:hover:bg-sub-org/35" } }, defaultVariants: { - variant: "neutral" + variant: "default" } } ); @@ -44,7 +47,6 @@ const badgeVariants = cva( type TBadgeProps = VariantProps & React.ComponentProps<"span"> & { asChild?: boolean; - variant: NonNullable["variant"]>; // TODO: REMOVE }; const Badge = forwardRef( diff --git a/frontend/src/components/v3/generic/Button/Button.stories.tsx b/frontend/src/components/v3/generic/Button/Button.stories.tsx new file mode 100644 index 0000000000..0f975e70de --- /dev/null +++ b/frontend/src/components/v3/generic/Button/Button.stories.tsx @@ -0,0 +1,357 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + AsteriskIcon, + BanIcon, + CheckIcon, + CircleXIcon, + ExternalLinkIcon, + InfoIcon, + RadarIcon, + TriangleAlertIcon, + UserIcon +} from "lucide-react"; + +import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform"; +import { UnstableButton } from "./Button"; + +/** + * Buttons act as an indicator that can optionally be made interactable. + * You can place text and icons inside a Button. + * Buttons are often used for the indication of a status, state or scope. + */ +const meta = { + title: "Generic/Button", + component: UnstableButton, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: [ + "default", + "outline", + "neutral", + "success", + "info", + "warning", + "danger", + "project", + "org", + "sub-org" + ] + }, + size: { + control: "select", + options: ["xs", "sm", "md", "lg"] + }, + isPending: { + control: "boolean" + }, + isFullWidth: { + control: "boolean" + }, + isDisabled: { + control: "boolean" + }, + as: { + table: { + disable: true + } + }, + children: { + table: { + disable: true + } + } + }, + args: { children: "Button", isPending: false, isDisabled: false, isFullWidth: false, size: "md" } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Variant: Default", + args: { + variant: "default", + children: <>Default + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other Button variants are not applicable or as the key when displaying key-value pairs with ButtonGroup." + } + } + } +}; + +export const Outline: Story = { + name: "Variant: Outline", + args: { + variant: "outline", + children: <>Outline + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other Button variants are not applicable or as the value when displaying key-value pairs with ButtonGroup." + } + } + } +}; + +export const Neutral: Story = { + name: "Variant: Neutral", + args: { + variant: "neutral", + children: ( + <> + + Disabled + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating neutral or disabled states." + } + } + } +}; + +export const Ghost: Story = { + name: "Variant: Ghost", + args: { + variant: "ghost", + children: ( + <> + + User + + ) + }, + parameters: { + docs: { + description: { + story: + "Use this variant when indicating a configuration or property value. Avoid using this variant as an interactive element as it is not intuitive to interact with." + } + } + } +}; + +export const Success: Story = { + name: "Variant: Success", + args: { + variant: "success", + children: ( + <> + + Success + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating successful or healthy states." + } + } + } +}; + +export const Info: Story = { + name: "Variant: Info", + args: { + variant: "info", + children: ( + <> + + Info + + ) + }, + parameters: { + docs: { + description: { + story: + "Use this variant when indicating informational states or when linking to external documentation." + } + } + } +}; + +export const Warning: Story = { + name: "Variant: Warning", + args: { + variant: "warning", + children: ( + <> + + Warning + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating activity or attention warranting states." + } + } + } +}; + +export const Danger: Story = { + name: "Variant: Danger", + args: { + variant: "danger", + children: ( + <> + + Danger + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating destructive or error states." + } + } + } +}; + +export const Organization: Story = { + name: "Variant: Organization", + args: { + variant: "org", + children: ( + <> + + Organization + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating organization scope or links." + } + } + } +}; + +export const SubOrganization: Story = { + name: "Variant: Sub-Organization", + args: { + variant: "sub-org", + children: ( + <> + + Sub-Organization + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating sub-organization scope or links." + } + } + } +}; + +export const Project: Story = { + name: "Variant: Project", + args: { + variant: "project", + children: ( + <> + + Project + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating project scope or links." + } + } + } +}; + +export const AsExternalLink: Story = { + name: "Example: As External Link", + args: { + variant: "info", + as: "a", + href: "https://www.infisical.com", + children: ( + <> + Link + + ) + }, + parameters: { + docs: { + description: { + story: 'Use the `as="a"` prop to use a Button as an external `a` tag component.' + } + } + } +}; + +export const AsRouterLink: Story = { + name: "Example: As Router Link", + args: { + variant: "project", + as: "link", + children: ( + <> + + Secret Scanning + + ) + }, + parameters: { + docs: { + description: { + story: 'Use the `as="link"` prop to use a Button as an internal `Link` component.' + } + } + } +}; + +export const IsFullWidth: Story = { + name: "Example: isFullWidth", + args: { + variant: "neutral", + isFullWidth: true, + + children: ( + <> + + Secret Value + + ) + }, + parameters: { + docs: { + description: { + story: + "Use the `isFullWidth` prop to expand the Buttons width to fill it's parent container." + } + } + }, + decorators: (Story) => ( +
+ +
+ ) +}; diff --git a/frontend/src/components/v3/generic/Button/Button.tsx b/frontend/src/components/v3/generic/Button/Button.tsx new file mode 100644 index 0000000000..4c065b1d0d --- /dev/null +++ b/frontend/src/components/v3/generic/Button/Button.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import { forwardRef } from "react"; +import { Link, LinkProps } from "@tanstack/react-router"; +import { cva, type VariantProps } from "cva"; + +import { Lottie } from "@app/components/v2"; +import { cn } from "@app/components/v3/utils"; + +const buttonVariants = cva( + cn( + "inline-flex items-center active:scale-[0.95] justify-center border cursor-pointer whitespace-nowrap", + " text-sm transition-all disabled:pointer-events-none disabled:opacity-75 shrink-0", + "[&>svg]:pointer-events-none [&>svg]:shrink-0", + "focus-visible:ring-ring outline-0 focus-visible:ring-2 select-none" + ), + { + variants: { + variant: { + default: + "border-foreground bg-foreground text-background hover:bg-foreground/90 hover:border-foreground/90", + neutral: + "border-neutral/10 bg-neutral/40 text-foreground hover:bg-neutral/50 hover:border-neutral/20", + outline: "text-foreground hover:bg-foreground/10 border-border hover:border-foreground/20", + ghost: "text-foreground hover:bg-foreground/10 border-transparent", + project: + "border-project/25 bg-project/15 text-foreground hover:bg-project/30 hover:border-project/30", + org: "border-org/25 bg-org/15 text-foreground hover:bg-org/30 hover:border-org/30", + "sub-org": + "border-sub-org/25 bg-sub-org/15 text-foreground hover:bg-sub-org/30 hover:border-sub-org/30", + success: + "border-success/25 bg-success/15 text-foreground hover:bg-success/30 hover:border-success/30", + info: "border-info/25 bg-info/15 text-foreground hover:bg-info/30 hover:border-info/30", + warning: + "border-warning/25 bg-warning/15 text-foreground hover:bg-warning/30 hover:border-warning/30", + danger: + "border-danger/25 bg-danger/15 text-foreground hover:bg-danger/30 hover:border-danger/30" + }, + size: { + xs: "h-7 px-2 rounded-[3px] text-xs [&>svg]:size-3 gap-1.5", + sm: "h-8 px-2.5 rounded-[4px] text-sm [&>svg]:size-3 gap-1.5", + md: "h-9 px-3 rounded-[5px] text-sm [&>svg]:size-3.5 gap-1.5", + lg: "h-10 px-3 rounded-[6px] text-sm [&>svg]:size-3.5 gap-1.5" + }, + isPending: { + true: "text-transparent" + }, + isFullWidth: { + true: "w-full", + false: "w-fit" + } + }, + defaultVariants: { + variant: "default", + size: "md" + } + } +); + +type UnstableButtonProps = (VariantProps & { + isPending?: boolean; + isFullWidth?: boolean; + isDisabled?: boolean; +}) & + ( + | ({ as?: "button" | undefined } & React.ComponentProps<"button">) + | ({ as: "link"; className?: string } & LinkProps) + | ({ as: "a" } & React.ComponentProps<"a">) + ); + +const UnstableButton = forwardRef( + ( + { + className, + variant = "default", + size = "md", + isPending = false, + isFullWidth = false, + isDisabled = false, + children, + ...props + }, + ref + ): JSX.Element => { + const sharedProps = { + "data-slot": "button", + className: cn(buttonVariants({ variant, size, isPending, isFullWidth }), className) + }; + + const child = ( + <> + {children} + {isPending && ( + + )} + + ); + + switch (props.as) { + case "a": + return ( + } + target="_blank" + rel="noopener noreferrer" + {...props} + {...sharedProps} + > + {child} + + ); + case "link": + return ( + } {...props} {...sharedProps}> + {child} + + ); + default: + return ( + + ); + } + } +); + +UnstableButton.displayName = "Button"; + +export { buttonVariants, UnstableButton, type UnstableButtonProps }; diff --git a/frontend/src/components/v3/generic/Button/index.ts b/frontend/src/components/v3/generic/Button/index.ts new file mode 100644 index 0000000000..e22c29adcf --- /dev/null +++ b/frontend/src/components/v3/generic/Button/index.ts @@ -0,0 +1 @@ +export * from "./Button"; diff --git a/frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx b/frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 0000000000..753d28a319 --- /dev/null +++ b/frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "cva"; + +import { cn } from "../../utils"; +import { UnstableSeparator } from "../Separator"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none" + } + }, + defaultVariants: { + orientation: "horizontal" + } + } +); + +function UnstableButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function UnstableButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot : "div"; + + return ( + + ); +} + +function UnstableButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + buttonGroupVariants, + UnstableButtonGroup, + UnstableButtonGroupSeparator, + UnstableButtonGroupText +}; diff --git a/frontend/src/components/v3/generic/ButtonGroup/index.ts b/frontend/src/components/v3/generic/ButtonGroup/index.ts new file mode 100644 index 0000000000..d22eaf4c2e --- /dev/null +++ b/frontend/src/components/v3/generic/ButtonGroup/index.ts @@ -0,0 +1 @@ +export * from "./ButtonGroup"; diff --git a/frontend/src/components/v3/generic/Card/Card.tsx b/frontend/src/components/v3/generic/Card/Card.tsx new file mode 100644 index 0000000000..f389305263 --- /dev/null +++ b/frontend/src/components/v3/generic/Card/Card.tsx @@ -0,0 +1,82 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; + +import { cn } from "../../utils"; + +function UnstableCard({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function UnstableCardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + UnstableCard, + UnstableCardAction, + UnstableCardContent, + CardDescription as UnstableCardDescription, + UnstableCardFooter, + UnstableCardHeader, + UnstableCardTitle +}; diff --git a/frontend/src/components/v3/generic/Card/index.ts b/frontend/src/components/v3/generic/Card/index.ts new file mode 100644 index 0000000000..24d3212465 --- /dev/null +++ b/frontend/src/components/v3/generic/Card/index.ts @@ -0,0 +1 @@ +export * from "./Card"; diff --git a/frontend/src/components/v3/generic/Detail/Detail.tsx b/frontend/src/components/v3/generic/Detail/Detail.tsx new file mode 100644 index 0000000000..16aa8d0d47 --- /dev/null +++ b/frontend/src/components/v3/generic/Detail/Detail.tsx @@ -0,0 +1,23 @@ +import { cn } from "../../utils"; + +function Detail({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DetailLabel({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DetailValue({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DetailGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Detail, DetailGroup, DetailLabel, DetailValue }; diff --git a/frontend/src/components/v3/generic/Detail/index.ts b/frontend/src/components/v3/generic/Detail/index.ts new file mode 100644 index 0000000000..f511dd353f --- /dev/null +++ b/frontend/src/components/v3/generic/Detail/index.ts @@ -0,0 +1 @@ +export * from "./Detail"; diff --git a/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx b/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx new file mode 100644 index 0000000000..5a93c3fb62 --- /dev/null +++ b/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx @@ -0,0 +1,255 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@app/components/v3/utils"; + +function UnstableDropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function UnstableDropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuItem({ + className, + inset, + variant = "default", + isDisabled, + ...props +}: Omit, "disabled"> & { + inset?: boolean; + variant?: "default" | "danger"; + isDisabled?: boolean; +}) { + return ( + + ); +} + +function UnstableDropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function UnstableDropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function UnstableDropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function UnstableDropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function UnstableDropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function UnstableDropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function UnstableDropdownMenuSubContent({ + className, + sideOffset = 8, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +type UnstableDropdownMenuChecked = DropdownMenuPrimitive.DropdownMenuCheckboxItemProps["checked"]; + +export { + UnstableDropdownMenu, + UnstableDropdownMenuCheckboxItem, + type UnstableDropdownMenuChecked, + UnstableDropdownMenuContent, + UnstableDropdownMenuGroup, + UnstableDropdownMenuItem, + UnstableDropdownMenuLabel, + UnstableDropdownMenuPortal, + UnstableDropdownMenuRadioGroup, + UnstableDropdownMenuRadioItem, + UnstableDropdownMenuSeparator, + UnstableDropdownMenuShortcut, + UnstableDropdownMenuSub, + UnstableDropdownMenuSubContent, + UnstableDropdownMenuSubTrigger, + UnstableDropdownMenuTrigger +}; diff --git a/frontend/src/components/v3/generic/Dropdown/index.ts b/frontend/src/components/v3/generic/Dropdown/index.ts new file mode 100644 index 0000000000..f024a9e9a1 --- /dev/null +++ b/frontend/src/components/v3/generic/Dropdown/index.ts @@ -0,0 +1 @@ +export * from "./Dropdown"; diff --git a/frontend/src/components/v3/generic/Empty/Empty.tsx b/frontend/src/components/v3/generic/Empty/Empty.tsx new file mode 100644 index 0000000000..3b86bcc88a --- /dev/null +++ b/frontend/src/components/v3/generic/Empty/Empty.tsx @@ -0,0 +1,100 @@ +import { cn } from "../../utils"; + +function UnstableEmpty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableEmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +// scott: TODO + +// const emptyMediaVariants = cva( +// "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", +// { +// variants: { +// variant: { +// default: "bg-transparent", +// icon: "bg-bunker-900 rounded text-foreground flex size-10 shrink-0 items-center justify-center [&_svg:not([class*='size-'])]:size-6" +// } +// }, +// defaultVariants: { +// variant: "default" +// } +// } +// ); + +// function EmptyMedia({ +// className, +// variant = "default", +// ...props +// }: React.ComponentProps<"div"> & VariantProps) { +// return ( +//
+// ); +// } + +function UnstableEmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableEmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-project", + className + )} + {...props} + /> + ); +} + +function UnstableEmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + UnstableEmpty, + UnstableEmptyContent, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle +}; diff --git a/frontend/src/components/v3/generic/Empty/index.ts b/frontend/src/components/v3/generic/Empty/index.ts new file mode 100644 index 0000000000..7aa85b1b7d --- /dev/null +++ b/frontend/src/components/v3/generic/Empty/index.ts @@ -0,0 +1 @@ +export * from "./Empty"; diff --git a/frontend/src/components/v3/generic/IconButton/IconButton.tsx b/frontend/src/components/v3/generic/IconButton/IconButton.tsx new file mode 100644 index 0000000000..d6fdfb4ce7 --- /dev/null +++ b/frontend/src/components/v3/generic/IconButton/IconButton.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "cva"; +import { twMerge } from "tailwind-merge"; + +import { Lottie } from "@app/components/v2"; +import { cn } from "@app/components/v3/utils"; + +const iconButtonVariants = cva( + cn( + "inline-flex items-center active:scale-[0.99] justify-center border cursor-pointer whitespace-nowrap rounded-[4px] text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-75 [&_svg]:pointer-events-none shrink-0 [&>svg]:shrink-0", + "focus-visible:ring-ring outline-0 focus-visible:ring-2" + ), + { + variants: { + variant: { + default: + "border-foreground bg-foreground text-background hover:bg-foreground/90 hover:border-foreground/90", + accent: + "border-accent/10 bg-accent/40 text-foreground hover:bg-accent/50 hover:border-accent/20", + outline: "text-foreground hover:bg-foreground/20 border-border hover:border-foreground/50", + ghost: "text-foreground hover:bg-foreground/40 border-transparent", + project: + "border-project/75 bg-project/40 text-foreground hover:bg-project/50 hover:border-kms", + org: "border-org/75 bg-org/40 text-foreground hover:bg-org/50 hover:border-org", + "sub-org": + "border-sub-org/75 bg-sub-org/40 text-foreground hover:bg-sub-org/50 hover:border-namespace", + success: + "border-success/75 bg-success/40 text-foreground hover:bg-success/50 hover:border-success", + info: "border-info/75 bg-info/40 text-foreground hover:bg-info/50 hover:border-info", + warning: + "border-warning/75 bg-warning/40 text-foreground hover:bg-warning/50 hover:border-warning", + danger: + "border-danger/75 bg-danger/40 text-foreground hover:bg-danger/50 hover:border-danger" + }, + size: { + xs: "h-7 w-7 [&>svg]:size-3.5 [&>svg]:stroke-[1.75]", + sm: "h-8 w-8 [&>svg]:size-4 [&>svg]:stroke-[1.5]", + md: "h-9 w-9 [&>svg]:size-6 [&>svg]:stroke-[1.5]", + lg: "h-10 w-10 [&>svg]:size-7 [&>svg]:stroke-[1.5]" + }, + isPending: { + true: "text-transparent" + }, + isFullWidth: { + true: "w-full", + false: "w-fit" + } + }, + defaultVariants: { + variant: "default", + size: "md" + } + } +); + +type UnstableIconButtonProps = React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + isPending?: boolean; + isDisabled?: boolean; + }; + +const UnstableIconButton = forwardRef( + ( + { + className, + variant = "default", + size = "md", + asChild = false, + isPending = false, + disabled = false, + isDisabled = false, + children, + ...props + }, + ref + ): JSX.Element => { + const Comp = asChild ? Slot : "button"; + + return ( + + {children} + {isPending && ( + + )} + + ); + } +); + +UnstableIconButton.displayName = "IconButton"; + +export { iconButtonVariants, UnstableIconButton, type UnstableIconButtonProps }; diff --git a/frontend/src/components/v3/generic/IconButton/index.ts b/frontend/src/components/v3/generic/IconButton/index.ts new file mode 100644 index 0000000000..53185101de --- /dev/null +++ b/frontend/src/components/v3/generic/IconButton/index.ts @@ -0,0 +1 @@ +export * from "./IconButton"; diff --git a/frontend/src/components/v3/generic/PageLoader/PageLoader.tsx b/frontend/src/components/v3/generic/PageLoader/PageLoader.tsx new file mode 100644 index 0000000000..346eead3af --- /dev/null +++ b/frontend/src/components/v3/generic/PageLoader/PageLoader.tsx @@ -0,0 +1,9 @@ +import { Lottie } from "@app/components/v2"; + +export function UnstablePageLoader() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/v3/generic/PageLoader/index.ts b/frontend/src/components/v3/generic/PageLoader/index.ts new file mode 100644 index 0000000000..70c6707ce9 --- /dev/null +++ b/frontend/src/components/v3/generic/PageLoader/index.ts @@ -0,0 +1 @@ +export * from "./PageLoader"; diff --git a/frontend/src/components/v3/generic/Separator/Separator.tsx b/frontend/src/components/v3/generic/Separator/Separator.tsx new file mode 100644 index 0000000000..0ebfe67459 --- /dev/null +++ b/frontend/src/components/v3/generic/Separator/Separator.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "../../utils"; + +function UnstableSeparator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { UnstableSeparator }; diff --git a/frontend/src/components/v3/generic/Separator/index.ts b/frontend/src/components/v3/generic/Separator/index.ts new file mode 100644 index 0000000000..4060cb5ecd --- /dev/null +++ b/frontend/src/components/v3/generic/Separator/index.ts @@ -0,0 +1 @@ +export * from "./Separator"; diff --git a/frontend/src/components/v3/generic/Table/Table.stories.tsx b/frontend/src/components/v3/generic/Table/Table.stories.tsx new file mode 100644 index 0000000000..09098795bf --- /dev/null +++ b/frontend/src/components/v3/generic/Table/Table.stories.tsx @@ -0,0 +1,138 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CopyIcon, EditIcon, MoreHorizontalIcon, TrashIcon } from "lucide-react"; + +import { + Badge, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableIconButton +} from "@app/components/v3/generic"; +import { ProjectIcon } from "@app/components/v3/platform"; + +import { + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "./Table"; + +const identities: { + name: string; + role: string; + managedBy?: { scope: "org" | "namespace"; name: string }; +}[] = [ + { + name: "machine-one", + role: "Admin", + managedBy: { + scope: "org", + name: "infisical" + } + }, + { + name: "machine-two", + role: "Viewer", + managedBy: { + scope: "namespace", + name: "engineering" + } + }, + { + name: "machine-three", + role: "Developer" + }, + { + name: "machine-four", + role: "Admin", + managedBy: { + scope: "namespace", + name: "dev-ops" + } + }, + { + name: "machine-five", + role: "Viewer", + managedBy: { + scope: "org", + name: "infisical" + } + }, + { + name: "machine-six", + role: "Developer" + } +]; + +function TableDemo() { + return ( + + + + Name + Role + Managed By + + + + + {identities.map((identity) => ( + + {identity.name} + {identity.role} + + + + Project + + + + + + + + + + + + + Copy ID + + + + Edit Identity + + + + Delete Identity + + + + + + ))} + + + ); +} + +const meta = { + title: "Generic/Table", + component: TableDemo, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + argTypes: {} +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const KitchenSInk: Story = { + name: "Example: Kitchen Sink", + args: {} +}; diff --git a/frontend/src/components/v3/generic/Table/Table.tsx b/frontend/src/components/v3/generic/Table/Table.tsx new file mode 100644 index 0000000000..ffd25b766c --- /dev/null +++ b/frontend/src/components/v3/generic/Table/Table.tsx @@ -0,0 +1,106 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; + +import { cn } from "@app/components/v3/utils"; + +function UnstableTable({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function UnstableTableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ); +} + +function UnstableTableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + tr]:last:border-b-0", className)} {...props} /> + ); +} + +function UnstableTableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ); +} + +function UnstableTableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function UnstableTableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + + + + + + + ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx similarity index 75% rename from frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx rename to frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx index 8ec279a9d3..eb5fc45e56 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx @@ -1,5 +1,6 @@ import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UserIcon } from "lucide-react"; import { OrgPermissionCan } from "@app/components/permissions"; import { @@ -14,19 +15,23 @@ import { } from "@app/components/v2"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; -import { TGroupUser } from "@app/hooks/api/groups/types"; +import { GroupMemberType, TGroupMemberUser } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - user: TGroupUser; + user: TGroupMemberUser; handlePopUpOpen: ( popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: object ) => void; }; -export const GroupMembershipRow = ({ - user: { firstName, lastName, username, joinedGroupAt, email, id }, +export const GroupMembershipUserRow = ({ + user: { + user: { firstName, lastName, email, username }, + joinedGroupAt, + id + }, handlePopUpOpen }: Props) => { const { currentOrg } = useOrganization(); @@ -36,15 +41,18 @@ export const GroupMembershipRow = ({ return ( - -
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ); +} + +function UnstableTableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ); +} + +function UnstableTableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { + UnstableTable, + UnstableTableBody, + UnstableTableCaption, + UnstableTableCell, + UnstableTableFooter, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +}; diff --git a/frontend/src/components/v3/generic/Table/index.ts b/frontend/src/components/v3/generic/Table/index.ts new file mode 100644 index 0000000000..e40efa4761 --- /dev/null +++ b/frontend/src/components/v3/generic/Table/index.ts @@ -0,0 +1 @@ +export * from "./Table"; diff --git a/frontend/src/components/v3/generic/index.ts b/frontend/src/components/v3/generic/index.ts index ae21190ba6..43ddf08b0a 100644 --- a/frontend/src/components/v3/generic/index.ts +++ b/frontend/src/components/v3/generic/index.ts @@ -1 +1,13 @@ +export * from "./Accordion"; +export * from "./Alert"; export * from "./Badge"; +export * from "./Button"; +export * from "./ButtonGroup"; +export * from "./Card"; +export * from "./Detail"; +export * from "./Dropdown"; +export * from "./Empty"; +export * from "./IconButton"; +export * from "./PageLoader"; +export * from "./Separator"; +export * from "./Table"; diff --git a/frontend/src/components/v3/platform/ScopeIcons.tsx b/frontend/src/components/v3/platform/ScopeIcons.tsx index 8f87123197..9f21ce2502 100644 --- a/frontend/src/components/v3/platform/ScopeIcons.tsx +++ b/frontend/src/components/v3/platform/ScopeIcons.tsx @@ -1,8 +1,8 @@ import { BoxesIcon, BoxIcon, Building2Icon, ServerIcon } from "lucide-react"; -const InstanceIcon = ServerIcon; -const OrgIcon = Building2Icon; -const SubOrgIcon = BoxesIcon; -const ProjectIcon = BoxIcon; - -export { InstanceIcon, OrgIcon, ProjectIcon, SubOrgIcon }; +export { + ServerIcon as InstanceIcon, + Building2Icon as OrgIcon, + BoxIcon as ProjectIcon, + BoxesIcon as SubOrgIcon +}; diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index ee922f9e69..d10913c84f 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -294,36 +294,36 @@ export const ROUTE_PATHS = Object.freeze({ }, CertManager: { CertAuthDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/ca/$caId", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId" + "/organizations/$orgId/projects/cert-manager/$projectId/ca/$caId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/ca/$caId" ), SubscribersPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/subscribers", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers" + "/organizations/$orgId/projects/cert-manager/$projectId/subscribers", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers" ), CertificateAuthoritiesPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities" + "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-authorities" ), AlertingPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/alerting", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/alerting" + "/organizations/$orgId/projects/cert-manager/$projectId/alerting", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/alerting" ), PkiCollectionDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/pki-collections/$collectionId" + "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId" ), PkiSubscriberDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/$subscriberName" + "/organizations/$orgId/projects/cert-manager/$projectId/subscribers/$subscriberName", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName" ), IntegrationsListPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/integrations", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/" + "/organizations/$orgId/projects/cert-manager/$projectId/integrations", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/" ), PkiSyncDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId" + "/organizations/$orgId/projects/cert-manager/$projectId/integrations/$syncId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/$syncId" ) }, Ssh: { diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 128dd31cea..554d09655b 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -61,7 +61,7 @@ export const getProjectBaseURL = (type: ProjectType) => { case ProjectType.SecretManager: return "/organizations/$orgId/projects/secret-management/$projectId"; case ProjectType.CertificateManager: - return "/organizations/$orgId/projects/cert-management/$projectId"; + return "/organizations/$orgId/projects/cert-manager/$projectId"; default: return `/organizations/$orgId/projects/${type}/$projectId` as const; } @@ -74,7 +74,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[] case ProjectType.SecretManager: return "/organizations/$orgId/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/organizations/$orgId/projects/cert-management/$projectId/policies" as const; + return "/organizations/$orgId/projects/cert-manager/$projectId/policies" as const; case ProjectType.SecretScanning: return `/organizations/$orgId/projects/${type}/$projectId/data-sources` as const; case ProjectType.PAM: @@ -88,7 +88,7 @@ export const getProjectTitle = (type: ProjectType) => { const titleConvert = { [ProjectType.SecretManager]: "Secrets Management", [ProjectType.KMS]: "Key Management", - [ProjectType.CertificateManager]: "Cert Management", + [ProjectType.CertificateManager]: "Certificate Manager", [ProjectType.SSH]: "SSH", [ProjectType.SecretScanning]: "Secret Scanning", [ProjectType.PAM]: "PAM" diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b3be5a6fdd..72b57d1598 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -4,6 +4,8 @@ export enum ActorType { USER = "user", SERVICE = "service", IDENTITY = "identity", + ACME_PROFILE = "acmeProfile", + ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index fc15b5064a..6e0eb22f3e 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -38,6 +38,13 @@ interface KmipClientActorMetadata { name: string; } +interface AcmeAccountActorMetadata { + profileId: string; + accountId: string; +} +interface AcmeProfileActorMetadata { + profileId: string; +} interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -67,13 +74,25 @@ export interface UnknownUserActor { type: ActorType.UNKNOWN_USER; } +export interface AcmeProfileActor { + type: ActorType.ACME_PROFILE; + metadata: AcmeProfileActorMetadata; +} + +export interface AcmeAccountActor { + type: ActorType.ACME_ACCOUNT; + metadata: AcmeAccountActorMetadata; +} + export type Actor = | UserActor | ServiceActor | IdentityActor | PlatformActor | UnknownUserActor - | KmipClientActor; + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index a4f6236595..e79c384d31 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -62,6 +62,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { acmeConfig?: { id: string; directoryUrl: string; + skipDnsOwnershipVerification?: boolean; }; }; diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index eebe2ccc3a..b705ec3ade 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,8 +1,15 @@ export { + useAddIdentityToGroup, useAddUserToGroup, useCreateGroup, useDeleteGroup, + useRemoveIdentityFromGroup, useRemoveUserFromGroup, useUpdateGroup } from "./mutations"; -export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries"; +export { + useGetGroupById, + useListGroupMachineIdentities, + useListGroupProjects, + useListGroupUsers +} from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index d45971995f..ea5ac22730 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { userKeys } from "../users/query-keys"; import { groupKeys } from "./queries"; -import { TGroup } from "./types"; +import { TGroup, TGroupMachineIdentity } from "./types"; export const useCreateGroup = () => { const queryClient = useQueryClient(); @@ -95,6 +95,7 @@ export const useAddUserToGroup = () => { }, onSuccess: (_, { slug }) => { queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); } }); }; @@ -119,6 +120,55 @@ export const useRemoveUserFromGroup = () => { onSuccess: (_, { slug, username }) => { queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) }); queryClient.invalidateQueries({ queryKey: userKeys.listUserGroupMemberships(username) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); + } + }); +}; + +export const useAddIdentityToGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupId, + identityId + }: { + groupId: string; + identityId: string; + slug: string; + }) => { + const { data } = await apiRequest.post>( + `/api/v1/groups/${groupId}/machine-identities/${identityId}` + ); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); + } + }); +}; + +export const useRemoveIdentityFromGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupId, + identityId + }: { + groupId: string; + identityId: string; + slug: string; + }) => { + const { data } = await apiRequest.delete>( + `/api/v1/groups/${groupId}/machine-identities/${identityId}` + ); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index 2e5b9f6841..cd2b9a00cb 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -4,9 +4,14 @@ import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "../generic/types"; import { - EFilterReturnedProjects, - EFilterReturnedUsers, + FilterMemberType, + FilterReturnedMachineIdentities, + FilterReturnedProjects, + FilterReturnedUsers, + GroupMembersOrderBy, TGroup, + TGroupMachineIdentity, + TGroupMember, TGroupProject, TGroupUser } from "./types"; @@ -27,10 +32,12 @@ export const groupKeys = { offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const, - specificProjectGroupUserMemberships: ({ - projectId, + allGroupIdentitiesMemberships: () => ["group-identities-memberships"] as const, + forGroupIdentitiesMemberships: (slug: string) => + [...groupKeys.allGroupIdentitiesMemberships(), slug] as const, + specificGroupIdentitiesMemberships: ({ slug, offset, limit, @@ -38,16 +45,34 @@ export const groupKeys = { filter }: { slug: string; - projectId: string; offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedMachineIdentities; + }) => + [...groupKeys.forGroupIdentitiesMemberships(slug), { offset, limit, search, filter }] as const, + allGroupMembers: () => ["group-members"] as const, + forGroupMembers: (slug: string) => [...groupKeys.allGroupMembers(), slug] as const, + specificGroupMembers: ({ + slug, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter + }: { + slug: string; + offset: number; + limit: number; + search: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; }) => [ - ...groupKeys.forGroupUserMemberships(slug), - projectId, - { offset, limit, search, filter } + ...groupKeys.forGroupMembers(slug), + { offset, limit, search, orderBy, orderDirection, memberTypeFilter } ] as const, allGroupProjects: () => ["group-projects"] as const, forGroupProjects: (groupId: string) => [...groupKeys.allGroupProjects(), groupId] as const, @@ -64,7 +89,7 @@ export const groupKeys = { offset: number; limit: number; search: string; - filter?: EFilterReturnedProjects; + filter?: FilterReturnedProjects; orderBy?: string; orderDirection?: OrderByDirection; }) => @@ -99,7 +124,7 @@ export const useListGroupUsers = ({ offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ @@ -115,7 +140,7 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search, + ...(search && { search }), ...(filter && { filter }) }); @@ -131,9 +156,66 @@ export const useListGroupUsers = ({ }); }; -export const useListProjectGroupUsers = ({ +export const useListGroupMembers = ({ + id, + groupSlug, + offset = 0, + limit = 10, + search, + orderBy, + orderDirection, + memberTypeFilter +}: { + id: string; + groupSlug: string; + offset: number; + limit: number; + search: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; +}) => { + return useQuery({ + queryKey: groupKeys.specificGroupMembers({ + slug: groupSlug, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter + }), + enabled: Boolean(groupSlug), + placeholderData: (previousData) => previousData, + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + ...(search && { search }), + ...(orderBy && { orderBy: orderBy.toString() }), + ...(orderDirection && { orderDirection }) + }); + + if (memberTypeFilter && memberTypeFilter.length > 0) { + memberTypeFilter.forEach((filter) => { + params.append("memberTypeFilter", filter); + }); + } + + const { data } = await apiRequest.get<{ members: TGroupMember[]; totalCount: number }>( + `/api/v1/groups/${id}/members`, + { + params + } + ); + + return data; + } + }); +}; + +export const useListGroupMachineIdentities = ({ id, - projectId, groupSlug, offset = 0, limit = 10, @@ -142,16 +224,14 @@ export const useListProjectGroupUsers = ({ }: { id: string; groupSlug: string; - projectId: string; offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedMachineIdentities; }) => { return useQuery({ - queryKey: groupKeys.specificProjectGroupUserMemberships({ + queryKey: groupKeys.specificGroupIdentitiesMemberships({ slug: groupSlug, - projectId, offset, limit, search, @@ -163,16 +243,16 @@ export const useListProjectGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search, + ...(search && { search }), ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( - `/api/v1/projects/${projectId}/groups/${id}/users`, - { - params - } - ); + const { data } = await apiRequest.get<{ + machineIdentities: TGroupMachineIdentity[]; + totalCount: number; + }>(`/api/v1/groups/${id}/machine-identities`, { + params + }); return data; } @@ -194,7 +274,7 @@ export const useListGroupProjects = ({ search: string; orderBy?: string; orderDirection?: OrderByDirection; - filter?: EFilterReturnedProjects; + filter?: FilterReturnedProjects; }) => { return useQuery({ queryKey: groupKeys.specificGroupProjects({ @@ -212,7 +292,7 @@ export const useListGroupProjects = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search, + ...(search && { search }), ...(filter && { filter }), ...(orderBy && { orderBy }), ...(orderDirection && { orderDirection }) diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 1c16a331b6..f5549a3c1d 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -42,16 +42,59 @@ export type TGroupWithProjectMemberships = { orgId: string; }; +export enum GroupMemberType { + USER = "user", + MACHINE_IDENTITY = "machineIdentity" +} + export type TGroupUser = { id: string; email: string; username: string; firstName: string; lastName: string; - isPartOfGroup: boolean; joinedGroupAt: Date; }; +export type TGroupMachineIdentity = { + id: string; + name: string; + joinedGroupAt: Date; +}; + +export type TGroupMemberUser = { + id: string; + joinedGroupAt: Date; + type: GroupMemberType.USER; + user: { + email: string; + username: string; + firstName: string; + lastName: string; + }; +}; + +export type TGroupMemberMachineIdentity = { + id: string; + joinedGroupAt: Date; + type: GroupMemberType.MACHINE_IDENTITY; + machineIdentity: { + id: string; + name: string; + }; +}; + +export type TGroupMember = TGroupMemberUser | TGroupMemberMachineIdentity; + +export enum GroupMembersOrderBy { + Name = "name" +} + +export enum FilterMemberType { + USERS = "users", + MACHINE_IDENTITIES = "machineIdentities" +} + export type TGroupProject = { id: string; name: string; @@ -61,12 +104,17 @@ export type TGroupProject = { joinedGroupAt: Date; }; -export enum EFilterReturnedUsers { +export enum FilterReturnedUsers { EXISTING_MEMBERS = "existingMembers", NON_MEMBERS = "nonMembers" } -export enum EFilterReturnedProjects { +export enum FilterReturnedMachineIdentities { + ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities", + NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities" +} + +export enum FilterReturnedProjects { ASSIGNED_PROJECTS = "assignedProjects", UNASSIGNED_PROJECTS = "unassignedProjects" } diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 878a35aa10..332c985a95 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -7,18 +7,30 @@ import { PamSessionStatus } from "../enums"; import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource"; +import { TKubernetesAccount, TKubernetesResource } from "./kubernetes-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource"; export * from "./aws-iam-resource"; +export * from "./kubernetes-resource"; export * from "./mysql-resource"; export * from "./postgres-resource"; export * from "./ssh-resource"; -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource; +export type TPamResource = + | TPostgresResource + | TMySQLResource + | TSSHResource + | TAwsIamResource + | TKubernetesResource; -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount; +export type TPamAccount = + | TPostgresAccount + | TMySQLAccount + | TSSHAccount + | TAwsIamAccount + | TKubernetesAccount; export type TPamFolder = { id: string; @@ -44,7 +56,28 @@ export type TTerminalEvent = { elapsedTime: number; // Seconds since session start (for replay) }; -export type TPamSessionLog = TPamCommandLog | TTerminalEvent; +export type THttpRequestEvent = { + timestamp: string; + requestId: string; + eventType: "request"; + headers: Record; + method: string; + url: string; + body?: string; +}; + +export type THttpResponseEvent = { + timestamp: string; + requestId: string; + eventType: "response"; + headers: Record; + status: string; + body?: string; +}; + +export type THttpEvent = THttpRequestEvent | THttpResponseEvent; + +export type TPamSessionLog = TPamCommandLog | TTerminalEvent | THttpEvent; export type TPamSession = { id: string; diff --git a/frontend/src/hooks/api/pam/types/kubernetes-resource.ts b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts new file mode 100644 index 0000000000..b7e1501d5f --- /dev/null +++ b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts @@ -0,0 +1,33 @@ +import { PamResourceType } from "../enums"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +export enum KubernetesAuthMethod { + ServiceAccountToken = "service-account-token" +} + +export type TKubernetesConnectionDetails = { + url: string; + sslRejectUnauthorized: boolean; + sslCertificate?: string; +}; + +export type TKubernetesServiceAccountTokenCredentials = { + authMethod: KubernetesAuthMethod.ServiceAccountToken; + serviceAccountToken: string; +}; + +export type TKubernetesCredentials = TKubernetesServiceAccountTokenCredentials; + +// Resources +export type TKubernetesResource = TBasePamResource & { + resourceType: PamResourceType.Kubernetes; +} & { + connectionDetails: TKubernetesConnectionDetails; + rotationAccountCredentials?: TKubernetesCredentials | null; +}; + +// Accounts +export type TKubernetesAccount = TBasePamAccount & { + credentials: TKubernetesCredentials; +}; diff --git a/frontend/src/hooks/api/shared/types.ts b/frontend/src/hooks/api/shared/types.ts index c57daf3fd0..8a07ff4e66 100644 --- a/frontend/src/hooks/api/shared/types.ts +++ b/frontend/src/hooks/api/shared/types.ts @@ -18,7 +18,7 @@ export type TIdentity = { updatedAt: string; hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; - activeLockoutAuthMethods: string[]; + activeLockoutAuthMethods: IdentityAuthMethod[]; metadata?: Array; }; diff --git a/frontend/src/index.css b/frontend/src/index.css index baa613b364..c1e564d92a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "tw-animate-css"; @source not "../public"; @@ -39,7 +40,7 @@ /* Colors v2 */ --color-background: #19191c; - --color-foreground: white; + --color-foreground: #ebebeb; --color-success: #2ecc71; --color-info: #63b0bd; --color-warning: #f1c40f; @@ -48,6 +49,14 @@ --color-sub-org: #96ff59; --color-project: #e0ed34; --color-neutral: #adaeb0; + --color-border: #2b2c30; + --color-label: #adaeb0; + --color-muted: #707174; + --color-popover: #141617; + --color-ring: #2d2f33; + --color-card: #16181a; + --color-accent: #7d7f80; + --color-container: #1a1c1e; /*legacy color schema */ --color-org-v1: #30b3ff; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 4cef73f46a..519e4265a5 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -170,7 +170,7 @@ export const Navbar = () => { const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false); const location = useLocation(); - const isBillingPage = location.pathname === "/organization/billing"; + const isBillingPage = location.pathname === `/organizations/${currentOrg.id}/billing`; const isModalIntrusive = Boolean(!isBillingPage && isCardDeclinedMoreThan30Days); diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index f3f283a355..814667eff3 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -43,7 +43,7 @@ export const PkiManagerLayout = () => { { {({ isActive }) => Certificates} { )} { {({ isActive }) => Alerting} { {({ isActive }) => Integrations} { <> {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && ( { )} {(subscription.pkiLegacyTemplates || hasExistingTemplates) && ( { )} { )} { {({ isActive }) => Audit Logs} { navigate({ to: "/admin" }); }; - if (config?.initialized) return ; + if (config?.initialized) { + return ( +
+ +
+ ); + } return (
diff --git a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx index cd75f917af..e996f191c1 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx @@ -82,7 +82,7 @@ export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => { }); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId", + to: "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId", params: { orgId: currentOrg.id, projectId, diff --git a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx index 71b57796de..601df6fafa 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx @@ -66,7 +66,7 @@ export const PkiCollectionTable = ({ handlePopUpOpen }: Props) => { key={`pki-collection-${pkiCollection.id}`} onClick={() => navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId", + to: "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId", params: { orgId: currentOrg.id, projectId, diff --git a/frontend/src/pages/cert-manager/AlertingPage/route.tsx b/frontend/src/pages/cert-manager/AlertingPage/route.tsx index 5dc2f3de7d..038e5cb9a4 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/route.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { AlertingPage } from "./AlertingPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/alerting" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/alerting" )({ component: AlertingPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx index 4f12c39501..4df47af75e 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx @@ -79,7 +79,7 @@ const Page = () => { handlePopUpClose("deleteCa"); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities", + to: "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities", params: { orgId: currentOrg.id, projectId @@ -100,7 +100,7 @@ const Page = () => { isAllowed ? (
{ @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Certificate Authorities", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities", + to: "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities", params: { orgId: params.orgId, projectId: params.projectId diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx index b52ca3daa2..41634e128f 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx @@ -96,7 +96,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { onClick={() => canReadCa && navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/ca/$caId", + to: "/organizations/$orgId/projects/cert-manager/$projectId/ca/$caId", params: { orgId: currentOrg.id, projectId: currentProject.id, diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/route.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/route.tsx index 39cd363bcc..1e17db0b09 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/route.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { CertificateAuthoritiesPage } from "./CertificateAuthoritiesPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-authorities" )({ component: CertificateAuthoritiesPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx index faec782c72..0fba9b6b4a 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx @@ -15,7 +15,7 @@ const IntegrationsListPageQuerySchema = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/" )({ component: IntegrationsListPage, validateSearch: zodValidator(IntegrationsListPageQuerySchema), diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx index c8243b914d..88e03a2f31 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx @@ -64,7 +64,7 @@ export const PkiCollectionPage = () => { }); handlePopUpClose("deletePkiCollection"); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/policies", + to: "/organizations/$orgId/projects/cert-manager/$projectId/policies", params: { orgId: currentOrg.id, projectId: params.projectId @@ -77,7 +77,7 @@ export const PkiCollectionPage = () => { {data && (
{ @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Certificate Collections", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/policies", + to: "/organizations/$orgId/projects/cert-manager/$projectId/policies", params: { orgId: params.orgId, projectId: params.projectId diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx index acea4d4d0f..779afb0a42 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx @@ -64,7 +64,7 @@ const Page = () => { handlePopUpClose("deletePkiSubscriber"); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers", + to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers", params: { orgId: currentOrg.id, projectId @@ -77,7 +77,7 @@ const Page = () => { {data && (
{ @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Subscribers", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers", + to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers", params: { orgId: params.orgId, projectId: params.projectId diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx index 880f4af8f9..bf59d6665d 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx @@ -77,7 +77,7 @@ export const PkiSubscribersTable = ({ handlePopUpOpen }: Props) => { key={`pki-subscriber-${subscriber.id}`} onClick={() => navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName", + to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers/$subscriberName", params: { orgId: currentOrg.id, projectId: currentProject.id, diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx index df39a68a2e..68652dd0b6 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PkiSubscribersPage } from "./PkiSubscribersPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/" )({ component: PkiSubscribersPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx index 49bed6c75d..78f67cc3ac 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx @@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations"; import { PkiSyncDetailsByIDPage } from "./index"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/$syncId" )({ component: PkiSyncDetailsByIDPage, beforeLoad: ({ context, params }) => { @@ -15,7 +15,7 @@ export const Route = createFileRoute( { label: "Integrations", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/integrations", + to: "/organizations/$orgId/projects/cert-manager/$projectId/integrations", params, search: { selectedTab: IntegrationsListPageTabs.PkiSyncs diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx index c943ede97e..305039892e 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PkiTemplateListPage } from "./PkiTemplateListPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-templates/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-templates/" )({ component: PkiTemplateListPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index c734e621e1..31265b5fb2 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -50,12 +50,12 @@ export const PoliciesPage = () => { return (
- {t("common.head-title", { title: "Certificate Management" })} + {t("common.head-title", { title: "Certificate Manager" })}
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 138e60de3a..43de8c6b23 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -79,7 +79,11 @@ const createSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: z .object({ template: z.string().min(1, "Azure ADCS template is required") @@ -219,7 +223,11 @@ const editSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: z .object({ template: z.string().optional() @@ -406,7 +414,13 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { + skipDnsOwnershipVerification: + profile.acmeConfig?.skipDnsOwnershipVerification || false + } + : undefined, externalConfigs: profile.externalConfigs ? { template: @@ -429,7 +443,9 @@ export const CreateProfileModal = ({ autoRenew: false, renewBeforeDays: 30 }, - acmeConfig: {}, + acmeConfig: { + skipDnsOwnershipVerification: false + }, externalConfigs: undefined } }); @@ -476,7 +492,13 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { + skipDnsOwnershipVerification: + profile.acmeConfig?.skipDnsOwnershipVerification || false + } + : undefined, externalConfigs: profile.externalConfigs ? { template: @@ -667,7 +689,9 @@ export const CreateProfileModal = ({ renewBeforeDays: 30 }); setValue("estConfig", undefined); - setValue("acmeConfig", undefined); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -797,7 +821,9 @@ export const CreateProfileModal = ({ } else if (watchedEnrollmentType === "acme") { setValue("estConfig", undefined); setValue("apiConfig", undefined); - setValue("acmeConfig", {}); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -846,7 +872,9 @@ export const CreateProfileModal = ({ } else if (value === "acme") { setValue("apiConfig", undefined); setValue("estConfig", undefined); - setValue("acmeConfig", {}); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -975,10 +1003,24 @@ export const CreateProfileModal = ({
( + name="acmeConfig.skipDnsOwnershipVerification" + render={({ field: { value, onChange }, fieldState: { error } }) => ( -
{/* FIXME: ACME configuration */}
+
+ +
+ + Skip DNS Ownership Validation + +

+ Skip DNS ownership verification during ACME certificate issuance. +

+
+
)} /> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx index 807d69bb9a..70794f2674 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PoliciesPage } from "./PoliciesPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/policies" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/policies" )({ component: PoliciesPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/SettingsPage/route.tsx b/frontend/src/pages/cert-manager/SettingsPage/route.tsx index 59eccb028f..4f205481a3 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/route.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { SettingsPage } from "./SettingsPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/settings" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/settings" )({ component: SettingsPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/layout.tsx b/frontend/src/pages/cert-manager/layout.tsx index 0462c230dc..463fc5c30e 100644 --- a/frontend/src/pages/cert-manager/layout.tsx +++ b/frontend/src/pages/cert-manager/layout.tsx @@ -8,7 +8,7 @@ import { PkiManagerLayout } from "@app/layouts/PkiManagerLayout"; import { ProjectSelect } from "@app/layouts/ProjectLayout/components/ProjectSelect"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout" )({ component: PkiManagerLayout, beforeLoad: async ({ params, context }) => { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx index 9f7141802a..b8d857f919 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx @@ -84,6 +84,7 @@ export const OrgIdentityLinkForm = ({ onClose }: Props) => { onChange={onChange} placeholder="Select machine identity..." // onInputChange={setSearchValue} + autoFocus options={rootOrgIdentities} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx index 737c454d1f..732a22cce7 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx @@ -185,7 +185,7 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx index 8463c303a5..90882d43d7 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx @@ -68,6 +68,12 @@ export const LogsTableRow = ({ auditLog, rowNumber, timezone }: Props) => { {auditLog.actor.type === ActorType.IDENTITY && ( )} + {auditLog.actor.type === ActorType.ACME_PROFILE && ( + + )} + {auditLog.actor.type === ActorType.ACME_ACCOUNT && ( + + )}
diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx index b9d1b184c3..9dbd193c4e 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx @@ -1,39 +1,35 @@ import { useState } from "react"; -import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { HardDriveIcon, UserIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; -import { - Button, - EmptyState, - Input, - Modal, - ModalContent, - Pagination, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; -import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; -import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { Button, Input, Modal, ModalContent, Tooltip } from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { AddGroupIdentitiesTab, AddGroupUsersTab } from "./AddGroupMemberModalTabs"; + +enum AddMemberType { + Users = "users", + MachineIdentities = "machineIdentities" +} + type Props = { popUp: UsePopUpState<["addGroupMembers"]>; handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; + isOidcManageGroupMembershipsEnabled: boolean; }; -export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { - const [page, setPage] = useState(1); - const [perPage, setPerPage] = useState(10); +export const AddGroupMembersModal = ({ + popUp, + handlePopUpToggle, + isOidcManageGroupMembershipsEnabled +}: Props) => { + const [addMemberType, setAddMemberType] = useState( + isOidcManageGroupMembershipsEnabled ? AddMemberType.MachineIdentities : AddMemberType.Users + ); + const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); @@ -42,47 +38,6 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { slug: string; }; - const offset = (page - 1) * perPage; - const { data, isPending } = useListGroupUsers({ - id: popUpData?.groupId, - groupSlug: popUpData?.slug, - offset, - limit: perPage, - search: debouncedSearch, - filter: EFilterReturnedUsers.NON_MEMBERS - }); - - const { totalCount = 0 } = data ?? {}; - - useResetPageHelper({ - totalCount, - offset, - setPage - }); - - const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - - const handleAddMember = async (username: string) => { - if (!popUpData?.slug) { - createNotification({ - text: "Some data is missing, please refresh the page and try again", - type: "error" - }); - return; - } - - await addUserToGroupMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - - createNotification({ - text: "Successfully assigned user to the group", - type: "success" - }); - }; - return ( { }} > - setSearchMemberFilter(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> - - - - - - - - - {isPending && } - {!isPending && - data?.users?.map(({ id, firstName, lastName, username }) => { - return ( - - - - - ); - })} - -
User -
-

{`${firstName ?? "-"} ${lastName ?? ""}`}

-

{username}

-
- - {(isAllowed) => { - return ( - - ); - }} - -
- {!isPending && totalCount > 0 && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} +
+ +
+ +
+
+ +
+
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
+ {addMemberType === AddMemberType.Users && + popUpData && + !isOidcManageGroupMembershipsEnabled && ( + )} - {!isPending && !data?.users?.length && ( - - )} -
+ {addMemberType === AddMemberType.MachineIdentities && popUpData && ( + + )}
); diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupIdentitiesTab.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupIdentitiesTab.tsx new file mode 100644 index 0000000000..0e09c0bbe9 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupIdentitiesTab.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { faServer } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { useResetPageHelper } from "@app/hooks"; +import { useAddIdentityToGroup, useListGroupMachineIdentities } from "@app/hooks/api"; +import { + FilterReturnedMachineIdentities, + TGroupMachineIdentity +} from "@app/hooks/api/groups/types"; + +type Props = { + groupId: string; + groupSlug: string; + search: string; +}; + +export const AddGroupIdentitiesTab = ({ groupId, groupSlug, search }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + + const offset = (page - 1) * perPage; + const { data, isPending } = useListGroupMachineIdentities({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES + }); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: addIdentityToGroupMutateAsync } = useAddIdentityToGroup(); + + const handleAddIdentity = async (identityId: string) => { + if (!groupSlug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addIdentityToGroupMutateAsync({ + groupId, + identityId, + slug: groupSlug + }); + + createNotification({ + text: "Successfully assigned machine identity to the group", + type: "success" + }); + }; + + return ( + + + + + + + + + {isPending && } + {!isPending && + data?.machineIdentities?.map((identity: TGroupMachineIdentity) => { + return ( + + + + + ); + })} + +
Machine Identity +
+

{identity.name}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.machineIdentities?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupUsersTab.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupUsersTab.tsx new file mode 100644 index 0000000000..a2ef53feba --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupUsersTab.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { faUsers } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { useResetPageHelper } from "@app/hooks"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { FilterReturnedUsers } from "@app/hooks/api/groups/types"; + +type Props = { + groupId: string; + groupSlug: string; + search: string; +}; + +export const AddGroupUsersTab = ({ groupId, groupSlug, search }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + + const offset = (page - 1) * perPage; + const { data, isPending } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: FilterReturnedUsers.NON_MEMBERS + }); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); + + const handleAddUser = async (username: string) => { + if (!groupSlug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addUserToGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: "Successfully assigned user to the group", + type: "success" + }); + }; + + return ( + + + + + + + + + {isPending && } + {!isPending && + data?.users?.map(({ id, firstName, lastName, username }) => { + return ( + + + + + ); + })} + +
User +
+

{`${firstName ?? "-"} ${lastName ?? ""}`}

+

{username}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.users?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/index.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/index.tsx new file mode 100644 index 0000000000..e40c6da6b7 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/index.tsx @@ -0,0 +1,2 @@ +export { AddGroupIdentitiesTab } from "./AddGroupIdentitiesTab"; +export { AddGroupUsersTab } from "./AddGroupUsersTab"; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx index c7040ec54a..ec97c216cd 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx @@ -27,7 +27,7 @@ import { useAddGroupToWorkspace as useAddProjectToGroup, useListGroupProjects } from "@app/hooks/api"; -import { EFilterReturnedProjects } from "@app/hooks/api/groups/types"; +import { FilterReturnedProjects } from "@app/hooks/api/groups/types"; import { ProjectType } from "@app/hooks/api/projects/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -57,7 +57,7 @@ export const AddGroupProjectModal = ({ popUp, handlePopUpToggle }: Props) => { offset, limit: perPage, search: debouncedSearch, - filter: EFilterReturnedProjects.UNASSIGNED_PROJECTS + filter: FilterReturnedProjects.UNASSIGNED_PROJECTS }); const { totalCount = 0 } = data ?? {}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx index 024bc54d64..da1a627f63 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -3,9 +3,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { useOidcManageGroupMembershipsEnabled, useRemoveUserFromGroup } from "@app/hooks/api"; +import { + useOidcManageGroupMembershipsEnabled, + useRemoveIdentityFromGroup, + useRemoveUserFromGroup +} from "@app/hooks/api"; +import { GroupMemberType } from "@app/hooks/api/groups/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddGroupMembersModal } from "../AddGroupMemberModal"; @@ -16,6 +21,10 @@ type Props = { groupSlug: string; }; +type RemoveMemberData = + | { memberType: GroupMemberType.USER; username: string } + | { memberType: GroupMemberType.MACHINE_IDENTITY; identityId: string; name: string }; + export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "addGroupMembers", @@ -28,52 +37,66 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { useOidcManageGroupMembershipsEnabled(currentOrg.id); const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); - const handleRemoveUserFromGroup = async (username: string) => { - await removeUserFromGroupMutateAsync({ - groupId, - username, - slug: groupSlug - }); + const { mutateAsync: removeIdentityFromGroupMutateAsync } = useRemoveIdentityFromGroup(); - createNotification({ - text: `Successfully removed user ${username} from the group`, - type: "success" - }); + const handleRemoveMemberFromGroup = async (memberData: RemoveMemberData) => { + if (memberData.memberType === GroupMemberType.USER) { + await removeUserFromGroupMutateAsync({ + groupId, + username: memberData.username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${memberData.username} from the group`, + type: "success" + }); + } else { + await removeIdentityFromGroupMutateAsync({ + groupId, + identityId: memberData.identityId, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed identity ${memberData.name} from the group`, + type: "success" + }); + } handlePopUpToggle("removeMemberFromGroup", false); }; + const getMemberName = (memberData: RemoveMemberData) => { + if (!memberData) return ""; + if (memberData.memberType === GroupMemberType.USER) { + return memberData.username; + } + return memberData.name; + }; + return (
-

Members

+

Group Members

{(isAllowed) => ( - -
- { - handlePopUpOpen("addGroupMembers", { - groupId, - slug: groupSlug - }); - }} - > - - -
-
+
+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
)}
@@ -84,21 +107,19 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { handlePopUpOpen={handlePopUpOpen} />
- + handlePopUpToggle("removeMemberFromGroup", isOpen)} deleteKey="confirm" onDeleteApproved={() => { - const userData = popUp?.removeMemberFromGroup?.data as { - username: string; - id: string; - }; - - return handleRemoveUserFromGroup(userData.username); + const memberData = popUp?.removeMemberFromGroup?.data as RemoveMemberData; + return handleRemoveMemberFromGroup(memberData); }} />
diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index f56f852d4a..0902b01d2f 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -1,16 +1,25 @@ -import { useMemo } from "react"; +import { useState } from "react"; import { faArrowDown, faArrowUp, + faCheckCircle, + faFilter, faFolder, faMagnifyingGlass, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { HardDriveIcon, UserIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, EmptyState, IconButton, Input, @@ -31,12 +40,18 @@ import { setUserTablePreference } from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; +import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; -import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { useListGroupMembers } from "@app/hooks/api/groups/queries"; +import { + FilterMemberType, + GroupMembersOrderBy, + GroupMemberType +} from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { GroupMembershipRow } from "./GroupMembershipRow"; +import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow"; +import { GroupMembershipUserRow } from "./GroupMembershipUserRow"; type Props = { groupId: string; @@ -47,10 +62,6 @@ type Props = { ) => void; }; -enum GroupMembersOrderBy { - Name = "name" -} - export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { const { search, @@ -61,11 +72,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props setPerPage, offset, orderDirection, - toggleOrderDirection + toggleOrderDirection, + orderBy } = usePagination(GroupMembersOrderBy.Name, { initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20) }); + const [memberTypeFilter, setMemberTypeFilter] = useState([]); + const handlePerPageChange = (newPerPage: number) => { setPerPage(newPerPage); setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage); @@ -76,66 +90,103 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props const { data: isOidcManageGroupMembershipsEnabled = false } = useOidcManageGroupMembershipsEnabled(currentOrg.id); - const { data: groupMemberships, isPending } = useListGroupUsers({ + const { data: groupMemberships, isPending } = useListGroupMembers({ id: groupId, groupSlug, offset, limit: perPage, search, - filter: EFilterReturnedUsers.EXISTING_MEMBERS + orderBy, + orderDirection, + memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined }); - const filteredGroupMemberships = useMemo(() => { - return groupMemberships && groupMemberships?.users - ? groupMemberships?.users - ?.filter((membership) => { - const userSearchString = `${membership.firstName && membership.firstName} ${ - membership.lastName && membership.lastName - } ${membership.email && membership.email} ${ - membership.username && membership.username - }`; - return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); - }) - .sort((a, b) => { - const [membershipOne, membershipTwo] = - orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; - - const membershipOneComparisonString = membershipOne.firstName - ? membershipOne.firstName - : membershipOne.email; - - const membershipTwoComparisonString = membershipTwo.firstName - ? membershipTwo.firstName - : membershipTwo.email; - - const comparison = membershipOneComparisonString - .toLowerCase() - .localeCompare(membershipTwoComparisonString.toLowerCase()); - - return comparison; - }) - : []; - }, [groupMemberships, orderDirection, search]); + const { members = [], totalCount = 0 } = groupMemberships ?? {}; useResetPageHelper({ - totalCount: filteredGroupMemberships?.length, + totalCount, offset, setPage }); + const filterOptions = [ + { + icon: , + label: "Users", + value: FilterMemberType.USERS + }, + { + icon: , + label: "Machine Identities", + value: FilterMemberType.MACHINE_IDENTITIES + } + ]; + return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + 0 && "border-primary/50 text-primary" + )} + > + + + + + Filter by Member Type + {filterOptions.map((option) => ( + { + e.preventDefault(); + setMemberTypeFilter((prev) => { + if (prev.includes(option.value)) { + return prev.filter((f) => f !== option.value); + } + return [...prev, option.value]; + }); + setPage(1); + }} + icon={ + memberTypeFilter.includes(option.value) && ( + + ) + } + > +
+ {option.icon} + {option.label} +
+
+ ))} +
+
+
- - @@ -158,37 +208,43 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props {isPending && } {!isPending && - filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { - return ( - { + return userGroupMembership.type === GroupMemberType.USER ? ( + + ) : ( + ); })}
+ +
Name
Email Added On
- {Boolean(filteredGroupMemberships.length) && ( + {Boolean(totalCount) && ( )} - {!isPending && !filteredGroupMemberships?.length && ( + {!isPending && !members.length && ( )} - {!groupMemberships?.users.length && ( + {!groupMemberships?.members.length && ( {(isAllowed) => ( , + data?: object + ) => void; +}; + +export const GroupMembershipIdentityRow = ({ + identity: { + machineIdentity: { name }, + joinedGroupAt, + id + }, + handlePopUpOpen +}: Props) => { + return ( +
+ + +

{name}

+
+ +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+
+ + + + + + + + + + {(isAllowed) => { + return ( +
+ } + onClick={() => + handlePopUpOpen("removeMemberFromGroup", { + memberType: GroupMemberType.MACHINE_IDENTITY, + identityId: id, + name + }) + } + isDisabled={!isAllowed} + > + Remove Identity From Group + +
+ ); + }} +
+
+
+
+
-

{`${firstName ?? "-"} ${lastName ?? ""}`}

+
+ -

{email}

+
+

+ {`${firstName ?? "-"} ${lastName ?? ""}`}{" "} + ({email}) +

-

{new Date(joinedGroupAt).toLocaleDateString()}

+

{new Date(joinedGroupAt).toLocaleDateString()}

@@ -75,7 +83,12 @@ export const GroupMembershipRow = ({
} - onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })} + onClick={() => + handlePopUpOpen("removeMemberFromGroup", { + memberType: GroupMemberType.USER, + username + }) + } isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled} > Remove User From Group diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx index d6997c6cb7..1ef653dc2a 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx @@ -41,7 +41,7 @@ export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => { return (
-

Projects

+

Group Projects

{(isAllowed) => ( { identityId={identityId} handlePopUpOpen={handlePopUpOpen} /> +
+
{!isAuthHidden && ( )} +
-
)} @@ -171,14 +172,6 @@ const Page = () => { ) } /> - handlePopUpToggle("viewAuthMethod", isOpen)} - authMethod={popUp.viewAuthMethod.data?.authMethod} - lockedOut={popUp.viewAuthMethod.data?.lockedOut || false} - identityId={identityId} - onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity} - /> ); }; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx index dd62342685..aeed2af506 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx @@ -1,16 +1,14 @@ -import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, Tooltip } from "@app/components/v2"; +import { Button } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; -import { - IdentityAuthMethod, - identityAuthToNameMap, - useGetOrgIdentityMembershipById -} from "@app/hooks/api"; +import { IdentityAuthMethod, useGetOrgIdentityMembershipById } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { ViewIdentityAuth } from "../ViewIdentityAuth"; + type Props = { identityId: string; handlePopUpOpen: ( @@ -23,37 +21,42 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P const { data, refetch } = useGetOrgIdentityMembershipById(identityId); return data ? ( -
-
+
+

Authentication

+ {!Object.values(IdentityAuthMethod).every((method) => + data.identity.authMethods.includes(method) + ) && ( + + {(isAllowed) => ( + + )} + + )}
{data.identity.authMethods.length > 0 ? ( -
- {data.identity.authMethods.map((authMethod) => ( - - ))} -
+ ) : (

@@ -61,30 +64,6 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P

)} - {!Object.values(IdentityAuthMethod).every((method) => - data.identity.authMethods.includes(method) - ) && ( - - {(isAllowed) => ( - - )} - - )}
) : (
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityAuthFieldDisplay.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityAuthFieldDisplay.tsx new file mode 100644 index 0000000000..ba5036d74c --- /dev/null +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityAuthFieldDisplay.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from "react"; + +import { Detail, DetailLabel, DetailValue } from "@app/components/v3"; + +type Props = { + label: string; + children: ReactNode; + className?: string; +}; + +export const IdentityAuthFieldDisplay = ({ label, children, className }: Props) => { + return ( + + {label} + + {children ? ( +

{children}

+ ) : ( +

Not set

+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityAuthLockoutFields.tsx similarity index 100% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityAuthLockoutFields.tsx diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityTokenAuthTokensTable.tsx similarity index 100% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityTokenAuthTokensTable.tsx diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityUniversalAuthClientSecretsTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityUniversalAuthClientSecretsTable.tsx similarity index 100% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityUniversalAuthClientSecretsTable.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/IdentityUniversalAuthClientSecretsTable.tsx diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAliCloudAuthContent.tsx similarity index 73% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAliCloudAuthContent.tsx index 5249d7f654..30837ea27e 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAliCloudAuthContent.tsx @@ -2,7 +2,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, Spinner } from "@app/components/v2"; import { useGetIdentityAliCloudAuth } from "@app/hooks/api"; -import { IdentityAliCloudAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -10,10 +9,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityAliCloudAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityAliCloudAuth(identityId); @@ -34,23 +31,8 @@ export const ViewIdentityAliCloudAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAuth.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAuth.tsx new file mode 100644 index 0000000000..d6e322487f --- /dev/null +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAuth.tsx @@ -0,0 +1,322 @@ +import { subject } from "@casl/ability"; +import { useParams } from "@tanstack/react-router"; +import { EllipsisIcon, LockIcon } from "lucide-react"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { VariablePermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, Modal, ModalContent, Tooltip } from "@app/components/v2"; +import { + Badge, + UnstableAccordion, + UnstableAccordionContent, + UnstableAccordionItem, + UnstableAccordionTrigger, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableIconButton +} from "@app/components/v3"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + ProjectPermissionIdentityActions, + ProjectPermissionSub, + useOrganization +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + IdentityAuthMethod, + identityAuthToNameMap, + useDeleteIdentityAliCloudAuth, + useDeleteIdentityAwsAuth, + useDeleteIdentityAzureAuth, + useDeleteIdentityGcpAuth, + useDeleteIdentityJwtAuth, + useDeleteIdentityKubernetesAuth, + useDeleteIdentityLdapAuth, + useDeleteIdentityOciAuth, + useDeleteIdentityOidcAuth, + useDeleteIdentityTlsCertAuth, + useDeleteIdentityTokenAuth, + useDeleteIdentityUniversalAuth +} from "@app/hooks/api"; +import { IdentityAliCloudAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm"; +import { IdentityAwsAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm"; +import { IdentityAzureAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm"; +import { IdentityGcpAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm"; +import { IdentityJwtAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm"; +import { IdentityKubernetesAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm"; +import { IdentityLdapAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm"; +import { IdentityOciAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm"; +import { IdentityOidcAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm"; +import { IdentityTlsCertAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm"; +import { IdentityTokenAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm"; +import { IdentityUniversalAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm"; + +import { ViewIdentityAliCloudAuthContent } from "./ViewIdentityAliCloudAuthContent"; +import { ViewIdentityAwsAuthContent } from "./ViewIdentityAwsAuthContent"; +import { ViewIdentityAzureAuthContent } from "./ViewIdentityAzureAuthContent"; +import { ViewIdentityGcpAuthContent } from "./ViewIdentityGcpAuthContent"; +import { ViewIdentityJwtAuthContent } from "./ViewIdentityJwtAuthContent"; +import { ViewIdentityKubernetesAuthContent } from "./ViewIdentityKubernetesAuthContent"; +import { ViewIdentityLdapAuthContent } from "./ViewIdentityLdapAuthContent"; +import { ViewIdentityOciAuthContent } from "./ViewIdentityOciAuthContent"; +import { ViewIdentityOidcAuthContent } from "./ViewIdentityOidcAuthContent"; +import { ViewIdentityTlsCertAuthContent } from "./ViewIdentityTlsCertAuthContent"; +import { ViewIdentityTokenAuthContent } from "./ViewIdentityTokenAuthContent"; +import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthContent"; + +type Props = { + identityId: string; + authMethods: IdentityAuthMethod[]; + onResetAllLockouts: () => void; + activeLockoutAuthMethods: IdentityAuthMethod[]; +}; + +const AuthMethodComponentMap = { + [IdentityAuthMethod.UNIVERSAL_AUTH]: ViewIdentityUniversalAuthContent, + [IdentityAuthMethod.TOKEN_AUTH]: ViewIdentityTokenAuthContent, + [IdentityAuthMethod.TLS_CERT_AUTH]: ViewIdentityTlsCertAuthContent, + [IdentityAuthMethod.KUBERNETES_AUTH]: ViewIdentityKubernetesAuthContent, + [IdentityAuthMethod.LDAP_AUTH]: ViewIdentityLdapAuthContent, + [IdentityAuthMethod.OCI_AUTH]: ViewIdentityOciAuthContent, + [IdentityAuthMethod.OIDC_AUTH]: ViewIdentityOidcAuthContent, + [IdentityAuthMethod.GCP_AUTH]: ViewIdentityGcpAuthContent, + [IdentityAuthMethod.AWS_AUTH]: ViewIdentityAwsAuthContent, + [IdentityAuthMethod.ALICLOUD_AUTH]: ViewIdentityAliCloudAuthContent, + [IdentityAuthMethod.AZURE_AUTH]: ViewIdentityAzureAuthContent, + [IdentityAuthMethod.JWT_AUTH]: ViewIdentityJwtAuthContent +}; + +const EditAuthMethodMap = { + [IdentityAuthMethod.KUBERNETES_AUTH]: IdentityKubernetesAuthForm, + [IdentityAuthMethod.GCP_AUTH]: IdentityGcpAuthForm, + [IdentityAuthMethod.TLS_CERT_AUTH]: IdentityTlsCertAuthForm, + [IdentityAuthMethod.AWS_AUTH]: IdentityAwsAuthForm, + [IdentityAuthMethod.AZURE_AUTH]: IdentityAzureAuthForm, + [IdentityAuthMethod.ALICLOUD_AUTH]: IdentityAliCloudAuthForm, + [IdentityAuthMethod.UNIVERSAL_AUTH]: IdentityUniversalAuthForm, + [IdentityAuthMethod.TOKEN_AUTH]: IdentityTokenAuthForm, + [IdentityAuthMethod.OCI_AUTH]: IdentityOciAuthForm, + [IdentityAuthMethod.OIDC_AUTH]: IdentityOidcAuthForm, + [IdentityAuthMethod.JWT_AUTH]: IdentityJwtAuthForm, + [IdentityAuthMethod.LDAP_AUTH]: IdentityLdapAuthForm +}; + +export const Content = ({ + identityId, + authMethods, + onResetAllLockouts, + activeLockoutAuthMethods +}: Pick< + Props, + "authMethods" | "identityId" | "onResetAllLockouts" | "activeLockoutAuthMethods" +>) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { projectId } = useParams({ + strict: false + }); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "revokeAuthMethod", + "identityAuthMethod", + "upgradePlan" + ] as const); + + const { mutateAsync: revokeUniversalAuth } = useDeleteIdentityUniversalAuth(); + const { mutateAsync: revokeTokenAuth } = useDeleteIdentityTokenAuth(); + const { mutateAsync: revokeKubernetesAuth } = useDeleteIdentityKubernetesAuth(); + const { mutateAsync: revokeGcpAuth } = useDeleteIdentityGcpAuth(); + const { mutateAsync: revokeTlsCertAuth } = useDeleteIdentityTlsCertAuth(); + const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth(); + const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth(); + const { mutateAsync: revokeAliCloudAuth } = useDeleteIdentityAliCloudAuth(); + const { mutateAsync: revokeOciAuth } = useDeleteIdentityOciAuth(); + const { mutateAsync: revokeOidcAuth } = useDeleteIdentityOidcAuth(); + const { mutateAsync: revokeJwtAuth } = useDeleteIdentityJwtAuth(); + const { mutateAsync: revokeLdapAuth } = useDeleteIdentityLdapAuth(); + + const RemoveAuthMethodMap = { + [IdentityAuthMethod.KUBERNETES_AUTH]: revokeKubernetesAuth, + [IdentityAuthMethod.GCP_AUTH]: revokeGcpAuth, + [IdentityAuthMethod.TLS_CERT_AUTH]: revokeTlsCertAuth, + [IdentityAuthMethod.AWS_AUTH]: revokeAwsAuth, + [IdentityAuthMethod.AZURE_AUTH]: revokeAzureAuth, + [IdentityAuthMethod.ALICLOUD_AUTH]: revokeAliCloudAuth, + [IdentityAuthMethod.UNIVERSAL_AUTH]: revokeUniversalAuth, + [IdentityAuthMethod.TOKEN_AUTH]: revokeTokenAuth, + [IdentityAuthMethod.OCI_AUTH]: revokeOciAuth, + [IdentityAuthMethod.OIDC_AUTH]: revokeOidcAuth, + [IdentityAuthMethod.JWT_AUTH]: revokeJwtAuth, + [IdentityAuthMethod.LDAP_AUTH]: revokeLdapAuth + }; + + const handleDeleteAuthMethod = async (authMethod: IdentityAuthMethod) => { + await RemoveAuthMethodMap[authMethod]({ + identityId, + ...(projectId + ? { projectId } + : { + organizationId: orgId + }) + }); + + createNotification({ + text: "Successfully removed auth method", + type: "success" + }); + handlePopUpToggle("revokeAuthMethod", false); + }; + + const EditForm = popUp.identityAuthMethod?.data + ? EditAuthMethodMap[popUp.identityAuthMethod.data as IdentityAuthMethod] + : null; + + return ( + <> + + {authMethods.map((authMethod) => { + const Component = AuthMethodComponentMap[authMethod]; + + return ( + + + {identityAuthToNameMap[authMethod]} + {activeLockoutAuthMethods?.includes(authMethod) && ( + + + + + + )} + + + + + + + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("identityAuthMethod", authMethod); + }} + > + Edit Auth Method + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("revokeAuthMethod", authMethod); + }} + variant="danger" + > + Remove Auth Method + + )} + + + + + + handlePopUpOpen("identityAuthMethod", authMethod)} + onDelete={() => handlePopUpOpen("revokeAuthMethod", authMethod)} + onResetAllLockouts={onResetAllLockouts} + lockedOut={activeLockoutAuthMethods?.includes(authMethod)} + /> + + + ); + })} + + handlePopUpToggle("revokeAuthMethod", isOpen)} + deleteKey="confirm" + buttonText="Remove" + onDeleteApproved={() => + handleDeleteAuthMethod(popUp?.revokeAuthMethod?.data as IdentityAuthMethod) + } + /> + handlePopUpToggle("identityAuthMethod", isOpen)} + > + + {EditForm && ( + + )} + + + handlePopUpToggle("upgradePlan", isOpen)} + text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} + /> + + ); +}; + +export const ViewIdentityAuth = ({ + authMethods, + identityId, + onResetAllLockouts, + activeLockoutAuthMethods +}: Props) => { + return ( + onResetAllLockouts()} + /> + ); +}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAwsAuthContent.tsx similarity index 77% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAwsAuthContent.tsx index dde9f1d2a4..c16a8c3a14 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAwsAuthContent.tsx @@ -2,7 +2,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, Spinner } from "@app/components/v2"; import { useGetIdentityAwsAuth } from "@app/hooks/api"; -import { IdentityAwsAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -10,10 +9,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityAwsAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityAwsAuth(identityId); @@ -31,23 +28,8 @@ export const ViewIdentityAwsAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAzureAuthContent.tsx similarity index 76% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAzureAuthContent.tsx index 82e4c69e00..5f25f1a508 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityAzureAuthContent.tsx @@ -2,7 +2,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, Spinner } from "@app/components/v2"; import { useGetIdentityAzureAuth } from "@app/hooks/api"; -import { IdentityAzureAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -10,10 +9,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityAzureAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityAzureAuth(identityId); @@ -31,23 +28,8 @@ export const ViewIdentityAzureAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityContentWrapper.tsx similarity index 100% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityContentWrapper.tsx diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityGcpAuthContent.tsx similarity index 80% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityGcpAuthContent.tsx index 296b4d7645..5f24ec54c5 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityGcpAuthContent.tsx @@ -2,7 +2,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, Spinner } from "@app/components/v2"; import { useGetIdentityGcpAuth } from "@app/hooks/api"; -import { IdentityGcpAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -10,10 +9,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityGcpAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityGcpAuth(identityId); @@ -31,23 +28,8 @@ export const ViewIdentityGcpAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityJwtAuthContent.tsx similarity index 86% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityJwtAuthContent.tsx index 7fb81d2e1a..a372491eb8 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityJwtAuthContent.tsx @@ -5,18 +5,15 @@ import { EmptyState, Spinner, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { useGetIdentityJwtAuth } from "@app/hooks/api"; import { IdentityJwtConfigurationType } from "@app/hooks/api/identities/enums"; -import { IdentityJwtAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm"; -import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper"; +import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityContentWrapper"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; export const ViewIdentityJwtAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityJwtAuth(identityId); @@ -34,23 +31,8 @@ export const ViewIdentityJwtAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityKubernetesAuthContent.tsx similarity index 86% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityKubernetesAuthContent.tsx index 02b0e0c88c..e6992ca2e7 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityKubernetesAuthContent.tsx @@ -6,7 +6,6 @@ import { EyeIcon } from "lucide-react"; import { EmptyState, Spinner, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { gatewaysQueryKeys, useGetIdentityKubernetesAuth } from "@app/hooks/api"; -import { IdentityKubernetesAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -14,10 +13,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityKubernetesAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data: gateways } = useQuery(gatewaysQueryKeys.list()); @@ -44,23 +41,8 @@ export const ViewIdentityKubernetesAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityLdapAuthContent.tsx similarity index 83% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityLdapAuthContent.tsx index b6db96c41e..c835fd97cf 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityLdapAuthContent.tsx @@ -4,8 +4,7 @@ import { EyeIcon } from "lucide-react"; import { EmptyState, Spinner, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { useClearIdentityLdapAuthLockouts, useGetIdentityLdapAuth } from "@app/hooks/api"; -import { IdentityLdapAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm"; -import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper"; +import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityContentWrapper"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { LockoutFields } from "./IdentityAuthLockoutFields"; @@ -13,10 +12,8 @@ import { ViewAuthMethodProps } from "./types"; export const ViewIdentityLdapAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, onDelete, - popUp, + onEdit, lockedOut, onResetAllLockouts }: ViewAuthMethodProps) => { @@ -37,23 +34,8 @@ export const ViewIdentityLdapAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityOciAuthContent.tsx similarity index 75% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityOciAuthContent.tsx index c577c01e0a..7b7e582582 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityOciAuthContent.tsx @@ -2,7 +2,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, Spinner } from "@app/components/v2"; import { useGetIdentityOciAuth } from "@app/hooks/api"; -import { IdentityOciAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -10,10 +9,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityOciAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityOciAuth(identityId); @@ -31,23 +28,8 @@ export const ViewIdentityOciAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityOidcAuthContent.tsx similarity index 84% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityOidcAuthContent.tsx index dc7e5b9858..27b487a3fe 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityOidcAuthContent.tsx @@ -4,18 +4,15 @@ import { EyeIcon } from "lucide-react"; import { EmptyState, Spinner, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { useGetIdentityOidcAuth } from "@app/hooks/api"; -import { IdentityOidcAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm"; -import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper"; +import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityContentWrapper"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; export const ViewIdentityOidcAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityOidcAuth(identityId); @@ -33,23 +30,8 @@ export const ViewIdentityOidcAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityTlsCertAuthContent.tsx similarity index 78% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityTlsCertAuthContent.tsx index 03c4989a6f..6ffefbeb6f 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityTlsCertAuthContent.tsx @@ -4,7 +4,6 @@ import { EyeIcon } from "lucide-react"; import { EmptyState, Spinner, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { useGetIdentityTlsCertAuth } from "@app/hooks/api"; -import { IdentityTlsCertAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { ViewAuthMethodProps } from "./types"; @@ -12,10 +11,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityTlsCertAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, - onDelete, - popUp + onEdit, + onDelete }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityTlsCertAuth(identityId); @@ -36,23 +33,8 @@ export const ViewIdentityTlsCertAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityTokenAuthContent.tsx similarity index 75% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityTokenAuthContent.tsx index d42f9d2ca8..b86a4d9ad2 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityTokenAuthContent.tsx @@ -2,7 +2,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, Spinner } from "@app/components/v2"; import { useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth } from "@app/hooks/api"; -import { IdentityTokenAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { IdentityTokenAuthTokensTable } from "./IdentityTokenAuthTokensTable"; @@ -11,10 +10,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityTokenAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, onDelete, - popUp + onEdit }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityTokenAuth(identityId); const { data: tokens = [], isPending: clientSecretsPending } = @@ -34,23 +31,8 @@ export const ViewIdentityTokenAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityUniversalAuthContent.tsx similarity index 86% rename from frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx rename to frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityUniversalAuthContent.tsx index e9840a28cb..86a2e4fb9a 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/ViewIdentityUniversalAuthContent.tsx @@ -8,7 +8,6 @@ import { useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "@app/hooks/api"; -import { IdentityUniversalAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { LockoutFields } from "./IdentityAuthLockoutFields"; @@ -18,10 +17,8 @@ import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; export const ViewIdentityUniversalAuthContent = ({ identityId, - handlePopUpToggle, - handlePopUpOpen, onDelete, - popUp, + onEdit, lockedOut, onResetAllLockouts }: ViewAuthMethodProps) => { @@ -51,23 +48,8 @@ export const ViewIdentityUniversalAuthContent = ({ ); } - if (popUp.identityAuthMethod.isOpen) { - return ( - - ); - } - return ( - handlePopUpOpen("identityAuthMethod")} - onDelete={onDelete} - identityId={identityId} - > + {Number(data.accessTokenPeriod) > 0 ? ( {data.accessTokenPeriod} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/index.ts b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/index.ts new file mode 100644 index 0000000000..951c840876 --- /dev/null +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/index.ts @@ -0,0 +1 @@ +export * from "./ViewIdentityAuth"; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/types/index.ts b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/types/index.ts new file mode 100644 index 0000000000..e4b0154109 --- /dev/null +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuth/types/index.ts @@ -0,0 +1,7 @@ +export type ViewAuthMethodProps = { + identityId: string; + onDelete: () => void; + onEdit: () => void; + lockedOut: boolean; + onResetAllLockouts: () => void; +}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthFieldDisplay.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthFieldDisplay.tsx deleted file mode 100644 index eb81bbcef6..0000000000 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthFieldDisplay.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ReactNode } from "react"; - -type Props = { - label: string; - children: ReactNode; - className?: string; -}; - -export const IdentityAuthFieldDisplay = ({ label, children, className }: Props) => { - return ( -
- {label} - {children ? ( -

{children}

- ) : ( -

Not set

- )} -
- ); -}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx deleted file mode 100644 index 5c12ddd4cb..0000000000 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { useParams } from "@tanstack/react-router"; - -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { createNotification } from "@app/components/notifications"; -import { DeleteActionModal, Modal, ModalContent } from "@app/components/v2"; -import { useOrganization } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { - IdentityAuthMethod, - identityAuthToNameMap, - useDeleteIdentityAliCloudAuth, - useDeleteIdentityAwsAuth, - useDeleteIdentityAzureAuth, - useDeleteIdentityGcpAuth, - useDeleteIdentityJwtAuth, - useDeleteIdentityKubernetesAuth, - useDeleteIdentityLdapAuth, - useDeleteIdentityOciAuth, - useDeleteIdentityOidcAuth, - useDeleteIdentityTlsCertAuth, - useDeleteIdentityTokenAuth, - useDeleteIdentityUniversalAuth -} from "@app/hooks/api"; - -import { ViewAuthMethodProps } from "./types"; -import { ViewIdentityAliCloudAuthContent } from "./ViewIdentityAliCloudAuthContent"; -import { ViewIdentityAwsAuthContent } from "./ViewIdentityAwsAuthContent"; -import { ViewIdentityAzureAuthContent } from "./ViewIdentityAzureAuthContent"; -import { ViewIdentityGcpAuthContent } from "./ViewIdentityGcpAuthContent"; -import { ViewIdentityJwtAuthContent } from "./ViewIdentityJwtAuthContent"; -import { ViewIdentityKubernetesAuthContent } from "./ViewIdentityKubernetesAuthContent"; -import { ViewIdentityLdapAuthContent } from "./ViewIdentityLdapAuthContent"; -import { ViewIdentityOciAuthContent } from "./ViewIdentityOciAuthContent"; -import { ViewIdentityOidcAuthContent } from "./ViewIdentityOidcAuthContent"; -import { ViewIdentityTlsCertAuthContent } from "./ViewIdentityTlsCertAuthContent"; -import { ViewIdentityTokenAuthContent } from "./ViewIdentityTokenAuthContent"; -import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthContent"; - -type Props = { - identityId: string; - authMethod?: IdentityAuthMethod; - lockedOut: boolean; - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - onDeleteAuthMethod: () => void; - onResetAllLockouts: () => void; -}; - -type TRevokeOptions = { - identityId: string; -} & ({ projectId: string } | { organizationId: string }); - -export const Content = ({ - identityId, - authMethod, - lockedOut, - onDeleteAuthMethod, - onResetAllLockouts -}: Pick< - Props, - "authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod" | "onResetAllLockouts" ->) => { - const { currentOrg } = useOrganization(); - const orgId = currentOrg?.id || ""; - const { projectId } = useParams({ - strict: false - }); - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ - "revokeAuthMethod", - "upgradePlan", - "identityAuthMethod" - ] as const); - - const { mutateAsync: revokeUniversalAuth } = useDeleteIdentityUniversalAuth(); - const { mutateAsync: revokeTokenAuth } = useDeleteIdentityTokenAuth(); - const { mutateAsync: revokeKubernetesAuth } = useDeleteIdentityKubernetesAuth(); - const { mutateAsync: revokeGcpAuth } = useDeleteIdentityGcpAuth(); - const { mutateAsync: revokeTlsCertAuth } = useDeleteIdentityTlsCertAuth(); - const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth(); - const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth(); - const { mutateAsync: revokeAliCloudAuth } = useDeleteIdentityAliCloudAuth(); - const { mutateAsync: revokeOciAuth } = useDeleteIdentityOciAuth(); - const { mutateAsync: revokeOidcAuth } = useDeleteIdentityOidcAuth(); - const { mutateAsync: revokeJwtAuth } = useDeleteIdentityJwtAuth(); - const { mutateAsync: revokeLdapAuth } = useDeleteIdentityLdapAuth(); - - let Component: (props: ViewAuthMethodProps) => JSX.Element; - let revokeMethod: (revokeOptions: TRevokeOptions) => Promise; - - const handleDelete = () => handlePopUpOpen("revokeAuthMethod"); - - switch (authMethod) { - case IdentityAuthMethod.UNIVERSAL_AUTH: - revokeMethod = revokeUniversalAuth; - Component = ViewIdentityUniversalAuthContent; - break; - case IdentityAuthMethod.TOKEN_AUTH: - revokeMethod = revokeTokenAuth; - Component = ViewIdentityTokenAuthContent; - break; - case IdentityAuthMethod.KUBERNETES_AUTH: - revokeMethod = revokeKubernetesAuth; - Component = ViewIdentityKubernetesAuthContent; - break; - case IdentityAuthMethod.GCP_AUTH: - revokeMethod = revokeGcpAuth; - Component = ViewIdentityGcpAuthContent; - break; - case IdentityAuthMethod.TLS_CERT_AUTH: - revokeMethod = revokeTlsCertAuth; - Component = ViewIdentityTlsCertAuthContent; - break; - case IdentityAuthMethod.AWS_AUTH: - revokeMethod = revokeAwsAuth; - Component = ViewIdentityAwsAuthContent; - break; - case IdentityAuthMethod.AZURE_AUTH: - revokeMethod = revokeAzureAuth; - Component = ViewIdentityAzureAuthContent; - break; - case IdentityAuthMethod.OCI_AUTH: - revokeMethod = revokeOciAuth; - Component = ViewIdentityOciAuthContent; - break; - case IdentityAuthMethod.ALICLOUD_AUTH: - revokeMethod = revokeAliCloudAuth; - Component = ViewIdentityAliCloudAuthContent; - break; - case IdentityAuthMethod.OIDC_AUTH: - revokeMethod = revokeOidcAuth; - Component = ViewIdentityOidcAuthContent; - break; - case IdentityAuthMethod.JWT_AUTH: - revokeMethod = revokeJwtAuth; - Component = ViewIdentityJwtAuthContent; - break; - case IdentityAuthMethod.LDAP_AUTH: - revokeMethod = revokeLdapAuth; - Component = ViewIdentityLdapAuthContent; - break; - default: - throw new Error(`Unhandled Auth Method: ${authMethod}`); - } - - const handleDeleteAuthMethod = async () => { - await revokeMethod({ - identityId, - ...(projectId - ? { projectId } - : { - organizationId: orgId - }) - }); - - createNotification({ - text: "Successfully removed auth method", - type: "success" - }); - handlePopUpToggle("revokeAuthMethod", false); - onDeleteAuthMethod(); - }; - - return ( - <> - - handlePopUpToggle("revokeAuthMethod", isOpen)} - deleteKey="confirm" - buttonText="Remove" - onDeleteApproved={handleDeleteAuthMethod} - /> - handlePopUpToggle("upgradePlan", isOpen)} - text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} - /> - - ); -}; - -export const ViewIdentityAuthModal = ({ - isOpen, - onOpenChange, - authMethod, - identityId, - lockedOut, - onResetAllLockouts -}: Omit) => { - if (!identityId || !authMethod) return null; - - return ( - - - onOpenChange(false)} - onResetAllLockouts={() => onResetAllLockouts()} - /> - - - ); -}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/index.ts b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/index.ts deleted file mode 100644 index 2da1344a84..0000000000 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ViewIdentityAuthModal"; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts deleted file mode 100644 index da31a233cc..0000000000 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { UsePopUpState } from "@app/hooks/usePopUp"; - -export type ViewAuthMethodProps = { - identityId: string; - onDelete: () => void; - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan", "identityAuthMethod"]>) => void; - handlePopUpToggle: ( - popUpName: keyof UsePopUpState<["identityAuthMethod"]>, - state?: boolean - ) => void; - popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>; - lockedOut: boolean; - onResetAllLockouts: () => void; -}; diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index c0117b758d..300d30119d 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -33,7 +33,7 @@ export const ProjectsPage = () => { const hasChildRoute = matches.some( (match) => match.pathname.includes("/secret-management/") || - match.pathname.includes("/cert-management/") || + match.pathname.includes("/cert-manager/") || match.pathname.includes("/kms/") || match.pathname.includes("/pam/") || match.pathname.includes("/ssh/") || diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx index e1fc2712d8..de1d584d1c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx @@ -13,7 +13,7 @@ export const OrgProductSelectSection = () => { enabled: true }, pkiProductEnabled: { - name: "Certificate Management", + name: "Certificate Manager", enabled: true }, kmsProductEnabled: { diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index 17224536da..d6ea4c4faa 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -49,7 +49,7 @@ const PROJECT_TYPE_MENU_ITEMS = [ value: ProjectType.SecretManager }, { - label: "Certificates Management", + label: "Certificate Manager", value: ProjectType.CertificateManager }, { diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx index 65ba38e78e..31a846d4c5 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx @@ -119,8 +119,8 @@ const PageContent = () => { )}
-
-
+
+
diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx index 17bfae8a0f..74ff4b4e73 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx @@ -75,7 +75,7 @@ export const ApprovalStepsSection = ({ request }: Props) => { return (
-

Approval Workflow

+

Approval Sequence

{request.steps.map((step, index) => ( diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx index 1fb216ebd5..20dc6b1e11 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx @@ -127,7 +127,7 @@ export const RequestActionsSection = ({ request }: Props) => { Review - +