Add trusted IP rules to ST V3

This commit is contained in:
Tuan Dang
2023-09-30 20:52:04 +01:00
parent cdf4440848
commit eb2f433f43
11 changed files with 404 additions and 205 deletions
@@ -106,6 +106,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -209,6 +210,7 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -281,6 +283,7 @@ export const createSecretRaw = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -380,6 +383,7 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -460,6 +464,7 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -555,6 +560,7 @@ export const getSecrets = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -640,6 +646,7 @@ export const getSecretByName = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -713,6 +720,7 @@ export const createSecret = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -812,6 +820,7 @@ export const updateSecretByName = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -888,6 +897,7 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -946,6 +956,7 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -995,6 +1006,7 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -1044,6 +1056,7 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
} }
case ActorType.SERVICE_V3: { case ActorType.SERVICE_V3: {
await validateServiceTokenDataV3ClientForWorkspace({ await validateServiceTokenDataV3ClientForWorkspace({
authData: req.authData,
serviceTokenData: req.authData.authPayload as IServiceTokenDataV3, serviceTokenData: req.authData.authPayload as IServiceTokenDataV3,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
environment, environment,
@@ -7,7 +7,8 @@ import {
ServiceTokenDataV3Key ServiceTokenDataV3Key
} from "../../models"; } from "../../models";
import { import {
Scope IServiceTokenV3Scope,
IServiceTokenV3TrustedIp
} from "../../models/serviceTokenDataV3"; } from "../../models/serviceTokenDataV3";
import { import {
ActorType, ActorType,
@@ -23,6 +24,7 @@ import {
} from "../../ee/services/ProjectRoleService"; } from "../../ee/services/ProjectRoleService";
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { BadRequestError, ResourceNotFoundError } from "../../utils/errors"; import { BadRequestError, ResourceNotFoundError } from "../../utils/errors";
import { extractIPDetails, isValidIpOrCidr } from "../../utils/ip";
import { EEAuditLogService } from "../../ee/services"; import { EEAuditLogService } from "../../ee/services";
import { getJwtServiceTokenSecret } from "../../config"; import { getJwtServiceTokenSecret } from "../../config";
@@ -66,6 +68,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
workspaceId, workspaceId,
publicKey, publicKey,
scopes, scopes,
trustedIps,
expiresIn, expiresIn,
encryptedKey, // for ServiceTokenDataV3Key encryptedKey, // for ServiceTokenDataV3Key
nonce // for ServiceTokenDataV3Key nonce // for ServiceTokenDataV3Key
@@ -77,6 +80,17 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
ProjectPermissionSub.ServiceTokens ProjectPermissionSub.ServiceTokens
); );
// validate trusted ips
const reformattedTrustedIps = trustedIps.map((trustedIp) => {
const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress);
if (!isValidIPOrCidr) return res.status(400).send({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(trustedIp.ipAddress);
});
let expiresAt; let expiresAt;
if (expiresIn) { if (expiresIn) {
expiresAt = new Date(); expiresAt = new Date();
@@ -95,6 +109,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
publicKey, publicKey,
usageCount: 0, usageCount: 0,
trustedIps: reformattedTrustedIps,
scopes, scopes,
isActive, isActive,
expiresAt expiresAt
@@ -123,7 +138,8 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
metadata: { metadata: {
name, name,
isActive, isActive,
scopes: scopes as Array<Scope>, scopes: scopes as Array<IServiceTokenV3Scope>,
trustedIps: reformattedTrustedIps as Array<IServiceTokenV3TrustedIp>,
expiresAt expiresAt
} }
}, },
@@ -151,6 +167,7 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
name, name,
isActive, isActive,
scopes, scopes,
trustedIps,
expiresIn expiresIn
} }
} = await validateRequest(reqValidator.UpdateServiceTokenV3, req); } = await validateRequest(reqValidator.UpdateServiceTokenV3, req);
@@ -171,6 +188,20 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
ProjectPermissionSub.ServiceTokens ProjectPermissionSub.ServiceTokens
); );
// validate trusted ips
let reformattedTrustedIps;
if (trustedIps) {
reformattedTrustedIps = trustedIps.map((trustedIp) => {
const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress);
if (!isValidIPOrCidr) return res.status(400).send({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(trustedIp.ipAddress);
});
}
let expiresAt; let expiresAt;
if (expiresIn) { if (expiresIn) {
expiresAt = new Date(); expiresAt = new Date();
@@ -183,6 +214,7 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
name, name,
isActive, isActive,
scopes, scopes,
trustedIps: reformattedTrustedIps,
expiresAt expiresAt
}, },
{ {
@@ -201,7 +233,8 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
metadata: { metadata: {
name: serviceTokenData.name, name: serviceTokenData.name,
isActive, isActive,
scopes: scopes as Array<Scope>, scopes: scopes as Array<IServiceTokenV3Scope>,
trustedIps: reformattedTrustedIps as Array<IServiceTokenV3TrustedIp>,
expiresAt expiresAt
} }
}, },
@@ -254,7 +287,8 @@ export const deleteServiceTokenData = async (req: Request, res: Response) => {
metadata: { metadata: {
name: serviceTokenData.name, name: serviceTokenData.name,
isActive: serviceTokenData.isActive, isActive: serviceTokenData.isActive,
scopes: serviceTokenData.scopes as Array<Scope>, scopes: serviceTokenData.scopes as Array<IServiceTokenV3Scope>,
trustedIps: serviceTokenData.trustedIps as Array<IServiceTokenV3TrustedIp>,
expiresAt: serviceTokenData.expiresAt expiresAt: serviceTokenData.expiresAt
} }
}, },
+8 -4
View File
@@ -3,7 +3,8 @@ import {
EventType EventType
} from "./enums"; } from "./enums";
import { import {
Scope IServiceTokenV3Scope,
IServiceTokenV3TrustedIp
} from "../../../models/serviceTokenDataV3"; } from "../../../models/serviceTokenDataV3";
interface UserActorMetadata { interface UserActorMetadata {
@@ -229,7 +230,8 @@ interface CreateServiceTokenV3Event {
metadata: { metadata: {
name: string; name: string;
isActive: boolean; isActive: boolean;
scopes: Array<Scope>; scopes: Array<IServiceTokenV3Scope>;
trustedIps: Array<IServiceTokenV3TrustedIp>;
expiresAt?: Date; expiresAt?: Date;
} }
} }
@@ -239,7 +241,8 @@ interface UpdateServiceTokenV3Event {
metadata: { metadata: {
name?: string; name?: string;
isActive?: boolean; isActive?: boolean;
scopes?: Array<Scope>; scopes?: Array<IServiceTokenV3Scope>;
trustedIps?: Array<IServiceTokenV3TrustedIp>;
expiresAt?: Date; expiresAt?: Date;
} }
} }
@@ -249,8 +252,9 @@ interface DeleteServiceTokenV3Event {
metadata: { metadata: {
name: string; name: string;
isActive: boolean; isActive: boolean;
scopes: Array<Scope>; scopes: Array<IServiceTokenV3Scope>;
expiresAt?: Date; expiresAt?: Date;
trustedIps: Array<IServiceTokenV3TrustedIp>;
} }
} }
+1 -4
View File
@@ -19,10 +19,7 @@ import {
ServiceTokenData, ServiceTokenData,
TFolderRootSchema TFolderRootSchema
} from "../models"; } from "../models";
import { import { Permission } from "../models/serviceTokenDataV3";
Scope,
Permission
} from "../models/serviceTokenDataV3";
import { EventType, SecretVersion } from "../ee/models"; import { EventType, SecretVersion } from "../ee/models";
import { import {
BadRequestError, BadRequestError,
+38 -2
View File
@@ -1,16 +1,23 @@
import { Document, Schema, Types, model } from "mongoose"; import { Document, Schema, Types, model } from "mongoose";
import { IPType } from "../ee/models";
export enum Permission { export enum Permission {
READ = "read", READ = "read",
WRITE = "write" WRITE = "write"
} }
export interface Scope { export interface IServiceTokenV3Scope {
environment: string; environment: string;
secretPath: string; secretPath: string;
permissions: Permission[]; permissions: Permission[];
} }
export interface IServiceTokenV3TrustedIp {
ipAddress: string;
type: IPType;
prefix: number;
}
export interface IServiceTokenDataV3 extends Document { export interface IServiceTokenDataV3 extends Document {
_id: Types.ObjectId; _id: Types.ObjectId;
name: string; name: string;
@@ -21,7 +28,8 @@ export interface IServiceTokenDataV3 extends Document {
lastUsed?: Date; lastUsed?: Date;
usageCount: number; usageCount: number;
expiresAt?: Date; expiresAt?: Date;
scopes: Array<Scope>; scopes: Array<IServiceTokenV3Scope>;
trustedIps: Array<IServiceTokenV3TrustedIp>;
} }
const serviceTokenDataV3Schema = new Schema( const serviceTokenDataV3Schema = new Schema(
@@ -83,6 +91,34 @@ const serviceTokenDataV3Schema = new Schema(
} }
], ],
required: true required: true
},
trustedIps: {
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
} }
}, },
{ {
+37 -2
View File
@@ -1,6 +1,6 @@
import net from "net"; import net from "net";
import { IPType } from "../../ee/models"; import { IPType } from "../../ee/models";
import { InternalServerError } from "../errors"; import { InternalServerError, UnauthorizedRequestError } from "../errors";
/** /**
* Return details of IP [ip]: * Return details of IP [ip]:
@@ -98,4 +98,39 @@ export const isValidIpOrCidr = (ip: string): boolean => {
} }
return false; return false;
} }
/**
* Validates the IP address [ipAddress] against the trusted IPs [trustedIps].
* @param {Object} obj
* @param {String} obj.ipAddress - IP address to check
* @param {Object[]} obj.trustedIps - IPs to trust in blocklist
*/
export const checkIPAgainstBlocklist = ({
ipAddress,
trustedIps
}: {
ipAddress: string;
trustedIps: {
ipAddress: string;
type: IPType;
prefix: number;
}[]
}) => {
const blockList = new net.BlockList();
for (const trustedIp of trustedIps) {
if (trustedIp.prefix !== undefined) {
blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type);
} else {
blockList.addAddress(trustedIp.ipAddress, trustedIp.type);
}
}
const { type } = extractIPDetails(ipAddress);
const check = blockList.check(ipAddress, type);
if (!check) throw UnauthorizedRequestError({
message: "Failed to authenticate"
});
}
+24 -1
View File
@@ -4,6 +4,8 @@ import { Permission } from "../models/serviceTokenDataV3";
import { z } from "zod"; import { z } from "zod";
import { UnauthorizedRequestError } from "../utils/errors"; import { UnauthorizedRequestError } from "../utils/errors";
import { isValidScopeV3 } from "../helpers"; import { isValidScopeV3 } from "../helpers";
import { AuthData } from "../interfaces/middleware";
import { checkIPAgainstBlocklist } from "../utils/ip";
/** /**
* Validate that service token (client) can access workspace * Validate that service token (client) can access workspace
@@ -16,19 +18,27 @@ import { isValidScopeV3 } from "../helpers";
* @param {String[]} acceptedPermissions - accepted permissions as part of the endpoint * @param {String[]} acceptedPermissions - accepted permissions as part of the endpoint
*/ */
export const validateServiceTokenDataV3ClientForWorkspace = async ({ export const validateServiceTokenDataV3ClientForWorkspace = async ({
authData,
serviceTokenData, serviceTokenData,
workspaceId, workspaceId,
environment, environment,
secretPath = "/", secretPath = "/",
requiredPermissions requiredPermissions
}: { }: {
authData: AuthData;
serviceTokenData: IServiceTokenDataV3; serviceTokenData: IServiceTokenDataV3;
workspaceId: Types.ObjectId; workspaceId: Types.ObjectId;
environment?: string; environment?: string;
secretPath?: string; secretPath?: string;
requiredPermissions: Permission[]; requiredPermissions: Permission[];
}) => { }) => {
// validate ST V3 IP address
checkIPAgainstBlocklist({
ipAddress: authData.ipAddress,
trustedIps: serviceTokenData.trustedIps
});
if (!serviceTokenData.workspace.equals(workspaceId)) { if (!serviceTokenData.workspace.equals(workspaceId)) {
// case: invalid workspaceId passed // case: invalid workspaceId passed
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
@@ -63,6 +73,12 @@ export const CreateServiceTokenV3 = z.object({
}) })
.array() .array()
.min(1), .min(1),
trustedIps: z
.object({
ipAddress: z.string().trim(),
})
.array()
.min(1),
expiresIn: z.number().optional(), expiresIn: z.number().optional(),
encryptedKey: z.string().trim(), encryptedKey: z.string().trim(),
nonce: z.string().trim() nonce: z.string().trim()
@@ -85,6 +101,13 @@ export const UpdateServiceTokenV3 = z.object({
.array() .array()
.min(1) .min(1)
.optional(), .optional(),
trustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.optional(),
expiresIn: z.number().optional() expiresIn: z.number().optional()
}), }),
}); });
@@ -87,12 +87,14 @@ export const useUpdateServiceTokenV3 = () => {
name, name,
isActive, isActive,
scopes, scopes,
trustedIps,
expiresIn expiresIn
}) => { }) => {
const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, { const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, {
name, name,
isActive, isActive,
scopes, scopes,
trustedIps,
expiresIn expiresIn
}); });
@@ -44,6 +44,13 @@ export type ServiceTokenV3Scope = {
secretPath: string; secretPath: string;
}; };
export type ServiceTokenV3TrustedIp = {
_id: string;
ipAddress: string;
type: "ipv4" | "ipv6";
prefix?: number;
}
export type ServiceTokenDataV3 = { export type ServiceTokenDataV3 = {
_id: string; _id: string;
name: string; name: string;
@@ -52,6 +59,7 @@ export type ServiceTokenDataV3 = {
lastUsed?: string; lastUsed?: string;
usageCount: number; usageCount: number;
scopes: ServiceTokenV3Scope[]; scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[];
expiresAt?: string; expiresAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -62,6 +70,9 @@ export type CreateServiceTokenDataV3DTO = {
workspaceId: string; workspaceId: string;
publicKey: string; publicKey: string;
scopes: ServiceTokenV3Scope[]; scopes: ServiceTokenV3Scope[];
trustedIps: {
ipAddress: string;
}[];
expiresIn?: number; expiresIn?: number;
encryptedKey: string; encryptedKey: string;
nonce: string; nonce: string;
@@ -77,6 +88,9 @@ export type UpdateServiceTokenDataV3DTO = {
isActive?: boolean; isActive?: boolean;
name?: string; name?: string;
scopes?: ServiceTokenV3Scope[]; scopes?: ServiceTokenV3Scope[];
trustedIps?: {
ipAddress: string;
}[];
expiresIn?: number; expiresIn?: number;
} }
@@ -20,11 +20,7 @@ import {
Modal, Modal,
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem
// Accordion,
// AccordionItem,
// AccordionTrigger,
// AccordionContent
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { import {
@@ -35,7 +31,10 @@ import {
import { import {
Permission Permission
} from "@app/hooks/api/serviceTokens/enums"; } from "@app/hooks/api/serviceTokens/enums";
import { ServiceTokenV3Scope } from "@app/hooks/api/serviceTokens/types"; import {
ServiceTokenV3Scope,
ServiceTokenV3TrustedIp
} from "@app/hooks/api/serviceTokens/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const expirations = [ const expirations = [
@@ -58,23 +57,32 @@ const schema = yup.object({
name: yup.string().required("ST V3 name is required"), name: yup.string().required("ST V3 name is required"),
expiresIn: yup.string(), expiresIn: yup.string(),
scopes: yup scopes: yup
.array(
yup.object({
permission: yup.string().oneOf(Object.keys(permissionsMap), "Invalid permission").required().label("Permission"),
environment: yup.string().max(50).required().label("Environment"),
secretPath: yup
.string()
.required()
.default("/")
.label("Secret Path")
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
})
)
.min(1)
.required()
.label("Scope"),
trustedIps: yup
.array( .array(
yup.object({ yup.object({
permission: yup.string().oneOf(Object.keys(permissionsMap), "Invalid permission").required().label("Permission"), ipAddress: yup.string().max(50).required().label("IP Address")
environment: yup.string().max(50).required().label("Environment"),
secretPath: yup
.string()
.required()
.default("/")
.label("Secret Path")
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
}) })
) )
.min(1) .min(1)
.required() .required()
.label("Scope") .label("Trusted IP")
}).required(); }).required();
export type FormData = yup.InferType<typeof schema>; export type FormData = yup.InferType<typeof schema>;
@@ -107,6 +115,9 @@ export const AddServiceTokenV3Modal = ({
permission: "read", permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug, environment: currentWorkspace?.environments?.[0]?.slug,
secretPath: "/", secretPath: "/",
}],
trustedIps: [{
ipAddress: "0.0.0.0/0"
}] }]
} }
}); });
@@ -116,6 +127,7 @@ export const AddServiceTokenV3Modal = ({
serviceTokenDataId: string; serviceTokenDataId: string;
name: string; name: string;
scopes: ServiceTokenV3Scope[]; scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[];
}; };
if (serviceTokenData) { if (serviceTokenData) {
@@ -132,6 +144,14 @@ export const AddServiceTokenV3Modal = ({
secretPath: "/", secretPath: "/",
permission permission
}) })
}),
trustedIps: serviceTokenData.trustedIps.map(({
ipAddress,
prefix
}: ServiceTokenV3TrustedIp) => {
return ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
});
}) })
}); });
} else { } else {
@@ -141,17 +161,22 @@ export const AddServiceTokenV3Modal = ({
permission: "read", permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug, environment: currentWorkspace?.environments?.[0]?.slug,
secretPath: "/", secretPath: "/",
}],
trustedIps: [{
ipAddress: "0.0.0.0/0"
}] }]
}); });
} }
}, [popUp?.serviceTokenV3?.data]); }, [popUp?.serviceTokenV3?.data]);
const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" }); const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" });
const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" });
const onFormSubmit = async ({ const onFormSubmit = async ({
name, name,
expiresIn, expiresIn,
scopes scopes,
trustedIps
}: FormData) => { }: FormData) => {
try { try {
const serviceTokenData = popUp?.serviceTokenV3?.data as { const serviceTokenData = popUp?.serviceTokenV3?.data as {
@@ -167,14 +192,16 @@ export const AddServiceTokenV3Modal = ({
secretPath: scope.secretPath, secretPath: scope.secretPath,
permissions: permissionsMap[scope.permission] permissions: permissionsMap[scope.permission]
}); });
}) });
if (serviceTokenData) { if (serviceTokenData) {
// update // update
await updateMutateAsync({ await updateMutateAsync({
serviceTokenDataId: serviceTokenData.serviceTokenDataId, serviceTokenDataId: serviceTokenData.serviceTokenDataId,
name, name,
scopes: reformattedScopes, scopes: reformattedScopes,
trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn) expiresIn: expiresIn === "" ? undefined : Number(expiresIn)
}); });
} else { } else {
@@ -206,6 +233,7 @@ export const AddServiceTokenV3Modal = ({
workspaceId: currentWorkspace._id, workspaceId: currentWorkspace._id,
publicKey, publicKey,
scopes: reformattedScopes, scopes: reformattedScopes,
trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn), expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
encryptedKey: ciphertext, encryptedKey: ciphertext,
nonce nonce
@@ -270,175 +298,171 @@ export const AddServiceTokenV3Modal = ({
</FormControl> </FormControl>
)} )}
/> />
{tokenScopes.map(({ id }, index) => ( {tokenScopes.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}> <div className="flex items-end space-x-2 mb-3" key={id}>
<Controller <Controller
control={control} control={control}
name={`scopes.${index}.permission`} name={`scopes.${index}.permission`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
className="mb-0" className="mb-0"
label={index === 0 ? "Permission" : undefined} label={index === 0 ? "Permission" : undefined}
errorText={error?.message} errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
<SelectItem value="read" key="st-v3-read">
Read
</SelectItem>
<SelectItem value="readWrite" key="st-v3-write">
Read &amp; Write
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.environment`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
onClick={() => remove(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
> >
<FontAwesomeIcon icon={faXmark} /> <Select
</IconButton> defaultValue={field.value}
</div> {...field}
))} onValueChange={(e) => onChange(e)}
<div className="my-4 ml-1"> className="w-36"
<Button >
variant="outline_bg" <SelectItem value="read" key="st-v3-read">
onClick={() => Read
append({ </SelectItem>
permission: "read", <SelectItem value="readWrite" key="st-v3-write">
environment: currentWorkspace?.environments?.[0]?.slug || "", Read &amp; Write
secretPath: "/" </SelectItem>
}) </Select>
} </FormControl>
leftIcon={<FontAwesomeIcon icon={faPlus} />} )}
size="xs" />
<Controller
control={control}
name={`scopes.${index}.environment`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
onClick={() => remove(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: "/"
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
{tokenTrustedIps.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`trustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Trusted IP" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="123.456.789.0" />
</FormControl>
)}
/>
<IconButton
onClick={() => removeTrustedIp(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
> >
Add Scope <FontAwesomeIcon icon={faXmark} />
</Button> </IconButton>
</div> </div>
<Controller ))}
control={control} <div className="my-4 ml-1">
name="expiresIn" <Button
defaultValue="15552000" variant="outline_bg"
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( onClick={() =>
<FormControl appendTrustedIp({
label={`${popUp?.serviceTokenV3?.data ? "Update" : ""} Expire In`} ipAddress: "0.0.0.0/0"
errorText={error?.message} })
isError={Boolean(error)} }
className="mt-4" leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue="15552000"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.serviceTokenV3?.data ? "Update" : ""} Expire In`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
> >
<Select {expirations.map(({ label, value }) => (
defaultValue={field.value} <SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
{...field} {label}
onValueChange={(e) => onChange(e)} </SelectItem>
className="w-full" ))}
> </Select>
{expirations.map(({ label, value }) => ( </FormControl>
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}> )}
{label} />
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{/* <Accordion
type="multiple"
className="w-full"
>
<AccordionItem value="section-1">
<AccordionTrigger>Scopes</AccordionTrigger>
<AccordionContent>Description of Section 1</AccordionContent>
</AccordionItem>
</Accordion> */}
{/* <h3 className="text-mineshaft-400 text-sm mb-2">Temporariness</h3>
<Switch
id={`enable-ephemerality`}
onCheckedChange={(value) => setIsTemporary(value)}
isChecked={isTemporary}
>
<div className="w-96 mr-4">
<p className="text-gray-400 text-md">This token will be deactivated after your specified duration.</p>
</div>
</Switch>
{isTemporary && (
<Controller
control={control}
name="expiresIn"
defaultValue="15552000"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Duration"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirations.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)} */}
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -22,7 +22,7 @@ import {
useUpdateServiceTokenV3 useUpdateServiceTokenV3
} from "@app/hooks/api"; } from "@app/hooks/api";
import { Permission } from "@app/hooks/api/serviceTokens/enums" import { Permission } from "@app/hooks/api/serviceTokens/enums"
import { ServiceTokenV3Scope } from "@app/hooks/api/serviceTokens/types" import { ServiceTokenV3Scope, ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types"
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -32,6 +32,7 @@ type Props = {
serviceTokenDataId?: string; serviceTokenDataId?: string;
name?: string; name?: string;
scopes?: ServiceTokenV3Scope[]; scopes?: ServiceTokenV3Scope[];
trustedIps?: ServiceTokenV3TrustedIp[];
} }
) => void; ) => void;
}; };
@@ -89,7 +90,8 @@ export const ServiceTokenV3Table = ({
<Th>Name</Th> <Th>Name</Th>
<Th>Status</Th> <Th>Status</Th>
<Th>Scopes</Th> <Th>Scopes</Th>
<Th># Times Used</Th> <Th>Trusted IPs</Th>
{/* <Th># Times Used</Th> */}
<Th>Last Used</Th> <Th>Last Used</Th>
<Th>Created At</Th> <Th>Created At</Th>
<Th>Expires At</Th> <Th>Expires At</Th>
@@ -106,8 +108,9 @@ export const ServiceTokenV3Table = ({
name, name,
isActive, isActive,
lastUsed, lastUsed,
usageCount, // usageCount,
scopes, scopes,
trustedIps,
createdAt, createdAt,
expiresAt expiresAt
}) => { }) => {
@@ -154,7 +157,20 @@ export const ServiceTokenV3Table = ({
); );
})} })}
</Td> </Td>
<Td>{usageCount}</Td> <Td>
{trustedIps.map(({
_id: trustedIpId,
ipAddress,
prefix
}) => {
return (
<p key={`service-token-${_id}-}-trusted-ip-${trustedIpId}`}>
{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}
</p>
);
})}
</Td>
{/* <Td>{usageCount}</Td> */}
<Td>{lastUsed ? formatDate(lastUsed) : "-"}</Td> <Td>{lastUsed ? formatDate(lastUsed) : "-"}</Td>
<Td>{formatDate(createdAt)}</Td> <Td>{formatDate(createdAt)}</Td>
<Td>{expiresAt ? formatDate(expiresAt) : "-"}</Td> <Td>{expiresAt ? formatDate(expiresAt) : "-"}</Td>
@@ -170,6 +186,7 @@ export const ServiceTokenV3Table = ({
serviceTokenDataId: _id, serviceTokenDataId: _id,
name, name,
scopes, scopes,
trustedIps
}); });
}} }}
size="lg" size="lg"