From 69dae1f0b267fd1320ee524a27232780af7e2def Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 4 Dec 2023 16:13:00 +0700 Subject: [PATCH] Move MI from refresh token to client id / client secrets approach --- backend/src/controllers/v1/authController.ts | 2 +- .../v3/machineIdentityController.ts | 441 +++++++++---- backend/src/ee/models/auditLog/types.ts | 12 +- backend/src/ee/routes/v3/machineIdentity.ts | 29 +- backend/src/ee/services/EELicenseService.ts | 2 +- backend/src/ee/services/ProjectRoleService.ts | 2 +- backend/src/models/index.ts | 1 + backend/src/models/machineIdentity.ts | 97 +-- .../models/machineIdentityClientSecretData.ts | 74 +++ .../authModeValidators/machineIdentity.ts | 39 +- .../utils/authn/helpers/authDataExtractors.ts | 2 +- backend/src/validation/machineIdentity.ts | 55 +- .../src/hooks/api/machineIdentities/index.tsx | 7 +- .../hooks/api/machineIdentities/mutations.tsx | 66 +- .../hooks/api/machineIdentities/queries.tsx | 24 + .../src/hooks/api/machineIdentities/types.ts | 56 +- .../src/views/Org/MembersPage/MembersPage.tsx | 2 +- .../AddMachineIdentityModal.tsx | 580 ++++++++++-------- .../CreateClientSecretModal.tsx | 284 +++++++++ .../MachineIdentitySection.tsx | 10 +- .../MachineIdentityTable.tsx | 53 +- .../views/Project/MembersPage/MembersPage.tsx | 2 +- .../AddMachineIdentityModal.tsx | 4 +- .../MachineIdentitySection.tsx | 4 +- .../MachineIdentityTable.tsx | 2 +- 25 files changed, 1307 insertions(+), 543 deletions(-) create mode 100644 backend/src/models/machineIdentityClientSecretData.ts create mode 100644 frontend/src/hooks/api/machineIdentities/queries.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/CreateClientSecretModal.tsx diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index e4410bbe4..4ae5a79ff 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -25,7 +25,7 @@ declare module "jsonwebtoken" { userId: string; refreshVersion?: number; } - export interface MachineRefreshTokenJwtPayload extends jwt.JwtPayload { + export interface MachineAccessTokenJwtPayload extends jwt.JwtPayload { _id: string; authTokenType: string; tokenVersion: number; diff --git a/backend/src/ee/controllers/v3/machineIdentityController.ts b/backend/src/ee/controllers/v3/machineIdentityController.ts index 129884d32..183c123ef 100644 --- a/backend/src/ee/controllers/v3/machineIdentityController.ts +++ b/backend/src/ee/controllers/v3/machineIdentityController.ts @@ -1,15 +1,17 @@ -import jwt from "jsonwebtoken"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; import { Request, Response } from "express"; import { Types } from "mongoose"; import { + IMachineIdentityClientSecretData, IMachineIdentityTrustedIp, MachineIdentity, + MachineIdentityClientSecretData, MachineMembership, MachineMembershipOrg, Organization, } from "../../../models"; import { - ActorType, EventType, Role } from "../../models"; @@ -24,105 +26,291 @@ import { import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { EEAuditLogService, EELicenseService } from "../../services"; -import { getAuthSecret } from "../../../config"; +import { getAuthSecret, getSaltRounds } from "../../../config"; import { ADMIN, AuthTokenType, CUSTOM, MEMBER, NO_ACCESS } from "../../../variables"; import { OrgPermissionActions, OrgPermissionSubjects } from "../../services/RoleService"; import { ForbiddenError } from "@casl/ability"; +import { checkIPAgainstBlocklist } from "../../../utils/ip"; + +const packageClientSecretData = (clientSecretData: IMachineIdentityClientSecretData) => ({ + _id: clientSecretData._id, + machineIdentity: clientSecretData.machineIdentity, + isActive: clientSecretData.isActive, + description: clientSecretData.description, + clientSecretPrefix: clientSecretData.clientSecretPrefix, + clientSecretUsageCount: clientSecretData.clientSecretUsageCount, + clientSecretUsageLimit: clientSecretData.clientSecretUsageLimit, + expiresAt: clientSecretData.expiresAt +}); /** - * Return machine identity access and refresh token as per refresh operation + * Return client secrets for machine with id [machineId] * @param req * @param res */ - export const refreshToken = async (req: Request, res: Response) => { +export const getMIClientSecrets = async (req: Request, res: Response) => { + const { + params: { + machineId + } + } = await validateRequest(reqValidator.GetClientSecretsV3, req); + + const machineMembershipOrg = await MachineMembershipOrg.findOne({ + machineIdentity: new Types.ObjectId(machineId) + }); + + if (!machineMembershipOrg) throw ResourceNotFoundError(); + + const { permission } = await getUserOrgPermissions(req.user._id, machineMembershipOrg.organization.toString()); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.MachineIdentity + ); + + const rolePermission = await getOrgRolePermissions(machineMembershipOrg.role, machineMembershipOrg.organization.toString()); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to get client secrets for more privileged MI" + }); + + const clientSecretData = await MachineIdentityClientSecretData + .find({ + machineIdentity: machineMembershipOrg.machineIdentity, + isActive: true + }) + .sort({ createdAt: -1 }) + .limit(5); + + return res.status(200).send({ + clientSecretData: clientSecretData.map((clientSecretDatum) => packageClientSecretData(clientSecretDatum)) + }); +} + +/** + * Create a new client secret for machine with id [machineId] + * @param req + * @param res + */ +export const createMIClientSecret = async (req: Request, res: Response) => { + const { + params: { + machineId + }, + body: { + description, + ttl, + usageLimit + } + } = await validateRequest(reqValidator.CreateClientSecretV3, req); + + const machineMembershipOrg = await MachineMembershipOrg.findOne({ + machineIdentity: new Types.ObjectId(machineId) + }); + + if (!machineMembershipOrg) throw ResourceNotFoundError(); + + const { permission } = await getUserOrgPermissions(req.user._id, machineMembershipOrg.organization.toString()); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.MachineIdentity + ); + + const rolePermission = await getOrgRolePermissions(machineMembershipOrg.role, machineMembershipOrg.organization.toString()); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to create client secret for more privileged MI" + }); + + let expiresAt; + if (ttl > 0) { + expiresAt = new Date(new Date().getTime() + ttl * 1000); + } + + const clientSecret = crypto.randomBytes(32).toString("hex"); + const clientSecretHash = await bcrypt.hash(clientSecret, await getSaltRounds()); + + const machineIdentityClientSecretData = await new MachineIdentityClientSecretData({ + machineIdentity: machineMembershipOrg.machineIdentity, + isActive: true, + description, + clientSecretPrefix: clientSecret.slice(0, 4), + clientSecretHash, + clientSecretUsageCount: 0, + clientSecretUsageLimit: usageLimit, + accessTokenVersion: 1, + expiresAt + }).save(); + + return res.status(200).send({ + clientSecret, + clientSecretData: packageClientSecretData(machineIdentityClientSecretData) + }); +} + +/** + * Delete client secret with id [clientSecretId] + * @param req + * @param res + */ +export const deleteMIClientSecret = async (req: Request, res: Response) => { + const { + params: { + machineId, + clientSecretId + } + } = await validateRequest(reqValidator.DeleteClientSecretV3, req); + + const machineMembershipOrg = await MachineMembershipOrg.findOne({ + machineIdentity: new Types.ObjectId(machineId) + }); + + if (!machineMembershipOrg) throw ResourceNotFoundError(); + + const { permission } = await getUserOrgPermissions(req.user._id, machineMembershipOrg.organization.toString()); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.MachineIdentity + ); + + const rolePermission = await getOrgRolePermissions(machineMembershipOrg.role, machineMembershipOrg.organization.toString()); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to delete client secrets for more privileged MI" + }); + + const clientSecretData = await MachineIdentityClientSecretData.findOneAndDelete({ + _id: clientSecretId, + machineIdentity: machineId + }); + + if (!clientSecretData) throw ResourceNotFoundError(); + + return res.status(200).send({ + clientSecretData: packageClientSecretData(clientSecretData) + }) +} + +/** + * Return access token for machine identity with client id [clientId] + * and client secret [clientSecret] + * @param req + * @param res + */ +export const loginMI = async (req: Request, res: Response) => { const { body: { - refreshToken + clientId, + clientSecret } - } = await validateRequest(reqValidator.RefreshTokenV3, req); + } = await validateRequest(reqValidator.LoginMachineIdentityV3, req); - const decodedToken = ( - jwt.verify(refreshToken, await getAuthSecret()) - ); - - if (decodedToken.authTokenType !== AuthTokenType.MACHINE_REFRESH_TOKEN) throw UnauthorizedRequestError(); - - let machineIdentity = await MachineIdentity.findOne({ - _id: new Types.ObjectId(decodedToken._id), + const machineIdentity = await MachineIdentity.findOne({ + clientId, isActive: true }); if (!machineIdentity) throw UnauthorizedRequestError(); + + checkIPAgainstBlocklist({ + ipAddress: req.realIP, + trustedIps: machineIdentity.clientSecretTrustedIps + }); - if (decodedToken.tokenVersion !== machineIdentity.tokenVersion) { - // raise alarm - throw UnauthorizedRequestError(); + const clientSecretData = await MachineIdentityClientSecretData.find({ + machineIdentity: machineIdentity._id, + isActive: true + }); + + let validatedClientSecretDatum: IMachineIdentityClientSecretData | undefined; + + for (const clientSecretDatum of clientSecretData) { + const isSecretValid = await bcrypt.compare( + clientSecret, + clientSecretDatum.clientSecretHash + ); + + if (isSecretValid) { + validatedClientSecretDatum = clientSecretDatum; + break; + } } - const response: { - refreshToken?: string; - accessToken: string; - expiresIn: number; - tokenType: string; - } = { - refreshToken, - accessToken: "", - expiresIn: 0, - tokenType: "Bearer" - }; - - if (machineIdentity.isRefreshTokenRotationEnabled) { - machineIdentity = await MachineIdentity.findByIdAndUpdate( - machineIdentity._id, + if (!validatedClientSecretDatum) throw UnauthorizedRequestError(); + + const { + expiresAt, + clientSecretUsageCount, + clientSecretUsageLimit + } = validatedClientSecretDatum; + + if (expiresAt && new Date(expiresAt) < new Date()) { + // client secret expired + await MachineIdentityClientSecretData.findByIdAndUpdate( + validatedClientSecretDatum._id, { - $inc: { - tokenVersion: 1 - } + isActive: false }, { new: true } ); - - if (!machineIdentity) throw BadRequestError(); - - response.refreshToken = createToken({ - payload: { - serviceTokenDataId: machineIdentity._id.toString(), - authTokenType: AuthTokenType.MACHINE_REFRESH_TOKEN, - tokenVersion: machineIdentity.tokenVersion + + throw UnauthorizedRequestError(); + } + + if (clientSecretUsageLimit > 0 && clientSecretUsageCount === clientSecretUsageLimit) { + // number of times client secret can be used for + // a login operation reached + await MachineIdentityClientSecretData.findByIdAndUpdate( + validatedClientSecretDatum._id, + { + isActive: false }, - secret: await getAuthSecret() - }); + { + new: true + } + ); + + throw UnauthorizedRequestError(); } - response.accessToken = createToken({ - payload: { - _id: machineIdentity._id.toString(), - authTokenType: AuthTokenType.MACHINE_ACCESS_TOKEN, - tokenVersion: machineIdentity.tokenVersion - }, - expiresIn: machineIdentity.accessTokenTTL, - secret: await getAuthSecret() - }); - - response.expiresIn = machineIdentity.accessTokenTTL; - - await MachineIdentity.findByIdAndUpdate( - machineIdentity._id, + // increment usage count by 1 + await MachineIdentityClientSecretData.findByIdAndUpdate( + validatedClientSecretDatum._id, { - refreshTokenLastUsed: new Date(), - $inc: { refreshTokenUsageCount: 1 } + $inc: { clientSecretUsageCount: 1 } }, { new: true } ); - - return res.status(200).send(response); + + // token version + const accessToken = createToken({ + payload: { + machineId: machineIdentity._id.toString(), // consider changing to clientId and making it more extensible + clientSecretDataId: validatedClientSecretDatum._id.toString(), + authTokenType: AuthTokenType.MACHINE_ACCESS_TOKEN, + tokenVersion: validatedClientSecretDatum.accessTokenVersion + }, + expiresIn: machineIdentity.accessTokenTTL, + secret: await getAuthSecret() + }); + + return res.status(200).send({ + accessToken, + expiresIn: machineIdentity.accessTokenTTL, + tokenType: "Bearer" + }); } /** @@ -137,10 +325,9 @@ export const createMachineIdentity = async (req: Request, res: Response) => { name, organizationId, role, - trustedIps, - expiresIn, + clientSecretTrustedIps, + accessTokenTrustedIps, accessTokenTTL, - isRefreshTokenRotationEnabled } } = await validateRequest(reqValidator.CreateMachineIdentityV3, req); @@ -177,44 +364,44 @@ export const createMachineIdentity = async (req: Request, res: Response) => { const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); // validate trusted ips - const reformattedTrustedIps = trustedIps.map((trustedIp) => { - if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { + if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." }); - const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); + const isValidIPOrCidr = isValidIpOrCidr(clientSecretTrustedIp.ipAddress); if (!isValidIPOrCidr) return res.status(400).send({ message: "The IP is not a valid IPv4, IPv6, or CIDR block" }); - return extractIPDetails(trustedIp.ipAddress); + return extractIPDetails(clientSecretTrustedIp.ipAddress); + }); + + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(accessTokenTrustedIp.ipAddress); }); - - let expiresAt; - if (expiresIn) { - expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - } - - let user; - if (req.authData.actor.type === ActorType.USER) { - user = req.authData.authPayload._id; - } const isActive = true; const machineIdentity = await new MachineIdentity({ + clientId: crypto.randomUUID(), name, - user, organization: new Types.ObjectId(organizationId), - refreshTokenUsageCount: 0, - accessTokenUsageCount: 0, - tokenVersion: 1, - trustedIps: reformattedTrustedIps, isActive, - expiresAt, accessTokenTTL, - isRefreshTokenRotationEnabled + accessTokenUsageCount: 0, + clientSecretTrustedIps: reformattedClientSecretTrustedIps, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps, }).save(); await new MachineMembershipOrg({ @@ -224,15 +411,6 @@ export const createMachineIdentity = async (req: Request, res: Response) => { customRole }).save(); - const refreshToken = createToken({ - payload: { - _id: machineIdentity._id.toString(), - authTokenType: AuthTokenType.MACHINE_REFRESH_TOKEN, - tokenVersion: machineIdentity.tokenVersion - }, - secret: await getAuthSecret() - }); - await EEAuditLogService.createAuditLog( req.authData, { @@ -241,8 +419,8 @@ export const createMachineIdentity = async (req: Request, res: Response) => { name, isActive, role, - trustedIps: reformattedTrustedIps as Array, - expiresAt + clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array } }, { @@ -251,8 +429,7 @@ export const createMachineIdentity = async (req: Request, res: Response) => { ); return res.status(200).send({ - machineIdentity, - refreshToken + machineIdentity }); } @@ -268,10 +445,9 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { body: { name, role, - trustedIps, - expiresIn, - accessTokenTTL, - isRefreshTokenRotationEnabled + clientSecretTrustedIps, + accessTokenTrustedIps, + accessTokenTTL } } = await validateRequest(reqValidator.UpdateMachineIdentityV3, req); @@ -312,38 +488,49 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { const plan = await EELicenseService.getPlan(machineIdentity.organization); - // validate trusted ips - let reformattedTrustedIps; - if (trustedIps) { - reformattedTrustedIps = trustedIps.map((trustedIp) => { - if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + // validate client secret trusted ips + let reformattedClientSecretTrustedIps; + if (clientSecretTrustedIps) { + reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { + if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range." }); - const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); + const isValidIPOrCidr = isValidIpOrCidr(clientSecretTrustedIp.ipAddress); if (!isValidIPOrCidr) return res.status(400).send({ message: "The IP is not a valid IPv4, IPv6, or CIDR block" }); - return extractIPDetails(trustedIp.ipAddress); + return extractIPDetails(clientSecretTrustedIp.ipAddress); }); } - let expiresAt; - if (expiresIn) { - expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + // validate access token trusted ips + let reformattedAccessTokenTrustedIps; + if (accessTokenTrustedIps) { + reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); } machineIdentity = await MachineIdentity.findByIdAndUpdate( machineId, { name, - trustedIps: reformattedTrustedIps, - expiresAt, - accessTokenTTL, - isRefreshTokenRotationEnabled + clientSecretTrustedIps: reformattedClientSecretTrustedIps, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps, + accessTokenTTL }, { new: true @@ -381,8 +568,8 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { metadata: { name: machineIdentity.name, role, - trustedIps: reformattedTrustedIps as Array, - expiresAt + clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array } }, { @@ -436,6 +623,10 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => { machineIdentity: machineIdentity._id, }); + await MachineIdentityClientSecretData.deleteMany({ + machineIdentity: machineIdentity._id + }); + await EEAuditLogService.createAuditLog( req.authData, { @@ -444,8 +635,8 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => { name: machineIdentity.name, isActive: machineIdentity.isActive, role: machineMembershipOrg.role, - trustedIps: machineIdentity.trustedIps as Array, - expiresAt: machineIdentity.expiresAt + clientSecretTrustedIps: machineIdentity.clientSecretTrustedIps as Array, + accessTokenTrustedIps: machineIdentity.accessTokenTrustedIps as Array, } }, { diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index 542feee7b..a7b802113 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -231,8 +231,8 @@ interface CreateMachineIdentityEvent { name: string; isActive: boolean; role: string; - trustedIps: Array; - expiresAt?: Date; + clientSecretTrustedIps: Array; + accessTokenTrustedIps: Array; }; } @@ -241,8 +241,8 @@ interface UpdateMachineIdentityEvent { metadata: { name?: string; role?: string; - trustedIps?: Array; - expiresAt?: Date; + clientSecretTrustedIps?: Array; + accessTokenTrustedIps?: Array; }; } @@ -252,8 +252,8 @@ interface DeleteMachineIdentityEvent { name: string; isActive: boolean; role: string; - expiresAt?: Date; - trustedIps: Array; + clientSecretTrustedIps: Array; + accessTokenTrustedIps: Array; }; } diff --git a/backend/src/ee/routes/v3/machineIdentity.ts b/backend/src/ee/routes/v3/machineIdentity.ts index 6f0ac8e7c..5b9649566 100644 --- a/backend/src/ee/routes/v3/machineIdentity.ts +++ b/backend/src/ee/routes/v3/machineIdentity.ts @@ -4,9 +4,34 @@ import { requireAuth } from "../../../middleware"; import { AuthMode } from "../../../variables"; import { machineIdentityController } from "../../controllers/v3"; +router.get( + "/:machineId/client-secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + machineIdentityController.getMIClientSecrets +); + router.post( - "/me/token", - machineIdentityController.refreshToken + "/:machineId/client-secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + machineIdentityController.createMIClientSecret +); + +router.delete( + "/:machineId/client-secrets/:clientSecretId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + machineIdentityController.deleteMIClientSecret +); + +// consider moving to /auth/app/login +router.post( + "/login", + machineIdentityController.loginMI ); router.post( diff --git a/backend/src/ee/services/EELicenseService.ts b/backend/src/ee/services/EELicenseService.ts index 6baea04dc..267ccb46b 100644 --- a/backend/src/ee/services/EELicenseService.ts +++ b/backend/src/ee/services/EELicenseService.ts @@ -69,7 +69,7 @@ class EELicenseService { rbac: false, customRateLimits: false, customAlerts: false, - auditLogs: false, + auditLogs: true, auditLogsRetentionDays: 0, samlSSO: false, status: null, diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index 904301d7e..822ee9c07 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -327,7 +327,7 @@ export const getAuthDataProjectPermissions = async ({ checkIPAgainstBlocklist({ ipAddress: authData.ipAddress, - trustedIps: machineMembership.machineIdentity.trustedIps + trustedIps: machineMembership.machineIdentity.accessTokenTrustedIps }); role = machineMembership.role; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index dae46bbf7..ef6d2db80 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -21,6 +21,7 @@ export * from "./userAction"; export * from "./workspace"; export * from "./serviceTokenData"; // TODO: deprecate export * from "./machineIdentity"; +export * from "./machineIdentityClientSecretData"; export * from "./machineMembershipOrg"; export * from "./machineMembership"; export * from "./apiKeyData"; // TODO: deprecate diff --git a/backend/src/models/machineIdentity.ts b/backend/src/models/machineIdentity.ts index 7277244ce..830d74dd5 100644 --- a/backend/src/models/machineIdentity.ts +++ b/backend/src/models/machineIdentity.ts @@ -7,25 +7,27 @@ export interface IMachineIdentityTrustedIp { prefix: number; } +// TODO: rename to AppClient + export interface IMachineIdentity extends Document { _id: Types.ObjectId; + clientId: string; name: string; organization: Types.ObjectId; - user: Types.ObjectId; isActive: boolean; - refreshTokenLastUsed?: Date; - accessTokenLastUsed?: Date; - refreshTokenUsageCount: number; - accessTokenUsageCount: number; - tokenVersion: number; - isRefreshTokenRotationEnabled: boolean; - expiresAt?: Date; accessTokenTTL: number; - trustedIps: Array; + accessTokenLastUsed?: Date; + accessTokenUsageCount: number; + clientSecretTrustedIps: Array; + accessTokenTrustedIps: Array; } const machineIdentitySchema = new Schema( { + clientId: { + type: String, + required: true + }, name: { type: String, required: true @@ -35,55 +37,54 @@ const machineIdentitySchema = new Schema( ref: "Organization", required: true }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true - }, isActive: { type: Boolean, default: true, required: true }, - refreshTokenLastUsed: { - type: Date, - required: false - }, - accessTokenLastUsed: { - type: Date, - required: false - }, - refreshTokenUsageCount: { - type: Number, - default: 0, - required: true - }, - accessTokenUsageCount: { - type: Number, - default: 0, - required: true - }, - tokenVersion: { - type: Number, - default: 1, - required: true - }, - isRefreshTokenRotationEnabled: { - type: Boolean, - default: false, - required: true - }, - expiresAt: { // consider revising field name - type: Date, - required: false, - // expires: 0 - }, accessTokenTTL: { // seconds type: Number, default: 7200, required: true }, - trustedIps: { + accessTokenLastUsed: { + type: Date, + required: false + }, + accessTokenUsageCount: { + type: Number, + default: 0, + required: true + }, + clientSecretTrustedIps: { + type: [ + { + ipAddress: { + type: String, + required: true + }, + type: { + type: String, + enum: [ + IPType.IPV4, + IPType.IPV6 + ], + required: true + }, + prefix: { + type: Number, + required: false + } + } + ], + default: [{ + ipAddress: "0.0.0.0", + type: IPType.IPV4.toString(), + prefix: 0 + }], + required: true + }, + accessTokenTrustedIps: { type: [ { ipAddress: { diff --git a/backend/src/models/machineIdentityClientSecretData.ts b/backend/src/models/machineIdentityClientSecretData.ts new file mode 100644 index 000000000..9bdf950bb --- /dev/null +++ b/backend/src/models/machineIdentityClientSecretData.ts @@ -0,0 +1,74 @@ +import { Document, Schema, Types, model } from "mongoose"; + +export interface IMachineIdentityClientSecretData extends Document { + _id: Types.ObjectId; + machineIdentity: Types.ObjectId; + isActive: boolean; + description: string; + clientSecretPrefix: string; + clientSecretHash: string; + clientSecretLastUsed?: Date; + clientSecretUsageCount: number; + clientSecretUsageLimit: number; + accessTokenVersion: number; + expiresAt?: Date; +} + +const machineIdentityClientSecretDataSchema = new Schema( + { + machineIdentity: { + type: Schema.Types.ObjectId, + ref: "MachineIdentity", + required: true + }, + isActive: { + type: Boolean, + default: true, + required: true + }, + description: { + type: String, + required: true + }, + clientSecretPrefix: { + type: String, + required: true + }, + clientSecretHash: { + type: String, + required: true + }, + clientSecretLastUsed: { + type: Date, + required: false + }, + clientSecretUsageCount: { + // number of times client secret has been used + // in login operation + type: Number, + default: 0, + required: true + }, + clientSecretUsageLimit: { + // number of times client secret can be used for + // a login operation + type: Number, + default: 0, // default: used as many times as needed + required: true + }, + accessTokenVersion: { + type: Number, + default: 1, + required: true + }, + expiresAt: { + type: Date, + required: false + } + }, + { + timestamps: true + } +); + +export const MachineIdentityClientSecretData = model("MachineIdentityClientSecretData", machineIdentityClientSecretDataSchema); \ No newline at end of file diff --git a/backend/src/utils/authn/authModeValidators/machineIdentity.ts b/backend/src/utils/authn/authModeValidators/machineIdentity.ts index bdd1f823b..f8c9a7b27 100644 --- a/backend/src/utils/authn/authModeValidators/machineIdentity.ts +++ b/backend/src/utils/authn/authModeValidators/machineIdentity.ts @@ -1,6 +1,6 @@ import jwt from "jsonwebtoken"; import { Types } from "mongoose"; -import { MachineIdentity } from "../../../models"; +import { MachineIdentity, MachineIdentityClientSecretData } from "../../../models"; import { getAuthSecret } from "../../../config"; import { AuthTokenType } from "../../../variables"; import { UnauthorizedRequestError } from "../../errors"; @@ -12,45 +12,28 @@ interface ValidateMachineIdentityParams { export const validateMachineIdentity = async ({ authTokenValue }: ValidateMachineIdentityParams) => { - const decodedToken = ( + const decodedToken = ( jwt.verify(authTokenValue, await getAuthSecret()) ); if (decodedToken.authTokenType !== AuthTokenType.MACHINE_ACCESS_TOKEN) throw UnauthorizedRequestError(); - const machineIdentity = await MachineIdentity.findOne({ - _id: new Types.ObjectId(decodedToken._id), + const machineIdentityClientSecretData = await MachineIdentityClientSecretData.findOne({ + _id: new Types.ObjectId(decodedToken.clientSecretDataId), isActive: true }); - if (!machineIdentity) { - throw UnauthorizedRequestError({ - message: "Failed to authenticate" - }); - } else if (machineIdentity?.expiresAt && new Date(machineIdentity.expiresAt) < new Date()) { - // case: service token expired - await MachineIdentity.findByIdAndUpdate( - machineIdentity._id, - { - isActive: false - }, - { - new: true - } - ); - - throw UnauthorizedRequestError({ - message: "Failed to authenticate", - }); - } else if (decodedToken.tokenVersion !== machineIdentity.tokenVersion) { + if (!machineIdentityClientSecretData) throw UnauthorizedRequestError(); + + if (decodedToken.tokenVersion !== machineIdentityClientSecretData.accessTokenVersion) { // TODO: raise alarm throw UnauthorizedRequestError({ message: "Failed to authenticate", }); } - await MachineIdentity.findByIdAndUpdate( - machineIdentity._id, + const machineIdentity = await MachineIdentity.findByIdAndUpdate( + machineIdentityClientSecretData.machineIdentity, { accessTokenLastUsed: new Date(), $inc: { accessTokenUsageCount: 1 } @@ -59,6 +42,10 @@ export const validateMachineIdentity = async ({ new: true } ); + + if (!machineIdentity) throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); return machineIdentity; } \ No newline at end of file diff --git a/backend/src/utils/authn/helpers/authDataExtractors.ts b/backend/src/utils/authn/helpers/authDataExtractors.ts index 5491549db..97cb9b1f4 100644 --- a/backend/src/utils/authn/helpers/authDataExtractors.ts +++ b/backend/src/utils/authn/helpers/authDataExtractors.ts @@ -39,6 +39,6 @@ export const getAuthDataPayloadUserObj = (authData: AuthData) => { } if (authData.authPayload instanceof MachineIdentity) { - return { user: authData.authPayload.user }; + return {}; } } \ No newline at end of file diff --git a/backend/src/validation/machineIdentity.ts b/backend/src/validation/machineIdentity.ts index 15af09f14..37bfd85c0 100644 --- a/backend/src/validation/machineIdentity.ts +++ b/backend/src/validation/machineIdentity.ts @@ -1,9 +1,34 @@ import { z } from "zod"; import { NO_ACCESS } from "../variables"; -export const RefreshTokenV3 = z.object({ +export const GetClientSecretsV3 = z.object({ + params: z.object({ + machineId: z.string() + }) +}); + +export const CreateClientSecretV3 = z.object({ + params: z.object({ + machineId: z.string() + }), body: z.object({ - refreshToken: z.string().trim() + description: z.string().trim().default(""), + usageLimit: z.number().min(0).default(0), + ttl: z.number().min(0).default(0), + }), +}); + +export const DeleteClientSecretV3 = z.object({ + params: z.object({ + machineId: z.string(), + clientSecretId: z.string() + }) +}); + +export const LoginMachineIdentityV3 = z.object({ + body: z.object({ + clientId: z.string().trim(), + clientSecret: z.string().trim() }) }); @@ -12,16 +37,21 @@ export const CreateMachineIdentityV3 = z.object({ name: z.string().trim(), organizationId: z.string().trim(), role: z.string().trim().min(1).default(NO_ACCESS), - trustedIps: z + clientSecretTrustedIps: z .object({ ipAddress: z.string().trim(), }) .array() .min(1) .default([{ ipAddress: "0.0.0.0/0" }]), - expiresIn: z.number().optional(), - accessTokenTTL: z.number().int().min(1), - isRefreshTokenRotationEnabled: z.boolean().default(false) + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }]), + accessTokenTTL: z.number().int().min(1) }) }); @@ -32,16 +62,21 @@ export const UpdateMachineIdentityV3 = z.object({ body: z.object({ name: z.string().trim().optional(), role: z.string().trim().min(1).optional(), - trustedIps: z + clientSecretTrustedIps: z .object({ ipAddress: z.string().trim() }) .array() .min(1) .optional(), - expiresIn: z.number().optional(), - accessTokenTTL: z.number().int().min(1).optional(), - isRefreshTokenRotationEnabled: z.boolean().optional() + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(1).optional() }), }); diff --git a/frontend/src/hooks/api/machineIdentities/index.tsx b/frontend/src/hooks/api/machineIdentities/index.tsx index 3be974866..71acfab8e 100644 --- a/frontend/src/hooks/api/machineIdentities/index.tsx +++ b/frontend/src/hooks/api/machineIdentities/index.tsx @@ -1,4 +1,9 @@ export { useCreateMachineIdentity, + useCreateMachineIdentityClientSecret, useDeleteMachineIdentity, - useUpdateMachineIdentity} from "./mutations"; \ No newline at end of file + useDeleteMachineIdentityClientSecret, + useUpdateMachineIdentity} from "./mutations"; +export { + useGetMachineIdentityClientSecrets +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/machineIdentities/mutations.tsx b/frontend/src/hooks/api/machineIdentities/mutations.tsx index f9d000ef3..a74327d4b 100644 --- a/frontend/src/hooks/api/machineIdentities/mutations.tsx +++ b/frontend/src/hooks/api/machineIdentities/mutations.tsx @@ -3,12 +3,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; +import { machineIdentityKeys } from "./queries"; import { + CreateMachineIdentityClientSecretDTO, + CreateMachineIdentityClientSecretRes, CreateMachineIdentityDTO, CreateMachineIdentityRes, DeleteMachineIdentityDTO, MachineIdentity, - UpdateMachineIdentityDTO} from "./types"; + UpdateMachineIdentityDTO, +} from "./types"; export const useCreateMachineIdentity = () => { const queryClient = useQueryClient(); @@ -17,12 +21,56 @@ export const useCreateMachineIdentity = () => { const { data } = await apiRequest.post("/api/v3/machines/", body); return data; }, - onSuccess: ({ machineIdentity }) => { + onSuccess: ({ machineIdentity }) => { queryClient.invalidateQueries(organizationKeys.getOrgServiceMemberships(machineIdentity.organization)); } }); }; +export const useCreateMachineIdentityClientSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + machineId, + description, + ttl, + usageLimit + }) => { + + const { data } = await apiRequest.post(`/api/v3/machines/${machineId}/client-secrets`, { + machineId, + description, + ttl, + usageLimit + }); + + return data; + }, + onSuccess: (_, { machineId }) => { + queryClient.invalidateQueries(machineIdentityKeys.getMachineIdentityClientSecrets(machineId)); + } + }); +}; + +export const useDeleteMachineIdentityClientSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + machineId, + clientSecretId + }: { + machineId:string; + clientSecretId: string; + }) => { + const { data } = await apiRequest.delete(`/api/v3/machines/${machineId}/client-secrets/${clientSecretId}`); + return data; + }, + onSuccess: (_, { machineId }) => { + queryClient.invalidateQueries(machineIdentityKeys.getMachineIdentityClientSecrets(machineId)); + } + }); +}; + export const useUpdateMachineIdentity = () => { const queryClient = useQueryClient(); return useMutation({ @@ -30,21 +78,17 @@ export const useUpdateMachineIdentity = () => { machineId, name, role, - isActive, - trustedIps, - expiresIn, - accessTokenTTL, - isRefreshTokenRotationEnabled + clientSecretTrustedIps, + accessTokenTrustedIps, + accessTokenTTL }) => { const { data: { machineIdentity } } = await apiRequest.patch(`/api/v3/machines/${machineId}`, { name, role, - isActive, - trustedIps, - expiresIn, + clientSecretTrustedIps, + accessTokenTrustedIps, accessTokenTTL, - isRefreshTokenRotationEnabled }); return machineIdentity; diff --git a/frontend/src/hooks/api/machineIdentities/queries.tsx b/frontend/src/hooks/api/machineIdentities/queries.tsx new file mode 100644 index 000000000..86e1a7462 --- /dev/null +++ b/frontend/src/hooks/api/machineIdentities/queries.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + MachineIdentityClientSecret +} from "./types"; + +export const machineIdentityKeys = { + getMachineIdentityClientSecrets: (machineId: string) => [{ machineId }, "machine-identity-client-secrets"] as const +} + +export const useGetMachineIdentityClientSecrets = (machineId: string) => { + return useQuery({ + queryKey: machineIdentityKeys.getMachineIdentityClientSecrets(machineId), + queryFn: async () => { + const { data: { clientSecretData } } = await apiRequest.get<{ clientSecretData: MachineIdentityClientSecret[] }>( + `/api/v3/machines/${machineId}/client-secrets` + ); + + return clientSecretData; + } + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/machineIdentities/types.ts b/frontend/src/hooks/api/machineIdentities/types.ts index 829c3745e..4e4bf326a 100644 --- a/frontend/src/hooks/api/machineIdentities/types.ts +++ b/frontend/src/hooks/api/machineIdentities/types.ts @@ -9,21 +9,30 @@ export type MachineTrustedIp = { export type MachineIdentity = { _id: string; + clientId: string; name: string; organization: string; isActive: boolean; - refreshTokenLastUsed?: string; - accessTokenLastUsed?: string; - refreshTokenUsageCount: number; - accessTokenUsageCount: number; - trustedIps: MachineTrustedIp[]; - expiresAt?: string; accessTokenTTL: number; - isRefreshTokenRotationEnabled: boolean; + accessTokenLastUsed?: string; + accessTokenUsageCount: number; + clientSecretTrustedIps: MachineTrustedIp[]; + accessTokenTrustedIps: MachineTrustedIp[]; createdAt: string; updatedAt: string; }; +export type MachineIdentityClientSecret = { + _id: string; + machineIdentity: string; + isActive: boolean; + description: string; + clientSecretPrefix: string; + clientSecretUsageCount: number; + clientSecretUsageLimit: number; + expiresAt: string; +} + export type MachineMembershipOrg = { _id: string; machineIdentity: MachineIdentity; @@ -48,30 +57,47 @@ export type CreateMachineIdentityDTO = { name: string; organizationId: string; role?: string; - trustedIps: { + clientSecretTrustedIps: { + ipAddress: string; + }[]; + accessTokenTrustedIps: { ipAddress: string; }[]; - expiresIn?: number; accessTokenTTL: number; - isRefreshTokenRotationEnabled: boolean; +} + +export type CreateMachineIdentityClientSecretDTO = { + machineId: string; + description?: string; + ttl?: number; + usageLimit?: number; +} + +export type CreateMachineIdentityClientSecretRes = { + clientSecret: string; + machineIdentity: string; + isActive: boolean; + description: string; + clientSecretUsageCount: number; + clientSecretUsageLimit: number; + expiresAt?: Date; } export type CreateMachineIdentityRes = { - refreshToken: string; machineIdentity: MachineIdentity; } export type UpdateMachineIdentityDTO = { machineId: string; - isActive?: boolean; name?: string; role?: string; - trustedIps?: { + clientSecretTrustedIps?: { + ipAddress: string; + }[]; + accessTokenTrustedIps?: { ipAddress: string; }[]; - expiresIn?: number; accessTokenTTL?: number; - isRefreshTokenRotationEnabled?: boolean; } export type DeleteMachineIdentityDTO = { diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index a1b1ed1a5..09641cfcf 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -27,7 +27,7 @@ export const MembersPage = withPermission( People
-

Machine Identities

+

App Clients

Beta
diff --git a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx index 19d4f050d..ae436395b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import { motion } from "framer-motion"; @@ -16,7 +16,6 @@ import { ModalContent, Select, SelectItem, - Switch, Tab, TabList, TabPanel, @@ -40,18 +39,8 @@ enum TabSections { Advanced = "advanced" } -const expirations = [ - { label: "Never", value: "" }, - { label: "1 day", value: "86400" }, - { label: "7 days", value: "604800" }, - { label: "1 month", value: "2592000" }, - { label: "6 months", value: "15552000" }, - { label: "12 months", value: "31104000" } -]; - const schema = yup.object({ name: yup.string().required("MI name is required"), - expiresIn: yup.string(), accessTokenTTL: yup .string() .test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => { @@ -64,7 +53,7 @@ const schema = yup.object({ }) .required("Access Token TTL is required"), role: yup.string(), - trustedIps: yup + clientSecretTrustedIps: yup .array( yup.object({ ipAddress: yup.string().max(50).required().label("IP Address") @@ -72,8 +61,16 @@ const schema = yup.object({ ) .min(1) .required() - .label("Trusted IP"), - isRefreshTokenRotationEnabled: yup.boolean().default(false) + .label("Client Secret Trusted IP"), + accessTokenTrustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Access Token Trusted IP") }).required(); export type FormData = yup.InferType; @@ -90,7 +87,6 @@ export const AddMachineIdentityModal = ({ handlePopUpToggle }: Props) => { const { createNotification } = useNotificationContext(); - const [newServiceTokenJSON, setNewServiceTokenJSON] = useState(""); const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false); const { subscription } = useSubscription(); @@ -115,9 +111,12 @@ export const AddMachineIdentityModal = ({ defaultValues: { name: "", accessTokenTTL: "7200", - trustedIps: [{ + clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" - }] + }], + accessTokenTrustedIps: [{ + ipAddress: "0.0.0.0/0" + }], } }); @@ -130,11 +129,6 @@ export const AddMachineIdentityModal = ({ return () => clearTimeout(timer); }, [setIsServiceTokenJSONCopied]); - - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(newServiceTokenJSON); - setIsServiceTokenJSONCopied.on(); - }; useEffect(() => { @@ -146,9 +140,9 @@ export const AddMachineIdentityModal = ({ name: string; slug: string; }; - trustedIps: MachineTrustedIp[]; + clientSecretTrustedIps: MachineTrustedIp[]; + accessTokenTrustedIps: MachineTrustedIp[]; accessTokenTTL: number; - isRefreshTokenRotationEnabled: boolean; }; if (!roles?.length) return; @@ -156,9 +150,8 @@ export const AddMachineIdentityModal = ({ if (machineIdentity) { reset({ name: machineIdentity.name, - expiresIn: "", role: machineIdentity?.customRole?.slug ?? machineIdentity.role, - trustedIps: machineIdentity.trustedIps.map(({ + clientSecretTrustedIps: machineIdentity.clientSecretTrustedIps.map(({ ipAddress, prefix }: MachineTrustedIp) => { @@ -166,31 +159,48 @@ export const AddMachineIdentityModal = ({ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` }); }), - accessTokenTTL: String(machineIdentity.accessTokenTTL), - isRefreshTokenRotationEnabled: machineIdentity.isRefreshTokenRotationEnabled + accessTokenTrustedIps: machineIdentity.accessTokenTrustedIps.map(({ + ipAddress, + prefix + }: MachineTrustedIp) => { + return ({ + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }); + }), + accessTokenTTL: String(machineIdentity.accessTokenTTL) }); } else { reset({ name: "", - expiresIn: "", accessTokenTTL: "7200", role: roles[0].slug, - trustedIps: [{ + clientSecretTrustedIps: [{ + ipAddress: "0.0.0.0/0" + }], + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }] }); } }, [popUp?.machineIdentity?.data, roles]); - const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" }); + const { + fields: clientSecretTrustedIpsFields, + append: appendClientSecretTrustedIp, + remove: removeClientSecretTrustedIp + } = useFieldArray({ control, name: "clientSecretTrustedIps" }); + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); const onFormSubmit = async ({ name, - expiresIn, accessTokenTTL, role, - trustedIps, - isRefreshTokenRotationEnabled + clientSecretTrustedIps, + accessTokenTrustedIps }: FormData) => { try { @@ -207,26 +217,24 @@ export const AddMachineIdentityModal = ({ machineId: machineIdentity.machineId, name, role: role || undefined, - trustedIps, - expiresIn: (!expiresIn) ? undefined : Number(expiresIn), - accessTokenTTL: Number(accessTokenTTL), - isRefreshTokenRotationEnabled + clientSecretTrustedIps, + accessTokenTrustedIps, + accessTokenTTL: Number(accessTokenTTL) }); handlePopUpToggle("machineIdentity", false); } else { - const { refreshToken } = await createMutateAsync({ + await createMutateAsync({ name, role: role || undefined, organizationId: orgId, - trustedIps, - expiresIn: (!expiresIn) ? undefined : Number(expiresIn), + clientSecretTrustedIps, + accessTokenTrustedIps, accessTokenTTL: Number(accessTokenTTL), - isRefreshTokenRotationEnabled }); - - setNewServiceTokenJSON(refreshToken); + + handlePopUpToggle("machineIdentity", false); } createNotification({ @@ -247,248 +255,280 @@ export const AddMachineIdentityModal = ({ } } - const hasServiceTokenJSON = Boolean(newServiceTokenJSON); - return ( { handlePopUpToggle("machineIdentity", isOpen); reset(); - setNewServiceTokenJSON(""); }} > - - {!hasServiceTokenJSON ? ( -
- - -
- General - Advanced -
-
- - - ( - - - - )} - /> - ( - - - - )} - /> - - - - -
+ + + + +
+ General + Advanced +
+
+ + ( - - - - )} - /> - {tokenTrustedIps.map(({ id }, index) => ( -
- { - return ( - - { - if (subscription?.ipAllowlisting) { - field.onChange(e); - return; - } - - handlePopUpOpen("upgradePlan"); - }} - placeholder="123.456.789.0" - /> - - ); - }} - /> - { - if (subscription?.ipAllowlisting) { - removeTrustedIp(index); - return; - } - - handlePopUpOpen("upgradePlan"); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="p-3" - > - - -
- ))} -
- -
- ( - + + )} + /> + ( + + - - )} - /> -
+ {(roles || []).map(({ name, slug }) => ( + + {name} + + ))} + + + )} + /> + + + + +
+ {/* ( + + + + )} + /> */} + ( + + + + )} + /> + {clientSecretTrustedIpsFields.map(({ id }, index) => ( +
( - onChange(isChecked)} - isChecked={value} - > - Refresh Token Rotation - - )} + name={`clientSecretTrustedIps.${index}.ipAddress`} + defaultValue="0.0.0.0/0" + render={({ field, fieldState: { error } }) => { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} /> -

When enabled, as a result of exchanging a refresh token, a new refresh token will be issued and the existing token will be invalidated.

+ { + if (subscription?.ipAllowlisting) { + removeClientSecretTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + +
+ ))} +
+
- - -
- - -
- - ) : ( -
-

{newServiceTokenJSON}

- ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ +
- )} + handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/CreateClientSecretModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/CreateClientSecretModal.tsx new file mode 100644 index 000000000..65b60ed56 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/CreateClientSecretModal.tsx @@ -0,0 +1,284 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { faCheck, faCopy, faKey,faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { format } from "date-fns"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent +, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { + useCreateMachineIdentityClientSecret, + useDeleteMachineIdentityClientSecret, + useGetMachineIdentityClientSecrets} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + description: yup.string(), + ttl: yup.string() // TODO: optional +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["clientSecret"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["clientSecret"]>, state?: boolean) => void; +}; + +export const CreateClientSecretModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const [token, setToken] = useState(""); + const [isTokenCopied, setIsTokenCopied] = useToggle(false); + + const popUpData = (popUp?.clientSecret?.data as { + machineId?: string; + name?: string; + }); + + const { data, isLoading } = useGetMachineIdentityClientSecrets(popUpData?.machineId ?? ""); + + const { mutateAsync: createClientSecretMutateAsync } = useCreateMachineIdentityClientSecret(); + const { mutateAsync: deleteClientSecretMutateAsync } = useDeleteMachineIdentityClientSecret(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + description: "", + ttl: "" + } + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isTokenCopied) { + timer = setTimeout(() => setIsTokenCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isTokenCopied]); + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(token); + setIsTokenCopied.on(); + }; + + const onFormSubmit = async ({ + description, + ttl + }: FormData) => { + try { + + if (popUpData) { + + const { clientSecret } = await createClientSecretMutateAsync({ + machineId: popUpData.machineId, + description, + ttl: Number(ttl) + }); + + setToken(clientSecret); + } + + createNotification({ + text: "Successfully created client secret", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create client secret", + type: "error" + }); + } + } + + const hasToken = Boolean(token); + + return ( + { + handlePopUpToggle("clientSecret", isOpen); + reset(); + setToken(""); + }} + > + +

New Client Secret

+ {hasToken ? ( +
+
+

We will only show this secret once

+ +
+
+

{token}

+ + + + {t("common.click-to-copy")} + + +
+
+ ) : ( +
+ ( + + + + )} + /> + ( + +
+ + +
+
+ )} + /> + + )} +

Client Secrets

+ + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + _id, + description, + machineIdentity, + expiresAt, + clientSecretPrefix + }) => { + return ( + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
DescriptionExpires AtClient Secret +
{description === "" ? "-" : description}{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}{`${clientSecretPrefix}************`} + { + await deleteClientSecretMutateAsync({ + machineId: machineIdentity, + clientSecretId: _id + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + className="ml-4" + > + + +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx index ef2ec7f7c..e9d8764cc 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx @@ -13,6 +13,7 @@ import { useDeleteMachineIdentity } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddMachineIdentityModal } from "./AddMachineIdentityModal"; +import { CreateClientSecretModal } from "./CreateClientSecretModal"; import { MachineIdentityTable } from "./MachineIdentityTable"; export const MachineIdentitySection = withPermission( @@ -22,6 +23,7 @@ export const MachineIdentitySection = withPermission( const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "machineIdentity", "deleteMachineIdentity", + "clientSecret", "upgradePlan" ] as const); @@ -49,7 +51,7 @@ export const MachineIdentitySection = withPermission(

- Machine Identities (MIs) + App Clients

handlePopUpOpen("machineIdentity")} isDisabled={!isAllowed} > - Create MI + Create client )} @@ -76,6 +78,10 @@ export const MachineIdentitySection = withPermission( handlePopUpOpen={handlePopUpOpen} handlePopUpToggle={handlePopUpToggle} /> + , + popUpName: keyof UsePopUpState<["deleteMachineIdentity", "machineIdentity", "clientSecret"]>, data?: { machineId?: string; name?: string; @@ -41,9 +40,9 @@ type Props = { name: string; slug: string; }; - trustedIps?: MachineTrustedIp[]; + clientSecretTrustedIps?: MachineTrustedIp[]; + accessTokenTrustedIps?: MachineTrustedIp[]; accessTokenTTL?: number; - isRefreshTokenRotationEnabled?: boolean; } ) => void; }; @@ -121,12 +120,13 @@ export const MachineIdentityTable = ({ Name + Client ID {/* Status */} Role {/* Trusted IPs */} {/* Access Token TTL */} {/* Created At */} - Valid Until + {/* Valid Until */} @@ -139,12 +139,13 @@ export const MachineIdentityTable = ({ machineIdentity: { _id, name, + clientId, // isActive, - trustedIps, + clientSecretTrustedIps, + accessTokenTrustedIps, // createdAt, - expiresAt, + // expiresAt, accessTokenTTL, - isRefreshTokenRotationEnabled }, role, customRole @@ -152,6 +153,7 @@ export const MachineIdentityTable = ({ return ( {name} + {clientId} {/* */} {/* {accessTokenTTL} */} {/* {format(new Date(createdAt), "yyyy-MM-dd")} */} - {expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} + {/* {expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} */} + + + { + handlePopUpOpen("clientSecret", { + machineId: _id, + name + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + // isDisabled={!isAllowed} + > + + + @@ -278,7 +299,7 @@ export const MachineIdentityTable = ({ {!isLoading && data && data?.length === 0 && ( - + )} diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index f8769626c..1fa9c7cd0 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -32,7 +32,7 @@ export const MembersPage = withProjectPermission( People
-

Machine Identities

+

App Clients

Beta
diff --git a/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx index fdcc0e1c7..4c982a67a 100644 --- a/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx @@ -120,7 +120,7 @@ export const AddMachineIdentityModal = ({ reset(); }} > - + {filteredMachineMembershipOrgs.length ? (
( diff --git a/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx b/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx index 7f1a311d0..948934b42 100644 --- a/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx @@ -62,7 +62,7 @@ export const MachineIdentitySection = withProjectPermission(

- Machine Identities (MIs) + App Clients

handlePopUpOpen("machineIdentity")} isDisabled={!isAllowed} > - Add MI + Add client )} diff --git a/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentityTable.tsx b/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentityTable.tsx index 62523e56b..d7fe40023 100644 --- a/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentityTable.tsx +++ b/frontend/src/views/Project/MembersPage/components/MachineIdentityTab/components/MachineIdentitySection/MachineIdentityTable.tsx @@ -188,7 +188,7 @@ export const MachineIdentityTable = ({ {!isLoading && data && data?.length === 0 && ( - + )}