diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index b6d698af4..c4cb6e451 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -22,14 +22,14 @@ jobs: # uncomment this when testing locally using nektos/act - uses: KengoTODA/actions-setup-docker-compose@v1 if: ${{ env.ACT }} - name: Install `docker-compose` for local simulations + name: Install `docker compose` for local simulations with: version: "2.14.2" - name: 📦Build the latest image run: docker build --tag infisical-api . working-directory: backend - name: Start postgres and redis - run: touch .env && docker-compose -f docker-compose.dev.yml up -d db redis + run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis - name: Start the server run: | echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env @@ -72,6 +72,6 @@ jobs: run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR - name: cleanup run: | - docker-compose -f "docker-compose.dev.yml" down + docker compose -f "docker-compose.dev.yml" down docker stop infisical-api docker remove infisical-api diff --git a/.github/workflows/run-backend-tests.yml b/.github/workflows/run-backend-tests.yml index edb58f9a6..1fc9deff6 100644 --- a/.github/workflows/run-backend-tests.yml +++ b/.github/workflows/run-backend-tests.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v3 - uses: KengoTODA/actions-setup-docker-compose@v1 if: ${{ env.ACT }} - name: Install `docker-compose` for local simulations + name: Install `docker compose` for local simulations with: version: "2.14.2" - name: 🔧 Setup Node 20 @@ -33,7 +33,7 @@ jobs: run: npm install working-directory: backend - name: Start postgres and redis - run: touch .env && docker-compose -f docker-compose.dev.yml up -d db redis + run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis - name: Start integration test run: npm run test:e2e working-directory: backend @@ -44,4 +44,4 @@ jobs: ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 - name: cleanup run: | - docker-compose -f "docker-compose.dev.yml" down \ No newline at end of file + docker compose -f "docker-compose.dev.yml" down \ No newline at end of file diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ae892560..cfffdeac8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -50,6 +50,7 @@ import { TIntegrationServiceFactory } from "@app/services/integration/integratio import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; +import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -165,6 +166,7 @@ declare module "fastify" { rateLimit: TRateLimitServiceFactory; userEngagement: TUserEngagementServiceFactory; externalKms: TExternalKmsServiceFactory; + orgAdmin: TOrgAdminServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 015410daf..28bf9f7e2 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -138,6 +138,7 @@ export enum EventType { IMPORT_CA_CERT = "import-certificate-authority-cert", GET_CA_CRL = "get-certificate-authority-crl", ISSUE_CERT = "issue-cert", + SIGN_CERT = "sign-cert", GET_CERT = "get-cert", DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", @@ -148,7 +149,8 @@ export enum EventType { GET_KMS = "get-kms", UPDATE_PROJECT_KMS = "update-project-kms", GET_PROJECT_KMS_BACKUP = "get-project-kms-backup", - LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup" + LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup", + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" } interface UserActorMetadata { @@ -1161,6 +1163,15 @@ interface IssueCert { }; } +interface SignCert { + type: EventType.SIGN_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + interface GetCert { type: EventType.GET_CERT; metadata: { @@ -1253,6 +1264,16 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } +interface OrgAdminAccessProjectEvent { + type: EventType.ORG_ADMIN_ACCESS_PROJECT; + metadata: { + userId: string; + username: string; + email: string; + projectId: string; + }; // no metadata yet +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1353,6 +1374,7 @@ export type Event = | ImportCaCert | GetCaCrl | IssueCert + | SignCert | GetCert | DeleteCert | RevokeCert @@ -1363,4 +1385,5 @@ export type Event = | GetKmsEvent | UpdateProjectKmsEvent | GetProjectKmsBackupEvent - | LoadProjectKmsBackupEvent; + | LoadProjectKmsBackupEvent + | OrgAdminAccessProjectEvent; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 77eaacd3b..4c0770ad9 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -9,6 +9,10 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionAdminConsoleAction { + AccessAllProjects = "access-all-projects" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -22,7 +26,8 @@ export enum OrgPermissionSubjects { Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity", - Kms = "kms" + Kms = "kms", + AdminConsole = "organization-admin-console" } export type OrgPermissionSet = @@ -39,7 +44,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity] - | [OrgPermissionActions, OrgPermissionSubjects.Kms]; + | [OrgPermissionActions, OrgPermissionSubjects.Kms] + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -107,6 +113,8 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); + return build({ conditionsMatcher }); }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 575af97f9..bca04a758 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1051,7 +1051,7 @@ export const CERTIFICATE_AUTHORITIES = { RENEW_CA_CERT: { caId: "The ID of the CA to renew the CA certificate for", type: "The type of behavior to use for the renewal operation. Currently Infisical is only able to renew a CA certificate with the same key pair.", - notAfter: "The expiry date and time for the renewed CA certificatre in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The expiry date and time for the renewed CA certificate in YYYY-MM-DDTHH:mm:ss.sssZ format", certificate: "The renewed CA certificate body", certificateChain: "The certificate chain of the CA", serialNumber: "The serial number of the renewed CA certificate" @@ -1062,9 +1062,16 @@ export const CERTIFICATE_AUTHORITIES = { certificateChain: "The certificate chain of the CA", serialNumber: "The serial number of the CA certificate" }, + GET_CA_CERTS: { + caId: "The ID of the CA to get the CA certificates for", + certificate: "The certificate body of the CA certificate", + certificateChain: "The certificate chain of the CA certificate", + serialNumber: "The serial number of the CA certificate", + version: "The version of the CA certificate. The version is incremented for each CA renewal operation." + }, SIGN_INTERMEDIATE: { caId: "The ID of the CA to sign the intermediate certificate with", - csr: "The CSR to sign with the CA", + csr: "The pem-encoded CSR to sign with the CA", notBefore: "The date and time when the intermediate CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", notAfter: "The date and time when the intermediate CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", maxPathLength: @@ -1094,6 +1101,21 @@ export const CERTIFICATE_AUTHORITIES = { privateKey: "The private key of the issued certificate", serialNumber: "The serial number of the issued certificate" }, + SIGN_CERT: { + caId: "The ID of the CA to issue the certificate from", + csr: "The pem-encoded CSR to sign with the CA to be used for certificate issuance", + friendlyName: "A friendly name for the certificate", + commonName: "The common name (CN) for the certificate", + altNames: + "A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", + notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + certificate: "The issued certificate", + issuingCaCertificate: "The certificate of the issuing CA", + certificateChain: "The certificate chain of the issued certificate", + serialNumber: "The serial number of the issued certificate" + }, GET_CRL: { caId: "The ID of the CA to get the certificate revocation list (CRL) for", crl: "The certificate revocation list (CRL) of the CA" diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index c01d146ec..dcab16218 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -19,23 +19,43 @@ export const withTransaction = (db: Knex, dal: K) => ({ export type TFindFilter = Partial & { $in?: Partial<{ [k in keyof R]: R[k][] }>; + $search?: Partial<{ [k in keyof R]: R[k] }>; }; export const buildFindFilter = - ({ $in, ...filter }: TFindFilter) => + ({ $in, $search, ...filter }: TFindFilter) => (bd: Knex.QueryBuilder) => { void bd.where(filter); if ($in) { Object.entries($in).forEach(([key, val]) => { - void bd.whereIn(key as never, val as never); + if (val) { + void bd.whereIn(key as never, val as never); + } + }); + } + if ($search) { + Object.entries($search).forEach(([key, val]) => { + if (val) { + void bd.whereILike(key as never, val as never); + } }); } return bd; }; -export type TFindOpt = { +export type TFindReturn = Array< + Awaited[0] & + (TCount extends true + ? { + count: string; + } + : unknown) +>; + +export type TFindOpt = { limit?: number; offset?: number; sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>; + count?: TCount; tx?: Knex; }; @@ -66,18 +86,22 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Find one" }); } }, - find: async ( + find: async ( filter: TFindFilter, - { offset, limit, sort, tx }: TFindOpt = {} + { offset, limit, sort, count, tx }: TFindOpt = {} ) => { try { const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter)); + if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + void query.select("*"); + } if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); } - const res = await query; + const res = (await query) as TFindReturn; return res; } catch (error) { throw new DatabaseError({ error, name: "Find one" }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e8f80f020..a6ef33a72 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -129,6 +129,7 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; @@ -498,6 +499,16 @@ export const registerRoutes = async ( keyStore, licenseService }); + const orgAdminService = orgAdminServiceFactory({ + projectDAL, + permissionService, + projectUserMembershipRoleDAL, + userDAL, + projectBotDAL, + projectKeyDAL, + projectMembershipDAL + }); + const rateLimitService = rateLimitServiceFactory({ rateLimitDAL, licenseService @@ -1113,7 +1124,8 @@ export const registerRoutes = async ( identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, secretSharing: secretSharingService, userEngagement: userEngagementService, - externalKms: externalKmsService + externalKms: externalKmsService, + orgAdmin: orgAdminService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index e39c5702e..bc5294f05 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -340,16 +340,15 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { schema: { description: "Get list of past and current CA certificates for a CA", params: z.object({ - caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.caId) }), response: { 200: z.array( z.object({ - // TODO: consider not before and not after dates - certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificate), - certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificateChain), - serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.serialNumber), - version: z.number() + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.serialNumber), + version: z.number().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.version) }) ) } @@ -441,7 +440,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.caId) }), body: z.object({ - csr: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notBefore), notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notAfter), maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.maxPathLength) @@ -557,7 +556,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }), body: z .object({ - friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), ttl: z @@ -620,4 +619,81 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/:caId/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign certificate from CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId) + }), + body: z + .object({ + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName), + commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.certificateAuthority.signCertFromCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.SIGN_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); }; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 43ce44eaa..6c988d995 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -15,6 +15,7 @@ import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; +import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerProjectEnvRouter } from "./project-env-router"; @@ -50,6 +51,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); + await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); diff --git a/backend/src/server/routes/v1/org-admin-router.ts b/backend/src/server/routes/v1/org-admin-router.ts new file mode 100644 index 000000000..2d28b09bd --- /dev/null +++ b/backend/src/server/routes/v1/org-admin-router.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { SanitizedProjectSchema } from "../sanitizedSchemas"; + +export const registerOrgAdminRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/projects", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + search: z.string().optional(), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(50) + }), + response: { + 200: z.object({ + projects: SanitizedProjectSchema.array(), + count: z.coerce.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projects, count } = await server.services.orgAdmin.listOrgProjects({ + limit: req.query.limit, + offset: req.query.offset, + search: req.query.search, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type + }); + return { projects, count }; + } + }); + + server.route({ + method: "POST", + url: "/projects/:projectId/grant-admin-access", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { membership } = await server.services.orgAdmin.grantProjectAdminAccess({ + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.projectId + }); + if (req.auth.authMode === AuthMode.JWT) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.projectId, + event: { + type: EventType.ORG_ADMIN_ACCESS_PROJECT, + metadata: { + projectId: req.params.projectId, + username: req.auth.user.username, + email: req.auth.user.email || "", + userId: req.auth.userId + } + } + }); + } + + return { membership }; + } + }); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 9174ecf02..84d8d263d 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -24,6 +24,40 @@ export const createDistinguishedName = (parts: TDNParts) => { return dnParts.join(", "); }; +export const parseDistinguishedName = (dn: string): TDNParts => { + const parts: TDNParts = {}; + const dnParts = dn.split(/,\s*/); + + for (const part of dnParts) { + const [key, value] = part.split("="); + switch (key.toUpperCase()) { + case "C": + parts.country = value; + break; + case "O": + parts.organization = value; + break; + case "OU": + parts.ou = value; + break; + case "ST": + parts.province = value; + break; + case "CN": + parts.commonName = value; + break; + case "L": + parts.locality = value; + break; + default: + // Ignore unrecognized keys + break; + } + } + + return parts; +}; + export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { switch (keyAlgorithm) { case CertKeyAlgorithm.RSA_4096: diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 21cd5a930..0e356a4ae 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -23,7 +23,8 @@ import { getCaCertChain, // TODO: consider rename getCaCertChains, getCaCredentials, - keyAlgorithmToAlgCfg + keyAlgorithmToAlgCfg, + parseDistinguishedName } from "./certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; @@ -39,6 +40,7 @@ import { TImportCertToCaDTO, TIssueCertFromCaDTO, TRenewCaCertDTO, + TSignCertFromCaDTO, TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; @@ -989,7 +991,8 @@ export const certificateAuthorityServiceFactory = ({ }; /** - * Return new leaf certificate issued by CA with id [caId] + * Return new leaf certificate issued by CA with id [caId] and private key. + * Note: private key and CSR are generated within Infisical. */ const issueCertFromCa = async ({ caId, @@ -1189,6 +1192,204 @@ export const certificateAuthorityServiceFactory = ({ }; }; + /** + * Return new leaf certificate issued by CA with id [caId]. + * Note: CSR is generated externally and submitted to Infisical. + */ + const signCertFromCa = async ({ + caId, + csr, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TSignCertFromCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + const dn = parseDistinguishedName(csrObj.subject); + const cn = commonName || dn.commonName; + + if (!cn) + throw new BadRequestError({ + message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" + }); + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const extensions: x509.Extension[] = [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ]; + + if (altNames) { + const altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (hostnameRegex.test(altName)) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || csrObj.subject, + commonName: cn, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate + }, + tx + ); + + return cert; + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca + }; + }; + return { createCa, getCaById, @@ -1200,6 +1401,7 @@ export const certificateAuthorityServiceFactory = ({ getCaCert, signIntermediate, importCertToCa, - issueCertFromCa + issueCertFromCa, + signCertFromCa }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index b129c2a1b..f97844870 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -95,6 +95,17 @@ export type TIssueCertFromCaDTO = { notAfter?: string; } & Omit; +export type TSignCertFromCaDTO = { + caId: string; + csr: string; + friendlyName?: string; + commonName?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; +} & Omit; + export type TDNParts = { commonName?: string; organization?: string; diff --git a/backend/src/services/org-admin/org-admin-dal.ts b/backend/src/services/org-admin/org-admin-dal.ts new file mode 100644 index 000000000..da2ccf2f6 --- /dev/null +++ b/backend/src/services/org-admin/org-admin-dal.ts @@ -0,0 +1,5 @@ +export type TOrgAdminDALFactory = ReturnType; + +export const orgAdminDALFactory = () => { + return {}; +}; diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts new file mode 100644 index 000000000..4759db309 --- /dev/null +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -0,0 +1,191 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ProjectMembershipRole, ProjectVersion, SecretKeyEncoding } from "@app/db/schemas"; +import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; + +import { TProjectDALFactory } from "../project/project-dal"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types"; + +type TOrgAdminServiceFactoryDep = { + permissionService: Pick; + projectDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + projectBotDAL: Pick; + userDAL: Pick; + projectUserMembershipRoleDAL: Pick; +}; + +export type TOrgAdminServiceFactory = ReturnType; + +export const orgAdminServiceFactory = ({ + permissionService, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + projectBotDAL, + userDAL, + projectUserMembershipRoleDAL +}: TOrgAdminServiceFactoryDep) => { + const listOrgProjects = async ({ + actor, + limit, + actorId, + offset, + search, + actorOrgId, + actorAuthMethod + }: TListOrgProjectsDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAdminConsoleAction.AccessAllProjects, + OrgPermissionSubjects.AdminConsole + ); + const projects = await projectDAL.find( + { + orgId: actorOrgId, + $search: { + name: search ? `%${search}%` : undefined + } + }, + { offset, limit, sort: [["name", "asc"]], count: true } + ); + + const count = projects?.[0]?.count ? parseInt(projects?.[0]?.count, 10) : 0; + return { projects, count }; + }; + + const grantProjectAdminAccess = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }: TAccessProjectDTO) => { + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAdminConsoleAction.AccessAllProjects, + OrgPermissionSubjects.AdminConsole + ); + + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version === ProjectVersion.V1) { + throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); + } + + // check already there exist a membership if there return it + const projectMembership = await projectMembershipDAL.findOne({ + projectId, + userId: actorId + }); + if (projectMembership) { + // reset and make the user admin + await projectMembershipDAL.transaction(async (tx) => { + await projectUserMembershipRoleDAL.delete({ projectMembershipId: projectMembership.id }, tx); + await projectUserMembershipRoleDAL.create( + { + projectMembershipId: projectMembership.id, + role: ProjectMembershipRole.Admin + }, + tx + ); + }); + return { isExistingMember: true, membership: projectMembership }; + } + + // missing membership thus add admin back as admin to project + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const userEncryptionKey = await userDAL.findUserEncKeyByUserId(actorId); + if (!userEncryptionKey) throw new BadRequestError({ message: "user encryption key not found" }); + const [newWsMember] = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: [ + { + orgMembershipId: membership.id, + projectMembershipRole: ProjectMembershipRole.Admin, + userPublicKey: userEncryptionKey.publicKey + } + ] + }); + + const updatedMembership = await projectMembershipDAL.transaction(async (tx) => { + const newProjectMembership = await projectMembershipDAL.create( + { + projectId, + userId: actorId + }, + tx + ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: newProjectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); + + await projectKeyDAL.create( + { + encryptedKey: newWsMember.workspaceEncryptedKey, + nonce: newWsMember.workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: actorId, + projectId + }, + tx + ); + return newProjectMembership; + }); + return { isExistingMember: false, membership: updatedMembership }; + }; + + return { listOrgProjects, grantProjectAdminAccess }; +}; diff --git a/backend/src/services/org-admin/org-admin-types.ts b/backend/src/services/org-admin/org-admin-types.ts new file mode 100644 index 000000000..85669fc56 --- /dev/null +++ b/backend/src/services/org-admin/org-admin-types.ts @@ -0,0 +1,11 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TListOrgProjectsDTO = { + limit?: number; + offset?: number; + search?: string; +} & Omit; + +export type TAccessProjectDTO = { + projectId: string; +} & Omit; diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts index d37d620b2..9cdb52cff 100644 --- a/backend/src/services/project-bot/project-bot-fns.ts +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -66,10 +66,10 @@ export const getBotKeyFnFactory = ( await projectBotDAL.create({ name: "Infisical Bot (Ghost)", projectId, + isActive: true, tag, iv, encryptedPrivateKey: ciphertext, - isActive: true, publicKey: botKey.publicKey, algorithm, keyEncoding: encoding, @@ -80,6 +80,12 @@ export const getBotKeyFnFactory = ( } else { await projectBotDAL.updateById(bot.id, { isActive: true, + tag, + iv, + encryptedPrivateKey: ciphertext, + publicKey: botKey.publicKey, + algorithm, + keyEncoding: encoding, encryptedProjectKey: encryptedWorkspaceKey.ciphertext, encryptedProjectKeyNonce: encryptedWorkspaceKey.nonce, senderId: projectV1Keys.userId @@ -89,7 +95,6 @@ export const getBotKeyFnFactory = ( } const botPrivateKey = getBotPrivateKey({ bot }); - const botKey = decryptAsymmetric({ ciphertext: bot.encryptedProjectKey, privateKey: botPrivateKey, diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index a03aec934..8f87e8d55 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -256,7 +256,6 @@ export const projectMembershipServiceFactory = ({ } const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) { throw new BadRequestError({ message: "Failed to find bot" diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 34e568ca7..2cb03cf6d 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -490,10 +490,10 @@ export const secretV2BridgeServiceFactory = ({ ...secret, value: secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() - : undefined, + : "", comment: secret.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() - : undefined + : "" }) ); const expandSecretReferences = expandSecretReferencesFactory({ diff --git a/cli/packages/cmd/vault.go b/cli/packages/cmd/vault.go index 01bee147b..4720e094e 100644 --- a/cli/packages/cmd/vault.go +++ b/cli/packages/cmd/vault.go @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "encoding/base64" "fmt" "strings" @@ -13,53 +14,56 @@ import ( "github.com/spf13/cobra" ) -var AvailableVaultsAndDescriptions = []string{"auto (automatically select native vault on system)", "file (encrypted file vault)"} -var AvailableVaults = []string{"auto", "file"} +type VaultBackendType struct { + Name string + Description string +} + +var AvailableVaults = []VaultBackendType{ + { + Name: "auto", + Description: "automatically select the system keyring", + }, + { + Name: "file", + Description: "encrypted file vault", + }, +} var vaultSetCmd = &cobra.Command{ - Example: `infisical vault set pass`, - Use: "set [vault-name]", - Short: "Used to set the vault backend to store your login details securely at rest", + Example: `infisical vault set file --passphrase `, + Use: "set [file|auto] [flags]", + Short: "Used to configure the vault backends", DisableFlagsInUseLine: true, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - wantedVaultTypeName := args[0] - currentVaultBackend, err := util.GetCurrentVaultBackend() + + vaultType := args[0] + + passphrase, err := cmd.Flags().GetString("passphrase") if err != nil { - log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) + util.HandleError(err, "Unable to get passphrase flag") + } + + if vaultType == util.VAULT_BACKEND_FILE_MODE && passphrase != "" { + setFileVaultPassphrase(passphrase) return } - if wantedVaultTypeName == string(currentVaultBackend) { - log.Error().Msgf("You are already on vault backend [%s]", currentVaultBackend) - return - } - - if wantedVaultTypeName == "auto" || wantedVaultTypeName == "file" { - configFile, err := util.GetConfigFile() - if err != nil { - log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) - return - } - - configFile.VaultBackendType = wantedVaultTypeName // save selected vault - configFile.LoggedInUserEmail = "" // reset the logged in user to prompt them to re login - - err = util.WriteConfigFile(&configFile) - if err != nil { - log.Error().Msgf("Unable to set vault to [%s] because an error occurred when saving the config file [err=%s]", wantedVaultTypeName, err) - return - } - - fmt.Printf("\nSuccessfully, switched vault backend from [%s] to [%s]. Please login in again to store your login details in the new vault with [infisical login]\n", currentVaultBackend, wantedVaultTypeName) - - Telemetry.CaptureEvent("cli-command:vault set", posthog.NewProperties().Set("currentVault", currentVaultBackend).Set("wantedVault", wantedVaultTypeName).Set("version", util.CLI_VERSION)) - } else { - log.Error().Msgf("The requested vault type [%s] is not available on this system. Only the following vault backends are available for you system: %s", wantedVaultTypeName, strings.Join(AvailableVaults, ", ")) - } + util.PrintWarning("This command has been deprecated. Please use 'infisical vault use [file|auto]' to select which vault to use.\n") + selectVaultTypeCmd(cmd, args) }, } +var vaultUseCmd = &cobra.Command{ + Example: `infisical vault use [file|auto]`, + Use: "use [file|auto]", + Short: "Used to select the the type of vault backend to store sensitive data securely at rest", + DisableFlagsInUseLine: true, + Args: cobra.MinimumNArgs(1), + Run: selectVaultTypeCmd, +} + // runCmd represents the run command var vaultCmd = &cobra.Command{ Use: "vault", @@ -71,10 +75,30 @@ var vaultCmd = &cobra.Command{ }, } +func setFileVaultPassphrase(passphrase string) { + configFile, err := util.GetConfigFile() + if err != nil { + log.Error().Msgf("Unable to set passphrase for file vault because of [err=%s]", err) + return + } + + // encode with base64 + encodedPassphrase := base64.StdEncoding.EncodeToString([]byte(passphrase)) + configFile.VaultBackendPassphrase = encodedPassphrase + + err = util.WriteConfigFile(&configFile) + if err != nil { + log.Error().Msgf("Unable to set passphrase for file vault because of [err=%s]", err) + return + } + + util.PrintSuccessMessage("\nSuccessfully, set passphrase for file vault.\n") +} + func printAvailableVaultBackends() { fmt.Printf("Vaults are used to securely store your login details locally. Available vaults:") - for _, backend := range AvailableVaultsAndDescriptions { - fmt.Printf("\n- %s", backend) + for _, vaultType := range AvailableVaults { + fmt.Printf("\n- %s (%s)", vaultType.Name, vaultType.Description) } currentVaultBackend, err := util.GetCurrentVaultBackend() @@ -87,7 +111,53 @@ func printAvailableVaultBackends() { fmt.Printf("\n\nYou are currently using [%s] vault to store your login credentials\n", string(currentVaultBackend)) } +func selectVaultTypeCmd(cmd *cobra.Command, args []string) { + wantedVaultTypeName := args[0] + currentVaultBackend, err := util.GetCurrentVaultBackend() + if err != nil { + log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) + return + } + + if wantedVaultTypeName == string(currentVaultBackend) { + log.Error().Msgf("You are already on vault backend [%s]", currentVaultBackend) + return + } + + if wantedVaultTypeName == util.VAULT_BACKEND_AUTO_MODE || wantedVaultTypeName == util.VAULT_BACKEND_FILE_MODE { + configFile, err := util.GetConfigFile() + if err != nil { + log.Error().Msgf("Unable to set vault to [%s] because of [err=%s]", wantedVaultTypeName, err) + return + } + + configFile.VaultBackendType = wantedVaultTypeName // save selected vault + configFile.LoggedInUserEmail = "" // reset the logged in user to prompt them to re login + + err = util.WriteConfigFile(&configFile) + if err != nil { + log.Error().Msgf("Unable to set vault to [%s] because an error occurred when saving the config file [err=%s]", wantedVaultTypeName, err) + return + } + + fmt.Printf("\nSuccessfully, switched vault backend from [%s] to [%s]. Please login in again to store your login details in the new vault with [infisical login]\n", currentVaultBackend, wantedVaultTypeName) + + Telemetry.CaptureEvent("cli-command:vault set", posthog.NewProperties().Set("currentVault", currentVaultBackend).Set("wantedVault", wantedVaultTypeName).Set("version", util.CLI_VERSION)) + } else { + var availableVaultsNames []string + for _, vault := range AvailableVaults { + availableVaultsNames = append(availableVaultsNames, vault.Name) + } + log.Error().Msgf("The requested vault type [%s] is not available on this system. Only the following vault backends are available for you system: %s", wantedVaultTypeName, strings.Join(availableVaultsNames, ", ")) + } +} + func init() { + + vaultSetCmd.Flags().StringP("passphrase", "p", "", "Set the passphrase for the file vault") + vaultCmd.AddCommand(vaultSetCmd) + vaultCmd.AddCommand(vaultUseCmd) + rootCmd.AddCommand(vaultCmd) } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 9bdc26fbc..a98f02bae 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -11,10 +11,11 @@ type UserCredentials struct { // The file struct for Infisical config file type ConfigFile struct { - LoggedInUserEmail string `json:"loggedInUserEmail"` - LoggedInUserDomain string `json:"LoggedInUserDomain,omitempty"` - LoggedInUsers []LoggedInUser `json:"loggedInUsers,omitempty"` - VaultBackendType string `json:"vaultBackendType,omitempty"` + LoggedInUserEmail string `json:"loggedInUserEmail"` + LoggedInUserDomain string `json:"LoggedInUserDomain,omitempty"` + LoggedInUsers []LoggedInUser `json:"loggedInUsers,omitempty"` + VaultBackendType string `json:"vaultBackendType,omitempty"` + VaultBackendPassphrase string `json:"vaultBackendPassphrase,omitempty"` } type LoggedInUser struct { diff --git a/cli/packages/util/config.go b/cli/packages/util/config.go index 55c9df1b0..02030e1fa 100644 --- a/cli/packages/util/config.go +++ b/cli/packages/util/config.go @@ -1,6 +1,7 @@ package util import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -50,10 +51,11 @@ func WriteInitalConfig(userCredentials *models.UserCredentials) error { } configFile := models.ConfigFile{ - LoggedInUserEmail: userCredentials.Email, - LoggedInUserDomain: config.INFISICAL_URL, - LoggedInUsers: existingConfigFile.LoggedInUsers, - VaultBackendType: existingConfigFile.VaultBackendType, + LoggedInUserEmail: userCredentials.Email, + LoggedInUserDomain: config.INFISICAL_URL, + LoggedInUsers: existingConfigFile.LoggedInUsers, + VaultBackendType: existingConfigFile.VaultBackendType, + VaultBackendPassphrase: existingConfigFile.VaultBackendPassphrase, } configFileMarshalled, err := json.Marshal(configFile) @@ -215,6 +217,14 @@ func GetConfigFile() (models.ConfigFile, error) { return models.ConfigFile{}, err } + if configFile.VaultBackendPassphrase != "" { + decodedPassphrase, err := base64.StdEncoding.DecodeString(configFile.VaultBackendPassphrase) + if err != nil { + return models.ConfigFile{}, fmt.Errorf("GetConfigFile: Unable to decode base64 passphrase [err=%s]", err) + } + os.Setenv("INFISICAL_VAULT_FILE_PASSPHRASE", string(decodedPassphrase)) + } + return configFile, nil } diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index 5b0a93513..5cd66f50b 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -8,6 +8,10 @@ const ( INFISICAL_WORKSPACE_CONFIG_FILE_NAME = ".infisical.json" INFISICAL_TOKEN_NAME = "INFISICAL_TOKEN" INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME = "INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN" + INFISICAL_VAULT_FILE_PASSPHRASE_ENV_NAME = "INFISICAL_VAULT_FILE_PASSPHRASE" // This works because we've forked the keyring package and added support for this env variable. This explains why you won't find any occurrences of it in the CLI codebase. + + VAULT_BACKEND_AUTO_MODE = "auto" + VAULT_BACKEND_FILE_MODE = "file" // Universal Auth INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID" diff --git a/cli/packages/util/keyringwrapper.go b/cli/packages/util/keyringwrapper.go index 3bf2dd6c4..cadb72ebd 100644 --- a/cli/packages/util/keyringwrapper.go +++ b/cli/packages/util/keyringwrapper.go @@ -1,6 +1,9 @@ package util import ( + "encoding/base64" + + "github.com/manifoldco/promptui" "github.com/zalando/go-keyring" ) @@ -20,16 +23,51 @@ func SetValueInKeyring(key, value string) error { PrintErrorAndExit(1, err, "Unable to get current vault. Tip: run [infisical rest] then try again") } - return keyring.Set(currentVaultBackend, MAIN_KEYRING_SERVICE, key, value) + err = keyring.Set(currentVaultBackend, MAIN_KEYRING_SERVICE, key, value) + + if err != nil { + configFile, _ := GetConfigFile() + + if configFile.VaultBackendPassphrase == "" { + PrintWarning("System keyring could not be used, falling back to `file` vault for sensitive data storage.") + passphrasePrompt := promptui.Prompt{ + Label: "Enter the passphrase to use for keyring encryption", + } + passphrase, err := passphrasePrompt.Run() + if err != nil { + return err + } + + encodedPassphrase := base64.StdEncoding.EncodeToString([]byte(passphrase)) + configFile.VaultBackendPassphrase = encodedPassphrase + err = WriteConfigFile(&configFile) + if err != nil { + return err + } + + // We call this function at last to trigger the environment variable to be set + GetConfigFile() + } + + err = keyring.Set(VAULT_BACKEND_FILE_MODE, MAIN_KEYRING_SERVICE, key, value) + } + + return err } func GetValueInKeyring(key string) (string, error) { currentVaultBackend, err := GetCurrentVaultBackend() if err != nil { - PrintErrorAndExit(1, err, "Unable to get current vault. Tip: run [infisical rest] then try again") + PrintErrorAndExit(1, err, "Unable to get current vault. Tip: run [infisical reset] then try again") } - return keyring.Get(currentVaultBackend, MAIN_KEYRING_SERVICE, key) + value, err := keyring.Get(currentVaultBackend, MAIN_KEYRING_SERVICE, key) + + if err != nil { + value, err = keyring.Get(VAULT_BACKEND_FILE_MODE, MAIN_KEYRING_SERVICE, key) + } + return value, err + } func DeleteValueInKeyring(key string) error { @@ -38,5 +76,11 @@ func DeleteValueInKeyring(key string) error { return err } - return keyring.Delete(currentVaultBackend, MAIN_KEYRING_SERVICE, key) + err = keyring.Delete(currentVaultBackend, MAIN_KEYRING_SERVICE, key) + + if err != nil { + err = keyring.Delete(VAULT_BACKEND_FILE_MODE, MAIN_KEYRING_SERVICE, key) + } + + return err } diff --git a/cli/packages/util/vault.go b/cli/packages/util/vault.go index 14d6d10d9..5907d93fc 100644 --- a/cli/packages/util/vault.go +++ b/cli/packages/util/vault.go @@ -11,11 +11,11 @@ func GetCurrentVaultBackend() (string, error) { } if configFile.VaultBackendType == "" { - return "auto", nil + return VAULT_BACKEND_AUTO_MODE, nil } - if configFile.VaultBackendType != "auto" && configFile.VaultBackendType != "file" { - return "auto", nil + if configFile.VaultBackendType != VAULT_BACKEND_AUTO_MODE && configFile.VaultBackendType != VAULT_BACKEND_FILE_MODE { + return VAULT_BACKEND_AUTO_MODE, nil } return configFile.VaultBackendType, nil diff --git a/company/handbook/meetings.mdx b/company/handbook/meetings.mdx new file mode 100644 index 000000000..af6a3b54e --- /dev/null +++ b/company/handbook/meetings.mdx @@ -0,0 +1,15 @@ +--- +title: "Meetings" +sidebarTitle: "Meetings" +description: "The guide to meetings at Infisical." +--- + +## "Let's schedule a meeting about this" + +Being a remote-first company, we try to be as async as possible. When an issue arises, it's best to create a public Slack thread and tag all the necessary team members. Otherwise, if you were to "put a meeting on a calendar", the decision making process will inevitable slow down by at least a day (e.g., trying to find the right time for folks in different time zones is not always straightforward). + +In other words, we have almost no (recurring) meetings and prefer written communication or quick Slack huddles. + +## Weekly All-hands + +All-hands is the single recurring meeting that we run every Monday at 8:30am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too). diff --git a/company/mint.json b/company/mint.json index e6ef851cc..d5f6395e5 100644 --- a/company/mint.json +++ b/company/mint.json @@ -59,7 +59,8 @@ "handbook/onboarding", "handbook/spending-money", "handbook/time-off", - "handbook/hiring" + "handbook/hiring", + "handbook/meetings" ] } ], diff --git a/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx b/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx new file mode 100644 index 000000000..ce253807c --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx @@ -0,0 +1,4 @@ +--- +title: "List CA certificates" +openapi: "GET /api/v1/pki/ca/{caId}/ca-certificates" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/renew.mdx b/docs/api-reference/endpoints/certificate-authorities/renew.mdx new file mode 100644 index 000000000..901811f2d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/renew.mdx @@ -0,0 +1,4 @@ +--- +title: "Renew" +openapi: "POST /api/v1/pki/ca/{caId}/renew" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx new file mode 100644 index 000000000..95c8d8c65 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign certificate" +openapi: "POST /api/v1/pki/ca/{caId}/sign-certificate" +--- diff --git a/docs/cli/commands/vault.mdx b/docs/cli/commands/vault.mdx index 9030c580c..803af127f 100644 --- a/docs/cli/commands/vault.mdx +++ b/docs/cli/commands/vault.mdx @@ -32,6 +32,6 @@ description: "Change the vault type in Infisical" To safeguard your login details when using the CLI, Infisical places them in a system vault or an encrypted text file, protected by a passphrase that only the user knows. -To avoid constantly entering your passphrase when using the `file` vault type, set the `INFISICAL_VAULT_FILE_PASSPHRASE` environment variable with your password in your shell +To avoid constantly entering your passphrase when using the `file` vault type, use the `infisical vault set file --passphrase ` CLI command to specify your password once. diff --git a/docs/documentation/platform/kms/aws-kms.mdx b/docs/documentation/platform/kms/aws-kms.mdx index 14769f301..f9fa54b4d 100644 --- a/docs/documentation/platform/kms/aws-kms.mdx +++ b/docs/documentation/platform/kms/aws-kms.mdx @@ -16,7 +16,7 @@ Before you begin, you'll first need to choose a method of authentication with AW 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. - ![IAM Role Creation](../../images/integrations/aws/integration-aws-iam-assume-role.png) + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) 2. Select **AWS Account** as the **Trusted Entity Type**. 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If you are self-hosting, provide the AWS account number where Infisical is hosted. diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index 7dc3bca88..ab6f4df59 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -74,7 +74,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. - To create a certificate, make an API request to the [Create Certificate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint, + To create a certificate, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-cert) API endpoint, specifying the issuing CA. ### Sample request @@ -84,6 +84,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. --header 'Content-Type: application/json' \ --data-raw '{ "commonName": "My Certificate", + "ttl": "1y", }' ``` @@ -103,6 +104,31 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + If you have an external private key, you can also create a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-cert) API endpoint, specifying the issuing CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//sign-certificate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "csr": "...", + "ttl": "1y", + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + privateKey: "...", + serialNumber: "..." + } + ``` + diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx index 0ebb31e2c..3a7191a1d 100644 --- a/docs/documentation/platform/pki/private-ca.mdx +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -36,7 +36,7 @@ A typical workflow for setting up a Private CA hierarchy consists of the followi intermediate certificate back to the intermediate CA as part of Step 2. -## Guide +## Guide to Creating a CA Hierarchy In the following steps, we explore how to create a simple Private CA hierarchy consisting of a root CA and an intermediate CA. @@ -240,6 +240,51 @@ consisting of a root CA and an intermediate CA. +## Guide to CA Renewal + +In the following steps, we explore how to renew a CA certificate via same key pair. + + + + Head to the CA Page of the CA you wish you renew and press **Renew CA** on + the left side. ![pki ca renewal + page](/images/platform/pki/ca-renewal-page.png) Input a new **Valid Until** + date to be used for the renewed CA certificate and press **Renew** to renew + the CA. ![pki ca renewal. modal](/images/platform/pki/ca-renewal-modal.png) + + The new **Valid Until** date must be within the validity period of the + parent CA. + + + + + To renew a CA certificate, make an API request to the [Renew CA](/api-reference/endpoints/certificate-authorities/renew) API endpoint, specifying the new `notAfter` date for the CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//renew' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "type": "existing", + "notAfter": "2029-06-12" + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + serialNumber: "..." + } + ``` + + + + ## FAQ @@ -247,4 +292,8 @@ consisting of a root CA and an intermediate CA. Infisical supports `RSA 2048`, `RSA 4096`, `ECDSA P-256`, `ECDSA P-384` key algorithms specified at the time of creating a CA. + + At the moment, Infisical only supports CA renewal via same key pair. We + anticipate supporting CA renewal via new key pair in the coming month. + diff --git a/docs/images/platform/pki/ca-renewal-modal.png b/docs/images/platform/pki/ca-renewal-modal.png new file mode 100644 index 000000000..c86d944f3 Binary files /dev/null and b/docs/images/platform/pki/ca-renewal-modal.png differ diff --git a/docs/images/platform/pki/ca-renewal-page.png b/docs/images/platform/pki/ca-renewal-page.png new file mode 100644 index 000000000..43c690ae7 Binary files /dev/null and b/docs/images/platform/pki/ca-renewal-page.png differ diff --git a/docs/mint.json b/docs/mint.json index f6bc0e5e9..5146084fb 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -155,7 +155,7 @@ ] }, { - "group": "Key Management", + "group": "Key Management (KMS)", "pages": [ "documentation/platform/kms/overview", "documentation/platform/kms/aws-kms", @@ -667,11 +667,14 @@ "api-reference/endpoints/certificate-authorities/read", "api-reference/endpoints/certificate-authorities/update", "api-reference/endpoints/certificate-authorities/delete", + "api-reference/endpoints/certificate-authorities/renew", + "api-reference/endpoints/certificate-authorities/list-ca-certs", "api-reference/endpoints/certificate-authorities/csr", "api-reference/endpoints/certificate-authorities/cert", "api-reference/endpoints/certificate-authorities/sign-intermediate", "api-reference/endpoints/certificate-authorities/import-cert", "api-reference/endpoints/certificate-authorities/issue-cert", + "api-reference/endpoints/certificate-authorities/sign-cert", "api-reference/endpoints/certificate-authorities/crl" ] }, diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 76804c137..8e4bcbe71 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -94,7 +94,7 @@ export const DeleteActionModal = ({ setInputData(e.target.value)} - placeholder="Type confirm..." + placeholder={`Type ${deleteKey} here`} /> diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index c8afb389b..f0ba950c1 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -50,7 +50,7 @@ export const Pagination = ({ >
- {(page - 1) * perPage} - {(page - 1) * perPage + perPage} of {count} + {(page - 1) * perPage} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 36206873d..5b7ef0174 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -20,7 +20,12 @@ export enum OrgPermissionSubjects { Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity", - Kms = "kms" + Kms = "kms", + AdminConsole = "organization-admin-console" +} + +export enum OrgPermissionAdminConsoleAction { + AccessAllProjects = "access-all-projects" } export type OrgPermissionSet = @@ -37,6 +42,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity] - | [OrgPermissionActions, OrgPermissionSubjects.Kms]; + | [OrgPermissionActions, OrgPermissionSubjects.Kms] + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/helpers/parseEnvVar.ts b/frontend/src/helpers/parseEnvVar.ts new file mode 100644 index 000000000..27640b515 --- /dev/null +++ b/frontend/src/helpers/parseEnvVar.ts @@ -0,0 +1,14 @@ +/** Extracts the key and value from a passed in env string based on the provided delimiters. */ +export const getKeyValue = (pastedContent: string, delimiters: string[]) => { + const foundDelimiter = delimiters.find((delimiter) => pastedContent.includes(delimiter)); + + if (!foundDelimiter) { + return { key: pastedContent.trim(), value: "" }; + } + + const [key, value] = pastedContent.split(foundDelimiter); + return { + key: key.trim(), + value: (value ?? "").trim() + }; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 082bff02c..819c36c20 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -56,7 +56,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_CERT]: "Get certificate", [EventType.DELETE_CERT]: "Delete certificate", [EventType.REVOKE_CERT]: "Revoke certificate", - [EventType.GET_CERT_BODY]: "Get certificate body" + [EventType.GET_CERT_BODY]: "Get certificate body", + [EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index ad49998c5..94963f640 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -70,5 +70,6 @@ export enum EventType { GET_CERT = "get-cert", DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", - GET_CERT_BODY = "get-cert-body" + GET_CERT_BODY = "get-cert-body", + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index cdc973ed9..1d80d6128 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -579,6 +579,16 @@ interface GetCertBody { }; } +interface OrgAdminAccessProjectEvent { + type: EventType.ORG_ADMIN_ACCESS_PROJECT; + metadata: { + userId: string; + username: string; + email: string; + projectId: string; + }; // no metadata yet +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -635,7 +645,8 @@ export type Event = | GetCert | DeleteCert | RevokeCert - | GetCertBody; + | GetCertBody + | OrgAdminAccessProjectEvent; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index ea3e7f560..5cbd1fb27 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -19,6 +19,7 @@ export * from "./keys"; export * from "./kms"; export * from "./ldapConfig"; export * from "./oidcConfig"; +export * from "./orgAdmin"; export * from "./organization"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; diff --git a/frontend/src/hooks/api/orgAdmin/index.tsx b/frontend/src/hooks/api/orgAdmin/index.tsx new file mode 100644 index 000000000..57eed413a --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/index.tsx @@ -0,0 +1,2 @@ +export { useOrgAdminAccessProject } from "./mutation"; +export { useOrgAdminGetProjects } from "./queries"; diff --git a/frontend/src/hooks/api/orgAdmin/mutation.tsx b/frontend/src/hooks/api/orgAdmin/mutation.tsx new file mode 100644 index 000000000..9fa93722e --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/mutation.tsx @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TOrgAdminAccessProjectDTO } from "./types"; + +export const useOrgAdminAccessProject = () => + useMutation({ + mutationFn: async ({ projectId }: TOrgAdminAccessProjectDTO) => { + const { data } = await apiRequest.post( + `/api/v1/organization-admin/projects/${projectId}/grant-admin-access` + ); + return data; + } + }); diff --git a/frontend/src/hooks/api/orgAdmin/queries.tsx b/frontend/src/hooks/api/orgAdmin/queries.tsx new file mode 100644 index 000000000..2856de0a2 --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/queries.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { Workspace } from "../types"; +import { TOrgAdminGetProjectsDTO } from "./types"; + +export const orgAdminQueryKeys = { + getProjects: (filter: TOrgAdminGetProjectsDTO) => ["org-admin-projects", filter] as const +}; + +export const useOrgAdminGetProjects = ({ search, offset, limit = 50 }: TOrgAdminGetProjectsDTO) => { + return useQuery({ + queryKey: orgAdminQueryKeys.getProjects({ search, offset, limit }), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: Workspace[]; count: number }>( + "/api/v1/organization-admin/projects", + { + params: { + limit, + offset, + search + } + } + ); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/orgAdmin/types.ts b/frontend/src/hooks/api/orgAdmin/types.ts new file mode 100644 index 000000000..87626a466 --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/types.ts @@ -0,0 +1,9 @@ +export type TOrgAdminGetProjectsDTO = { + limit?: number; + offset?: number; + search?: string; +}; + +export type TOrgAdminAccessProjectDTO = { + projectId: string; +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index f3ffaf86e..57087feb9 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -317,6 +317,7 @@ export const useDeleteWorkspace = () => { }, onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(["org-admin-projects"]); } }); }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 783b15f1e..51bb08e3d 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -21,6 +21,7 @@ export type Workspace = { pitVersionLimit: number; auditLogsRetentionDays: number; slug: string; + createdAt: string; }; export type WorkspaceEnv = { diff --git a/frontend/src/hooks/usePopUp.tsx b/frontend/src/hooks/usePopUp.tsx index e9d8257e3..28780db9e 100644 --- a/frontend/src/hooks/usePopUp.tsx +++ b/frontend/src/hooks/usePopUp.tsx @@ -13,7 +13,7 @@ interface UsePopUpProps { export type UsePopUpState | UsePopUpProps[]> = { [P in T extends UsePopUpProps[] ? T[number]["name"] : T[number]]: { isOpen: boolean; - data?: unknown; + data?: any; }; }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 87d4c8458..6a65e32e8 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -476,10 +476,15 @@ export const AppLayout = ({ children }: LayoutProps) => { {user?.superAdmin && ( - Admin Panel + Server Admin Panel )} + + + Organization Admin Console + +
+
+ ); + + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + } ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} + > +
+ +
{workspace.name}
+
+
+
{workspace.environments?.length || 0} environments
- -
- ); - - const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - index === 0 && "rounded-t-md" - } ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} - > -
- -
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
- {isFavorite ? ( - { - e.stopPropagation(); - removeProjectFromFavorites(workspace.id); - }} - /> - ) : ( - { - e.stopPropagation(); - addProjectToFavorites(workspace.id); - }} - /> - )} -
-
- ); - - const projectsGridView = ( - <> - {favoriteWorkspaces.length > 0 && ( - <> -

Favorites

-
0 && "border-b border-mineshaft-600" - } py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`} - > - {favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))} -
- + {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> )} -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {!isProjectViewLoading && - nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))} -
- - ); +
+
+ ); - const projectsListView = ( -
+ const projectsGridView = ( + <> + {favoriteWorkspaces.length > 0 && ( + <> +

Favorites

+
0 && "border-b border-mineshaft-600" + } py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`} + > + {favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))} +
+ + )} +
{isProjectViewLoading && Array.apply(0, Array(3)).map((_x, i) => (
- +
+ +
+
+ +
+
+ +
))} {!isProjectViewLoading && - workspacesWithFaveProp.map((workspace, ind) => - renderProjectListItem(workspace, workspace.isFavorite, ind) - )} + nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))}
- ); + + ); - return ( -
- - {t("common.head-title", { title: t("settings.members.title") })} - - - {!serverDetails?.redisConfigured && ( -
-

Announcements

-
- - Attention: Updated versions of Infisical now require Redis for full functionality. - Learn how to configure it - + {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectViewLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )} +
+ ); + + return ( +
+ + {t("common.head-title", { title: t("settings.members.title") })} + + + {!serverDetails?.redisConfigured && ( +
+

Announcements

+
+ + Attention: Updated versions of Infisical now require Redis for full functionality. Learn + how to configure it + + + here + + + . +
+
+ )} +
+
+

Projects

+
+
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ { + localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); + setProjectsViewMode(ProjectsViewMode.GRID); + }} + ariaLabel="grid" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + + { + localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); + setProjectsViewMode(ProjectsViewMode.LIST); + }} + ariaLabel="list" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + +
+ + {(isAllowed) => ( + + )} + +
+ {projectsViewMode === ProjectsViewMode.LIST ? projectsListView : projectsGridView} + {isWorkspaceEmpty && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions.
)} -
-
-

Projects

-
-
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
- { - localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); - setProjectsViewMode(ProjectsViewMode.GRID); - }} - ariaLabel="grid" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - - { - localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); - setProjectsViewMode(ProjectsViewMode.LIST); - }} - ariaLabel="list" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - -
- - {(isAllowed) => ( -
+
+

Explore Infisical

+
+ {features.map((feature) => ( + -
-

Explore Infisical

-
- {features.map((feature) => ( -
-
{feature.name}
-
- {feature.description} -
-
-

- Setup time: 20 min -

- - Learn more{" "} - - -
-
- ))} -
-
- {!( - new Date().getTime() - new Date(user?.createdAt).getTime() < - 30 * 24 * 60 * 60 * 1000 - ) && ( -
-

Onboarding Guide

-
- - {orgWorkspaces.length !== 0 && ( - <> - - - - )} -
- -
-
+
+ {!(new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000) && ( +
+

Onboarding Guide

+
+ {orgWorkspaces.length !== 0 && ( -
-
-
- - {false && ( -
- -
- )} -
-
Inject secrets locally
-
- Replace .env files with a more secure and efficient alternative. -
+ <> + + + + )} +
+ +
+
+ {orgWorkspaces.length !== 0 && ( +
+
+
+ + {false && ( +
+ +
+ )} +
+
Inject secrets locally
+
+ Replace .env files with a more secure and efficient alternative.
-
- About 2 min -
- - {false &&
} +
+ About 2 min +
- )} - {orgWorkspaces.length !== 0 && ( - - )} -
- )} - { - handlePopUpToggle("addNewWs", isModalOpen); - reset(); - }} + + {false &&
} +
+ )} + {orgWorkspaces.length !== 0 && ( + + )} +
+ )} + { + handlePopUpToggle("addNewWs", isModalOpen); + reset(); + }} + > + - -
+ + ( + + + + )} + /> +
( - - - + name="addMembers" + defaultValue={false} + render={({ field: { onBlur, value, onChange } }) => ( + + {(isAllowed) => ( +
+ + Add all members of my organization to this project + +
+ )} +
)} /> -
- ( - - {(isAllowed) => ( -
- +
+ + + +
Advanced Settings
+
+ + ( + + { - onChange(e); - }} - className="mb-12 w-full bg-mineshaft-600" - > - - Default Infisical KMS + + Default Infisical KMS + + {externalKmsList?.map((kms) => ( + + {kms.slug} - {externalKmsList?.map((kms) => ( - - {kms.slug} - - ))} - - - )} - control={control} - name="kmsKeyId" - /> - -
-
-
- - -
+ ))} + + + )} + control={control} + name="kmsKeyId" + /> + + + +
+ +
- - - - handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." - /> - {/* */} -
- ); - }, - { - action: OrgPermissionActions.Read, - subject: OrgPermissionSubjects.Workspace - } -); +
+ + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You have exceeded the number of projects allowed on the free plan." + /> + {/* */} +
+ ); +}; Object.assign(OrganizationPage, { requireAuth: true }); diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx deleted file mode 100644 index 465029f8b..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; -import { faMoneyBill } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { motion } from "framer-motion"; -import { twMerge } from "tailwind-merge"; - -import { Checkbox, Select, SelectItem } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -import { TFormSchema } from "../../../../RolePage/components/OrgRoleModifySection.utils"; - -type Props = { - isNonEditable?: boolean; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -const PERMISSIONS = [ - { action: "read", label: "View projects" }, - { action: "create", label: "Create new projects" } -] as const; - -export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { - const rule = useWatch({ - control, - name: "permissions.workspace" - }); - const [isCustom, setIsCustom] = useToggle(); - - const selectedPermissionCategory = useMemo(() => { - const actions = Object.keys(rule || {}) as Array; - const totalActions = PERMISSIONS.length; - const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); - - if (isCustom) return Permission.Custom; - if (score === 0) return Permission.NoAccess; - if (score === totalActions) return Permission.FullAccess; - if (score === 1 && rule?.read) return Permission.ReadOnly; - - return Permission.Custom; - }, [rule, isCustom]); - - useEffect(() => { - if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - }, [selectedPermissionCategory]); - - const handlePermissionChange = (val: Permission) => { - if (val === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - - switch (val) { - case Permission.NoAccess: - setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true }); - break; - case Permission.FullAccess: - setValue("permissions.workspace", { read: true, create: true }, { shouldDirty: true }); - break; - case Permission.ReadOnly: - setValue("permissions.workspace", { read: true, create: false }, { shouldDirty: true }); - break; - default: - setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true }); - break; - } - }; - - return ( -
-
-
- -
-
-
Project
-
- View and create new projects in this organization -
-
-
- -
-
- - {isCustom && - PERMISSIONS.map(({ action, label }) => ( - ( - - {label} - - )} - /> - ))} - -
- ); -}; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index e85e62d0c..13cf2316b 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -12,6 +12,12 @@ const generalPermissionSchema = z }) .optional(); +const adminConsolePermissionSchmea = z + .object({ + "access-all-projects": z.boolean().optional() + }) + .optional(); + export const formSchema = z.object({ name: z.string().trim(), description: z.string().trim().optional(), @@ -23,7 +29,6 @@ export const formSchema = z.object({ .object({ workspace: z .object({ - read: z.boolean().optional(), create: z.boolean().optional() }) .optional(), @@ -38,7 +43,8 @@ export const formSchema = z.object({ scim: generalPermissionSchema, ldap: generalPermissionSchema, billing: generalPermissionSchema, - identity: generalPermissionSchema + identity: generalPermissionSchema, + "organization-admin-console": adminConsolePermissionSchmea }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgPermissionAdminConsoleRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgPermissionAdminConsoleRow.tsx new file mode 100644 index 000000000..cc21abdf1 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgPermissionAdminConsoleRow.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [ + { action: "access-all-projects", label: "Access all organization projects" } +] as const; + +export const OrgPermissionAdminConsoleRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.organization-admin-console" + }); + + const selectedPermissionCategory = useMemo(() => { + if (rule?.["access-all-projects"]) { + return Permission.Custom; + } + return Permission.NoAccess; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + if (val === Permission.NoAccess) { + setValue( + "permissions.organization-admin-console", + { "access-all-projects": false }, + { shouldDirty: true } + ); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Organization Admin Console + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.organization-admin-console.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgRoleWorkspaceRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgRoleWorkspaceRow.tsx new file mode 100644 index 000000000..a4eb51774 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgRoleWorkspaceRow.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [{ action: "create", label: "Create projects" }] as const; + +export const OrgRoleWorkspaceRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.workspace" + }); + + const selectedPermissionCategory = useMemo(() => { + if (rule?.create) { + return Permission.Custom; + } + return Permission.NoAccess; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + if (val === Permission.NoAccess) { + setValue("permissions.workspace", { create: false }, { shouldDirty: true }); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Project + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.organization-admin-console.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index 6f4cc7c88..ba76effe1 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -61,7 +61,10 @@ const getPermissionList = (option: string) => { type Props = { isEditable: boolean; title: string; - formName: keyof Omit, "workspace">; + formName: keyof Omit< + Exclude, + "workspace" | "organization-admin-console" + >; setValue: UseFormSetValue; control: Control; }; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index fe19620f5..f4b237cfe 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -12,6 +12,8 @@ import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils"; +import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow"; +import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow"; import { RolePermissionRow } from "./RolePermissionRow"; const SIMPLE_PERMISSION_OPTIONS = [ @@ -153,6 +155,16 @@ export const RolePermissionsSection = ({ roleId }: Props) => { /> ); })} + + diff --git a/frontend/src/views/OrgAdminPage/OrgAdminPage.tsx b/frontend/src/views/OrgAdminPage/OrgAdminPage.tsx new file mode 100644 index 000000000..406b65c69 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/OrgAdminPage.tsx @@ -0,0 +1,30 @@ +import { useState } from "react"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; + +import { OrgAdminProjects } from "./components/OrgAdminProjects"; + +enum TabSections { + Projects = "projects" +} + +export const OrgAdminPage = () => { + const [activeTab, setActiveTab] = useState(TabSections.Projects); + return ( +
+
+
+

Organization Admin Console

+
+ setActiveTab(el as TabSections)}> + + Projects + + + + + +
+
+ ); +}; diff --git a/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx new file mode 100644 index 000000000..516f248f0 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; +import { faEllipsis, faMagnifyingGlass, faSignIn } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { motion } from "framer-motion"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Input, + Pagination, + Spinner, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + OrgPermissionAdminConsoleAction, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { withPermission } from "@app/hoc"; +import { useDebounce } from "@app/hooks"; +import { useOrgAdminAccessProject, useOrgAdminGetProjects } from "@app/hooks/api"; + +export const OrgAdminProjects = withPermission( + () => { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search); + const [perPage, setPerPage] = useState(25); + const router = useRouter(); + const orgAdminAccessProject = useOrgAdminAccessProject(); + + const { data, isLoading: isProjectsLoading } = useOrgAdminGetProjects({ + offset: (page - 1) * perPage, + limit: perPage, + search: debouncedSearch || undefined + }); + + const projects = data?.projects || []; + const projectCount = data?.count || 0; + const isEmpty = !isProjectsLoading && projects.length === 0; + + const handleAccessProject = async (projectId: string) => { + try { + await orgAdminAccessProject.mutateAsync({ + projectId + }); + await router.push({ + pathname: "/project/[projectId]/secrets/overview", + query: { + projectId + } + }); + } catch { + createNotification({ + text: "Failed to access project", + type: "error" + }); + } + }; + + return ( + +
+
+

Projects

+
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search by project name" + /> + + + + + + + + + + + {isProjectsLoading && } + {!isProjectsLoading && + projects?.map(({ name, slug, createdAt, id }) => ( + + + + + + + ))} + +
NameSlugCreated At +
{name}{slug}{format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa")} +
+ + + + + + { + e.stopPropagation(); + e.preventDefault(); + handleAccessProject(id); + }} + icon={} + disabled={ + orgAdminAccessProject.variables?.projectId === id && + orgAdminAccessProject.isLoading + } + > + Access{" "} + {orgAdminAccessProject.variables?.projectId === id && + orgAdminAccessProject.isLoading && } + + + +
+
+ {!isProjectsLoading && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {isEmpty && } +
+
+
+
+ ); + }, + { + action: OrgPermissionAdminConsoleAction.AccessAllProjects, + subject: OrgPermissionSubjects.AdminConsole + } +); diff --git a/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/index.tsx b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/index.tsx new file mode 100644 index 000000000..b331589a4 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/index.tsx @@ -0,0 +1 @@ +export { OrgAdminProjects } from "./OrgAdminProjects"; diff --git a/frontend/src/views/OrgAdminPage/index.tsx b/frontend/src/views/OrgAdminPage/index.tsx new file mode 100644 index 000000000..1fe7b5541 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/index.tsx @@ -0,0 +1 @@ +export { OrgAdminPage } from "./OrgAdminPage"; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 5eada2663..668b5e5c6 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -317,6 +317,12 @@ export const LogsTableRow = ({ auditLog }: Props) => { })} ); + case EventType.ORG_ADMIN_ACCESS_PROJECT: + return ( + +

{`Email: ${event.metadata.email}`}

+ + ); case EventType.CREATE_CA: case EventType.GET_CA: case EventType.UPDATE_CA: diff --git a/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx b/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx index d66e0b71b..6f9cf3a1c 100644 --- a/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx +++ b/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx @@ -165,7 +165,7 @@ export const CaRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { isLoading={isSubmitting} isDisabled={isSubmitting} > - Create + Renew