Merge branch 'main' of https://github.com/Infisical/infisical into feat/machine-identity-groups

This commit is contained in:
Piyush Gupta
2025-12-13 10:37:02 +05:30
106 changed files with 8092 additions and 6383 deletions

View File

@@ -192,3 +192,28 @@ Feature: Challenge
And the value response with jq ".status" should be equal to 400
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badCSR"
And the value response with jq ".detail" should be equal to "Invalid CSR: Common name + SANs mismatch with order identifiers"
Scenario: Get certificate without passing challenge when skip DNS ownership verification is enabled
Given I create an ACME profile with config as "acme_profile"
"""
{
"skipDnsOwnershipVerification": true
}
"""
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{
"COMMON_NAME": "localhost"
}
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And the value order.body with jq ".status" should be equal to "ready"
And I poll and finalize the ACME order order as finalized_order
And the value finalized_order.body with jq ".status" should be equal to "valid"
And I parse the full-chain certificate from order finalized_order as cert
And the value cert with jq ".subject.common_name" should be equal to "localhost"

View File

@@ -266,6 +266,46 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str):
)
@given(
'I create an ACME profile with config as "{profile_var}"'
)
def step_impl(context: Context, profile_var: str):
profile_slug = faker.slug()
jwt_token = context.vars["AUTH_TOKEN"]
acme_config = replace_vars(json.loads(context.text), context.vars)
response = context.http_client.post(
"/api/v1/cert-manager/certificate-profiles",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"slug": profile_slug,
"description": "ACME Profile created by BDD test",
"enrollmentType": "acme",
"caId": context.vars["CERT_CA_ID"],
"certificateTemplateId": context.vars["CERT_TEMPLATE_ID"],
"acmeConfig": acme_config,
},
)
response.raise_for_status()
resp_json = response.json()
profile_id = resp_json["certificateProfile"]["id"]
kid = profile_id
response = context.http_client.get(
f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
headers=dict(authorization="Bearer {}".format(jwt_token)),
)
response.raise_for_status()
resp_json = response.json()
secret = resp_json["eabSecret"]
context.vars[profile_var] = AcmeProfile(
profile_id,
eab_kid=kid,
eab_secret=secret,
)
@given('I have an ACME cert profile with external ACME CA as "{profile_var}"')
def step_impl(context: Context, profile_var: str):
profile_id = context.vars.get("PROFILE_ID")

View File

@@ -0,0 +1,38 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { dropConstraintIfExists } from "./utils/dropConstraintIfExists";
const FOREIGN_KEY_CONSTRAINT_NAME = "certificate_requests_acme_order_id_fkey";
const INDEX_NAME = "certificate_requests_acme_order_id_idx";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.CertificateRequests)) {
const hasAcmeOrderId = await knex.schema.hasColumn(TableName.CertificateRequests, "acmeOrderId");
if (!hasAcmeOrderId) {
await knex.schema.alterTable(TableName.CertificateRequests, (t) => {
t.uuid("acmeOrderId").nullable();
t.foreign("acmeOrderId", FOREIGN_KEY_CONSTRAINT_NAME)
.references("id")
.inTable(TableName.PkiAcmeOrder)
.onDelete("SET NULL");
t.index("acmeOrderId", INDEX_NAME);
});
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.CertificateRequests)) {
const hasAcmeOrderId = await knex.schema.hasColumn(TableName.CertificateRequests, "acmeOrderId");
if (hasAcmeOrderId) {
await dropConstraintIfExists(TableName.CertificateRequests, FOREIGN_KEY_CONSTRAINT_NAME, knex);
await knex.schema.alterTable(TableName.CertificateRequests, (t) => {
t.dropIndex("acmeOrderId", INDEX_NAME);
t.dropColumn("acmeOrderId");
});
}
}
}

View File

@@ -0,0 +1,23 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) {
if (!(await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification"))) {
await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => {
t.boolean("skipDnsOwnershipVerification").defaultTo(false).notNullable();
});
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) {
if (await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification")) {
await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => {
t.dropColumn("skipDnsOwnershipVerification");
});
}
}
}

View File

@@ -26,7 +26,8 @@ export const CertificateRequestsSchema = z.object({
keyAlgorithm: z.string().nullable().optional(),
signatureAlgorithm: z.string().nullable().optional(),
errorMessage: z.string().nullable().optional(),
metadata: z.string().nullable().optional()
metadata: z.string().nullable().optional(),
acmeOrderId: z.string().uuid().nullable().optional()
});
export type TCertificateRequests = z.infer<typeof CertificateRequestsSchema>;

View File

@@ -13,7 +13,8 @@ export const PkiAcmeEnrollmentConfigsSchema = z.object({
id: z.string().uuid(),
encryptedEabSecret: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
skipDnsOwnershipVerification: z.boolean().default(false)
});
export type TPkiAcmeEnrollmentConfigs = z.infer<typeof PkiAcmeEnrollmentConfigsSchema>;

View File

@@ -11,6 +11,7 @@ import {
} from "@app/ee/services/external-kms/providers/model";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors";
import { deterministicStringify } from "@app/lib/fn/object";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -88,9 +89,11 @@ export const registerExternalKmsEndpoints = <
...rest
} = externalKms;
const credentialsToHash = deterministicStringify(configuration.credential);
const credentialsHash = crypto.nativeCrypto
.createHash("sha256")
.update(externalKmsData.encryptedProviderInputs)
.update(Buffer.from(credentialsToHash))
.digest("hex");
return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } };
}
@@ -153,9 +156,12 @@ export const registerExternalKmsEndpoints = <
external: { providerInput: externalKmsConfiguration, ...externalKmsData },
...rest
} = externalKms;
const credentialsToHash = deterministicStringify(externalKmsConfiguration.credential);
const credentialsHash = crypto.nativeCrypto
.createHash("sha256")
.update(externalKmsData.encryptedProviderInputs)
.update(Buffer.from(credentialsToHash))
.digest("hex");
return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } };
}
@@ -222,9 +228,12 @@ export const registerExternalKmsEndpoints = <
external: { providerInput: externalKmsConfiguration, ...externalKmsData },
...rest
} = externalKms;
const credentialsToHash = deterministicStringify(externalKmsConfiguration.credential);
const credentialsHash = crypto.nativeCrypto
.createHash("sha256")
.update(externalKmsData.encryptedProviderInputs)
.update(Buffer.from(credentialsToHash))
.digest("hex");
return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } };
}
@@ -277,9 +286,12 @@ export const registerExternalKmsEndpoints = <
external: { providerInput: configuration, ...externalKmsData },
...rest
} = externalKms;
const credentialsToHash = deterministicStringify(configuration.credential);
const credentialsHash = crypto.nativeCrypto
.createHash("sha256")
.update(externalKmsData.encryptedProviderInputs)
.update(Buffer.from(credentialsToHash))
.digest("hex");
return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } };

View File

@@ -142,6 +142,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
data: {
...req.body,
...req.body.type,
name: req.body.slug,
permissions: req.body.permissions
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts

View File

@@ -49,6 +49,7 @@ import { TWebhookPayloads } from "@app/services/webhook/webhook-types";
import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types";
import { KmipPermission } from "../kmip/kmip-enum";
import { AcmeChallengeType, AcmeIdentifierType } from "../pki-acme/pki-acme-schemas";
import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types";
export type TListProjectAuditLogDTO = {
@@ -78,7 +79,9 @@ export type TCreateAuditLogDTO = {
| ScimClientActor
| PlatformActor
| UnknownUserActor
| KmipClientActor;
| KmipClientActor
| AcmeProfileActor
| AcmeAccountActor;
orgId?: string;
projectId?: string;
} & BaseAuthData;
@@ -574,7 +577,18 @@ export enum EventType {
APPROVAL_REQUEST_CANCEL = "approval-request-cancel",
APPROVAL_REQUEST_GRANT_LIST = "approval-request-grant-list",
APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get",
APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke"
APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke",
// PKI ACME
CREATE_ACME_ACCOUNT = "create-acme-account",
RETRIEVE_ACME_ACCOUNT = "retrieve-acme-account",
CREATE_ACME_ORDER = "create-acme-order",
FINALIZE_ACME_ORDER = "finalize-acme-order",
DOWNLOAD_ACME_CERTIFICATE = "download-acme-certificate",
RESPOND_TO_ACME_CHALLENGE = "respond-to-acme-challenge",
PASS_ACME_CHALLENGE = "pass-acme-challenge",
ATTEMPT_ACME_CHALLENGE = "attempt-acme-challenge",
FAIL_ACME_CHALLENGE = "fail-acme-challenge"
}
export const filterableSecretEvents: EventType[] = [
@@ -615,6 +629,15 @@ interface KmipClientActorMetadata {
name: string;
}
interface AcmeProfileActorMetadata {
profileId: string;
}
interface AcmeAccountActorMetadata {
profileId: string;
accountId: string;
}
interface UnknownUserActorMetadata {}
export interface UserActor {
@@ -652,7 +675,25 @@ export interface ScimClientActor {
metadata: ScimClientActorMetadata;
}
export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor | KmipClientActor;
export interface AcmeProfileActor {
type: ActorType.ACME_PROFILE;
metadata: AcmeProfileActorMetadata;
}
export interface AcmeAccountActor {
type: ActorType.ACME_ACCOUNT;
metadata: AcmeAccountActorMetadata;
}
export type Actor =
| UserActor
| ServiceActor
| IdentityActor
| ScimClientActor
| PlatformActor
| KmipClientActor
| AcmeProfileActor
| AcmeAccountActor;
interface GetSecretsEvent {
type: EventType.GET_SECRETS;
@@ -4368,6 +4409,84 @@ interface ApprovalRequestGrantRevokeEvent {
};
}
interface CreateAcmeAccountEvent {
type: EventType.CREATE_ACME_ACCOUNT;
metadata: {
accountId: string;
publicKeyThumbprint: string;
emails?: string[];
};
}
interface RetrieveAcmeAccountEvent {
type: EventType.RETRIEVE_ACME_ACCOUNT;
metadata: {
accountId: string;
publicKeyThumbprint: string;
};
}
interface CreateAcmeOrderEvent {
type: EventType.CREATE_ACME_ORDER;
metadata: {
orderId: string;
identifiers: Array<{
type: AcmeIdentifierType;
value: string;
}>;
};
}
interface FinalizeAcmeOrderEvent {
type: EventType.FINALIZE_ACME_ORDER;
metadata: {
orderId: string;
csr: string;
};
}
interface DownloadAcmeCertificateEvent {
type: EventType.DOWNLOAD_ACME_CERTIFICATE;
metadata: {
orderId: string;
};
}
interface RespondToAcmeChallengeEvent {
type: EventType.RESPOND_TO_ACME_CHALLENGE;
metadata: {
challengeId: string;
type: AcmeChallengeType;
};
}
interface PassedAcmeChallengeEvent {
type: EventType.PASS_ACME_CHALLENGE;
metadata: {
challengeId: string;
type: AcmeChallengeType;
};
}
interface AttemptAcmeChallengeEvent {
type: EventType.ATTEMPT_ACME_CHALLENGE;
metadata: {
challengeId: string;
type: AcmeChallengeType;
retryCount: number;
errorMessage: string;
};
}
interface FailAcmeChallengeEvent {
type: EventType.FAIL_ACME_CHALLENGE;
metadata: {
challengeId: string;
type: AcmeChallengeType;
retryCount: number;
errorMessage: string;
};
}
export type Event =
| CreateSubOrganizationEvent
| UpdateSubOrganizationEvent
@@ -4768,4 +4887,13 @@ export type Event =
| ApprovalRequestCancelEvent
| ApprovalRequestGrantListEvent
| ApprovalRequestGrantGetEvent
| ApprovalRequestGrantRevokeEvent;
| ApprovalRequestGrantRevokeEvent
| CreateAcmeAccountEvent
| RetrieveAcmeAccountEvent
| CreateAcmeOrderEvent
| FinalizeAcmeOrderEvent
| DownloadAcmeCertificateEvent
| RespondToAcmeChallengeEvent
| PassedAcmeChallengeEvent
| AttemptAcmeChallengeEvent
| FailAcmeChallengeEvent;

View File

@@ -380,6 +380,7 @@ export const externalKmsServiceFactory = ({
const findById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id: kmsId }: TGetExternalKmsByIdDTO) => {
const kmsDoc = await kmsDAL.findById(kmsId);
if (!kmsDoc) throw new NotFoundError({ message: `Could not find KMS with ID '${kmsId}'` });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,

View File

@@ -71,23 +71,29 @@ export const pamFolderDALFactory = (db: TDbClient) => {
const findByPath = async (projectId: string, path: string, tx?: Knex) => {
try {
const dbInstance = tx || db.replicaNode();
const folders = await dbInstance(TableName.PamFolder)
.where(`${TableName.PamFolder}.projectId`, projectId)
.select(selectAllTableCols(TableName.PamFolder));
const pathSegments = path.split("/").filter(Boolean);
if (pathSegments.length === 0) {
return undefined;
}
const foldersByParentId = new Map<string | null, typeof folders>();
for (const folder of folders) {
const children = foldersByParentId.get(folder.parentId ?? null) ?? [];
children.push(folder);
foldersByParentId.set(folder.parentId ?? null, children);
}
let parentId: string | null = null;
let currentFolder: Awaited<ReturnType<typeof orm.findOne>> | undefined;
let currentFolder: (typeof folders)[0] | undefined;
for await (const segment of pathSegments) {
const query = dbInstance(TableName.PamFolder)
.where(`${TableName.PamFolder}.projectId`, projectId)
.where(`${TableName.PamFolder}.name`, segment);
if (parentId) {
void query.where(`${TableName.PamFolder}.parentId`, parentId);
} else {
void query.whereNull(`${TableName.PamFolder}.parentId`);
}
currentFolder = await query.first();
for (const segment of pathSegments) {
const childFolders: typeof folders = foldersByParentId.get(parentId) || [];
currentFolder = childFolders.find((folder) => folder.name === segment);
if (!currentFolder) {
return undefined;

View File

@@ -122,6 +122,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
const result = await (tx || db)(TableName.PkiAcmeChallenge)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`)
.join(
TableName.PkiCertificateProfile,
`${TableName.PkiAcmeAccount}.profileId`,
`${TableName.PkiCertificateProfile}.id`
)
.select(
selectAllTableCols(TableName.PkiAcmeChallenge),
db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"),
@@ -131,7 +136,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"),
db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"),
db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"),
db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint")
db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint"),
db.ref("profileId").withSchema(TableName.PkiAcmeAccount).as("profileId"),
db.ref("projectId").withSchema(TableName.PkiCertificateProfile).as("projectId")
)
// For all challenges, acquire update lock on the auth to avoid race conditions
.forUpdate(TableName.PkiAcmeAuth)
@@ -149,6 +156,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
authExpiresAt,
accountId,
accountPublicKeyThumbprint,
profileId,
projectId,
...challenge
} = result;
return {
@@ -161,7 +170,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
expiresAt: authExpiresAt,
account: {
id: accountId,
publicKeyThumbprint: accountPublicKeyThumbprint
publicKeyThumbprint: accountPublicKeyThumbprint,
project: {
id: projectId
},
profileId
}
}
};

View File

@@ -5,7 +5,9 @@ import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
import {
AcmeConnectionError,
@@ -25,10 +27,12 @@ type TPkiAcmeChallengeServiceFactoryDep = {
| "markAsInvalidCascadeById"
| "updateById"
>;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export const pkiAcmeChallengeServiceFactory = ({
acmeChallengeDAL
acmeChallengeDAL,
auditLogService
}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => {
const appCfg = getConfig();
const markChallengeAsReady = async (challengeId: string): Promise<TPkiAcmeChallenges> => {
@@ -113,7 +117,25 @@ export const pkiAcmeChallengeServiceFactory = ({
}
logger.info({ challengeId }, "ACME challenge response is correct, marking challenge as valid");
await acmeChallengeDAL.markAsValidCascadeById(challengeId);
await auditLogService.createAuditLog({
projectId: challenge.auth.account.project.id,
actor: {
type: ActorType.ACME_ACCOUNT,
metadata: {
profileId: challenge.auth.account.profileId,
accountId: challenge.auth.account.id
}
},
event: {
type: EventType.PASS_ACME_CHALLENGE,
metadata: {
challengeId,
type: challenge.type as AcmeChallengeType
}
}
});
} catch (exp) {
let finalAttempt = false;
if (retryCount >= 2) {
logger.error(
exp,
@@ -121,35 +143,59 @@ export const pkiAcmeChallengeServiceFactory = ({
);
// This is the last attempt to validate the challenge response, if it fails, we mark the challenge as invalid
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId);
finalAttempt = true;
}
// Properly type and inspect the error
if (axios.isAxiosError(exp)) {
const axiosError = exp as AxiosError;
const errorCode = axiosError.code;
const errorMessage = axiosError.message;
try {
// Properly type and inspect the error
if (axios.isAxiosError(exp)) {
const axiosError = exp as AxiosError;
const errorCode = axiosError.code;
const errorMessage = axiosError.message;
if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
throw new AcmeConnectionError({ message: "Connection refused" });
if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
throw new AcmeConnectionError({ message: "Connection refused" });
}
if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
}
if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) {
throw new AcmeConnectionError({ message: "Connection reset by peer" });
}
if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
logger.error(exp, "Connection timed out while validating ACME challenge response");
throw new AcmeConnectionError({ message: "Connection timed out" });
}
logger.error(exp, "Unknown error validating ACME challenge response");
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
}
if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
}
if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) {
throw new AcmeConnectionError({ message: "Connection reset by peer" });
}
if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
logger.error(exp, "Connection timed out while validating ACME challenge response");
throw new AcmeConnectionError({ message: "Connection timed out" });
if (exp instanceof Error) {
logger.error(exp, "Error validating ACME challenge response");
throw exp;
}
logger.error(exp, "Unknown error validating ACME challenge response");
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
} catch (outterExp) {
await auditLogService.createAuditLog({
projectId: challenge.auth.account.project.id,
actor: {
type: ActorType.ACME_ACCOUNT,
metadata: {
profileId: challenge.auth.account.profileId,
accountId: challenge.auth.account.id
}
},
event: {
type: finalAttempt ? EventType.FAIL_ACME_CHALLENGE : EventType.ATTEMPT_ACME_CHALLENGE,
metadata: {
challengeId,
type: challenge.type as AcmeChallengeType,
retryCount,
errorMessage: exp instanceof Error ? exp.message : "Unknown error"
}
}
});
throw outterExp;
}
if (exp instanceof Error) {
logger.error(exp, "Error validating ACME challenge response");
throw exp;
}
logger.error(exp, "Unknown error validating ACME challenge response");
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
}
};

View File

@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
export type TPkiAcmeOrderDALFactory = ReturnType<typeof pkiAcmeOrderDALFactory>;
@@ -19,6 +20,43 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => {
}
};
const findWithCertificateRequestForSync = async (id: string, tx?: Knex) => {
try {
const order = await (tx || db)(TableName.PkiAcmeOrder)
.leftJoin(
TableName.CertificateRequests,
`${TableName.PkiAcmeOrder}.id`,
`${TableName.CertificateRequests}.acmeOrderId`
)
.select(
selectAllTableCols(TableName.PkiAcmeOrder),
db.ref("id").withSchema(TableName.CertificateRequests).as("certificateRequestId"),
db.ref("status").withSchema(TableName.CertificateRequests).as("certificateRequestStatus"),
db.ref("certificateId").withSchema(TableName.CertificateRequests).as("certificateId")
)
.forUpdate(TableName.PkiAcmeOrder)
.where(`${TableName.PkiAcmeOrder}.id`, id)
.first();
if (!order) {
return null;
}
const { certificateRequestId, certificateRequestStatus, certificateId, ...details } = order;
return {
...details,
certificateRequest:
certificateRequestId && certificateRequestStatus
? {
id: certificateRequestId,
status: certificateRequestStatus as CertificateRequestStatus,
certificateId
}
: undefined
};
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME order by id with certificate request" });
}
};
const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => {
try {
const rows = await (tx || db)(TableName.PkiAcmeOrder)
@@ -72,6 +110,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => {
return {
...pkiAcmeOrderOrm,
findByIdForFinalization,
findWithCertificateRequestForSync,
findByAccountAndOrderIdWithAuthorizations,
listByAccountId
};

View File

@@ -6,8 +6,8 @@ export enum AcmeIdentifierType {
export enum AcmeOrderStatus {
Pending = "pending",
Processing = "processing",
Ready = "ready",
Processing = "processing",
Valid = "valid",
Invalid = "invalid"
}

View File

@@ -7,8 +7,10 @@ import {
importJWK,
JWSHeaderParameters
} from "jose";
import { Knex } from "knex";
import { z, ZodError } from "zod";
import { TPkiAcmeOrders } from "@app/db/schemas";
import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts";
import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
@@ -17,20 +19,15 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { ActorType } from "@app/services/auth/auth-type";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import {
CertExtendedKeyUsage,
CertKeyUsage,
CertSubjectAlternativeNameType
} from "@app/services/certificate/certificate-types";
import { orderCertificate } from "@app/services/certificate-authority/acme/acme-certificate-authority-fns";
import { CertSubjectAlternativeNameType } from "@app/services/certificate/certificate-types";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
import {
TCertificateIssuanceQueueFactory,
TIssueCertificateFromProfileJobData
} from "@app/services/certificate-authority/certificate-issuance-queue";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
@@ -40,6 +37,8 @@ import {
EnrollmentType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
import { TCertificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
@@ -47,6 +46,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal";
import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal";
@@ -99,13 +99,9 @@ import {
type TPkiAcmeServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateTemplateV2DAL: Pick<TCertificateTemplateV2DALFactory, "findById">;
acmeAccountDAL: Pick<
TPkiAcmeAccountDALFactory,
@@ -113,11 +109,13 @@ type TPkiAcmeServiceFactoryDep = {
>;
acmeOrderDAL: Pick<
TPkiAcmeOrderDALFactory,
| "findById"
| "create"
| "transaction"
| "updateById"
| "findByAccountAndOrderIdWithAuthorizations"
| "findByIdForFinalization"
| "findWithCertificateRequestForSync"
| "listByAccountId"
>;
acmeAuthDAL: Pick<TPkiAcmeAuthDALFactory, "create" | "findByAccountIdAndAuthIdWithChallenges">;
@@ -134,19 +132,18 @@ type TPkiAcmeServiceFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
certificateTemplateV2Service: Pick<TCertificateTemplateV2ServiceFactory, "validateCertificateRequest">;
certificateRequestService: Pick<TCertificateRequestServiceFactory, "createCertificateRequest">;
certificateIssuanceQueue: Pick<TCertificateIssuanceQueueFactory, "queueCertificateIssuance">;
acmeChallengeService: Pick<TPkiAcmeChallengeServiceFactory, "markChallengeAsReady">;
pkiAcmeQueueService: Pick<TPkiAcmeQueueServiceFactory, "queueChallengeValidation">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export const pkiAcmeServiceFactory = ({
projectDAL,
appConnectionDAL,
certificateDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateProfileDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateTemplateV2DAL,
acmeAccountDAL,
acmeOrderDAL,
@@ -158,8 +155,11 @@ export const pkiAcmeServiceFactory = ({
licenseService,
certificateV3Service,
certificateTemplateV2Service,
certificateRequestService,
certificateIssuanceQueue,
acmeChallengeService,
pkiAcmeQueueService
pkiAcmeQueueService,
auditLogService
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
@@ -364,6 +364,52 @@ export const pkiAcmeServiceFactory = ({
};
};
const checkAndSyncAcmeOrderStatus = async ({ orderId }: { orderId: string }): Promise<TPkiAcmeOrders> => {
const order = await acmeOrderDAL.findById(orderId);
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
if (order.status !== AcmeOrderStatus.Processing) {
// We only care about processing orders, as they are the ones that have async certificate requests
return order;
}
return acmeOrderDAL.transaction(async (tx) => {
// Lock the order for syncing with async cert request
const orderWithCertificateRequest = await acmeOrderDAL.findWithCertificateRequestForSync(orderId, tx);
if (!orderWithCertificateRequest) {
throw new NotFoundError({ message: "ACME order not found" });
}
// Check the status again after we have acquired the lock, as things may have changed since we last checked
if (
orderWithCertificateRequest.status !== AcmeOrderStatus.Processing ||
!orderWithCertificateRequest.certificateRequest
) {
return orderWithCertificateRequest;
}
let newStatus: AcmeOrderStatus | undefined;
let newCertificateId: string | undefined;
switch (orderWithCertificateRequest.certificateRequest.status) {
case CertificateRequestStatus.PENDING:
break;
case CertificateRequestStatus.ISSUED:
newStatus = AcmeOrderStatus.Valid;
newCertificateId = orderWithCertificateRequest.certificateRequest.certificateId ?? undefined;
break;
case CertificateRequestStatus.FAILED:
newStatus = AcmeOrderStatus.Invalid;
break;
default:
throw new AcmeServerInternalError({
message: `Invalid certificate request status: ${orderWithCertificateRequest.certificateRequest.status as string}`
});
}
if (newStatus) {
return acmeOrderDAL.updateById(orderId, { status: newStatus, certificateId: newCertificateId }, tx);
}
return orderWithCertificateRequest;
});
};
const getAcmeDirectory = async (profileId: string): Promise<TGetAcmeDirectoryResponse> => {
const profile = await validateAcmeProfile(profileId);
return {
@@ -446,6 +492,23 @@ export const pkiAcmeServiceFactory = ({
throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" });
}
if (existingAccount) {
await auditLogService.createAuditLog({
projectId: profile.projectId,
actor: {
type: ActorType.ACME_PROFILE,
metadata: {
profileId: profile.id
}
},
event: {
type: EventType.RETRIEVE_ACME_ACCOUNT,
metadata: {
accountId: existingAccount.id,
publicKeyThumbprint
}
}
});
return {
status: 200,
body: {
@@ -518,7 +581,25 @@ export const pkiAcmeServiceFactory = ({
publicKeyThumbprint,
emails: contact ?? []
});
// TODO: create audit log here
await auditLogService.createAuditLog({
projectId: profile.projectId,
actor: {
type: ActorType.ACME_PROFILE,
metadata: {
profileId: profile.id
}
},
event: {
type: EventType.CREATE_ACME_ACCOUNT,
metadata: {
accountId: newAccount.id,
publicKeyThumbprint: newAccount.publicKeyThumbprint,
emails: newAccount.emails
}
}
});
return {
status: 201,
body: {
@@ -567,6 +648,8 @@ export const pkiAcmeServiceFactory = ({
accountId: string;
payload: TCreateAcmeOrderPayload;
}): Promise<TAcmeResponse<TAcmeOrderResource>> => {
const profile = await validateAcmeProfile(profileId);
const skipDnsOwnershipVerification = profile.acmeConfig?.skipDnsOwnershipVerification ?? false;
// TODO: check and see if we have existing orders for this account that meet the criteria
// if we do, return the existing order
// TODO: check the identifiers and see if are they even allowed for this profile.
@@ -592,7 +675,7 @@ export const pkiAcmeServiceFactory = ({
const createdOrder = await acmeOrderDAL.create(
{
accountId: account.id,
status: AcmeOrderStatus.Pending,
status: skipDnsOwnershipVerification ? AcmeOrderStatus.Ready : AcmeOrderStatus.Pending,
notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined,
notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined,
// TODO: read config from the profile to get the expiration time instead
@@ -611,7 +694,7 @@ export const pkiAcmeServiceFactory = ({
const auth = await acmeAuthDAL.create(
{
accountId: account.id,
status: AcmeAuthStatus.Pending,
status: skipDnsOwnershipVerification ? AcmeAuthStatus.Valid : AcmeAuthStatus.Pending,
identifierType: identifier.type,
identifierValue: identifier.value,
// RFC 8555 suggests a token with at least 128 bits of entropy
@@ -623,15 +706,17 @@ export const pkiAcmeServiceFactory = ({
},
tx
);
// TODO: support other challenge types here. Currently only HTTP-01 is supported.
await acmeChallengeDAL.create(
{
authId: auth.id,
status: AcmeChallengeStatus.Pending,
type: AcmeChallengeType.HTTP_01
},
tx
);
if (!skipDnsOwnershipVerification) {
// TODO: support other challenge types here. Currently only HTTP-01 is supported.
await acmeChallengeDAL.create(
{
authId: auth.id,
status: AcmeChallengeStatus.Pending,
type: AcmeChallengeType.HTTP_01
},
tx
);
}
return auth;
})
);
@@ -643,7 +728,26 @@ export const pkiAcmeServiceFactory = ({
})),
tx
);
// TODO: create audit log here
await auditLogService.createAuditLog({
projectId: profile.projectId,
actor: {
type: ActorType.ACME_ACCOUNT,
metadata: {
profileId: account.profileId,
accountId: account.id
}
},
event: {
type: EventType.CREATE_ACME_ORDER,
metadata: {
orderId: createdOrder.id,
identifiers: authorizations.map((auth) => ({
type: auth.identifierType as AcmeIdentifierType,
value: auth.identifierValue
}))
}
}
});
return { ...createdOrder, authorizations, account };
});
@@ -673,9 +777,12 @@ export const pkiAcmeServiceFactory = ({
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
// Sync order first in case if there is a certificate request that needs to be processed
await checkAndSyncAcmeOrderStatus({ orderId });
const updatedOrder = (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId))!;
return {
status: 200,
body: buildAcmeOrderResource({ profileId, order }),
body: buildAcmeOrderResource({ profileId, order: updatedOrder }),
headers: {
Location: buildUrl(profileId, `/orders/${orderId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
@@ -683,6 +790,129 @@ export const pkiAcmeServiceFactory = ({
};
};
const processCertificateIssuanceForOrder = async ({
caType,
accountId,
actorOrgId,
profileId,
orderId,
csr,
finalizingOrder,
certificateRequest,
profile,
ca,
tx
}: {
caType: CaType;
accountId: string;
actorOrgId: string;
profileId: string;
orderId: string;
csr: string;
finalizingOrder: {
notBefore?: Date | null;
notAfter?: Date | null;
};
certificateRequest: ReturnType<typeof extractCertificateRequestFromCSR>;
profile: TCertificateProfileWithConfigs;
ca: Awaited<ReturnType<typeof certificateAuthorityDAL.findByIdWithAssociatedCa>>;
tx?: Knex;
}): Promise<{ certificateId?: string; certIssuanceJobData?: TIssueCertificateFromProfileJobData }> => {
if (caType === CaType.INTERNAL) {
const result = await certificateV3Service.signCertificateFromProfile({
actor: ActorType.ACME_ACCOUNT,
actorId: accountId,
actorAuthMethod: null,
actorOrgId,
profileId,
csr,
notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined,
notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined,
validity: !finalizingOrder.notAfter
? {
// 47 days, the default TTL comes with Let's Encrypt
// TODO: read config from the profile to get the expiration time instead
ttl: `${47}d`
}
: // ttl is not used if notAfter is provided
({ ttl: "0d" } as const),
enrollmentType: EnrollmentType.ACME
});
return {
certificateId: result.certificateId
};
}
const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } =
extractAlgorithmsFromCSR(csr);
const updatedCertificateRequest = {
...certificateRequest,
keyAlgorithm: extractedKeyAlgorithm,
signatureAlgorithm: extractedSignatureAlgorithm,
validity: finalizingOrder.notAfter
? (() => {
const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date();
const notAfter = new Date(finalizingOrder.notAfter);
const diffMs = notAfter.getTime() - notBefore.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
return { ttl: `${diffDays}d` };
})()
: certificateRequest.validity
};
const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
template.id,
updatedCertificateRequest
);
if (!validationResult.isValid) {
throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` });
}
const certRequest = await certificateRequestService.createCertificateRequest({
actor: ActorType.ACME_ACCOUNT,
actorId: accountId,
actorAuthMethod: null,
actorOrgId,
projectId: profile.projectId,
caId: ca.id,
profileId: profile.id,
commonName: updatedCertificateRequest.commonName ?? "",
keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [],
extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [],
keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "",
signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "",
altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","),
notBefore: updatedCertificateRequest.notBefore,
notAfter: updatedCertificateRequest.notAfter,
status: CertificateRequestStatus.PENDING,
acmeOrderId: orderId,
csr,
tx
});
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const csrPem = csrObj.toString("pem");
return {
certIssuanceJobData: {
certificateId: orderId,
profileId: profile.id,
caId: profile.caId || "",
ttl: updatedCertificateRequest.validity?.ttl || "1y",
signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "",
keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "",
commonName: updatedCertificateRequest.commonName || "",
altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value) || [],
keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [],
extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [],
certificateRequestId: certRequest.id,
csr: csrPem
}
};
};
const finalizeAcmeOrder = async ({
profileId,
accountId,
@@ -707,7 +937,11 @@ export const pkiAcmeServiceFactory = ({
throw new NotFoundError({ message: "ACME order not found" });
}
if (order.status === AcmeOrderStatus.Ready) {
const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => {
const {
order: updatedOrder,
error,
certIssuanceJobData
} = await acmeOrderDAL.transaction(async (tx) => {
const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!;
// TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead?
const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!;
@@ -754,94 +988,31 @@ export const pkiAcmeServiceFactory = ({
}
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
let errorToReturn: Error | undefined;
let certIssuanceJobDataToReturn: TIssueCertificateFromProfileJobData | undefined;
try {
const { certificateId } = await (async () => {
if (caType === CaType.INTERNAL) {
const result = await certificateV3Service.signCertificateFromProfile({
actor: ActorType.ACME_ACCOUNT,
actorId: accountId,
actorAuthMethod: null,
actorOrgId,
profileId,
csr,
notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined,
notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined,
validity: !finalizingOrder.notAfter
? {
// 47 days, the default TTL comes with Let's Encrypt
// TODO: read config from the profile to get the expiration time instead
ttl: `${47}d`
}
: // ttl is not used if notAfter is provided
({ ttl: "0d" } as const),
enrollmentType: EnrollmentType.ACME
});
return { certificateId: result.certificateId };
}
const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!;
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const csrPem = csrObj.toString("pem");
const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } =
extractAlgorithmsFromCSR(csr);
certificateRequest.keyAlgorithm = extractedKeyAlgorithm;
certificateRequest.signatureAlgorithm = extractedSignatureAlgorithm;
if (finalizingOrder.notAfter) {
const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date();
const notAfter = new Date(finalizingOrder.notAfter);
const diffMs = notAfter.getTime() - notBefore.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
certificateRequest.validity = { ttl: `${diffDays}d` };
}
const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
template.id,
certificateRequest
);
if (!validationResult.isValid) {
throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` });
}
// TODO: this is pretty slow, and we are holding the transaction open for a long time,
// we should queue the certificate issuance to a background job instead
const cert = await orderCertificate(
{
caId: certificateAuthority!.id,
// It is possible that the CSR does not have a common name, in which case we use an empty string
// (more likely than not for a CSR from a modern ACME client like certbot, cert-manager, etc.)
commonName: certificateRequest.commonName ?? "",
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
csr: Buffer.from(csrPem),
// TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT],
extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH]
},
{
appConnectionDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL
}
);
return { certificateId: cert.id };
})();
const result = await processCertificateIssuanceForOrder({
caType,
accountId,
actorOrgId,
profileId,
orderId,
csr,
finalizingOrder,
certificateRequest,
profile,
ca,
tx
});
await acmeOrderDAL.updateById(
orderId,
{
status: AcmeOrderStatus.Valid,
status: result.certificateId ? AcmeOrderStatus.Valid : AcmeOrderStatus.Processing,
csr,
certificateId
certificateId: result.certificateId
},
tx
);
certIssuanceJobDataToReturn = result.certIssuanceJobData;
} catch (exp) {
await acmeOrderDAL.updateById(
orderId,
@@ -859,18 +1030,43 @@ export const pkiAcmeServiceFactory = ({
} else if (exp instanceof AcmeError) {
errorToReturn = exp;
} else {
errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" });
errorToReturn = new AcmeServerInternalError({
message: "Failed to sign certificate with internal error"
});
}
}
return {
order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!,
error: errorToReturn
error: errorToReturn,
certIssuanceJobData: certIssuanceJobDataToReturn
};
});
if (error) {
throw error;
}
if (certIssuanceJobData) {
// TODO: ideally, this should be done inside the transaction, but the pg-boss queue doesn't support external transactions
// as it seems to be. we need to commit the transaction before queuing the job, otherwise the job will fail (not found error).
await certificateIssuanceQueue.queueCertificateIssuance(certIssuanceJobData);
}
order = updatedOrder;
await auditLogService.createAuditLog({
projectId: profile.projectId,
actor: {
type: ActorType.ACME_ACCOUNT,
metadata: {
profileId,
accountId
}
},
event: {
type: EventType.FINALIZE_ACME_ORDER,
metadata: {
orderId: updatedOrder.id,
csr: updatedOrder.csr!
}
}
});
} else if (order.status !== AcmeOrderStatus.Valid) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" });
}
@@ -898,14 +1094,16 @@ export const pkiAcmeServiceFactory = ({
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
if (order.status !== AcmeOrderStatus.Valid) {
// Sync order first in case if there is a certificate request that needs to be processed
const syncedOrder = await checkAndSyncAcmeOrderStatus({ orderId });
if (syncedOrder.status !== AcmeOrderStatus.Valid) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" });
}
if (!order.certificateId) {
if (!syncedOrder.certificateId) {
throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" });
}
const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId });
const certBody = await certificateBodyDAL.findOne({ certId: syncedOrder.certificateId });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: profile.projectId,
projectDAL,
@@ -926,6 +1124,24 @@ export const pkiAcmeServiceFactory = ({
const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n");
const certChain = certificateChain.trim().replace("\n", "\r\n");
await auditLogService.createAuditLog({
projectId: profile.projectId,
actor: {
type: ActorType.ACME_ACCOUNT,
metadata: {
profileId,
accountId
}
},
event: {
type: EventType.DOWNLOAD_ACME_CERTIFICATE,
metadata: {
orderId
}
}
});
return {
status: 200,
body:
@@ -1008,6 +1224,7 @@ export const pkiAcmeServiceFactory = ({
authzId: string;
challengeId: string;
}): Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>> => {
const profile = await validateAcmeProfile(profileId);
const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId);
if (!result) {
throw new NotFoundError({ message: "ACME challenge not found" });
@@ -1015,6 +1232,23 @@ export const pkiAcmeServiceFactory = ({
await acmeChallengeService.markChallengeAsReady(challengeId);
await pkiAcmeQueueService.queueChallengeValidation(challengeId);
const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!;
await auditLogService.createAuditLog({
projectId: profile.projectId,
actor: {
type: ActorType.ACME_ACCOUNT,
metadata: {
profileId,
accountId
}
},
event: {
type: EventType.RESPOND_TO_ACME_CHALLENGE,
metadata: {
challengeId,
type: challenge.type as AcmeChallengeType
}
}
});
return {
status: 200,
body: {

View File

@@ -103,3 +103,34 @@ export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: st
return deepEqual(filtered1, filtered2);
};
export const deterministicStringify = (value: unknown): string => {
if (value === null || value === undefined) {
return JSON.stringify(value);
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
const items = value.map((item) => deterministicStringify(item));
return `[${items.join(",")}]`;
}
if (typeof value === "object") {
const sortedKeys = Object.keys(value).sort();
const sortedObj: Record<string, unknown> = {};
for (const key of sortedKeys) {
const val = (value as Record<string, unknown>)[key];
if (typeof val === "object" && val !== null) {
sortedObj[key] = JSON.parse(deterministicStringify(val));
} else {
sortedObj[key] = val;
}
}
return JSON.stringify(sortedObj);
}
return JSON.stringify(value);
};

View File

@@ -2308,7 +2308,8 @@ export const registerRoutes = async (
});
const acmeChallengeService = pkiAcmeChallengeServiceFactory({
acmeChallengeDAL
acmeChallengeDAL,
auditLogService
});
const pkiAcmeQueueService = await pkiAcmeQueueServiceFactory({
@@ -2318,13 +2319,9 @@ export const registerRoutes = async (
const pkiAcmeService = pkiAcmeServiceFactory({
projectDAL,
appConnectionDAL,
certificateDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateProfileDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateTemplateV2DAL,
acmeAccountDAL,
acmeOrderDAL,
@@ -2336,8 +2333,11 @@ export const registerRoutes = async (
licenseService,
certificateV3Service,
certificateTemplateV2Service,
certificateRequestService,
certificateIssuanceQueue,
acmeChallengeService,
pkiAcmeQueueService
pkiAcmeQueueService,
auditLogService
});
const pkiSubscriberService = pkiSubscriberServiceFactory({

View File

@@ -47,7 +47,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional(),
acmeConfig: z.object({}).optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: ExternalConfigUnionSchema
})
.refine(
@@ -245,7 +249,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
acmeConfig: z
.object({
id: z.string(),
directoryUrl: z.string()
directoryUrl: z.string(),
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: ExternalConfigUnionSchema
@@ -434,6 +439,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: ExternalConfigUnionSchema
})
.refine(

View File

@@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ...
IDENTITY = "identity",
Machine = "machine",
SCIM_CLIENT = "scimClient",
ACME_PROFILE = "acmeProfile",
ACME_ACCOUNT = "acmeAccount",
UNKNOWN_USER = "unknownUser"
}

View File

@@ -168,7 +168,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"),
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"),
db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret")
db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret"),
db
.ref("skipDnsOwnershipVerification")
.withSchema(TableName.PkiAcmeEnrollmentConfig)
.as("acmeConfigSkipDnsOwnershipVerification")
)
.where(`${TableName.PkiCertificateProfile}.id`, id)
.first();
@@ -198,7 +202,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const acmeConfig = result.acmeConfigId
? ({
id: result.acmeConfigId,
encryptedEabSecret: result.acmeConfigEncryptedEabSecret
encryptedEabSecret: result.acmeConfigEncryptedEabSecret,
skipDnsOwnershipVerification: result.acmeConfigSkipDnsOwnershipVerification ?? false
} as TCertificateProfileWithConfigs["acmeConfig"])
: undefined;
@@ -356,7 +361,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"),
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId")
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId"),
db
.ref("skipDnsOwnershipVerification")
.withSchema(TableName.PkiAcmeEnrollmentConfig)
.as("acmeSkipDnsOwnershipVerification")
);
if (processedRules) {
@@ -393,7 +402,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const acmeConfig = result.acmeId
? {
id: result.acmeId as string
id: result.acmeId as string,
skipDnsOwnershipVerification: !!result.acmeSkipDnsOwnershipVerification
}
: undefined;

View File

@@ -30,7 +30,11 @@ export const createCertificateProfileSchema = z
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional(),
acmeConfig: z.object({}).optional()
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional()
})
.refine(
(data) => {
@@ -155,6 +159,11 @@ export const updateCertificateProfileSchema = z
autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional()
})
.refine(

View File

@@ -403,7 +403,13 @@ export const certificateProfileServiceFactory = ({
apiConfigId = apiConfig.id;
} else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) {
const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL);
const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx);
const acmeConfig = await acmeEnrollmentConfigDAL.create(
{
skipDnsOwnershipVerification: data.acmeConfig.skipDnsOwnershipVerification ?? false,
encryptedEabSecret
},
tx
);
acmeConfigId = acmeConfig.id;
}
@@ -505,7 +511,7 @@ export const certificateProfileServiceFactory = ({
const updatedData =
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data;
const { estConfig, apiConfig, ...profileUpdateData } = updatedData;
const { estConfig, apiConfig, acmeConfig, ...profileUpdateData } = updatedData;
const updatedProfile = await certificateProfileDAL.transaction(async (tx) => {
if (estConfig && existingProfile.estConfigId) {
@@ -547,6 +553,16 @@ export const certificateProfileServiceFactory = ({
);
}
if (acmeConfig && existingProfile.acmeConfigId) {
await acmeEnrollmentConfigDAL.updateById(
existingProfile.acmeConfigId,
{
skipDnsOwnershipVerification: acmeConfig.skipDnsOwnershipVerification ?? false
},
tx
);
}
const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx);
return profileResult;
});

View File

@@ -46,7 +46,9 @@ export type TCertificateProfileUpdate = Omit<
autoRenew?: boolean;
renewBeforeDays?: number;
};
acmeConfig?: unknown;
acmeConfig?: {
skipDnsOwnershipVerification?: boolean;
};
};
export type TCertificateProfileWithConfigs = TCertificateProfile & {
@@ -83,6 +85,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
id: string;
directoryUrl: string;
encryptedEabSecret?: Buffer;
skipDnsOwnershipVerification?: boolean;
};
};

View File

@@ -91,6 +91,7 @@ export const certificateRequestServiceFactory = ({
permissionService
}: TCertificateRequestServiceFactoryDep) => {
const createCertificateRequest = async ({
acmeOrderId,
actor,
actorId,
actorAuthMethod,
@@ -123,6 +124,7 @@ export const certificateRequestServiceFactory = ({
{
status,
projectId,
acmeOrderId,
...validatedData
},
tx

View File

@@ -21,6 +21,7 @@ export type TCreateCertificateRequestDTO = TProjectPermission & {
metadata?: string;
status: CertificateRequestStatus;
certificateId?: string;
acmeOrderId?: string;
};
export type TGetCertificateRequestDTO = TProjectPermission & {

View File

@@ -1,61 +1,13 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types";
export type TAcmeEnrollmentConfigDALFactory = ReturnType<typeof acmeEnrollmentConfigDALFactory>;
export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => {
const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig);
const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*");
const [acmeConfig] = result;
if (!acmeConfig) {
throw new Error("Failed to create ACME enrollment config");
}
return acmeConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Create ACME enrollment config" });
}
};
const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*");
const [acmeConfig] = result;
if (!acmeConfig) {
return null;
}
return acmeConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Update ACME enrollment config" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first();
return acmeConfig || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find ACME enrollment config by id" });
}
};
return {
...acmeEnrollmentConfigOrm,
create,
updateById,
findById
...acmeEnrollmentConfigOrm
};
};

View File

@@ -37,4 +37,6 @@ export interface TApiConfigData {
renewBeforeDays?: number;
}
export interface TAcmeConfigData {}
export interface TAcmeConfigData {
skipDnsOwnershipVerification?: boolean;
}

View File

@@ -1986,7 +1986,7 @@ export const projectServiceFactory = ({
if (project.type === ProjectType.SecretManager) {
projectTypeUrl = "secret-management";
} else if (project.type === ProjectType.CertificateManager) {
projectTypeUrl = "cert-management";
projectTypeUrl = "cert-manager";
}
const callbackPath = `/organizations/${project.orgId}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`;

View File

@@ -61,9 +61,7 @@ export const PkiExpirationAlertTemplate = ({
</Section>
<Section className="text-center mt-[32px] mb-[16px]">
<BaseButton href={`${siteUrl}/projects/cert-management/${projectId}/policies`}>
View Certificate Alerts
</BaseButton>
<BaseButton href={`${siteUrl}/projects/cert-manager/${projectId}/policies`}>View Certificate Alerts</BaseButton>
</Section>
</BaseEmailWrapper>
);

View File

@@ -1,4 +1,6 @@
import {
AcmeAccountActor,
AcmeProfileActor,
IdentityActor,
KmipClientActor,
PlatformActor,
@@ -60,6 +62,8 @@ export type TSecretModifiedEvent = {
| ScimClientActor
| PlatformActor
| UnknownUserActor
| AcmeAccountActor
| AcmeProfileActor
| KmipClientActor;
};
};

View File

@@ -3074,6 +3074,186 @@
{
"source": "/documentation/platform/pki/est",
"destination": "/documentation/platform/pki/enrollment-methods/est"
},
{
"source": "/api-reference/endpoints/integrations/create-auth",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/create",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/delete-auth-by-id",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/delete-auth",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/delete",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/find-auth",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/list-auth",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/list-project-integrations",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/endpoints/integrations/update",
"destination": "/integrations/secret-syncs"
},
{
"source": "/api-reference/overview/examples/integration",
"destination": "/integrations/secret-syncs"
},
{
"source": "/documentation/platform/integrations",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cicd/circleci",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cicd/codefresh",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cicd/octopus-deploy",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cicd/rundeck",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cicd/travisci",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/aws-parameter-store",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/aws-secret-manager",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/azure-app-configuration",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/azure-devops",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/azure-key-vault",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/checkly",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/cloud-66",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/cloudflare-pages",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/cloudflare-workers",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/databricks",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/digital-ocean-app-platform",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/flyio",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/gcp-secret-manager",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/hashicorp-vault",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/hasura-cloud",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/heroku",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/laravel-forge",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/netlify",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/northflank",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/qovery",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/railway",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/render",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/supabase",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/terraform-cloud",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/vercel",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/windmill",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/overview",
"destination": "/integrations/secret-syncs"
},
{
"source": "/integrations/cloud/aws-amplify",
"destination": "/integrations/cicd/aws-amplify"
},
{
"source": "/integrations/cloud/teamcity",
"destination": "/integrations/secret-syncs/teamcity"
}
]
}

View File

@@ -26,6 +26,17 @@ In the following steps, we explore how to issue a X.509 certificate using the AC
![pki acme config](/images/platform/pki/enrollment-methods/acme/acme-config.png)
<Note>
By default, when the ACME client requests a certificate against the certificate profile for a particular domain, Infisical will verify domain ownership using the [HTTP-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) method prior to issuing a certificate back to the client.
If you want Infisical to skip domain ownership validation entirely, you can enable the **Skip DNS Ownership Validation** checkbox.
Note that skipping domain ownership validation for the ACME enrollment method is **not the same** as skipping validation for an [External ACME CA integration](/documentation/platform/pki/ca/acme-ca).
When using the ACME enrollment, the domain ownership check occurring between the ACME client and Infisical can be skipped. In contrast, External ACME CA integrations always require domain ownership validation, as Infisical must complete a DNS-01 challenge with the upstream ACME-compatible CA.
</Note>
</Step>
<Step title="Obtain the ACME configuration">
Once you've created the certificate profile, you can obtain its ACME configuration details by clicking the **Reveal ACME EAB** option on the profile.

View File

@@ -139,7 +139,7 @@ The following steps show how to install cert-manager (using `kubectl`) and obtai
```
<Note>
- Currently, the Infisical ACME server only supports the HTTP-01 challenge and requires successful challenge completion before issuing certificates. Support for optional challenges and DNS-01 is planned for a future release.
- Currently, the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) only supports the [HTTP-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) method. Support for the [DNS-01 challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) method is planned for a future release. If domain ownership validation is not desired, you can disable it by enabling the **Skip DNS ownership validation** option in your ACME certificate profile configuration.
- An `Issuer` is namespace-scoped. Certificates can only be issued using an `Issuer` that exists in the same namespace as the `Certificate` resource.
- If you need to issue certificates across multiple namespaces with a single resource, create a `ClusterIssuer` instead. The configuration is identical except `kind: ClusterIssuer` and no `metadata.namespace`.
- More details: https://cert-manager.io/docs/configuration/acme/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -1,8 +0,0 @@
---
title: "TeamCity"
description: "How to sync secrets from Infisical to TeamCity"
---
<Note>
The TeamCity Native Integration will be deprecated in 2026. Please migrate to our new [TeamCity Sync](../secret-syncs/teamcity).
</Note>

View File

@@ -66,7 +66,7 @@ const PROJECT_TYPE_MENU_ITEMS = [
value: ProjectType.SecretManager
},
{
label: "Certificates Management",
label: "Certificate Manager",
value: ProjectType.CertificateManager
},
{

View File

@@ -9,7 +9,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, Input, Select, SelectItem, Switch, Tooltip } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs";
import {
SecretSync,
SecretSyncInitialSyncBehavior,
useSecretSyncOption
} from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
import { AwsParameterStoreSyncOptionsFields } from "./AwsParameterStoreSyncOptionsFields";
@@ -139,13 +143,25 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
</FormControl>
)}
/>
{!syncOption?.canImportSecrets && (
{!syncOption?.canImportSecrets ? (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faTriangleExclamation} />
{destinationName} only supports overwriting destination secrets.{" "}
{!currentSyncOption.disableSecretDeletion &&
"Secrets not present in Infisical will be removed from the destination."}
`Secrets not present in Infisical will be removed from the destination. Consider adding a key schema or disabling secret deletion if you do not want existing secrets to be removed from ${destinationName}.`}
</p>
) : (
currentSyncOption.initialSyncBehavior ===
SecretSyncInitialSyncBehavior.OverwriteDestination &&
!currentSyncOption.disableSecretDeletion && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faTriangleExclamation} />
Secrets not present in Infisical will be removed from the destination. If you have
secrets in {destinationName} that you do not want deleted, consider setting initial
sync behavior to import destination secrets. Alternatively, configure a key schema
or disable secret deletion below to have Infisical ignore these secrets.
</p>
)
)}
</>
)}
@@ -183,26 +199,26 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
className="max-w-md"
content={
<span>
We highly recommend using a{" "}
We highly recommend configuring a{" "}
<a
href="https://infisical.com/docs/integrations/secret-syncs/overview#key-schemas"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Key Schema
key schema
</a>{" "}
to ensure that Infisical only manages the specific keys you intend, keeping
everything else untouched.
to ensure that Infisical only manages secrets in {destinationName} that match
the key pattern.
<br />
<br />
Destination secrets that do not match the schema will not be deleted or updated.
</span>
}
>
<div>
<span>Infisical strongly advises setting a Key Schema</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
<div className="text-info">
<span>Infisical strongly advises configuring a key schema</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} />
</div>
</Tooltip>
}

View File

@@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P
<div className="mr-4 flex w-full items-center">
<h1
className={twMerge(
"text-2xl font-medium text-white capitalize underline decoration-2 underline-offset-4",
"text-2xl font-medium text-white underline decoration-2 underline-offset-4",
scope === "org" && "decoration-org/90",
scope === "instance" && "decoration-neutral/90",
scope === "namespace" && "decoration-sub-org/90",

View File

@@ -11,8 +11,9 @@ const alertVariants = cva(
variants: {
variant: {
default: "bg-container text-card-foreground",
info: "bg-info/10 text-info border-info/20",
org: "bg-org/10 text-org border-org/20"
info: "bg-info/5 text-info border-info/20",
org: "bg-org/5 text-org border-org/20",
"sub-org": "bg-sub-org/5 text-sub-org border-sub-org/20"
}
},
defaultVariants: {

View File

@@ -294,36 +294,36 @@ export const ROUTE_PATHS = Object.freeze({
},
CertManager: {
CertAuthDetailsByIDPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId"
"/organizations/$orgId/projects/cert-manager/$projectId/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/ca/$caId"
),
SubscribersPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/subscribers",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers"
"/organizations/$orgId/projects/cert-manager/$projectId/subscribers",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers"
),
CertificateAuthoritiesPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities"
"/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-authorities"
),
AlertingPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/alerting",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/alerting"
"/organizations/$orgId/projects/cert-manager/$projectId/alerting",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/alerting"
),
PkiCollectionDetailsByIDPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/pki-collections/$collectionId"
"/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId"
),
PkiSubscriberDetailsByIDPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/$subscriberName"
"/organizations/$orgId/projects/cert-manager/$projectId/subscribers/$subscriberName",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName"
),
IntegrationsListPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/integrations",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/"
"/organizations/$orgId/projects/cert-manager/$projectId/integrations",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/"
),
PkiSyncDetailsByIDPage: setRoute(
"/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId"
"/organizations/$orgId/projects/cert-manager/$projectId/integrations/$syncId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/$syncId"
)
},
Ssh: {

View File

@@ -61,7 +61,7 @@ export const getProjectBaseURL = (type: ProjectType) => {
case ProjectType.SecretManager:
return "/organizations/$orgId/projects/secret-management/$projectId";
case ProjectType.CertificateManager:
return "/organizations/$orgId/projects/cert-management/$projectId";
return "/organizations/$orgId/projects/cert-manager/$projectId";
default:
return `/organizations/$orgId/projects/${type}/$projectId` as const;
}
@@ -74,7 +74,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
case ProjectType.SecretManager:
return "/organizations/$orgId/projects/secret-management/$projectId/overview" as const;
case ProjectType.CertificateManager:
return "/organizations/$orgId/projects/cert-management/$projectId/policies" as const;
return "/organizations/$orgId/projects/cert-manager/$projectId/policies" as const;
case ProjectType.SecretScanning:
return `/organizations/$orgId/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM:
@@ -88,7 +88,7 @@ export const getProjectTitle = (type: ProjectType) => {
const titleConvert = {
[ProjectType.SecretManager]: "Secrets Management",
[ProjectType.KMS]: "Key Management",
[ProjectType.CertificateManager]: "Cert Management",
[ProjectType.CertificateManager]: "Certificate Manager",
[ProjectType.SSH]: "SSH",
[ProjectType.SecretScanning]: "Secret Scanning",
[ProjectType.PAM]: "PAM"

View File

@@ -4,6 +4,8 @@ export enum ActorType {
USER = "user",
SERVICE = "service",
IDENTITY = "identity",
ACME_PROFILE = "acmeProfile",
ACME_ACCOUNT = "acmeAccount",
UNKNOWN_USER = "unknownUser"
}

View File

@@ -38,6 +38,13 @@ interface KmipClientActorMetadata {
name: string;
}
interface AcmeAccountActorMetadata {
profileId: string;
accountId: string;
}
interface AcmeProfileActorMetadata {
profileId: string;
}
interface UserActor {
type: ActorType.USER;
metadata: UserActorMetadata;
@@ -67,13 +74,25 @@ export interface UnknownUserActor {
type: ActorType.UNKNOWN_USER;
}
export interface AcmeProfileActor {
type: ActorType.ACME_PROFILE;
metadata: AcmeProfileActorMetadata;
}
export interface AcmeAccountActor {
type: ActorType.ACME_ACCOUNT;
metadata: AcmeAccountActorMetadata;
}
export type Actor =
| UserActor
| ServiceActor
| IdentityActor
| PlatformActor
| UnknownUserActor
| KmipClientActor;
| KmipClientActor
| AcmeProfileActor
| AcmeAccountActor;
interface GetSecretsEvent {
type: EventType.GET_SECRETS;

View File

@@ -62,6 +62,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
acmeConfig?: {
id: string;
directoryUrl: string;
skipDnsOwnershipVerification?: boolean;
};
};

View File

@@ -43,7 +43,7 @@ export const PkiManagerLayout = () => {
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/policies"
to="/organizations/$orgId/projects/cert-manager/$projectId/policies"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -52,7 +52,7 @@ export const PkiManagerLayout = () => {
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities"
to="/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -65,7 +65,7 @@ export const PkiManagerLayout = () => {
)}
</Link>
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/alerting"
to="/organizations/$orgId/projects/cert-manager/$projectId/alerting"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -74,7 +74,7 @@ export const PkiManagerLayout = () => {
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Alerting</Tab>}
</Link>
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/integrations"
to="/organizations/$orgId/projects/cert-manager/$projectId/integrations"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -83,7 +83,7 @@ export const PkiManagerLayout = () => {
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Integrations</Tab>}
</Link>
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/app-connections"
to="/organizations/$orgId/projects/cert-manager/$projectId/app-connections"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -95,7 +95,7 @@ export const PkiManagerLayout = () => {
<>
{(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/subscribers"
to="/organizations/$orgId/projects/cert-manager/$projectId/subscribers"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -108,7 +108,7 @@ export const PkiManagerLayout = () => {
)}
{(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/certificate-templates"
to="/organizations/$orgId/projects/cert-manager/$projectId/certificate-templates"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -124,7 +124,7 @@ export const PkiManagerLayout = () => {
</>
)}
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/access-management"
to="/organizations/$orgId/projects/cert-manager/$projectId/access-management"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -144,7 +144,7 @@ export const PkiManagerLayout = () => {
)}
</Link>
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/audit-logs"
to="/organizations/$orgId/projects/cert-manager/$projectId/audit-logs"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
@@ -153,7 +153,7 @@ export const PkiManagerLayout = () => {
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/settings"
to="/organizations/$orgId/projects/cert-manager/$projectId/settings"
params={{
orgId: currentOrg.id,
projectId: currentProject.id

View File

@@ -82,7 +82,7 @@ export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => {
});
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId",
to: "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId",
params: {
orgId: currentOrg.id,
projectId,

View File

@@ -66,7 +66,7 @@ export const PkiCollectionTable = ({ handlePopUpOpen }: Props) => {
key={`pki-collection-${pkiCollection.id}`}
onClick={() =>
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId",
to: "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId",
params: {
orgId: currentOrg.id,
projectId,

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { AlertingPage } from "./AlertingPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/alerting"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/alerting"
)({
component: AlertingPage,
beforeLoad: ({ context }) => {

View File

@@ -79,7 +79,7 @@ const Page = () => {
handlePopUpClose("deleteCa");
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities",
to: "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities",
params: {
orgId: currentOrg.id,
projectId
@@ -100,7 +100,7 @@ const Page = () => {
isAllowed ? (
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities"
to="/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities"
params={{
orgId: currentOrg.id,
projectId

View File

@@ -3,7 +3,7 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { CertAuthDetailsByIDPage } from "./CertAuthDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/ca/$caId"
)({
component: CertAuthDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -13,7 +13,7 @@ export const Route = createFileRoute(
{
label: "Certificate Authorities",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities",
to: "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -96,7 +96,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
onClick={() =>
canReadCa &&
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/ca/$caId",
to: "/organizations/$orgId/projects/cert-manager/$projectId/ca/$caId",
params: {
orgId: currentOrg.id,
projectId: currentProject.id,

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { CertificateAuthoritiesPage } from "./CertificateAuthoritiesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-authorities"
)({
component: CertificateAuthoritiesPage,
beforeLoad: ({ context }) => {

View File

@@ -15,7 +15,7 @@ const IntegrationsListPageQuerySchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/"
)({
component: IntegrationsListPage,
validateSearch: zodValidator(IntegrationsListPageQuerySchema),

View File

@@ -64,7 +64,7 @@ export const PkiCollectionPage = () => {
});
handlePopUpClose("deletePkiCollection");
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/policies",
to: "/organizations/$orgId/projects/cert-manager/$projectId/policies",
params: {
orgId: currentOrg.id,
projectId: params.projectId
@@ -77,7 +77,7 @@ export const PkiCollectionPage = () => {
{data && (
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/policies"
to="/organizations/$orgId/projects/cert-manager/$projectId/policies"
params={{
orgId: currentOrg.id,
projectId: params.projectId

View File

@@ -3,7 +3,7 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { PkiCollectionDetailsByIDPage } from "./PkiCollectionDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/pki-collections/$collectionId"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId"
)({
component: PkiCollectionDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -13,7 +13,7 @@ export const Route = createFileRoute(
{
label: "Certificate Collections",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/policies",
to: "/organizations/$orgId/projects/cert-manager/$projectId/policies",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -64,7 +64,7 @@ const Page = () => {
handlePopUpClose("deletePkiSubscriber");
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers",
to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers",
params: {
orgId: currentOrg.id,
projectId
@@ -77,7 +77,7 @@ const Page = () => {
{data && (
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/organizations/$orgId/projects/cert-management/$projectId/subscribers"
to="/organizations/$orgId/projects/cert-manager/$projectId/subscribers"
params={{
orgId: currentOrg.id,
projectId

View File

@@ -3,7 +3,7 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { PkiSubscriberDetailsByIDPage } from "./PkiSubscriberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/$subscriberName"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName"
)({
component: PkiSubscriberDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -13,7 +13,7 @@ export const Route = createFileRoute(
{
label: "Subscribers",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers",
to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -77,7 +77,7 @@ export const PkiSubscribersTable = ({ handlePopUpOpen }: Props) => {
key={`pki-subscriber-${subscriber.id}`}
onClick={() =>
navigate({
to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName",
to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers/$subscriberName",
params: {
orgId: currentOrg.id,
projectId: currentProject.id,

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { PkiSubscribersPage } from "./PkiSubscribersPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/"
)({
component: PkiSubscribersPage,
beforeLoad: ({ context }) => {

View File

@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { PkiSyncDetailsByIDPage } from "./index";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/$syncId"
)({
component: PkiSyncDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/integrations",
to: "/organizations/$orgId/projects/cert-manager/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.PkiSyncs

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { PkiTemplateListPage } from "./PkiTemplateListPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-templates/"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-templates/"
)({
component: PkiTemplateListPage,
beforeLoad: ({ context }) => {

View File

@@ -50,12 +50,12 @@ export const PoliciesPage = () => {
return (
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Management" })}</title>
<title>{t("common.head-title", { title: "Certificate Manager" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificate Management"
title="Certificate Manager"
description="Streamline certificate management by creating and maintaining templates, profiles, and certificates in one place"
/>

View File

@@ -79,7 +79,11 @@ const createSchema = z
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional(),
acmeConfig: z.object({}).optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: z
.object({
template: z.string().min(1, "Azure ADCS template is required")
@@ -219,7 +223,11 @@ const editSchema = z
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional(),
acmeConfig: z.object({}).optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: z
.object({
template: z.string().optional()
@@ -406,7 +414,13 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
acmeConfig:
profile.enrollmentType === EnrollmentType.ACME
? {
skipDnsOwnershipVerification:
profile.acmeConfig?.skipDnsOwnershipVerification || false
}
: undefined,
externalConfigs: profile.externalConfigs
? {
template:
@@ -429,7 +443,9 @@ export const CreateProfileModal = ({
autoRenew: false,
renewBeforeDays: 30
},
acmeConfig: {},
acmeConfig: {
skipDnsOwnershipVerification: false
},
externalConfigs: undefined
}
});
@@ -476,7 +492,13 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
acmeConfig:
profile.enrollmentType === EnrollmentType.ACME
? {
skipDnsOwnershipVerification:
profile.acmeConfig?.skipDnsOwnershipVerification || false
}
: undefined,
externalConfigs: profile.externalConfigs
? {
template:
@@ -667,7 +689,9 @@ export const CreateProfileModal = ({
renewBeforeDays: 30
});
setValue("estConfig", undefined);
setValue("acmeConfig", undefined);
setValue("acmeConfig", {
skipDnsOwnershipVerification: false
});
}
onChange(value);
}}
@@ -797,7 +821,9 @@ export const CreateProfileModal = ({
} else if (watchedEnrollmentType === "acme") {
setValue("estConfig", undefined);
setValue("apiConfig", undefined);
setValue("acmeConfig", {});
setValue("acmeConfig", {
skipDnsOwnershipVerification: false
});
}
onChange(value);
}}
@@ -846,7 +872,9 @@ export const CreateProfileModal = ({
} else if (value === "acme") {
setValue("apiConfig", undefined);
setValue("estConfig", undefined);
setValue("acmeConfig", {});
setValue("acmeConfig", {
skipDnsOwnershipVerification: false
});
}
onChange(value);
}}
@@ -975,10 +1003,24 @@ export const CreateProfileModal = ({
<div className="mb-4 space-y-4">
<Controller
control={control}
name="acmeConfig"
render={({ fieldState: { error } }) => (
name="acmeConfig.skipDnsOwnershipVerification"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="flex items-center gap-2">{/* FIXME: ACME configuration */}</div>
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
<Checkbox
id="skipDnsOwnershipVerification"
isChecked={value || false}
onCheckedChange={onChange}
/>
<div className="space-y-1">
<span className="text-sm font-medium text-mineshaft-100">
Skip DNS Ownership Validation
</span>
<p className="text-xs text-bunker-300">
Skip DNS ownership verification during ACME certificate issuance.
</p>
</div>
</div>
</FormControl>
)}
/>

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { PoliciesPage } from "./PoliciesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/policies"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/policies"
)({
component: PoliciesPage,
beforeLoad: ({ context }) => {

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "./SettingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/settings"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/settings"
)({
component: SettingsPage,
beforeLoad: ({ context }) => {

View File

@@ -8,7 +8,7 @@ import { PkiManagerLayout } from "@app/layouts/PkiManagerLayout";
import { ProjectSelect } from "@app/layouts/ProjectLayout/components/ProjectSelect";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout"
)({
component: PkiManagerLayout,
beforeLoad: async ({ params, context }) => {

View File

@@ -84,6 +84,7 @@ export const OrgIdentityLinkForm = ({ onClose }: Props) => {
onChange={onChange}
placeholder="Select machine identity..."
// onInputChange={setSearchValue}
autoFocus
options={rootOrgIdentities}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}

View File

@@ -185,7 +185,7 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Machine 1" />
<Input {...field} autoFocus placeholder="Machine 1" />
</FormControl>
)}
/>

View File

@@ -68,6 +68,12 @@ export const LogsTableRow = ({ auditLog, rowNumber, timezone }: Props) => {
{auditLog.actor.type === ActorType.IDENTITY && (
<Tag label="identity_name" value={auditLog.actor.metadata.name} />
)}
{auditLog.actor.type === ActorType.ACME_PROFILE && (
<Tag label="acme_profile_id" value={auditLog.actor.metadata.profileId} />
)}
{auditLog.actor.type === ActorType.ACME_ACCOUNT && (
<Tag label="acme_account_id" value={auditLog.actor.metadata.accountId} />
)}
</div>
</Td>
</Tr>

View File

@@ -16,7 +16,7 @@ export const IdentityAuthFieldDisplay = ({ label, children, className }: Props)
{children ? (
<p className="break-words">{children}</p>
) : (
<p className="text-muted italic">Not set</p>
<p className="text-muted">Not set</p>
)}
</DetailValue>
</Detail>

View File

@@ -33,7 +33,7 @@ export const ProjectsPage = () => {
const hasChildRoute = matches.some(
(match) =>
match.pathname.includes("/secret-management/") ||
match.pathname.includes("/cert-management/") ||
match.pathname.includes("/cert-manager/") ||
match.pathname.includes("/kms/") ||
match.pathname.includes("/pam/") ||
match.pathname.includes("/ssh/") ||

View File

@@ -13,7 +13,7 @@ export const OrgProductSelectSection = () => {
enabled: true
},
pkiProductEnabled: {
name: "Certificate Management",
name: "Certificate Manager",
enabled: true
},
kmsProductEnabled: {

View File

@@ -49,7 +49,7 @@ const PROJECT_TYPE_MENU_ITEMS = [
value: ProjectType.SecretManager
},
{
label: "Certificates Management",
label: "Certificate Manager",
value: ProjectType.CertificateManager
},
{

View File

@@ -1,4 +1,7 @@
import { Button } from "@app/components/v2";
import { faBorderAll, faList } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton } from "@app/components/v2";
import { PamAccountView } from "@app/hooks/api/pam";
type Props = {
@@ -9,30 +12,32 @@ type Props = {
export const AccountViewToggle = ({ value, onChange }: Props) => {
return (
<div className="flex gap-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Button
<IconButton
variant="outline_bg"
onClick={() => {
onChange(PamAccountView.Flat);
}}
ariaLabel="grid"
size="xs"
className={`${
value === PamAccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Hide Folders
</Button>
<Button
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
onChange(PamAccountView.Nested);
}}
ariaLabel="list"
size="xs"
className={`${
value === PamAccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Show Folders
</Button>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { LogInIcon, PackageOpenIcon } from "lucide-react";
import { Badge, UnstableButton } from "@app/components/v3";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
type Props = {
account: TPamAccount;
onAccess: (resource: TPamAccount) => void;
accountPath?: string;
};
export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
const { name, description, resource } = account;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
return (
<button
type="button"
key={account.id}
className="flex flex-col overflow-clip rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-4 text-start transition-transform duration-100"
>
<div className="flex items-center gap-3.5">
<img
alt={resourceTypeName}
src={`/images/integrations/${image}`}
className="size-10 object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-lg font-medium text-mineshaft-100">{name}</p>
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
<LogInIcon />
Connect
</UnstableButton>
</div>
<p
className={`${accountPath ? "text-mineshaft-300" : "text-mineshaft-400"} truncate text-xs leading-4`}
>
{resourceTypeName} - {accountPath || "root"}
</p>
</div>
</div>
<Badge variant="neutral" className="mt-3.5">
<PackageOpenIcon />
{resource.name}
</Badge>
<p className="mt-2 truncate text-sm text-mineshaft-400">{description || "No description"}</p>
</button>
);
};

View File

@@ -10,7 +10,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { FolderIcon, PackageOpenIcon } from "lucide-react";
import { PackageOpenIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -39,8 +39,6 @@ type Props = {
onUpdate: (resource: TPamAccount) => void;
onDelete: (resource: TPamAccount) => void;
search: string;
isFlatView: boolean;
accountPath?: string;
isAccessLoading?: boolean;
};
@@ -50,8 +48,6 @@ export const PamAccountRow = ({
onAccess,
onUpdate,
onDelete,
isFlatView,
accountPath,
isAccessLoading
}: Props) => {
const { id, name } = account;
@@ -95,14 +91,6 @@ export const PamAccountRow = ({
<HighlightText text={account.resource.name} highlight={search} />
</span>
</Badge>
{isFlatView && accountPath && (
<Badge variant="neutral">
<FolderIcon />
<span>
<HighlightText text={accountPath} highlight={search} />
</span>
</Badge>
)}
{"lastRotatedAt" in account && account.lastRotatedAt && (
<Tooltip
className="max-w-sm text-center"

View File

@@ -62,6 +62,7 @@ import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/quer
import { AccountViewToggle } from "./AccountViewToggle";
import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
import { PamAccessAccountModal } from "./PamAccessAccountModal";
import { PamAccountCard } from "./PamAccountCard";
import { PamAccountRow } from "./PamAccountRow";
import { PamAddAccountModal } from "./PamAddAccountModal";
import { PamAddFolderModal } from "./PamAddFolderModal";
@@ -128,7 +129,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
setOrderDirection,
setOrderBy
} = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, {
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 20),
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 18),
initSearch
});
@@ -231,10 +232,25 @@ export const PamAccountsTable = ({ projectId }: Props) => {
const resources = resourcesData?.resources || [];
function accessAccount(account: TPamAccount) {
// For AWS IAM, directly open console without modal
if (account.resource.resourceType === PamResourceType.AwsIam) {
let fullAccountPath = account?.name;
const folderPath = account.folderId ? folderPaths[account.folderId] : undefined;
if (folderPath) {
const path = folderPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${account?.name}`;
}
accessAwsIam(account, fullAccountPath);
} else {
handlePopUpOpen("accessAccount", account);
}
}
return (
<div>
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
<div className="mt-4 flex gap-2">
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
{(isAllowed) =>
isAllowed && (
@@ -392,32 +408,64 @@ export const PamAccountsTable = ({ projectId }: Props) => {
)}
</ProjectPermissionCan>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Accounts
<IconButton
variant="plain"
className={getClassName(PamAccountOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PamAccountOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
{!isLoading && (
<>
{accountView !== PamAccountView.Flat &&
foldersToRender.map((folder) => (
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
{accountView === PamAccountView.Flat ? (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{filteredAccounts.map((account) => (
<PamAccountCard
key={account.id}
account={account}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e: TPamAccount) => accessAccount(e)}
/>
))}
</div>
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
className="rounded border border-mineshaft-500"
/>
)}
{Boolean(totalCount) && !isLoading && !isContentEmpty && (
<Pagination
className="col-span-full justify-start! border-transparent bg-transparent pl-2"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
perPageList={[9, 18, 48, 99]}
/>
)}
</>
) : (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Accounts
<IconButton
variant="plain"
className={getClassName(PamAccountOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PamAccountOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
{!isLoading && (
<>
{foldersToRender.map((folder) => (
<PamFolderRow
key={folder.id}
folder={folder}
@@ -427,53 +475,39 @@ export const PamAccountsTable = ({ projectId }: Props) => {
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
/>
))}
{filteredAccounts.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
isFlatView={accountView === PamAccountView.Flat}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
isAccessLoading={loadingAccountId === account.id}
onAccess={(e: TPamAccount) => {
// For AWS IAM, directly open console without modal
if (e.resource.resourceType === PamResourceType.AwsIam) {
let fullAccountPath = e?.name;
const folderPath = e.folderId ? folderPaths[e.folderId] : undefined;
if (folderPath) {
const path = folderPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${e?.name}`;
}
accessAwsIam(e, fullAccountPath);
} else {
handlePopUpOpen("accessAccount", e);
}
}}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</>
)}
</TBody>
</Table>
{Boolean(totalCount) && !isLoading && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
</TableContainer>
{filteredAccounts.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
isAccessLoading={loadingAccountId === account.id}
onAccess={(e: TPamAccount) => accessAccount(e)}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</>
)}
</TBody>
</Table>
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
{Boolean(totalCount) && !isLoading && !isContentEmpty && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
perPageList={[9, 18, 48, 99]}
/>
)}
</TableContainer>
)}
<PamDeleteFolderModal
isOpen={popUp.deleteFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}

View File

@@ -48,6 +48,7 @@ export const PamAddAccountModal = ({
onComplete={(account) => {
if (onComplete) onComplete(account);
onOpenChange(false);
setSelectedResource(null);
}}
onBack={() => setSelectedResource(null)}
resourceId={selectedResource.id}

View File

@@ -170,7 +170,7 @@ export const ProjectIdentityModal = ({ onClose, identity }: ContentProps) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Machine 1" />
<Input {...field} autoFocus placeholder="Machine 1" />
</FormControl>
)}
/>

View File

@@ -113,6 +113,7 @@ export const ProjectLinkIdentityModal = ({ handlePopUpToggle }: Props) => {
onChange={onChange}
isLoading={isMembershipsLoading}
placeholder="Select machine identity..."
autoFocus
// onInputChange={setSearchValue}
options={filteredIdentityMembershipOrgs.map((membership) => ({
name: membership.name,

View File

@@ -151,6 +151,11 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
type: "success"
});
handlePopUpToggle("addMember", false);
if (requesterEmail) {
navigate({
search: (prev) => ({ ...prev, requesterEmail: "" })
});
}
reset();
};
@@ -203,7 +208,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => {
if (!isOpen)
if (!isOpen && requesterEmail)
navigate({
search: (prev) => ({ ...prev, requesterEmail: "" })
});

View File

@@ -12,7 +12,7 @@ const AccessControlPageQuerySchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/access-management"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/access-management"
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema),

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { AppConnectionsPage } from "./AppConnectionsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/app-connections"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/app-connections"
)({
component: AppConnectionsPage,
beforeLoad: ({ context }) => {

View File

@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { AuditLogsPage } from "./AuditLogsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/audit-logs"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/audit-logs"
)({
component: AuditLogsPage,
beforeLoad: ({ context }) => {

View File

@@ -5,7 +5,7 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/groups/$groupId"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/groups/$groupId"
)({
component: GroupDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Access Control",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/access-management",
to: "/organizations/$orgId/projects/cert-manager/$projectId/access-management",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -1,7 +1,6 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { DropdownMenu } from "@radix-ui/react-dropdown-menu";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { ChevronLeftIcon, EllipsisIcon, InfoIcon } from "lucide-react";
@@ -17,6 +16,7 @@ import {
} from "@app/components/v2";
import {
OrgIcon,
SubOrgIcon,
UnstableAlert,
UnstableAlertDescription,
UnstableAlertTitle,
@@ -26,6 +26,7 @@ import {
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
@@ -63,7 +64,7 @@ const Page = () => {
select: (el) => el.identityId as string
});
const { currentProject, projectId } = useProject();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } =
useGetProjectIdentityMembershipV2(projectId, identityId);
@@ -164,6 +165,12 @@ const Page = () => {
return <UnstablePageLoader />;
}
const isOrgIdentity = !isProjectIdentity;
const isSubOrgIdentity =
isOrgIdentity &&
isSubOrganization &&
currentOrg.rootOrgId !== identityMembershipDetails?.identity.orgId;
return (
<div className="mx-auto flex max-w-8xl flex-col">
{identityMembershipDetails ? (
@@ -187,7 +194,7 @@ const Page = () => {
description={`Configure and manage${isProjectIdentity ? " machine identity and " : " "}project access control`}
title={identityMembershipDetails.identity.name}
>
<DropdownMenu>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
@@ -197,7 +204,7 @@ const Page = () => {
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(identityMembershipDetails.id);
navigator.clipboard.writeText(identityMembershipDetails.identity.id);
createNotification({
text: "Machine identity ID copied to clipboard",
type: "info"
@@ -211,7 +218,6 @@ const Page = () => {
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity.id
})}
passThrough={false}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
@@ -221,7 +227,7 @@ const Page = () => {
Assume Privileges
<Tooltip
side="bottom"
content="Assume the privileges of the machine identity, allowing you to replicate their access behavior."
content="Assume the privileges of this machine identity, allowing you to replicate their access behavior."
>
<div>
<InfoIcon className="text-muted" />
@@ -251,12 +257,13 @@ const Page = () => {
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</DropdownMenu>
</UnstableDropdownMenu>
</PageHeader>
<div className="flex flex-col gap-5 lg:flex-row">
<ProjectIdentityDetailsSection
identity={identity || { ...identityMembershipDetails?.identity, projectId: "" }}
isOrgIdentity={!isProjectIdentity}
isOrgIdentity={isOrgIdentity}
isSubOrgIdentity={isSubOrgIdentity}
membership={identityMembershipDetails!}
/>
@@ -275,15 +282,15 @@ const Page = () => {
</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<UnstableAlert variant="org">
<OrgIcon />
<UnstableAlert variant={isSubOrgIdentity ? "sub-org" : "org"}>
{isSubOrgIdentity ? <SubOrgIcon /> : <OrgIcon />}
<UnstableAlertTitle>
Machine identity managed by organization
Machine identity managed by {isSubOrgIdentity ? "sub-" : ""}organization
</UnstableAlertTitle>
<UnstableAlertDescription>
<p>
This machine identity&apos;s authentication methods are controlled by your
organization. To make changes,{" "}
This machine identity&apos;s authentication methods are managed by your
{isSubOrgIdentity ? "sub-" : ""}organization. <br /> To make changes,{" "}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity}
@@ -295,10 +302,10 @@ const Page = () => {
className="inline-block cursor-pointer text-foreground underline underline-offset-2"
params={{
identityId,
orgId: currentOrg.id
orgId: identityMembershipDetails.identity.orgId
}}
>
go to organization access control
go to {isSubOrgIdentity ? "sub-" : ""}organization access control
</Link>
) : null
}

View File

@@ -114,6 +114,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting }
} = form;
@@ -310,7 +311,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
onClick={() => reset()}
>
Discard Changes
</Button>

View File

@@ -81,9 +81,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
return (
<>
<UnstableCard>
<UnstableCardHeader
// className="border-b"
>
<UnstableCardHeader>
<UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
<UnstableCardDescription>
Assign one-off policies to this machine identity

View File

@@ -96,9 +96,7 @@ export const IdentityRoleDetailsSection = ({
return (
<>
<UnstableCard>
<UnstableCardHeader
// className="border-b"
>
<UnstableCardHeader>
<UnstableCardTitle>Project Roles</UnstableCardTitle>
<UnstableCardDescription>
Manage roles assigned to this machine identity
@@ -208,8 +206,6 @@ export const IdentityRoleDetailsSection = ({
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<UnstableDropdownMenuItem
@@ -223,7 +219,6 @@ export const IdentityRoleDetailsSection = ({
isDisabled={!isAllowed}
variant="danger"
>
{/* <TrashIcon /> */}
Remove Role
</UnstableDropdownMenuItem>
)}

View File

@@ -12,6 +12,7 @@ import {
DetailValue,
OrgIcon,
ProjectIcon,
SubOrgIcon,
UnstableButtonGroup,
UnstableCard,
UnstableCardAction,
@@ -30,10 +31,16 @@ import { ProjectIdentityModal } from "@app/pages/project/AccessControlPage/compo
type Props = {
identity: TProjectIdentity;
isOrgIdentity?: boolean;
isSubOrgIdentity?: boolean;
membership: IdentityProjectMembershipV1;
};
export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, membership }: Props) => {
export const ProjectIdentityDetailsSection = ({
identity,
isOrgIdentity,
isSubOrgIdentity,
membership
}: Props) => {
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars
const [_, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
@@ -43,10 +50,8 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
return (
<>
<UnstableCard className="w-full max-w-[22rem]">
<UnstableCardHeader
// className="border-b"
>
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
{!isOrgIdentity && (
@@ -92,7 +97,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
variant="ghost"
size="xs"
>
{/* TODO(scott): color this should be a button variant */}
{/* TODO(scott): color this should be a button variant and create re-usable copy button */}
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
@@ -102,9 +107,9 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
<DetailLabel>Managed by</DetailLabel>
<DetailValue>
{isOrgIdentity ? (
<Badge variant="org">
<OrgIcon />
Organization
<Badge variant={isSubOrgIdentity ? "sub-org" : "org"}>
{isSubOrgIdentity ? <SubOrgIcon /> : <OrgIcon />}
{isSubOrgIdentity ? "Sub-" : ""}Organization
</Badge>
) : (
<Badge variant="project">
@@ -129,7 +134,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
</UnstableButtonGroup>
))
) : (
<span className="text-muted italic">No metadata</span>
<span className="text-muted">No metadata</span>
)}
</DetailValue>
</Detail>
@@ -145,7 +150,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
{membership.lastLoginAuthMethod ? (
identityAuthToNameMap[membership.lastLoginAuthMethod]
) : (
<span className="text-muted italic">N/A</span>
<span className="text-muted">N/A</span>
)}
</DetailValue>
</Detail>
@@ -155,7 +160,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
{membership.lastLoginTime ? (
format(membership.lastLoginTime, "PPpp")
) : (
<span className="text-muted italic">N/A</span>
<span className="text-muted">N/A</span>
)}
</DetailValue>
</Detail>

View File

@@ -5,7 +5,7 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/identities/$identityId"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/identities/$identityId"
)({
component: IdentityDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Access Control",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/access-management",
to: "/organizations/$orgId/projects/cert-manager/$projectId/access-management",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -3,25 +3,34 @@ import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { formatRelative } from "date-fns";
import { EllipsisIcon, InfoIcon } from "lucide-react";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
Spinner,
Tooltip
} from "@app/components/v2";
import {
Badge,
UnstableButton,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger
} from "@app/components/v3";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useOrganization,
useProject
useProject,
useUser
} from "@app/context";
import { getProjectBaseURL, getProjectHomePage } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
@@ -35,6 +44,7 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
import { ProjectMemberDetailsSection } from "./components/ProjectMemberDetailsSection";
export const Page = () => {
const navigate = useNavigate();
@@ -44,12 +54,14 @@ export const Page = () => {
});
const { currentOrg } = useOrganization();
const { currentProject, projectId } = useProject();
const {
user: { id: currentUserId }
} = useUser();
const { data: membershipDetails, isPending: isMembershipDetailsLoading } =
useGetWorkspaceUserDetails(projectId, membershipId);
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } =
useDeleteUserFromWorkspace();
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const assumePrivileges = useAssumeProjectPrivileges();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -112,8 +124,10 @@ export const Page = () => {
);
}
const isOwnProjectMembershipDetails = currentUserId === membershipDetails?.user?.id;
return (
<div className="mx-auto flex max-w-8xl flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col">
{membershipDetails ? (
<>
<Link
@@ -125,7 +139,7 @@ export const Page = () => {
search={{
selectedTab: ProjectAccessControlTabs.Member
}}
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"
>
<FontAwesomeIcon icon={faChevronLeft} />
Project Users
@@ -135,62 +149,100 @@ export const Page = () => {
title={
membershipDetails.user.firstName || membershipDetails.user.lastName
? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}`
: "-"
: membershipDetails.user.email ||
membershipDetails.user.username ||
membershipDetails.inviteEmail ||
"Unnamed User"
}
description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`}
description="Configure and manage project access control"
>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Assume privileges of the user"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={assumePrivileges.isPending}
onClick={() =>
handlePopUpOpen("assumePrivileges", { userId: membershipDetails?.user?.id })
}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove from project"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={isRemovingUserFromWorkspace}
onClick={() => handlePopUpOpen("removeMember")}
>
Remove User
</Button>
)}
</ProjectPermissionCan>
{isOwnProjectMembershipDetails ? (
<Tooltip
side="right"
content="You cannot modify your own membership. Ask a project admin to make changes to your membership."
>
<Badge variant="info" className="ml-2">
<InfoIcon /> Your project membership
</Badge>
</Tooltip>
) : (
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(membershipDetails.user.id);
createNotification({
text: "User ID copied to clipboard",
type: "info"
});
}}
>
Copy User ID
</UnstableDropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={() =>
handlePopUpOpen("assumePrivileges", {
userId: membershipDetails.user.id
})
}
>
Assume Privileges
<Tooltip
side="bottom"
content="Assume the privileges of this user, allowing you to replicate their access behavior."
>
<div>
<InfoIcon className="text-muted" />
</div>
</Tooltip>
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("removeMember")}
>
Remove User From Project
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
)}
</PageHeader>
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
onOpenUpgradeModal={() =>
handlePopUpOpen("upgradePlan", {
text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan."
})
}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
<div className="flex flex-col gap-5 lg:flex-row">
<ProjectMemberDetailsSection membership={membershipDetails} />
<div className="flex flex-1 flex-col gap-y-5">
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
onOpenUpgradeModal={() =>
handlePopUpOpen("upgradePlan", {
text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan."
})
}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
</div>
</div>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"

View File

@@ -1,26 +1,35 @@
import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { ClockAlertIcon, ClockIcon, EllipsisIcon, PlusIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
DeleteActionModal,
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
Badge,
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
@@ -68,188 +77,216 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P
handlePopUpClose("deletePrivilege");
};
const hasAdditionalPrivileges = Boolean(userProjectPrivileges?.length);
return (
<div className="relative">
<AnimatePresence>
{popUp?.modifyPrivilege.isOpen ? (
<motion.div
key="privilege-modify"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-40 w-full"
>
<MembershipProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
projectMembershipId={membershipDetails?.id}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
}
/>
</motion.div>
) : (
<motion.div
key="privilege-list"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
className="absolute w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">
Project Additional Privileges
</h3>
{userId !== membershipDetails?.user?.id &&
membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Add Privilege"
<>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
<UnstableCardDescription>Assign one-off policies to this user</UnstableCardDescription>
{!isOwnProjectMembershipDetails && hasAdditionalPrivileges && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableButton
variant="outline"
size="xs"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
<PlusIcon />
Add Additional Privileges
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
{/* eslint-disable-next-line no-nested-ternary */}
{isPending ? (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isPending &&
userProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
) : userProjectPrivileges?.length ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/2">Name</UnstableTableHead>
<UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
{!isOwnProjectMembershipDetails && <UnstableTableHead className="w-5" />}
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{!isPending &&
userProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (privilegeDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (privilegeDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr
key={`user-project-privilege-${privilegeDetails?.id}`}
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}
}}
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)}
>
<Td>{privilegeDetails.slug}</Td>
<Td>
<Tooltip asChild={false} content={toolTipText}>
<Tag
className={twMerge(
"capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Tag>
</Tooltip>
</Td>
<Td>
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
return (
<UnstableTableRow key={`user-project-privilege-${privilegeDetails?.id}`}>
<UnstableTableCell className="max-w-0 truncate">
{privilegeDetails.slug}
</UnstableTableCell>
<UnstableTableCell>
{isTemporary ? (
<Tooltip content={toolTipText}>
<Badge
className="capitalize"
variant={isExpired ? "danger" : "warning"}
>
{isExpired ? <ClockAlertIcon /> : <ClockIcon />}
{text}
</Badge>
</Tooltip>
) : (
text
)}
</UnstableTableCell>
{!isOwnProjectMembershipDetails && (
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="delete-icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}}
>
Edit Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
variant="danger"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id,
slug: privilegeDetails?.slug
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
Remove Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<IconButton
ariaLabel="more-icon"
variant="plain"
className="group relative"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !userProjectPrivileges?.length && (
<EmptyState title="This user has no additional privileges" icon={faFolder} />
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove role ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
)}
</UnstableTableRow>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>This user has no additional privileges</UnstableEmptyTitle>
<UnstableEmptyDescription>
Add an additional privilege to grant one-off access policies
</UnstableEmptyDescription>
</UnstableEmptyHeader>
{!isOwnProjectMembershipDetails && (
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
>
<PlusIcon />
Add Additional Privileges
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
)}
</UnstableEmpty>
)}
</UnstableCardContent>
</UnstableCard>
<Modal
isOpen={popUp.modifyPrivilege.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("modifyPrivilege", isOpen)}
>
<ModalContent
className="max-w-6xl"
title="Additional Privileges"
subTitle="Additional privileges take precedence over roles when permissions conflict"
>
<MembershipProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
projectMembershipId={membershipDetails?.id}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove role ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</>
);
};

View File

@@ -1,5 +1,5 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faCaretDown, faChevronLeft, faClock, faSave } from "@fortawesome/free-solid-svg-icons";
import { faCaretDown, faClock, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
@@ -20,6 +20,7 @@ import {
Tag,
Tooltip
} from "@app/components/v2";
import { UnstableSeparator } from "@app/components/v3";
import {
ProjectPermissionMemberActions,
ProjectPermissionSub,
@@ -111,6 +112,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting }
} = form;
@@ -176,59 +178,9 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<form className="flex flex-col gap-y-4" onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<Button
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
className="text-lg font-medium text-mineshaft-100"
variant="link"
onClick={onGoBack}
>
Back
</Button>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge(
"mr-4 h-10 border border-primary",
isDirty && "bg-primary text-black"
)}
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<AddPoliciesButton
isDisabled={isDisabled}
projectType={currentProject.type}
projectId={projectId}
/>
</div>
</div>
</div>
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
<div className="text-lg">Overview</div>
<p className="mb-4 text-sm text-mineshaft-300">
Additional privileges take precedence over roles when permissions conflict
</p>
<div>
<div className="flex items-end space-x-6">
<div className="w-full max-w-md">
<Controller
@@ -344,22 +296,61 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
</div>
</div>
</div>
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
{(isCreate || !isPending) && <PermissionEmptyState />}
<div>
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
<UnstableSeparator />
<div>
<div className="mb-3 flex w-full items-center justify-between">
<div className="text-lg">Policies</div>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
Discard Changes
</Button>
)}
<div className="flex items-center">
<AddPoliciesButton
isDisabled={isDisabled}
projectType={currentProject.type}
projectId={projectId}
/>
</div>
</div>
</div>
{(isCreate || !isPending) && <PermissionEmptyState />}
<div className="scrollbar-thin max-h-[50vh] overflow-y-auto">
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
(permissionSubject) => (
<GeneralPermissionPolicies
subject={permissionSubject}
actions={PROJECT_PERMISSION_OBJECT[permissionSubject].actions}
title={PROJECT_PERMISSION_OBJECT[permissionSubject].title}
key={`project-permission-${permissionSubject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(permissionSubject, isDisabled)}
</GeneralPermissionPolicies>
)
)}
</div>
</div>
<UnstableSeparator />
<div className="flex w-full items-center justify-end gap-x-2">
<Button colorSchema="secondary" variant="plain" onClick={onGoBack}>
Cancel
</Button>
<Button
type="submit"
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
</div>
</FormProvider>
</form>

View File

@@ -1,27 +1,35 @@
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ClockAlertIcon, ClockIcon, EllipsisIcon, PencilIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
DeleteActionModal,
EmptyState,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
Badge,
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3/generic";
import { ProjectPermissionActions, ProjectPermissionSub, useProject, useUser } from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
@@ -88,131 +96,176 @@ export const MemberRoleDetailsSection = ({
handlePopUpClose("deleteRole");
};
const hasRoles = Boolean(membershipDetails?.roles.length);
return (
<div className="mb-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Project Roles</h3>
{!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Edit Role(s)"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
<>
<UnstableCard>
<UnstableCardHeader>
<UnstableCardTitle>Project Roles</UnstableCardTitle>
<UnstableCardDescription>Manage roles assigned to this user</UnstableCardDescription>
{!isOwnProjectMembershipDetails && hasRoles && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Role</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isMembershipDetailsLoading && (
<TableSkeleton columns={3} innerKey="user-project-memberships" />
)}
{!isMembershipDetailsLoading &&
membershipDetails?.roles?.map((roleDetails) => {
const isTemporary = roleDetails?.isTemporary;
const isExpired =
roleDetails.isTemporary &&
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (roleDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(roleDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(roleDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr className="group h-10" key={`user-project-membership-${roleDetails?.id}`}>
<Td className="capitalize">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</Td>
<Td>
<Tooltip asChild={false} content={toolTipText}>
<Tag
className={twMerge(
"capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Tag>
</Tooltip>
</Td>
<Td>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isMembershipDetailsLoading && !membershipDetails?.roles?.length && (
<EmptyState title="This user has no roles" icon={faFolder} />
{(isAllowed) => (
<UnstableButton
size="xs"
variant="outline"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<PencilIcon />
Edit Roles
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</TableContainer>
</div>
</UnstableCardHeader>
<UnstableCardContent>
{
/* eslint-disable-next-line no-nested-ternary */
isMembershipDetailsLoading ? (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
) : hasRoles ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/2">Role</UnstableTableHead>
<UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
{!isOwnProjectMembershipDetails && <UnstableTableHead className="w-5" />}
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{membershipDetails?.roles?.map((roleDetails) => {
const isTemporary = roleDetails?.isTemporary;
const isExpired =
roleDetails.isTemporary &&
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (roleDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(roleDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(roleDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<UnstableTableRow
className="group h-10"
key={`user-project-identity-${roleDetails?.id}`}
>
<UnstableTableCell className="max-w-0 truncate">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</UnstableTableCell>
<UnstableTableCell>
{isTemporary ? (
<Tooltip content={toolTipText}>
<Badge
className="capitalize"
variant={isExpired ? "danger" : "warning"}
>
{isExpired ? <ClockAlertIcon /> : <ClockIcon />}
{text}
</Badge>
</Tooltip>
) : (
text
)}
</UnstableTableCell>
{!isOwnProjectMembershipDetails && (
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
isDisabled={!isAllowed}
variant="danger"
>
Remove Role
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
)}
</UnstableTableRow>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>This user doesn&nbsp;t have any roles</UnstableEmptyTitle>
<UnstableEmptyDescription>
Give this user one or more roles
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
>
<PencilIcon />
Edit Roles
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)
}
</UnstableCardContent>
</UnstableCard>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
deleteKey="remove"
@@ -234,6 +287,6 @@ export const MemberRoleDetailsSection = ({
/>
</ModalContent>
</Modal>
</div>
</>
);
};

View File

@@ -0,0 +1,105 @@
import { format } from "date-fns";
import { CheckIcon, ClipboardListIcon } from "lucide-react";
import { Tooltip } from "@app/components/v2";
import {
Detail,
DetailGroup,
DetailLabel,
DetailValue,
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableIconButton
} from "@app/components/v3";
import { useTimedReset } from "@app/hooks";
import { TWorkspaceUser } from "@app/hooks/api/types";
type Props = {
membership: TWorkspaceUser;
};
export const ProjectMemberDetailsSection = ({ membership }: Props) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention
const [_copyId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention
const [_copyEmail, isCopyingEmail, setCopyEmail] = useTimedReset<string>({
initialState: "Copy email to clipboard"
});
const {
user: { email, username, firstName, lastName, id: userId }
} = membership;
const name = firstName || lastName ? `${firstName} ${lastName}`.trim() : null;
return (
<UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader>
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>User membership details</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{name || <span className="text-muted">Not set</span>}</DetailValue>
</Detail>
<Detail>
<DetailLabel>ID</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{membership.user.id}
<Tooltip content="Copy user ID to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(userId);
setCopyTextId("Copied");
}}
variant="ghost"
size="xs"
>
{/* TODO(scott): color this should be a button variant and create re-usable copy button */}
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Email</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{email}
<Tooltip content="Copy user email to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(email);
setCopyEmail("Copied");
}}
variant="ghost"
size="xs"
>
{/* TODO(scott): color this should be a button variant and create re-usable copy button */}
{isCopyingEmail ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</DetailValue>
</Detail>
{username !== email && (
<Detail>
<DetailLabel>Username</DetailLabel>
<DetailValue>{username || <span className="text-muted">Not set</span>}</DetailValue>
</Detail>
)}
<Detail>
<DetailLabel>Joined project</DetailLabel>
<DetailValue>{format(membership.createdAt, "PPpp")}</DetailValue>
</Detail>
</DetailGroup>
</UnstableCardContent>
</UnstableCard>
);
};

View File

@@ -5,7 +5,7 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { MemberDetailsByIDPage } from "./MemberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/members/$membershipId"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/members/$membershipId"
)({
component: MemberDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Access Control",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/access-management",
to: "/organizations/$orgId/projects/cert-manager/$projectId/access-management",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -5,7 +5,7 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/roles/$roleSlug"
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/roles/$roleSlug"
)({
component: RoleDetailsBySlugPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Access Control",
link: linkOptions({
to: "/organizations/$orgId/projects/cert-management/$projectId/access-management",
to: "/organizations/$orgId/projects/cert-manager/$projectId/access-management",
params: {
orgId: params.orgId,
projectId: params.projectId

View File

@@ -1,7 +1,5 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
@@ -14,7 +12,6 @@ import {
useProject
} from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
import { useGetWorkspaceIntegrations } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/projects/types";
import { IntegrationsListPageTabs } from "@app/types/integrations";
@@ -35,9 +32,6 @@ export const IntegrationsListPage = () => {
from: ROUTE_PATHS.SecretManager.IntegrationsListPage.id
});
const { data: integrations } = useGetWorkspaceIntegrations(currentProject.id);
const hasNativeIntegrations = Boolean(integrations?.length);
const updateSelectedTab = (tab: string) => {
navigate({
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
@@ -77,11 +71,9 @@ export const IntegrationsListPage = () => {
<Tab variant="project" value={IntegrationsListPageTabs.InfrastructureIntegrations}>
Infrastructure Integrations
</Tab>
{hasNativeIntegrations && (
<Tab variant="project" value={IntegrationsListPageTabs.NativeIntegrations}>
Native Integrations
</Tab>
)}
<Tab variant="project" value={IntegrationsListPageTabs.NativeIntegrations}>
Native Integrations
</Tab>
</TabList>
<TabPanel value={IntegrationsListPageTabs.SecretSyncs}>
<ProjectPermissionCan
@@ -98,47 +90,15 @@ export const IntegrationsListPage = () => {
<TabPanel value={IntegrationsListPageTabs.InfrastructureIntegrations}>
<InfrastructureIntegrationTab />
</TabPanel>
{hasNativeIntegrations && (
<TabPanel value={IntegrationsListPageTabs.NativeIntegrations}>
<div className="mb-4 flex items-start rounded-md border border-yellow-600/75 bg-yellow-900/20 px-3 py-2">
<div className="flex text-sm text-yellow-100">
<FontAwesomeIcon icon={faWarning} className="mt-1 mr-2 text-yellow-600" />
<div>
<p className="font-medium">
We&apos;re moving Native Integrations to{" "}
<a
href="https://infisical.com/docs/integrations/secret-syncs/overview"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-100"
>
Secret Syncs
</a>
.
</p>
<p className="mt-0.5 text-yellow-100/80">
If the integration you need isn&apos;t available in the Secret Syncs menu,
please get in touch with us at{" "}
<a
href="mailto:team@infisical.com"
className="underline underline-offset-2 hover:text-mineshaft-100"
>
team@infisical.com
</a>
.
</p>
</div>
</div>
</div>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Integrations}
>
<NativeIntegrationsTab />
</ProjectPermissionCan>
</TabPanel>
)}
<TabPanel value={IntegrationsListPageTabs.NativeIntegrations}>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Integrations}
>
<NativeIntegrationsTab />
</ProjectPermissionCan>
</TabPanel>
</Tabs>
</div>
</div>

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