diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index cbe1bed7ec..c89ec9ae4a 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({ client, passReqToCallback: true, usePKCE: supportsPKCE, - params: supportsPKCE ? { code_challenge_method: "S256" } : undefined + params: { prompt: "login", ...(supportsPKCE ? { code_challenge_method: "S256" } : {}) } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any (_req: any, tokenSet: TokenSet, cb: any) => { 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 c217211a02..6e6292c670 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -47,7 +47,6 @@ 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"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; @@ -61,7 +60,6 @@ import { AcmeMalformedError, AcmeOrderNotReadyError, AcmeServerInternalError, - AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns"; @@ -129,7 +127,6 @@ type TPkiAcmeServiceFactoryDep = { TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" | "createCipherPairWithDataKey" >; - licenseService: Pick; certificateV3Service: Pick; certificateTemplateV2Service: Pick; certificateRequestService: Pick; @@ -152,7 +149,6 @@ export const pkiAcmeServiceFactory = ({ acmeChallengeDAL, keyStore, kmsService, - licenseService, certificateV3Service, certificateTemplateV2Service, certificateRequestService, @@ -169,12 +165,6 @@ export const pkiAcmeServiceFactory = ({ if (profile.enrollmentType !== EnrollmentType.ACME) { throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" }); } - const orgLicensePlan = await licenseService.getPlan(profile.project!.orgId); - if (!orgLicensePlan.pkiAcme) { - throw new AcmeUnauthorizedError({ - message: "Failed to validate ACME profile: Plan restriction. Upgrade plan to continue" - }); - } return profile; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f1661a3540..733fe68f5a 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1227,7 +1227,6 @@ export const registerRoutes = async ( certificateAuthorityDAL, externalCertificateAuthorityDAL, permissionService, - licenseService, kmsService, projectDAL }); @@ -2340,7 +2339,6 @@ export const registerRoutes = async ( acmeChallengeDAL, keyStore, kmsService, - licenseService, certificateV3Service, certificateTemplateV2Service, certificateRequestService, diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index d10188e6f8..184dd1980b 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -5,9 +5,8 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "../auth/auth-type"; import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; @@ -175,10 +174,6 @@ describe("CertificateProfileService", () => { }) } as unknown as Pick; - const mockLicenseService = { - getPlan: vi.fn() - } as unknown as Pick; - const mockKmsService = { encryptWithKmsKey: vi .fn() @@ -258,7 +253,6 @@ describe("CertificateProfileService", () => { certificateAuthorityDAL: mockCertificateAuthorityDAL, externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL, permissionService: mockPermissionService, - licenseService: mockLicenseService, kmsService: mockKmsService, projectDAL: mockProjectDAL }); @@ -287,9 +281,6 @@ describe("CertificateProfileService", () => { id: "project-123", orgId: "org-123" }); - (mockLicenseService.getPlan as any).mockResolvedValue({ - pkiAcme: true - }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); @@ -423,30 +414,6 @@ describe("CertificateProfileService", () => { expect(result).toEqual(sampleProfile); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); }); - - it("should throw BadRequestError when plan does not support ACME", async () => { - (mockLicenseService.getPlan as any).mockResolvedValue({ - pkiAcme: false - }); - - await expect( - service.createProfile({ - ...mockActor, - projectId: "project-123", - data: { - ...validProfileData, - enrollmentType: EnrollmentType.ACME, - acmeConfig: {}, - apiConfig: undefined, - estConfig: undefined - } - }) - ).rejects.toThrowError( - new BadRequestError({ - message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" - }) - ); - }); }); describe("updateProfile", () => { @@ -756,9 +723,6 @@ describe("CertificateProfileService", () => { id: "project-123", orgId: "org-123" }); - (mockLicenseService.getPlan as any).mockResolvedValue({ - pkiAcme: true - }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 4cb60a5229..bda2367663 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -2,7 +2,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; -import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCertificateActions, @@ -233,7 +232,6 @@ type TCertificateProfileServiceFactoryDep = { certificateAuthorityDAL: Pick; externalCertificateAuthorityDAL: Pick; permissionService: Pick; - licenseService: Pick; kmsService: Pick; projectDAL: Pick; }; @@ -271,7 +269,6 @@ export const certificateProfileServiceFactory = ({ certificateAuthorityDAL, externalCertificateAuthorityDAL, permissionService, - licenseService, kmsService, projectDAL }: TCertificateProfileServiceFactoryDep) => { @@ -309,12 +306,6 @@ export const certificateProfileServiceFactory = ({ if (!project) { throw new NotFoundError({ message: "Project not found" }); } - const plan = await licenseService.getPlan(project.orgId); - if (!plan.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { - throw new BadRequestError({ - message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" - }); - } // Validate that certificate template exists and belongs to the same project if (data.certificateTemplateId) { diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-error-handlers.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-error-handlers.ts new file mode 100644 index 0000000000..76fe179b9f --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-error-handlers.ts @@ -0,0 +1,200 @@ +import { AxiosError } from "axios"; + +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; + +type ErrorContext = { + host?: string; + port?: number; + kubernetesHost?: string; +}; + +export enum KubernetesAuthErrorContext { + KubernetesHost = "kubernetes-host", + KubernetesApiServer = "kubernetes-api-server", + GatewayProxy = "gateway-proxy" +} + +type ErrorContextConfig = { + serviceName: string; + errorNamePrefix: string; + defaultErrorName: string; + default401Message: string; + default403Message: string; +}; + +const COMMON_KUBERNETES_MESSAGES = { + default401Message: + "Token reviewer JWT is invalid or expired. Please verify the token reviewer JWT is correct and has not expired.", + default403Message: + "Token reviewer JWT does not have permission to perform TokenReviews. Ensure the service account has the 'system:auth-delegator' ClusterRole binding." +} as const; + +const ERROR_CONTEXT_CONFIGS: Record = { + [KubernetesAuthErrorContext.KubernetesHost]: { + serviceName: "Kubernetes host", + errorNamePrefix: "KubernetesHost", + defaultErrorName: "KubernetesHostConnectionError", + ...COMMON_KUBERNETES_MESSAGES + }, + [KubernetesAuthErrorContext.KubernetesApiServer]: { + serviceName: "Kubernetes API server", + errorNamePrefix: "Kubernetes", + defaultErrorName: "KubernetesConnectionError", + ...COMMON_KUBERNETES_MESSAGES + }, + [KubernetesAuthErrorContext.GatewayProxy]: { + serviceName: "gateway proxy", + errorNamePrefix: "Gateway", + defaultErrorName: "GatewayConnectionError", + default401Message: + "Gateway service account is not authorized to perform TokenReviews. Verify the gateway has the 'system:auth-delegator' ClusterRole binding.", + default403Message: + "Gateway service account does not have permission to perform TokenReviews. Ensure it has the 'system:auth-delegator' ClusterRole binding." + } +}; + +/** + * Handles Axios network-level errors (connection refused, DNS failures, timeouts, etc.) + * Returns a BadRequestError with a descriptive message, or null if the error is not a network error. + */ +export const handleAxiosNetworkError = ( + err: AxiosError, + context: ErrorContext, + contextType: KubernetesAuthErrorContext +): BadRequestError | null => { + const { host, kubernetesHost } = context; + const target = host || kubernetesHost || "server"; + const { errorNamePrefix: prefix, serviceName } = ERROR_CONTEXT_CONFIGS[contextType]; + + if (err.code === "ECONNREFUSED") { + return new BadRequestError({ + name: `${prefix}ConnectionRefused`, + message: `Failed to connect to ${serviceName} at ${target}: Connection refused. Verify the host URL and ensure the ${serviceName.toLowerCase()} is accessible.` + }); + } + + if (err.code === "ENOTFOUND") { + return new BadRequestError({ + name: `${prefix}HostNotFound`, + message: `Failed to resolve ${serviceName} hostname: ${target}. Verify the hostname is correct.` + }); + } + + if (err.code === "ETIMEDOUT" || err.code === "ECONNABORTED") { + return new BadRequestError({ + name: `${prefix}ConnectionTimeout`, + message: `Connection to ${serviceName} at ${target} timed out. Verify network connectivity and firewall rules.` + }); + } + + if (err.code === "DEPTH_ZERO_SELF_SIGNED_CERT" || err.code === "SELF_SIGNED_CERT_IN_CHAIN") { + return new BadRequestError({ + name: `${prefix}CertificateError`, + message: `SSL certificate verification failed for ${serviceName} at ${target}. The server uses a self-signed certificate. Please provide the CA certificate in the configuration.` + }); + } + + if (err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || err.code === "CERT_HAS_EXPIRED") { + return new BadRequestError({ + name: `${prefix}CertificateError`, + message: `SSL certificate verification failed for ${serviceName} at ${target}. Verify the CA certificate is correct and the server certificate is valid.` + }); + } + + return null; +}; + +/** + * Handles Axios HTTP response errors (401, 403, etc.) + * Returns an appropriate error, or null if not an HTTP error. + */ +export const handleAxiosHttpError = ( + err: AxiosError, + contextType: KubernetesAuthErrorContext +): UnauthorizedError | BadRequestError | null => { + if (!err.response) { + return null; + } + + let message = (err.response.data as { message?: string })?.message; + const statusCode = err.response.status; + const { errorNamePrefix: prefix, default401Message, default403Message } = ERROR_CONTEXT_CONFIGS[contextType]; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (statusCode === 401) { + return new UnauthorizedError({ + message: message || default401Message, + name: `${prefix}TokenReviewerUnauthorized` + }); + } + + if (statusCode === 403) { + return new UnauthorizedError({ + message: message || default403Message, + name: `${prefix}TokenReviewerForbidden` + }); + } + + if (message) { + return new BadRequestError({ + message, + name: `${prefix}TokenReviewRequestError` + }); + } + + // Generic HTTP error + return new BadRequestError({ + name: `${prefix}TokenReviewRequestError`, + message: `${prefix} returned HTTP ${statusCode}: ${err.response.statusText || "Unknown error"}` + }); +}; + +/** + * Handles generic Axios errors (fallback when network/HTTP handlers don't match) + */ +export const handleAxiosGenericError = ( + err: AxiosError, + context: ErrorContext, + contextType: KubernetesAuthErrorContext +): BadRequestError => { + const { host, kubernetesHost } = context; + const target = host || kubernetesHost || "server"; + const { defaultErrorName, serviceName } = ERROR_CONTEXT_CONFIGS[contextType]; + + return new BadRequestError({ + name: defaultErrorName, + message: `Failed to communicate with ${serviceName} at ${target}: ${err.message}` + }); +}; + +/** + * Checks if an error is a known error type that should be re-thrown as-is. + */ +export const isKnownError = (err: unknown): boolean => { + return err instanceof UnauthorizedError || err instanceof BadRequestError || err instanceof NotFoundError; +}; + +/** + * Comprehensive Axios error handler that processes network, HTTP, and generic errors. + * Returns an error to throw. + */ +export const handleAxiosError = ( + err: AxiosError, + context: ErrorContext, + contextType: KubernetesAuthErrorContext +): BadRequestError | UnauthorizedError => { + const networkError = handleAxiosNetworkError(err, context, contextType); + if (networkError) { + return networkError; + } + + const httpError = handleAxiosHttpError(err, contextType); + if (httpError) { + return httpError; + } + + return handleAxiosGenericError(err, context, contextType); +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts index 194e69b3c3..e840471968 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts @@ -1,3 +1,5 @@ +import { BadRequestError } from "@app/lib/errors"; + /** * Extracts the K8s service account name and namespace * from the username in this format: system:serviceaccount:default:infisical-auth @@ -11,5 +13,8 @@ export const extractK8sUsername = (username: string) => { name: parts[3] }; } - throw new Error("Invalid username format"); + throw new BadRequestError({ + name: "KubernetesUsernameParseError", + message: `Invalid Kubernetes service account username format: "${username}". Expected format: system:serviceaccount::` + }); }; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 5d4021fef6..f619436e7f 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import axios, { AxiosError } from "axios"; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -28,6 +28,7 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, @@ -52,6 +53,7 @@ import { TMembershipIdentityDALFactory } from "../membership-identity/membership import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; +import { handleAxiosError, isKnownError, KubernetesAuthErrorContext } from "./identity-kubernetes-auth-error-handlers"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; import { IdentityKubernetesAuthTokenReviewMode, @@ -62,6 +64,11 @@ import { TRevokeKubernetesAuthDTO, TUpdateKubernetesAuthDTO } from "./identity-kubernetes-auth-types"; +import { + GatewayRequestExecutor, + validateKubernetesHostConnectivity, + validateTokenReviewerPermissions +} from "./identity-kubernetes-auth-validators"; type TIdentityKubernetesAuthServiceFactoryDep = { identityDAL: Pick; @@ -185,6 +192,70 @@ export const identityKubernetesAuthServiceFactory = ({ return callbackResult; }; + /** + * Supports two modes: + * - Gateway reviewer mode: Gateway uses its own service account (no kubernetesHost option) + * - API mode through gateway: Gateway proxies TCP connection to kubernetesHost (kubernetesHost option provided) + */ + const $createGatewayValidationRequest = ( + gatewayId: string, + options?: { kubernetesHost?: string; caCert?: string } + ): GatewayRequestExecutor => { + const useGatewayServiceAccount = !options?.kubernetesHost; + + let targetHost: string | undefined; + let targetPort: number | undefined; + if (options?.kubernetesHost) { + const parsedUrl = new URL(options.kubernetesHost); + targetHost = parsedUrl.hostname; + targetPort = parsedUrl.port ? Number(parsedUrl.port) : 443; + } + + return async (method: "get" | "post", url: string, body?: object, headers?: Record) => { + let response: AxiosResponse | undefined; + + await $gatewayProxyWrapper( + { + gatewayId, + reviewTokenThroughGateway: useGatewayServiceAccount, + targetHost, + targetPort, + caCert: options?.caCert + }, + async (host: string, port: number, httpsAgent?: https.Agent) => { + const config: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + ...(useGatewayServiceAccount + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : headers) + }, + timeout: 10000, + signal: AbortSignal.timeout(10000), + validateStatus: () => true, + ...(httpsAgent ? { httpsAgent } : {}) + }; + + if (method === "get") { + response = await request.get(`${host}:${port}${url}`, config); + } else { + response = await request.post(`${host}:${port}${url}`, body, config); + } + return response.data; + } + ); + + if (!response) { + throw new BadRequestError({ + name: "GatewayConnectionError", + message: "Failed to get response from gateway" + }); + } + + return response; + }; + }; + const login = async ({ identityId, jwt: serviceAccountJwt, subOrganizationName }: TLoginKubernetesAuthDTO) => { const appCfg = getConfig(); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); @@ -276,28 +347,37 @@ export const identityKubernetesAuthServiceFactory = ({ .catch((err) => { const tokenReviewerJwtSnippet = `${tokenReviewerJwt?.substring?.(0, 10) || ""}...${tokenReviewerJwt?.substring?.(tokenReviewerJwt.length - 10) || ""}`; const serviceAccountJwtSnippet = `${serviceAccountJwt?.substring?.(0, 10) || ""}...${serviceAccountJwt?.substring?.(serviceAccountJwt.length - 10) || ""}`; + if (err instanceof AxiosError) { logger.error( - { response: err.response, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + { + response: err.response, + host, + port, + tokenReviewerJwtSnippet, + serviceAccountJwtSnippet, + code: err.code + }, "tokenReviewCallbackRaw: Kubernetes token review request error (request error)" ); - if (err.response) { - const { message } = err?.response?.data as unknown as { message?: string }; - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } - } else { - logger.error( - { error: err as Error, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, - "tokenReviewCallbackRaw: Kubernetes token review request error (non-request error)" - ); + throw handleAxiosError(err, { host, port }, KubernetesAuthErrorContext.KubernetesApiServer); } - throw err; + + logger.error( + { error: err as Error, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + "tokenReviewCallbackRaw: Kubernetes token review request error (non-request error)" + ); + + if (isKnownError(err)) { + throw err; + } + + throw new BadRequestError({ + name: "KubernetesTokenReviewError", + message: (err as Error).message || "Unexpected error during token review", + error: err + }); }); return res.data; @@ -335,23 +415,24 @@ export const identityKubernetesAuthServiceFactory = ({ } ) .catch((err) => { + logger.error( + { error: err as Error, host, port }, + "tokenReviewCallbackThroughGateway: Kubernetes token review request error" + ); + if (err instanceof AxiosError) { - if (err.response) { - let { message } = err?.response?.data as unknown as { message?: string }; - - if (!message && typeof err.response.data === "string") { - message = err.response.data; - } - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } + throw handleAxiosError(err, { host, port }, KubernetesAuthErrorContext.GatewayProxy); } - throw err; + + if (isKnownError(err)) { + throw err; + } + + throw new BadRequestError({ + name: "GatewayTokenReviewError", + message: (err as Error).message || "Unexpected error during gateway token review", + error: err + }); }); return res.data; @@ -571,7 +652,18 @@ export const identityKubernetesAuthServiceFactory = ({ "user_agent.original": requestContext.get("userAgent") }); } - throw error; + + if (isKnownError(error)) { + throw error; + } + + logger.error({ error, identityId }, "Unexpected error during Kubernetes auth login"); + + throw new BadRequestError({ + name: "KubernetesAuthLoginError", + message: (error as Error).message || "An unexpected error occurred during Kubernetes authentication", + error + }); } }; @@ -691,6 +783,39 @@ export const identityKubernetesAuthServiceFactory = ({ OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway ); + + if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const gatewayExecutor = $createGatewayValidationRequest(gatewayId); + logger.info({ gatewayId }, "Validating gateway connectivity to Kubernetes"); + await validateKubernetesHostConnectivity({ gatewayExecutor }); + await validateTokenReviewerPermissions({ gatewayExecutor }); + } else if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && kubernetesHost) { + // API mode through gateway: gateway proxies requests with user's JWT + const gatewayExecutor = $createGatewayValidationRequest(gatewayId, { + kubernetesHost, + caCert: caCert || undefined + }); + logger.info({ gatewayId, kubernetesHost }, "Validating Kubernetes connectivity through gateway"); + await validateKubernetesHostConnectivity({ gatewayExecutor }); + if (tokenReviewerJwt) { + await validateTokenReviewerPermissions({ gatewayExecutor, tokenReviewerJwt }); + } + } + } else if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && kubernetesHost) { + logger.info({ kubernetesHost }, "Validating Kubernetes host connectivity for new auth method"); + await validateKubernetesHostConnectivity({ + kubernetesHost, + caCert: caCert || undefined + }); + + if (tokenReviewerJwt) { + logger.info({ kubernetesHost }, "Validating token reviewer JWT permissions for new auth method"); + await validateTokenReviewerPermissions({ + kubernetesHost, + tokenReviewerJwt, + caCert: caCert || undefined + }); + } } const { encryptor } = await kmsService.createCipherPairWithDataKey({ @@ -850,6 +975,75 @@ export const identityKubernetesAuthServiceFactory = ({ const gatewayIdValue = isGatewayV1 ? gatewayId : null; const gatewayV2IdValue = isGatewayV1 ? null : gatewayId; + const effectiveTokenReviewMode = tokenReviewMode ?? identityKubernetesAuth.tokenReviewMode; + const effectiveKubernetesHost = + kubernetesHost !== undefined ? kubernetesHost : identityKubernetesAuth.kubernetesHost; + const effectiveGatewayId = + gatewayId !== undefined ? gatewayId : (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId); + + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.scopeOrgId + }); + + let effectiveCaCert: string | undefined; + if (caCert !== undefined) { + effectiveCaCert = caCert; + } else if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { + effectiveCaCert = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate + }).toString(); + } else { + effectiveCaCert = undefined; + } + + if (effectiveGatewayId) { + if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const gatewayExecutor = $createGatewayValidationRequest(effectiveGatewayId); + logger.info( + { gatewayId: effectiveGatewayId }, + "Validating gateway connectivity to Kubernetes for auth method update" + ); + + await validateKubernetesHostConnectivity({ gatewayExecutor }); + await validateTokenReviewerPermissions({ gatewayExecutor }); + } else if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && effectiveKubernetesHost) { + const gatewayExecutor = $createGatewayValidationRequest(effectiveGatewayId, { + kubernetesHost: effectiveKubernetesHost, + caCert: effectiveCaCert + }); + logger.info( + { gatewayId: effectiveGatewayId, kubernetesHost: effectiveKubernetesHost }, + "Validating Kubernetes connectivity through gateway for auth method update" + ); + + await validateKubernetesHostConnectivity({ gatewayExecutor }); + if (tokenReviewerJwt) { + await validateTokenReviewerPermissions({ gatewayExecutor, tokenReviewerJwt }); + } + } + } else if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + if (kubernetesHost) { + logger.info({ kubernetesHost }, "Validating Kubernetes host connectivity for auth method update"); + await validateKubernetesHostConnectivity({ + kubernetesHost, + caCert: effectiveCaCert + }); + } + + if (tokenReviewerJwt && effectiveKubernetesHost) { + logger.info( + { kubernetesHost: effectiveKubernetesHost }, + "Validating token reviewer JWT permissions for auth method update" + ); + await validateTokenReviewerPermissions({ + kubernetesHost: effectiveKubernetesHost, + tokenReviewerJwt, + caCert: effectiveCaCert + }); + } + } + const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, tokenReviewMode, @@ -866,11 +1060,6 @@ export const identityKubernetesAuthServiceFactory = ({ : undefined }; - const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identityMembershipOrg.scopeOrgId - }); - if (caCert !== undefined) { updateQuery.encryptedKubernetesCaCertificate = encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob; } diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-validators.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-validators.ts new file mode 100644 index 0000000000..902c5b1b3b --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-validators.ts @@ -0,0 +1,229 @@ +import { AxiosError, AxiosResponse } from "axios"; +import https from "https"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { handleAxiosError, KubernetesAuthErrorContext } from "./identity-kubernetes-auth-error-handlers"; + +export type GatewayRequestExecutor = ( + method: "get" | "post", + url: string, + body?: object, + headers?: Record +) => Promise>; + +/** + * Validates that the Kubernetes host is reachable by making a simple HTTPS request. + * This does not validate credentials, just network connectivity. + * + * Supports two modes: + * - API mode: Direct call to Kubernetes API (default) + * - Gateway mode: Call through gateway using gatewayExecutor + */ +export const validateKubernetesHostConnectivity = async ({ + kubernetesHost, + caCert, + gatewayExecutor +}: { + kubernetesHost?: string; + caCert?: string; + gatewayExecutor?: GatewayRequestExecutor; +}): Promise => { + const isGatewayMode = Boolean(gatewayExecutor); + const logContext = isGatewayMode ? { context: "gateway" } : { kubernetesHost }; + const errorContext = isGatewayMode + ? KubernetesAuthErrorContext.GatewayProxy + : KubernetesAuthErrorContext.KubernetesHost; + + try { + let response: AxiosResponse; + + if (gatewayExecutor) { + response = await gatewayExecutor("get", "/version"); + } else { + if (!kubernetesHost) { + throw new BadRequestError({ + name: "KubernetesHostConnectionError", + message: "Kubernetes host is required for API mode validation" + }); + } + + const httpsAgent = new https.Agent({ + ca: caCert || undefined, + rejectUnauthorized: Boolean(caCert) + }); + + await blockLocalAndPrivateIpAddresses(kubernetesHost); + + response = await request.get(`${kubernetesHost}/version`, { + httpsAgent, + timeout: 10000, + signal: AbortSignal.timeout(10000), + validateStatus: () => true + }); + } + + if (response.status >= 500) { + throw new BadRequestError({ + name: isGatewayMode ? "GatewayConnectionError" : "KubernetesHostConnectionError", + message: `Kubernetes API returned server error: ${response.status} - ${response.statusText}` + }); + } + + logger.info(logContext, "Kubernetes host connectivity validated successfully"); + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + + const error = err as Error; + logger.error({ error, ...logContext }, "Failed to connect to Kubernetes host"); + + if (err instanceof AxiosError) { + throw handleAxiosError(err, { kubernetesHost }, errorContext); + } + + throw new BadRequestError({ + name: isGatewayMode ? "GatewayConnectionError" : "KubernetesHostConnectionError", + message: isGatewayMode + ? `Failed to connect to Kubernetes through gateway: ${error.message}` + : `Failed to connect to Kubernetes host at ${kubernetesHost}: ${error.message}`, + error + }); + } +}; + +/** + * Validates that the token reviewer has the necessary permissions to perform token reviews. + * This is done by making a TokenReview request with a fake token to verify RBAC permissions + * without authenticating a real workload. + * + * Supports three modes: + * - API mode: Direct call to Kubernetes API using tokenReviewerJwt + * - Gateway mode (gateway reviewer): Gateway uses its own service account + * - Gateway mode (API reviewer): Gateway proxies request with user-provided tokenReviewerJwt + */ +export const validateTokenReviewerPermissions = async ({ + kubernetesHost, + tokenReviewerJwt, + caCert, + gatewayExecutor +}: { + kubernetesHost?: string; + tokenReviewerJwt?: string; + caCert?: string; + gatewayExecutor?: GatewayRequestExecutor; +}): Promise => { + const isGatewayMode = Boolean(gatewayExecutor); + const isGatewayWithUserJwt = isGatewayMode && Boolean(tokenReviewerJwt); + const logContext = isGatewayMode ? { context: "gateway" } : { kubernetesHost }; + const errorContext = isGatewayMode + ? KubernetesAuthErrorContext.GatewayProxy + : KubernetesAuthErrorContext.KubernetesApiServer; + + let errorNamePrefix = "TokenReviewer"; + if (isGatewayMode && !isGatewayWithUserJwt) { + errorNamePrefix = "GatewayTokenReview"; + } + + try { + const testToken = "test-token-for-permission-validation"; + const tokenReviewBody = { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: testToken + } + }; + + let response: AxiosResponse; + + if (gatewayExecutor) { + // Gateway mode: optionally pass user JWT if provided (API mode through gateway) + const headers = tokenReviewerJwt ? { Authorization: `Bearer ${tokenReviewerJwt}` } : undefined; + response = await gatewayExecutor("post", "/apis/authentication.k8s.io/v1/tokenreviews", tokenReviewBody, headers); + } else { + // Direct API mode: call Kubernetes API directly + if (!kubernetesHost || !tokenReviewerJwt) { + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: "Kubernetes host and token reviewer JWT are required for API mode validation" + }); + } + + const httpsAgent = new https.Agent({ + ca: caCert || undefined, + rejectUnauthorized: Boolean(caCert) + }); + + await blockLocalAndPrivateIpAddresses(kubernetesHost); + + response = await request.post(`${kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, tokenReviewBody, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + httpsAgent, + timeout: 10000, + signal: AbortSignal.timeout(10000), + validateStatus: () => true + }); + } + + if (response.status === 401) { + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: + isGatewayMode && !isGatewayWithUserJwt + ? "Gateway service account is not authorized. Verify the gateway is deployed correctly and has a valid service account." + : "The token reviewer JWT is invalid or expired. Please provide a valid service account token with TokenReview permissions." + }); + } + + if (response.status === 403) { + const errorMessage = + (response.data as { message?: string })?.message || + (isGatewayMode && !isGatewayWithUserJwt + ? "Gateway service account does not have permission to perform TokenReviews." + : "The token reviewer JWT does not have permission to perform TokenReviews."); + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: `${errorMessage}. Ensure the service account has the 'system:auth-delegator' ClusterRole binding.` + }); + } + + if (response.status >= 200 && response.status < 300) { + const data = response.data as { status?: { authenticated?: boolean; error?: string } }; + logger.info( + { ...logContext, authenticated: data?.status?.authenticated }, + "Token reviewer permission validation successful" + ); + return; + } + + const errorMessage = (response.data as { message?: string })?.message || response.statusText; + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: `Unexpected response from Kubernetes API: ${response.status} - ${errorMessage}` + }); + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + + const error = err as Error; + logger.error({ error, ...logContext }, "Failed to validate token reviewer permissions"); + + if (err instanceof AxiosError) { + throw handleAxiosError(err, { kubernetesHost }, errorContext); + } + + throw new BadRequestError({ + name: `${errorNamePrefix}PermissionError`, + message: `Failed to validate token reviewer permissions: ${error.message}` + }); + } +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b75b6df221..aa8bd8ebd1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -7,7 +7,6 @@ services: restart: "always" ports: - 8080:80 - - 8443:443 volumes: - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro depends_on: @@ -201,4 +200,4 @@ volumes: ldap_config: grafana_storage: softhsm_tokens: - driver: local \ No newline at end of file + driver: local diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 3eed68f05a..59b4b1b39c 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -5,10 +5,8 @@ description: "Learn how to use Infisical for secret management in Ansible." You can find the Infisical Ansible collection on [Ansible Galaxy](https://galaxy.ansible.com/ui/repo/published/infisical/vault/). - This Ansible Infisical collection includes a variety of Ansible content to help automate the management of Infisical services. This collection is maintained by the Infisical team. - ## Ansible version compatibility Tested with the Ansible Core >= 2.12.0 versions, and the current development version of Ansible. Ansible Core versions prior to 2.12.0 have not been tested. @@ -20,33 +18,65 @@ Requires Python 3.7 or greater. ## Installing this collection You can install the Infisical collection with the Ansible Galaxy CLI: -```bash -$ ansible-galaxy collection install infisical.vault +```bash +ansible-galaxy collection install infisical.vault ``` The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: - + ```bash -$ pip install infisicalsdk +pip install infisicalsdk ``` ## Using this collection -You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword: +You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword. -### Authentication +## Authentication The Infisical Ansible Collection supports [Universal Auth](/documentation/platform/identities/universal-auth), [OIDC Auth](/documentation/platform/identities/oidc-auth/general), and [Token Auth](/documentation/platform/identities/token-auth) for authenticating against Infisical. +### Login Module (Recommended) + +The recommended approach is to use the `login` module to authenticate once and reuse the credentials across multiple tasks. This reduces authentication overhead and makes playbooks cleaner. Alternatively, you can still pass credentials directly to each plugin/module if preferred. + +```yaml +- name: Login to Infisical + infisical.vault.login: + url: "https://app.infisical.com" + auth_method: universal_auth + universal_auth_client_id: "{{ client_id }}" + universal_auth_client_secret: "{{ client_secret }}" + register: infisical_login + +- name: Read secrets using cached login + infisical.vault.read_secrets: + login_data: "{{ infisical_login.login_data }}" + project_id: "{{ project_id }}" + env_slug: "dev" + path: "/" + as_dict: true + register: secrets + +- name: Use the secrets + debug: + msg: "Database URL is {{ secrets.secrets.DATABASE_URL }}" +``` - Using Universal Auth for authentication is the most straight-forward way to get started with using the Ansible collection. + Using Universal Auth for authentication is the most straight-forward way to get started with using the Ansible collection. To use Universal Auth, you need to provide the Client ID and Client Secret of your Infisical Machine Identity. ```yaml - lookup('infisical.vault.read_secrets', auth_method="universal-auth", universal_auth_client_id='', universal_auth_client_secret='' ...rest) + - name: Login with Universal Auth + infisical.vault.login: + url: "https://app.infisical.com" + auth_method: universal_auth + universal_auth_client_id: "" + universal_auth_client_secret: "" + register: infisical_login ``` You can also provide the `auth_method`, `universal_auth_client_id`, and `universal_auth_client_secret` parameters through environment variables: @@ -66,8 +96,15 @@ The Infisical Ansible Collection supports [Universal Auth](/documentation/platfo ```yaml - lookup('infisical.vault.read_secrets', auth_method="oidc-auth", identity_id='', jwt='' ...rest) + - name: Login with OIDC Auth + infisical.vault.login: + url: "https://app.infisical.com" + auth_method: oidc_auth + identity_id: "" + jwt: "" + register: infisical_login ``` + You can also provide the `auth_method`, `identity_id`, and `jwt` parameters through environment variables: | Parameter Name | Environment Variable Name | @@ -86,7 +123,12 @@ The Infisical Ansible Collection supports [Universal Auth](/documentation/platfo ```yaml - lookup('infisical.vault.read_secrets', auth_method="token_auth", token='' ...rest) + - name: Login with Token Auth + infisical.vault.login: + url: "https://app.infisical.com" + auth_method: token_auth + token: "" + register: infisical_login ``` You can also provide the `auth_method` and `token` parameters through environment variables: @@ -99,35 +141,288 @@ The Infisical Ansible Collection supports [Universal Auth](/documentation/platfo -### Examples +## Available Plugins and Modules + +### Lookup Plugins +- `infisical.vault.login` - Authenticate and return reusable login data +- `infisical.vault.read_secrets` - Read secrets from Infisical + +### Modules + +**Authentication:** +- `infisical.vault.login` - Authenticate and return reusable login data + +**Static Secrets:** +- `infisical.vault.read_secrets` - Read secrets from Infisical +- `infisical.vault.create_secret` - Create a new secret +- `infisical.vault.update_secret` - Update an existing secret +- `infisical.vault.delete_secret` - Delete a secret + +**Dynamic Secrets:** +- `infisical.vault.create_dynamic_secret` - Create a dynamic secret configuration +- `infisical.vault.get_dynamic_secret` - Get a dynamic secret by name +- `infisical.vault.update_dynamic_secret` - Update a dynamic secret +- `infisical.vault.delete_dynamic_secret` - Delete a dynamic secret + +**Dynamic Secret Leases:** +- `infisical.vault.create_dynamic_secret_lease` - Create a lease (generates credentials) +- `infisical.vault.get_dynamic_secret_lease` - Get lease details +- `infisical.vault.renew_dynamic_secret_lease` - Renew an existing lease +- `infisical.vault.delete_dynamic_secret_lease` - Delete/revoke a lease + +## Examples + +### Reading Secrets ```yaml --- +- name: Read secrets from Infisical + hosts: localhost + gather_facts: false + + tasks: + - name: Login to Infisical + infisical.vault.login: + url: "https://app.infisical.com" + auth_method: universal_auth + universal_auth_client_id: "{{ lookup('env', 'INFISICAL_CLIENT_ID') }}" + universal_auth_client_secret: "{{ lookup('env', 'INFISICAL_CLIENT_SECRET') }}" + register: infisical_login + + - name: Read all secrets as dictionary + infisical.vault.read_secrets: + login_data: "{{ infisical_login.login_data }}" + project_id: "your-project-id" + env_slug: "dev" + path: "/" + as_dict: true + register: secrets + + - name: Use the secrets + debug: + msg: "Database: {{ secrets.secrets.DATABASE_URL }}" +``` + +#### Reading secrets with full metadata + +Use the `raw` option to retrieve complete secret metadata including version, creation time, tags, and more: + +```yaml +- name: Read all secrets with full metadata + infisical.vault.read_secrets: + login_data: "{{ infisical_login.login_data }}" + project_id: "your-project-id" + env_slug: "dev" + path: "/" + raw: true + register: raw_secrets + # Returns: [{"id": "...", "secretKey": "HOST", "secretValue": "google.com", "version": 1, "type": "shared", ...}, ...] + +- name: Read all secrets with full metadata as dict + infisical.vault.read_secrets: + login_data: "{{ infisical_login.login_data }}" + project_id: "your-project-id" + env_slug: "dev" + path: "/" + raw: true + as_dict: true + register: raw_secrets_dict + # Returns: {"HOST": {"id": "...", "secretKey": "HOST", "secretValue": "google.com", "version": 1, ...}, ...} +``` + +#### Using the Lookup Plugin + +The `read_secrets` lookup plugin allows for inline secret retrieval. Unlike modules that run on target hosts, lookup plugins run on the Ansible controller during playbook parsing. This is useful for retrieving secrets to use in variable definitions: + +```yaml vars: - read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://app.infisical.com') }}" # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] - - read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://app.infisical.com') }}" # { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1 - - read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://app.infisical.com') }}" # { "key": "HOST", "value": "google.com" } ``` +### Managing Secrets (CRUD) -## Troubleshoot +Create, update, and delete secrets programmatically: + +```yaml +- name: Create a secret + infisical.vault.create_secret: + login_data: "{{ infisical_login.login_data }}" + project_id: "{{ project_id }}" + env_slug: "dev" + path: "/" + secret_name: "API_KEY" + secret_value: "my-api-key" + secret_comment: "API key for external service" + register: created_secret + +- name: Update a secret + infisical.vault.update_secret: + login_data: "{{ infisical_login.login_data }}" + project_id: "{{ project_id }}" + env_slug: "dev" + path: "/" + secret_name: "API_KEY" + secret_value: "new-api-key" + register: updated_secret + +- name: Rename a secret + infisical.vault.update_secret: + login_data: "{{ infisical_login.login_data }}" + project_id: "{{ project_id }}" + env_slug: "dev" + path: "/" + secret_name: "OLD_SECRET_NAME" + new_secret_name: "NEW_SECRET_NAME" + register: renamed_secret + +- name: Delete a secret + infisical.vault.delete_secret: + login_data: "{{ infisical_login.login_data }}" + project_id: "{{ project_id }}" + env_slug: "dev" + path: "/" + secret_name: "API_KEY" + register: deleted_secret +``` + +### Dynamic Secrets + +Dynamic secrets generate credentials on-demand with automatic expiration. They support various providers like SQL databases, AWS, GCP, Azure, and more. For the full list of supported providers and their configuration options, see the [Dynamic Secrets documentation](/documentation/platform/dynamic-secrets/overview). + +#### Creating a Dynamic Secret + +```yaml +# Create a dynamic secret for PostgreSQL +- name: Create a PostgreSQL dynamic secret + infisical.vault.create_dynamic_secret: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + name: "postgres-dev" + provider_type: "sql-database" + inputs: + client: "postgres" + host: "localhost" + port: 5432 + database: "mydb" + username: "admin" + password: "admin-password" + creationStatement: "CREATE USER \"{{username}}\" WITH PASSWORD '{{password}}';" + revocationStatement: "DROP USER \"{{username}}\";" + default_ttl: "1h" + max_ttl: "24h" + register: dynamic_secret +``` + + + For the full list of supported provider types and their input configurations, see the [Dynamic Secrets API Documentation](https://infisical.com/docs/api-reference/endpoints/dynamic-secrets/create#body-provider). + + +#### Getting and Using Dynamic Secret Credentials + +To use a dynamic secret, you need to create a **lease** which generates the actual credentials: + +```yaml +# Create a lease to get database credentials +- name: Get database credentials + infisical.vault.create_dynamic_secret_lease: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + dynamic_secret_name: "postgres-dev" + ttl: "30m" + register: lease + +# Use the generated credentials +- name: Connect to database + community.postgresql.postgresql_query: + login_host: localhost + login_user: "{{ lease.data.DB_USERNAME }}" + login_password: "{{ lease.data.DB_PASSWORD }}" + db: mydb + query: "SELECT version();" +``` + +#### Managing Leases + +```yaml +# Get lease details +- name: Get lease information + infisical.vault.get_dynamic_secret_lease: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + lease_id: "{{ lease.lease.id }}" + register: lease_details + +# Renew a lease before it expires +- name: Renew a lease for 2 more hours + infisical.vault.renew_dynamic_secret_lease: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + lease_id: "{{ lease.lease.id }}" + ttl: "2h" + register: renewed_lease + +# Revoke the credentials when done +- name: Delete the lease + infisical.vault.delete_dynamic_secret_lease: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + lease_id: "{{ lease.lease.id }}" +``` + +#### Updating and Deleting Dynamic Secrets + +```yaml +# Update a dynamic secret's TTL +- name: Update dynamic secret TTL + infisical.vault.update_dynamic_secret: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + name: "postgres-dev" + default_ttl: "2h" + max_ttl: "48h" + register: updated_secret + +# Delete a dynamic secret (also revokes all active leases) +- name: Delete a dynamic secret + infisical.vault.delete_dynamic_secret: + login_data: "{{ infisical_login.login_data }}" + project_slug: "my-project" + env_slug: "dev" + path: "/" + name: "postgres-dev" + register: deleted_secret +``` + +## Troubleshoot - If you get this Python error when you running the lookup plugin:- + If you get this Python error when you running the lookup plugin: ``` objc[72832]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called. We cannot safely call it or ignore it in the fork() child process. Crashing instead. Set a breakpoint on objc_initializeAfterForkError to debug. Fatal Python error: Aborted ``` - You will need to add this to your shell environment or ansible wrapper script:- + You will need to add this to your shell environment or ansible wrapper script: ``` export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index d69f191737..a545a38942 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -71,8 +71,9 @@ The SDK methods are organized into the following high-level categories: 1. `auth`: Handles authentication methods. 2. `secrets`: Manages CRUD operations for secrets. -3. `kms`: Perform cryptographic operations with Infisical KMS. -4. `folders`: Manages folder-related operations. +3. `dynamic_secrets`: Manages dynamic secrets and leases. +4. `kms`: Perform cryptographic operations with Infisical KMS. +5. `folders`: Manages folder-related operations. ### `auth` @@ -283,6 +284,257 @@ deleted_secret = client.secrets.delete_secret_by_name( **Returns:** - `BaseSecret`: The response after deleting the secret. +### `dynamic_secrets` + +This sub-class handles operations related to dynamic secrets. Dynamic secrets generate credentials on-demand with automatic expiration. For more information, see the [Dynamic Secrets documentation](/documentation/platform/dynamic-secrets/overview). + +#### Create Dynamic Secret + +```python +from infisical_sdk import DynamicSecretProviders + +dynamic_secret = client.dynamic_secrets.create( + name="postgres-dev", + provider_type=DynamicSecretProviders.SQL_DATABASE, + inputs={ + "client": "postgres", + "host": "localhost", + "port": 5432, + "database": "mydb", + "username": "admin", + "password": "admin-password", + "creationStatement": "CREATE USER \"{{username}}\" WITH PASSWORD '{{password}}';", + "revocationStatement": "DROP USER \"{{username}}\";" + }, + default_ttl="1h", + max_ttl="24h", + project_slug="my-project", + environment_slug="dev", + path="/", + metadata=[{"key": "team", "value": "backend"}] # Optional +) +``` + +**Parameters:** +- `name` (str): The name of the dynamic secret. +- `provider_type` (Union[DynamicSecretProviders, str]): The provider type (e.g., `DynamicSecretProviders.SQL_DATABASE`). +- `inputs` (Dict[str, Any]): The provider-specific configuration inputs. See the [Dynamic Secrets API documentation](/api-reference/endpoints/dynamic-secrets/create) for provider-specific inputs. +- `default_ttl` (str): The default time to live for leases (e.g., "1h", "30m"). +- `max_ttl` (str): The maximum time to live for leases (e.g., "24h"). +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to create the dynamic secret. +- `path` (str, optional): The path where the dynamic secret will be created. Defaults to "/". +- `metadata` (List[Dict[str, str]], optional): Optional list of metadata items with 'key' and 'value'. + +**Returns:** +- `DynamicSecret`: The created dynamic secret. + +#### Supported Provider Types + +The `DynamicSecretProviders` enum includes the following providers: + +| Provider | Enum Value | +| -------- | ---------- | +| AWS ElastiCache | `DynamicSecretProviders.AWS_ELASTICACHE` | +| AWS IAM | `DynamicSecretProviders.AWS_IAM` | +| Azure Entra ID | `DynamicSecretProviders.AZURE_ENTRA_ID` | +| Azure SQL Database | `DynamicSecretProviders.AZURE_SQL_DATABASE` | +| Cassandra | `DynamicSecretProviders.CASSANDRA` | +| Couchbase | `DynamicSecretProviders.COUCHBASE` | +| Elasticsearch | `DynamicSecretProviders.ELASTICSEARCH` | +| GCP IAM | `DynamicSecretProviders.GCP_IAM` | +| GitHub | `DynamicSecretProviders.GITHUB` | +| Kubernetes | `DynamicSecretProviders.KUBERNETES` | +| LDAP | `DynamicSecretProviders.LDAP` | +| MongoDB Atlas | `DynamicSecretProviders.MONGO_ATLAS` | +| MongoDB | `DynamicSecretProviders.MONGODB` | +| RabbitMQ | `DynamicSecretProviders.RABBITMQ` | +| Redis | `DynamicSecretProviders.REDIS` | +| SAP ASE | `DynamicSecretProviders.SAP_ASE` | +| SAP HANA | `DynamicSecretProviders.SAP_HANA` | +| Snowflake | `DynamicSecretProviders.SNOWFLAKE` | +| SQL Database | `DynamicSecretProviders.SQL_DATABASE` | +| TOTP | `DynamicSecretProviders.TOTP` | +| Vertica | `DynamicSecretProviders.VERTICA` | + +#### Get Dynamic Secret by Name + +```python +dynamic_secret = client.dynamic_secrets.get_by_name( + name="postgres-dev", + project_slug="my-project", + environment_slug="dev", + path="/" +) +``` + +**Parameters:** +- `name` (str): The name of the dynamic secret. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to retrieve the dynamic secret. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". + +**Returns:** +- `DynamicSecret`: The dynamic secret. + +#### Update Dynamic Secret + +```python +updated_secret = client.dynamic_secrets.update( + name="postgres-dev", + project_slug="my-project", + environment_slug="dev", + path="/", + default_ttl="2h", # Optional + max_ttl="48h", # Optional + new_name="postgres-dev-updated", # Optional + inputs={"port": 5433}, # Optional - updated provider inputs + metadata=[{"key": "team", "value": "platform"}], # Optional + username_template="dev-{{identity.name}}" # Optional +) +``` + +**Parameters:** +- `name` (str): The current name of the dynamic secret. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to update the dynamic secret. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". +- `default_ttl` (str, optional): The new default time to live for leases. +- `max_ttl` (str, optional): The new maximum time to live for leases. +- `new_name` (str, optional): The new name for the dynamic secret. +- `inputs` (Dict[str, Any], optional): Updated provider-specific configuration inputs. +- `metadata` (List[Dict[str, str]], optional): Updated metadata list with 'key' and 'value' items. +- `username_template` (str, optional): The new username template for the dynamic secret. + +**Returns:** +- `DynamicSecret`: The updated dynamic secret. + +#### Delete Dynamic Secret + +```python +deleted_secret = client.dynamic_secrets.delete( + name="postgres-dev", + project_slug="my-project", + environment_slug="dev", + path="/", + is_forced=False # Optional +) +``` + +**Parameters:** +- `name` (str): The name of the dynamic secret to delete. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to delete the dynamic secret. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". +- `is_forced` (bool, optional): A boolean flag to delete the dynamic secret from Infisical without trying to remove it from the external provider. Defaults to `False`. + +**Returns:** +- `DynamicSecret`: The deleted dynamic secret. + +### `dynamic_secrets.leases` + +This sub-class handles operations related to dynamic secret leases. A lease represents a set of generated credentials with a specific TTL. + +#### Create Lease + +Create a lease to generate credentials from a dynamic secret: + +```python +lease_response = client.dynamic_secrets.leases.create( + dynamic_secret_name="postgres-dev", + project_slug="my-project", + environment_slug="dev", + path="/", + ttl="30m" # Optional +) + +# Access the generated credentials +username = lease_response.data["DB_USERNAME"] +password = lease_response.data["DB_PASSWORD"] +lease_id = lease_response.lease.id +``` + +**Parameters:** +- `dynamic_secret_name` (str): The name of the dynamic secret to create a lease for. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to create the lease. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". +- `ttl` (str, optional): The time to live for the lease (e.g., "1h", "30m"). + +**Returns:** +- `CreateLeaseResponse`: Response containing: + - `lease`: The lease object with ID, expiration, and metadata. + - `dynamicSecret`: The associated dynamic secret. + - `data`: The generated credentials. The structure depends on the dynamic secret provider. + +#### Get Lease + +```python +lease = client.dynamic_secrets.leases.get( + lease_id="", + project_slug="my-project", + environment_slug="dev", + path="/" +) +``` + +**Parameters:** +- `lease_id` (str): The ID of the lease to retrieve. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to retrieve the lease. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". + +**Returns:** +- `DynamicSecretLease`: The lease with dynamicSecret included. + +#### Renew Lease + +Extend the TTL of an existing lease: + +```python +renewed_lease = client.dynamic_secrets.leases.renew( + lease_id="", + project_slug="my-project", + environment_slug="dev", + path="/", + ttl="2h" # Optional - new TTL +) +``` + +**Parameters:** +- `lease_id` (str): The ID of the lease to renew. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to renew the lease. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". +- `ttl` (str, optional): The new time to live for the lease. + +**Returns:** +- `DynamicSecretLease`: The renewed lease. + +#### Revoke Lease + +Revoke a lease and its associated credentials: + +```python +revoked_lease = client.dynamic_secrets.leases.revoke( + lease_id="", + project_slug="my-project", + environment_slug="dev", + path="/", + is_forced=False # Optional +) +``` + +**Parameters:** +- `lease_id` (str): The ID of the lease to revoke. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to revoke the lease. +- `path` (str, optional): The path to the dynamic secret. Defaults to "/". +- `is_forced` (bool, optional): A boolean flag to revoke the lease from Infisical without trying to remove it from the external provider. Defaults to `False`. + +**Returns:** +- `DynamicSecretLease`: The revoked lease. + ### `kms` This sub-class handles KMS related operations: diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 22727bdf47..0c2a671761 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -30,10 +30,10 @@ const SCOPE_BADGE: Record, { icon: LucideIcon; class export const PageHeader = ({ title, description, children, className, scope }: Props) => (
-
+

); diff --git a/frontend/src/components/v3/generic/Alert/Alert.tsx b/frontend/src/components/v3/generic/Alert/Alert.tsx index 2e8190df15..af5fd7a498 100644 --- a/frontend/src/components/v3/generic/Alert/Alert.tsx +++ b/frontend/src/components/v3/generic/Alert/Alert.tsx @@ -6,7 +6,7 @@ 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", + "relative w-full border px-4 rounded-md 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: { diff --git a/frontend/src/components/v3/generic/Button/Button.tsx b/frontend/src/components/v3/generic/Button/Button.tsx index 4c065b1d0d..f9cb10e63b 100644 --- a/frontend/src/components/v3/generic/Button/Button.tsx +++ b/frontend/src/components/v3/generic/Button/Button.tsx @@ -8,7 +8,7 @@ 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", + "inline-flex items-center rounded-md 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" @@ -36,10 +36,10 @@ const buttonVariants = cva( "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" + xs: "h-7 px-2 text-xs rounded-sm [&>svg]:size-3 gap-1.5", + sm: "h-8 px-2.5 text-sm [&>svg]:size-3 gap-1.5", + md: "h-9 px-3 text-sm [&>svg]:size-3.5 gap-1.5", + lg: "h-10 px-3 text-sm [&>svg]:size-3.5 gap-1.5" }, isPending: { true: "text-transparent" diff --git a/frontend/src/components/v3/generic/Card/Card.tsx b/frontend/src/components/v3/generic/Card/Card.tsx index f389305263..f6dc2ce775 100644 --- a/frontend/src/components/v3/generic/Card/Card.tsx +++ b/frontend/src/components/v3/generic/Card/Card.tsx @@ -9,7 +9,7 @@ function UnstableCard({ className, ...props }: React.ComponentProps<"div">) {
) { } function DetailValue({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ); } function DetailGroup({ className, ...props }: React.ComponentProps<"div">) { diff --git a/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx b/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx index 5a93c3fb62..ed43ed3af5 100644 --- a/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx +++ b/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx @@ -104,7 +104,7 @@ function UnstableDropdownMenuCheckboxItem({ > - + {children} diff --git a/frontend/src/components/v3/generic/Empty/Empty.tsx b/frontend/src/components/v3/generic/Empty/Empty.tsx index 3b86bcc88a..56ffc54749 100644 --- a/frontend/src/components/v3/generic/Empty/Empty.tsx +++ b/frontend/src/components/v3/generic/Empty/Empty.tsx @@ -5,7 +5,7 @@ function UnstableEmpty({ className, ...props }: React.ComponentProps<"div">) {
svg]:shrink-0", + "inline-flex items-center active:scale-[0.99] justify-center border cursor-pointer whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&>svg]:shrink-0", "focus-visible:ring-ring outline-0 focus-visible:ring-2" ), { @@ -22,22 +22,22 @@ const iconButtonVariants = cva( 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", + "border-project/65 bg-project/20 text-foreground hover:bg-project/30 hover:border-kms", + org: "border-org/65 bg-org/20 text-foreground hover:bg-org/30 hover:border-org", "sub-org": - "border-sub-org/75 bg-sub-org/40 text-foreground hover:bg-sub-org/50 hover:border-namespace", + "border-sub-org/65 bg-sub-org/20 text-foreground hover:bg-sub-org/30 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", + "border-success/65 bg-success/20 text-foreground hover:bg-success/30 hover:border-success", + info: "border-info/65 bg-info/20 text-foreground hover:bg-info/30 hover:border-info", warning: - "border-warning/75 bg-warning/40 text-foreground hover:bg-warning/50 hover:border-warning", + "border-warning/65 bg-warning/20 text-foreground hover:bg-warning/30 hover:border-warning", danger: - "border-danger/75 bg-danger/40 text-foreground hover:bg-danger/50 hover:border-danger" + "border-danger/65 bg-danger/20 text-foreground hover:bg-danger/30 hover:border-danger" }, size: { - xs: "h-7 w-7 [&>svg]:size-3.5 [&>svg]:stroke-[1.75]", + xs: "h-7 w-7 [&>svg]:size-4 rounded-sm [&>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]", + md: "h-9 w-9 [&>svg]:size-4 [&>svg]:stroke-[1.5]", lg: "h-10 w-10 [&>svg]:size-7 [&>svg]:stroke-[1.5]" }, isPending: { diff --git a/frontend/src/components/v3/generic/Input/Input.tsx b/frontend/src/components/v3/generic/Input/Input.tsx new file mode 100644 index 0000000000..a55cb1f508 --- /dev/null +++ b/frontend/src/components/v3/generic/Input/Input.tsx @@ -0,0 +1,20 @@ +import { cn } from "../../utils"; + +function UnstableInput({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { UnstableInput }; diff --git a/frontend/src/components/v3/generic/Input/index.ts b/frontend/src/components/v3/generic/Input/index.ts new file mode 100644 index 0000000000..be66d7661c --- /dev/null +++ b/frontend/src/components/v3/generic/Input/index.ts @@ -0,0 +1 @@ +export * from "./Input"; diff --git a/frontend/src/components/v3/generic/Pagination/Pagination.tsx b/frontend/src/components/v3/generic/Pagination/Pagination.tsx new file mode 100644 index 0000000000..4bda9c4c31 --- /dev/null +++ b/frontend/src/components/v3/generic/Pagination/Pagination.tsx @@ -0,0 +1,121 @@ +import { ReactElement } from "react"; +import { + ChevronDownIcon, + ChevronFirstIcon, + ChevronLastIcon, + ChevronLeftIcon, + ChevronRightIcon +} from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +import { + UnstableDropdownMenu, + UnstableDropdownMenuCheckboxItem, + UnstableDropdownMenuContent, + UnstableDropdownMenuTrigger +} from "../Dropdown"; +import { UnstableIconButton } from "../IconButton"; + +type UnstablePaginationProps = { + count: number; + page: number; + perPage?: number; + onChangePage: (pageNumber: number) => void; + onChangePerPage: (newRows: number) => void; + className?: string; + perPageList?: number[]; + startAdornment?: ReactElement; +}; + +const UnstablePagination = ({ + count, + page = 1, + perPage = 20, + onChangePage, + onChangePerPage, + perPageList = [10, 20, 50, 100], + className, + startAdornment +}: UnstablePaginationProps) => { + const prevPageNumber = Math.max(1, page - 1); + const canGoPrev = page > 1; + + const upperLimit = Math.ceil(count / perPage); + const nextPageNumber = Math.min(upperLimit, page + 1); + const canGoNext = page + 1 <= upperLimit; + const canGoFirst = page > 1; + const canGoLast = page < upperLimit; + + return ( +
+ {startAdornment} +
+
+ {(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count} +
+ + + + + + + + {perPageList.map((perPageOption) => ( + { + const totalPages = Math.ceil(count / perPageOption); + + if (page > totalPages) { + onChangePage(totalPages); + } + + onChangePerPage(perPageOption); + }} + > + {perPageOption} rows per page + + ))} + + +
+
+ onChangePage(1)} + isDisabled={!canGoFirst} + > + + + onChangePage(prevPageNumber)} + isDisabled={!canGoPrev} + > + + + onChangePage(nextPageNumber)} + isDisabled={!canGoNext} + > + + + onChangePage(upperLimit)} + isDisabled={!canGoLast} + > + + +
+
+ ); +}; + +export { UnstablePagination, type UnstablePaginationProps }; diff --git a/frontend/src/components/v3/generic/Pagination/index.ts b/frontend/src/components/v3/generic/Pagination/index.ts new file mode 100644 index 0000000000..8404fe460b --- /dev/null +++ b/frontend/src/components/v3/generic/Pagination/index.ts @@ -0,0 +1 @@ +export * from "./Pagination"; diff --git a/frontend/src/components/v3/generic/Table/Table.tsx b/frontend/src/components/v3/generic/Table/Table.tsx index ffd25b766c..4f006a5bc1 100644 --- a/frontend/src/components/v3/generic/Table/Table.tsx +++ b/frontend/src/components/v3/generic/Table/Table.tsx @@ -8,7 +8,7 @@ function UnstableTable({ className, ...props }: React.ComponentProps<"table">) { return (
) data-slot="table-head" className={cn( "h-[30px] border-x-0 border-t-0 border-b border-border px-3 text-left align-middle text-xs whitespace-nowrap text-accent [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + "has-[>svg]:cursor-pointer [&>svg]:ml-1 [&>svg]:inline-block [&>svg]:size-4", className )} {...props} @@ -71,12 +72,17 @@ function UnstableTableHead({ className, ...props }: React.ComponentProps<"th">) ); } -function UnstableTableCell({ className, ...props }: React.ComponentProps<"td">) { +function UnstableTableCell({ + className, + isTruncatable, + ...props +}: React.ComponentProps<"td"> & { isTruncatable?: boolean }) { return (
[role=checkbox]]:translate-y-[2px]", + isTruncatable && "max-w-0 truncate", className )} {...props} diff --git a/frontend/src/components/v3/generic/index.ts b/frontend/src/components/v3/generic/index.ts index 43ddf08b0a..eb51dc7363 100644 --- a/frontend/src/components/v3/generic/index.ts +++ b/frontend/src/components/v3/generic/index.ts @@ -8,6 +8,8 @@ export * from "./Detail"; export * from "./Dropdown"; export * from "./Empty"; export * from "./IconButton"; +export * from "./Input"; export * from "./PageLoader"; +export * from "./Pagination"; export * from "./Separator"; export * from "./Table"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 1045f809ab..9fb7286df2 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -30,7 +30,7 @@ export const CertificateProfilesTab = () => { const [selectedProfile, setSelectedProfile] = useState( null ); - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + const { popUp, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); const deleteProfile = useDeleteCertificateProfile(); @@ -105,11 +105,7 @@ export const CertificateProfilesTab = () => { onDeleteProfile={handleDeleteProfile} /> - setIsCreateModalOpen(false)} - handlePopUpOpen={handlePopUpOpen} - /> + setIsCreateModalOpen(false)} /> handlePopUpToggle("upgradePlan", isOpen)} @@ -125,7 +121,6 @@ export const CertificateProfilesTab = () => { setIsEditModalOpen(false); setSelectedProfile(null); }} - handlePopUpOpen={handlePopUpOpen} profile={selectedProfile} mode="edit" /> 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 43de8c6b23..39dbcc4587 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -19,7 +19,7 @@ import { TextArea, Tooltip } from "@app/components/v2"; -import { useProject, useSubscription } from "@app/context"; +import { useProject } from "@app/context"; import { CaType } from "@app/hooks/api/ca/enums"; import { useGetAzureAdcsTemplates, useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { @@ -32,7 +32,6 @@ import { useUpdateCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; -import { UsePopUpState } from "@app/hooks/usePopUp"; const createSchema = z .object({ @@ -339,25 +338,12 @@ export type FormData = z.infer; interface Props { isOpen: boolean; onClose: () => void; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["upgradePlan"]>, - data?: { - isEnterpriseFeature?: boolean; - } - ) => void; profile?: TCertificateProfileWithDetails; mode?: "create" | "edit"; } -export const CreateProfileModal = ({ - isOpen, - onClose, - handlePopUpOpen, - profile, - mode = "create" -}: Props) => { +export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => { const { currentProject } = useProject(); - const { subscription } = useSubscription(); const { data: allCaData } = useListCasByProjectId(currentProject?.id || ""); const { data: templateData } = useListCertificateTemplatesV2({ @@ -532,15 +518,6 @@ export const CreateProfileModal = ({ }, [isEdit, profile, isAzureAdcsCa, azureAdcsTemplatesData, setValue]); const onFormSubmit = async (data: FormData) => { - if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { - reset(); - onClose(); - handlePopUpOpen("upgradePlan", { - isEnterpriseFeature: true - }); - return; - } - if (!currentProject?.id && !isEdit) return; // Validate Azure ADCS template requirement diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx index e90ba2c370..395a03d45c 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx @@ -16,7 +16,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; -import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, @@ -251,13 +250,8 @@ export const PamAccountsTable = ({ projectId }: Props) => { }); if (requiresApproval) { - createNotification({ - text: "This account is protected by an approval policy, you must request access", - type: "info" - }); - // Open request access modal with pre-populated path - handlePopUpOpen("requestAccount", { accountPath: fullAccountPath }); + handlePopUpOpen("requestAccount", { accountPath: fullAccountPath, accountAccessed: true }); return; } @@ -560,6 +554,7 @@ export const PamAccountsTable = ({ projectId }: Props) => { isOpen={popUp.requestAccount.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)} accountPath={popUp.requestAccount.data?.accountPath} + accountAccessed={popUp.requestAccount.data?.accountAccessed} /> void; }; @@ -45,7 +48,7 @@ const formSchema = z.object({ type FormData = z.infer; -const Content = ({ onOpenChange, accountPath }: Props) => { +const Content = ({ onOpenChange, accountPath, accountAccessed }: Props) => { const { projectId } = useProject(); const { mutateAsync: createApprovalRequest, isPending: isSubmitting } = useCreateApprovalRequest(); @@ -94,6 +97,15 @@ const Content = ({ onOpenChange, accountPath }: Props) => { return (
+ {accountAccessed && ( + + + This account is protected by an approval policy + + You must request access by filling out the fields below. + + + )} setSearchRoles(""); }; + const filteredRoles = + projectRoles?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) ?? []; + return (
- {projectRoles - ?.filter( - ({ name, slug }) => - name.toLowerCase().includes(searchRoles.toLowerCase()) || - slug.toLowerCase().includes(searchRoles.toLowerCase()) - ) - ?.map(({ id, name, slug }) => { + {filteredRoles.length > 0 ? ( + filteredRoles.map(({ id, name, slug }) => { const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; return (
-
+
); - })} + }) + ) : ( + No roles match search... + )}
@@ -385,7 +390,9 @@ export const GroupRoles = ({ return (
-
{formatProjectRoleName(role, customRoleName)}
+
+ {formatProjectRoleName(role, customRoleName)} +
{isTemporary && (
diff --git a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx index b6c0574cca..b316b2c64d 100644 --- a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx @@ -57,7 +57,7 @@ export const GroupsSection = () => { return (
-
+

Project Groups

diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx index 2fd8bfb62b..57d33ff928 100644 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx @@ -195,7 +195,7 @@ export const IdentityTab = withProjectPermission( return (
-
+

Project Machine Identities

diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx index 5801812353..7def928f44 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx @@ -47,7 +47,7 @@ export const MembersSection = () => { return (
-
+

Project Users

diff --git a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index cd76677f0d..24d5ea8b74 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -162,7 +162,7 @@ export const ProjectRoleList = () => { return (
-
+

Project Roles

@@ -193,7 +193,7 @@ export const ProjectRoleList = () => { - - - + + - - - - - + + + + + {name} + {new Date(joinedGroupAt).toLocaleDateString()} + + + + + + + + + + {(isAllowed) => { + return ( + onAssumePrivileges(id)} + isDisabled={!isAllowed} + > + Assume Privileges + + ); + }} + + + + + ); }; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx index 3288bea81b..64ac6a4efb 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx @@ -1,18 +1,15 @@ -import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { UserIcon } from "lucide-react"; +import { EllipsisIcon, UserIcon } from "lucide-react"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - IconButton, - Td, - Tooltip, - Tr -} from "@app/components/v2"; + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableIconButton, + UnstableTableCell, + UnstableTableRow +} from "@app/components/v3"; import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/context"; import { TGroupMemberUser } from "@app/hooks/api/groups/types"; @@ -30,55 +27,41 @@ export const GroupMembershipUserRow = ({ onAssumePrivileges }: Props) => { return ( - - - - - - + + + + + + {`${firstName ?? "-"} ${lastName ?? ""}`}{" "} + ({email}) + + {new Date(joinedGroupAt).toLocaleDateString()} + + + + + + + + + + {(isAllowed) => { + return ( + onAssumePrivileges(id)} + isDisabled={!isAllowed} + > + Assume Privileges + + ); + }} + + + + + ); }; diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx index b16b74690f..ccaee1c0c7 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -169,10 +169,7 @@ export const IdentityRoleDetailsSection = ({ } return ( - + {roleDetails.role === "custom" ? roleDetails.customRoleName @@ -235,7 +232,7 @@ export const IdentityRoleDetailsSection = ({ - This machine identity doesn t have any roles + This machine identity doesn't have any roles Give this machine identity one or more roles diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx index 34df3888b1..fab2d8574c 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx @@ -134,7 +134,7 @@ export const ProjectIdentityDetailsSection = ({ )) ) : ( - No metadata + )} @@ -150,7 +150,7 @@ export const ProjectIdentityDetailsSection = ({ {membership.lastLoginAuthMethod ? ( identityAuthToNameMap[membership.lastLoginAuthMethod] ) : ( - N/A + )} @@ -160,7 +160,7 @@ export const ProjectIdentityDetailsSection = ({ {membership.lastLoginTime ? ( format(membership.lastLoginTime, "PPpp") ) : ( - N/A + )} diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx index f935eb67b7..3d625dffb3 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx @@ -235,7 +235,7 @@ export const MemberRoleDetailsSection = ({ ) : ( - This user doesn t have any roles + This user doesn't have any roles Give this user one or more roles diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx index 09cd3593de..8695d00226 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/ProjectMemberDetailsSection.tsx @@ -48,7 +48,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => { Name - {name || Not set} + {name || } ID @@ -91,7 +91,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => { {username !== email && ( Username - {username || Not set} + {username || } )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx index ed6dc2824a..a317a89c80 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx @@ -150,7 +150,7 @@ export const DynamicSecretListView = ({ metadata: secret.metadata })} renderTooltip - allowedLabel="Edit" + allowedLabel="Generate Lease" > {(isAllowed) => (
+
Name {
+
Slug { }) } > -
{name}{slug}{name}{slug} {isCustomProjectRole(slug) ? ( diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx index 5c019c5bef..901a5043a9 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -41,7 +41,7 @@ export const ServiceTokenSection = withProjectPermission( return (
-
+

Service Tokens

diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index 7192b414c7..060a5f6288 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -1,12 +1,18 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useParams } from "@tanstack/react-router"; -import { formatRelative } from "date-fns"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { ChevronLeftIcon, EllipsisIcon } from "lucide-react"; +import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { EmptyState, PageHeader, Spinner } from "@app/components/v2"; +import { DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2"; +import { + UnstableButton, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger +} from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -14,6 +20,8 @@ import { useProject } from "@app/context"; import { getProjectBaseURL } from "@app/helpers/project"; +import { usePopUp } from "@app/hooks"; +import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; import { useGetWorkspaceGroupMembershipDetails } from "@app/hooks/api/projects/queries"; import { ProjectAccessControlTabs } from "@app/types/project"; @@ -34,6 +42,37 @@ const Page = () => { groupId ); + const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace(); + const navigate = useNavigate(); + + const { handlePopUpToggle, popUp, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "deleteGroup" + ] as const); + + const onRemoveGroupSubmit = async () => { + await deleteMutateAsync({ + groupId: groupMembership!.group.id, + projectId: currentProject.id + }); + + createNotification({ + text: "Successfully removed group from project", + type: "success" + }); + + navigate({ + to: `${getProjectBaseURL(currentProject.type)}/access-management`, + params: { + projectId: currentProject.id + }, + search: { + selectedTab: "groups" + } + }); + + handlePopUpClose("deleteGroup"); + }; + if (isPending) return (
@@ -42,9 +81,9 @@ const Page = () => { ); return ( -
+
{groupMembership ? ( -
+ <> { search={{ selectedTab: ProjectAccessControlTabs.Groups }} - className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" + className="mb-4 flex w-fit items-center gap-x-1 text-sm text-mineshaft-400 transition duration-100 hover:text-mineshaft-400/80" > - + Project Groups -
-
- -
+ description="Configure and manage project access control" + > + + + + Options + + + + + { + navigator.clipboard.writeText(groupMembership.group.id); + createNotification({ + text: "Group ID copied to clipboard", + type: "info" + }); + }} + > + Copy Group ID + + + + {(isAllowed) => ( + handlePopUpOpen("deleteGroup")} + > + Remove From Project + + )} + + + + +
+
-
+ ) : ( )} + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + buttonText="Remove" + onDeleteApproved={onRemoveGroupSubmit} + />
); }; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupDetailsSection.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupDetailsSection.tsx index 368a740c23..f45de9bd37 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupDetailsSection.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupDetailsSection.tsx @@ -1,23 +1,22 @@ -import { faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; +import { CheckIcon, ClipboardListIcon } from "lucide-react"; -import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { Tooltip } from "@app/components/v2"; import { - DeleteActionModal, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - IconButton -} from "@app/components/v2"; -import { CopyButton } from "@app/components/v2/CopyButton"; -import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; -import { getProjectBaseURL } from "@app/helpers/project"; -import { usePopUp } from "@app/hooks"; -import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; + Detail, + DetailGroup, + DetailLabel, + DetailValue, + UnstableCard, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle, + UnstableIconButton +} from "@app/components/v3"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useTimedReset } from "@app/hooks"; import { TGroupMembership } from "@app/hooks/api/groups/types"; import { GroupRoles } from "@app/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupRoles"; @@ -26,123 +25,68 @@ type Props = { }; export const GroupDetailsSection = ({ groupMembership }: Props) => { - const { handlePopUpToggle, popUp, handlePopUpClose, handlePopUpOpen } = usePopUp([ - "deleteGroup" - ] as const); + const { group } = groupMembership; - const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace(); - const { currentProject } = useProject(); - const navigate = useNavigate(); - - const onRemoveGroupSubmit = async () => { - await deleteMutateAsync({ - groupId: groupMembership.group.id, - projectId: currentProject.id - }); - - createNotification({ - text: "Successfully removed group from project", - type: "success" - }); - - navigate({ - to: `${getProjectBaseURL(currentProject.type)}/access-management`, - params: { - projectId: currentProject.id - }, - search: { - selectedTab: "groups" - } - }); - - handlePopUpClose("deleteGroup"); - }; + // eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars + const [_, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); return ( -
-
-

Group Details

- - - - - - - - - {(isAllowed) => { - return ( - } - onClick={() => handlePopUpOpen("deleteGroup")} - isDisabled={!isAllowed} - > - Remove Group From Project - - ); - }} - - - -
-
-
-

Group ID

-
-

{groupMembership.group.id}

- -
-
-
-

Name

-

{groupMembership.group.name}

-
-
-

Slug

-
-

{groupMembership.group.slug}

- -
-
-
-

Project Role

- - {(isAllowed) => ( - - )} - -
-
-

Assigned to Project

-

- {format(groupMembership.createdAt, "M/d/yyyy")} -

-
-
- handlePopUpToggle("deleteGroup", isOpen)} - deleteKey="confirm" - buttonText="Remove" - onDeleteApproved={onRemoveGroupSubmit} - /> -
+ + + Details + Group details + + + + + Name + {group.name} + + + ID + + {group.id} + + { + navigator.clipboard.writeText(group.id); + setCopyTextId("Copied"); + }} + variant="ghost" + size="xs" + > + {/* TODO(scott): color this should be a button variant and create re-usable copy button */} + {isCopyingId ? : } + + + + + + Project Role + + + {(isAllowed) => ( + + )} + + + + + Joined project + {format(groupMembership.createdAt, "PPpp")} + + + + ); }; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx index 4534e4371e..5b0bb3a30a 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -1,3 +1,10 @@ +import { + UnstableCard, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle +} from "@app/components/v3"; import { TGroupMembership } from "@app/hooks/api/groups/types"; import { GroupMembersTable } from "./GroupMembersTable"; @@ -8,13 +15,14 @@ type Props = { export const GroupMembersSection = ({ groupMembership }: Props) => { return ( -
-
-

Group Members

-
-
+ + + Group Members + View members of this group + + -
-
+ + ); }; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index f8854a2142..1fb276efc2 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -1,38 +1,29 @@ import { useEffect, useState } from "react"; -import { - faArrowDown, - faArrowUp, - faCheckCircle, - faFilter, - faFolder, - faMagnifyingGlass, - faSearch -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { HardDriveIcon, UserIcon } from "lucide-react"; +import { ChevronDownIcon, FilterIcon, HardDriveIcon, UserIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; +import { ConfirmActionModal, Lottie } from "@app/components/v2"; import { - ConfirmActionModal, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - EmptyState, - IconButton, - Input, - Pagination, - Table, - TableContainer, - TableSkeleton, - TBody, - Th, - THead, - Tr -} from "@app/components/v2"; + UnstableDropdownMenu, + UnstableDropdownMenuCheckboxItem, + UnstableDropdownMenuContent, + UnstableDropdownMenuLabel, + UnstableDropdownMenuTrigger, + UnstableEmpty, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle, + UnstableIconButton, + UnstableInput, + UnstablePagination, + UnstableTable, + UnstableTableBody, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3"; import { useOrganization, useProject } from "@app/context"; import { getProjectHomePage } from "@app/helpers/project"; import { @@ -114,6 +105,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined }); + const isFiltered = Boolean(search) || memberTypeFilter.length > 0; + const { members = [], totalCount = 0 } = groupMemberships ?? {}; useResetPageHelper({ @@ -167,40 +160,36 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { } ]; + if (isPending) { + return ( + // scott: todo proper loader +
+ +
+ ); + } + return ( -
-
- +
+ {/* TODO(scott): add input group with icon once component added */} + setSearch(e.target.value)} - leftIcon={} - placeholder="Search members..." + placeholder="Search group members..." /> - - - 0 && "border-primary/50 text-primary" - )} - > - - - - - Filter by Member Type + + + + + + + + Filter by Member Type {filterOptions.map((option) => ( - { e.preventDefault(); setMemberTypeFilter((prev) => { @@ -211,95 +200,85 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { }); setPage(1); }} - icon={ - memberTypeFilter.includes(option.value) && ( - - ) - } > -
- {option.icon} - {option.label} -
-
+ {option.icon} + {option.label} + ))} -
-
+ +
- - - - - - - - - - {isPending && } - {!isPending && - groupMemberships?.members?.map((userGroupMembership) => { - return userGroupMembership.type === GroupMemberType.USER ? ( - - handlePopUpOpen("assumePrivileges", { - actorId: userId, - actorType: ActorType.USER - }) - } - /> - ) : ( - - handlePopUpOpen("assumePrivileges", { - actorId: identityId, - actorType: ActorType.IDENTITY - }) - } - /> - ); - })} - -
- -
- Name - - - -
-
Added On -
- {Boolean(totalCount) && ( - - )} - {!isPending && !members.length && ( - - )} -
+ {members.length > 0 ? ( + + + + + + Name + + + Joined Group + + + + + {groupMemberships?.members?.map((userGroupMembership) => { + return userGroupMembership.type === GroupMemberType.USER ? ( + + handlePopUpOpen("assumePrivileges", { + actorId: userId, + actorType: ActorType.USER + }) + } + /> + ) : ( + + handlePopUpOpen("assumePrivileges", { + actorId: identityId, + actorType: ActorType.IDENTITY + }) + } + /> + ); + })} + + + ) : ( + + + + {isFiltered + ? "No group members match this search" + : "This group doesn't have any members"} + + + {isFiltered + ? "Adjust search filters to view group members." + : "Assign members from organization access control or contact an organization admin."} + + + + )} + {Boolean(totalCount) && ( + + )} { onConfirmed={handleAssumePrivileges} buttonText="Confirm" /> -
+ ); }; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx index 2e00c43e51..090a1f1df3 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx @@ -1,18 +1,15 @@ -import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { HardDriveIcon } from "lucide-react"; +import { EllipsisIcon, HardDriveIcon } from "lucide-react"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - IconButton, - Td, - Tooltip, - Tr -} from "@app/components/v2"; + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableIconButton, + UnstableTableCell, + UnstableTableRow +} from "@app/components/v3"; import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context"; import { TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types"; @@ -30,52 +27,38 @@ export const GroupMembershipIdentityRow = ({ onAssumePrivileges }: Props) => { return ( -
- - -

{name}

-
- -

{new Date(joinedGroupAt).toLocaleDateString()}

-
-
- - - - - - - - - - {(isAllowed) => { - return ( - } - onClick={() => onAssumePrivileges(id)} - isDisabled={!isAllowed} - > - Assume Privileges - - ); - }} - - - - -
- - -

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

-
- -

{new Date(joinedGroupAt).toLocaleDateString()}

-
-
- - - - - - - - - - {(isAllowed) => { - return ( - } - onClick={() => onAssumePrivileges(id)} - isDisabled={!isAllowed} - > - Assume Privileges - - ); - }} - - - - -