diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index 5a14b5838..a1bc7bdd3 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -1,6 +1,11 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { Membership, MembershipOrg, Workspace } from "../../models"; +import { + Membership, + MembershipOrg, + ServiceMembershipOrg, + Workspace +} from "../../models"; import { Role } from "../../ee/models"; import { deleteMembershipOrg } from "../../helpers/membershipOrg"; import { @@ -377,3 +382,23 @@ export const deleteOrganizationById = async (req: Request, res: Response) => { organization }); }; + +/** + * Return list of service memberships for organization with id [organizationId] + * @param req + * @param res + * @returns + */ +export const getOrganizationServiceMemberships = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgServiceMembersV2, req); + + const serviceMemberships = await ServiceMembershipOrg.find({ + organization: new Types.ObjectId(organizationId) + }).populate("service customRole"); + + return res.status(200).send({ + serviceMemberships + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 8c5e70952..f7daff0db 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -1,6 +1,14 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { Key, Membership, ServiceTokenData, Workspace } from "../../models"; +import { + Key, + Membership, + ServiceMembership, + ServiceTokenData, + ServiceTokenDataV3, + Workspace +} from "../../models"; +import { Role } from "../../ee/models"; import { pullSecrets as pull, v2PushSecrets as push, @@ -19,6 +27,8 @@ import { getAuthDataProjectPermissions } from "../../ee/services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; +import { BadRequestError, ResourceNotFoundError } from "../../utils/errors"; +import { ADMIN, MEMBER, VIEWER } from "../../variables"; interface V2PushSecret { type: string; // personal or shared @@ -491,3 +501,136 @@ export const toggleAutoCapitalization = async (req: Request, res: Response) => { workspace }); }; + +/** + * Add service account with id [serviceId] to workspace + * with id [workspaceId] + * @param req + * @param res + */ +export const addWorkspaceServiceMembership = async (req: Request, res: Response) => { + const { + params: { workspaceId }, + body: { + serviceId, + role + } + } = await validateRequest(reqValidator.AddWorkspaceServiceMemberV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.ServiceTokens + ); + + let serviceMembership = await ServiceMembership.findOne({ + service: new Types.ObjectId(serviceId), + workspace: new Types.ObjectId(workspaceId) + }); + + if (serviceMembership) throw BadRequestError({ + message: "Service account already exists in workspace" + }); + + const serviceTokenData = await ServiceTokenDataV3.findById(serviceId); + if (!serviceTokenData) throw ResourceNotFoundError(); + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw ResourceNotFoundError(); + + if (!serviceTokenData.organization.equals(workspace.organization)) throw BadRequestError({ + message: "Failed to add service account to workspace in another organization" + }); + + let customRole; + if (role) { + const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + if (isCustomRole) { + customRole = await Role.findOne({ + slug: role, + isOrgRole: false, + workspace: new Types.ObjectId(workspaceId) + }); + + if (!customRole) throw BadRequestError({ message: "Role not found" }); + } + } + + serviceMembership = await new ServiceMembership({ + service: serviceTokenData._id, + workspace: new Types.ObjectId(workspaceId), + role, + customRole + }).save(); + + return res.status(200).send({ + serviceMembership + }); +} + +/** + * Add service account with id [serviceId] to workspace + * with id [workspaceId] + * @param req + * @param res + */ + export const deleteWorkspaceServiceMembership = async (req: Request, res: Response) => { + const { + params: { workspaceId, serviceId } + } = await validateRequest(reqValidator.DeleteWorkspaceServiceMemberV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.ServiceTokens + ); + + const serviceMembership = await ServiceMembership.findOneAndDelete({ + service: new Types.ObjectId(serviceId), + workspace: new Types.ObjectId(workspaceId) + }); + + if (!serviceMembership) throw ResourceNotFoundError(); + + return res.status(200).send({ + serviceMembership + }); +} + +/** + * Return list of service memberships for workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ + export const getWorkspaceServiceMemberships = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceServiceMembersV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.ServiceTokens + ); + + const serviceMemberships = await ServiceMembership.find({ + workspace: new Types.ObjectId(workspaceId) + }).populate("service customRole"); + + return res.status(200).send({ + serviceMemberships + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v3/workspacesController.ts b/backend/src/controllers/v3/workspacesController.ts index 4842ed3be..32f04074a 100644 --- a/backend/src/controllers/v3/workspacesController.ts +++ b/backend/src/controllers/v3/workspacesController.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { validateRequest } from "../../helpers/validation"; -import { Membership, Secret, ServiceTokenDataV3, User } from "../../models"; +import { Membership, Secret, User } from "../../models"; import { SecretService } from "../../services"; import { getAuthDataProjectPermissions } from "../../ee/services/ProjectRoleService"; import { UnauthorizedRequestError } from "../../utils/errors"; @@ -140,17 +140,3 @@ export const nameWorkspaceSecrets = async (req: Request, res: Response) => { message: "Successfully named workspace secrets" }); }; - -export const getWorkspaceServiceTokenData = async (req: Request, res: Response) => { - const { - params: { workspaceId } - } = await validateRequest(reqValidator.GetWorkspaceServiceTokenDataV3, req); - - const serviceTokenData = await ServiceTokenDataV3.find({ - workspace: new Types.ObjectId(workspaceId) - }).populate("customRole"); - - return res.status(200).send({ - serviceTokenData - }); -} \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts index 3e97c9afe..0cc622064 100644 --- a/backend/src/ee/controllers/v3/serviceTokenDataController.ts +++ b/backend/src/ee/controllers/v3/serviceTokenDataController.ts @@ -1,12 +1,11 @@ import jwt from "jsonwebtoken"; import { Request, Response } from "express"; import { Types } from "mongoose"; -import { - IServiceTokenDataV3, - IUser, +import { ServiceTokenDataV3, - ServiceTokenDataV3Key, - Workspace + ServiceTokenDataV3Key, // TODO: remove + Organization, + ServiceMembershipOrg } from "../../../models"; import { IServiceTokenV3TrustedIp } from "../../../models/serviceTokenDataV3"; import { @@ -17,44 +16,13 @@ import { import { validateRequest } from "../../../helpers/validation"; import * as reqValidator from "../../../validation/serviceTokenDataV3"; import { createToken } from "../../../helpers/auth"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - getAuthDataProjectPermissions -} from "../../services/ProjectRoleService"; -import { ForbiddenError } from "@casl/ability"; + + import { BadRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { EEAuditLogService, EELicenseService } from "../../services"; import { getAuthSecret } from "../../../config"; -import { ADMIN, AuthTokenType, CUSTOM, MEMBER, VIEWER } from "../../../variables"; - -/** - * Return project key for service token V3 - * @param req - * @param res - */ -export const getServiceTokenDataKey = async (req: Request, res: Response) => { - const key = await ServiceTokenDataV3Key.findOne({ - serviceTokenData: (req.authData.authPayload as IServiceTokenDataV3)._id - }).populate<{ sender: IUser }>("sender", "publicKey"); - - if (!key) throw ResourceNotFoundError({ - message: "Failed to find project key for service token" - }); - - const { _id, workspace, encryptedKey, nonce, sender: { publicKey } } = key; - - return res.status(200).send({ - key: { - _id, - workspace, - encryptedKey, - publicKey, - nonce - } - }); -} +import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; /** * Return access and refresh token as per refresh operation @@ -159,44 +127,45 @@ export const createServiceTokenData = async (req: Request, res: Response) => { const { body: { name, - workspaceId, - publicKey, + organizationId, role, trustedIps, expiresIn, accessTokenTTL, - isRefreshTokenRotationEnabled, - encryptedKey, // for ServiceTokenDataV3Key - nonce, // for ServiceTokenDataV3Key + isRefreshTokenRotationEnabled } } = await validateRequest(reqValidator.CreateServiceTokenV3, req); - const { permission } = await getAuthDataProjectPermissions({ - authData: req.authData, - workspaceId: new Types.ObjectId(workspaceId) - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.ServiceTokens - ); + // const { permission } = await getAuthDataProjectPermissions({ + // authData: req.authData, + // workspaceId: new Types.ObjectId(workspaceId) + // }); + + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionActions.Create, + // ProjectPermissionSub.ServiceTokens + // ); - const workspace = await Workspace.findById(workspaceId); - if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + // const workspace = await Workspace.findById(workspaceId); + // if (!workspace) throw BadRequestError({ message: "Workspace not found" }); - const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + const organization = await Organization.findById(organizationId); + if (!organization) throw BadRequestError({ message: "Organization not found" }); + + const isCustomRole = ![ADMIN, MEMBER].includes(role); let customRole; if (isCustomRole) { customRole = await Role.findOne({ slug: role, - isOrgRole: false, - workspace: workspace._id + isOrgRole: true, + organization: new Types.ObjectId(organizationId) }); if (!customRole) throw BadRequestError({ message: "Role not found" }); } - const plan = await EELicenseService.getPlan(workspace.organization); + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); // validate trusted ips const reformattedTrustedIps = trustedIps.map((trustedIp) => { @@ -228,26 +197,22 @@ export const createServiceTokenData = async (req: Request, res: Response) => { const serviceTokenData = await new ServiceTokenDataV3({ name, user, - workspace: new Types.ObjectId(workspaceId), - publicKey, + organization: new Types.ObjectId(organizationId), refreshTokenUsageCount: 0, accessTokenUsageCount: 0, tokenVersion: 1, trustedIps: reformattedTrustedIps, - role: isCustomRole ? CUSTOM : role, - customRole, isActive, expiresAt, accessTokenTTL, isRefreshTokenRotationEnabled }).save(); - await new ServiceTokenDataV3Key({ - encryptedKey, - nonce, - sender: req.user._id, - serviceTokenData: serviceTokenData._id, - workspace: new Types.ObjectId(workspaceId) + await new ServiceMembershipOrg({ + service: serviceTokenData._id, + organization: serviceTokenData.organization, + role: isCustomRole ? CUSTOM : role, + customRole }).save(); const refreshToken = createToken({ @@ -262,7 +227,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { await EEAuditLogService.createAuditLog( req.authData, { - type: EventType.CREATE_SERVICE_TOKEN_V3, // TODO: update + type: EventType.CREATE_SERVICE_TOKEN_V3, metadata: { name, isActive, @@ -272,7 +237,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { } }, { - workspaceId: new Types.ObjectId(workspaceId) + organizationId: new Types.ObjectId(organizationId) } ); @@ -294,7 +259,7 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { body: { name, isActive, - role, + role, // bring this somewhere else? trustedIps, expiresIn, accessTokenTTL, @@ -307,34 +272,34 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { message: "Service token not found" }); - const { permission } = await getAuthDataProjectPermissions({ - authData: req.authData, - workspaceId: serviceTokenData.workspace - }); + // const { permission } = await getAuthDataProjectPermissions({ + // authData: req.authData, + // workspaceId: serviceTokenData.workspace + // }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.ServiceTokens - ); + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionActions.Edit, + // ProjectPermissionSub.ServiceTokens + // ); - const workspace = await Workspace.findById(serviceTokenData.workspace); - if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + // const workspace = await Workspace.findById(serviceTokenData.workspace); + // if (!workspace) throw BadRequestError({ message: "Workspace not found" }); let customRole; if (role) { - const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + const isCustomRole = ![ADMIN, MEMBER].includes(role); if (isCustomRole) { customRole = await Role.findOne({ slug: role, - isOrgRole: false, - workspace: workspace._id + isOrgRole: true, + organization: serviceTokenData.organization }); if (!customRole) throw BadRequestError({ message: "Role not found" }); } } - const plan = await EELicenseService.getPlan(workspace.organization); + const plan = await EELicenseService.getPlan(serviceTokenData.organization); // validate trusted ips let reformattedTrustedIps; @@ -365,15 +330,7 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { { name, isActive, - role: customRole ? CUSTOM : role, - ...(customRole ? { - customRole - } : {}), - ...(role && !customRole ? { // non-custom role - $unset: { - customRole: 1 - } - } : {}), + trustedIps: reformattedTrustedIps, expiresAt, accessTokenTTL, @@ -387,6 +344,26 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { if (!serviceTokenData) throw BadRequestError({ message: "Failed to update service token" }); + + await ServiceMembershipOrg.findOneAndUpdate( + { + service: serviceTokenData._id + }, + { + role: customRole ? CUSTOM : role, + ...(customRole ? { + customRole + } : {}), + ...(role && !customRole ? { // non-custom role + $unset: { + customRole: 1 + } + } : {}) + }, + { + new: true + } + ); await EEAuditLogService.createAuditLog( req.authData, @@ -401,7 +378,7 @@ export const updateServiceTokenData = async (req: Request, res: Response) => { } }, { - workspaceId: serviceTokenData.workspace + organizationId: serviceTokenData.organization } ); @@ -426,21 +403,29 @@ export const deleteServiceTokenData = async (req: Request, res: Response) => { message: "Service token not found" }); - const { permission } = await getAuthDataProjectPermissions({ - authData: req.authData, - workspaceId: serviceTokenData.workspace - }); + // const { permission } = await getAuthDataProjectPermissions({ + // authData: req.authData, + // workspaceId: serviceTokenData.workspace + // }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.ServiceTokens - ); + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionActions.Delete, + // ProjectPermissionSub.ServiceTokens + // ); serviceTokenData = await ServiceTokenDataV3.findByIdAndDelete(serviceTokenDataId); if (!serviceTokenData) throw BadRequestError({ message: "Failed to delete service token" }); + + const serviceMembershipOrg = await ServiceMembershipOrg.findOneAndDelete({ + service: serviceTokenData._id, + }); + + if (!serviceMembershipOrg) throw BadRequestError({ + message: "Failed to delete service token" + }); await ServiceTokenDataV3Key.findOneAndDelete({ serviceTokenData: serviceTokenData._id @@ -453,13 +438,13 @@ export const deleteServiceTokenData = async (req: Request, res: Response) => { metadata: { name: serviceTokenData.name, isActive: serviceTokenData.isActive, - role: serviceTokenData.role, + role: serviceMembershipOrg.role, trustedIps: serviceTokenData.trustedIps as Array, expiresAt: serviceTokenData.expiresAt } }, { - workspaceId: serviceTokenData.workspace + organizationId: serviceTokenData.organization } ); diff --git a/backend/src/ee/routes/v3/serviceTokenData.ts b/backend/src/ee/routes/v3/serviceTokenData.ts index ba1d04854..6b82abe3d 100644 --- a/backend/src/ee/routes/v3/serviceTokenData.ts +++ b/backend/src/ee/routes/v3/serviceTokenData.ts @@ -4,14 +4,6 @@ import { requireAuth } from "../../../middleware"; import { AuthMode } from "../../../variables"; import { serviceTokenDataController } from "../../controllers/v3"; -router.get( - "/me/key", - requireAuth({ - acceptedAuthModes: [AuthMode.SERVICE_ACCESS_TOKEN] - }), - serviceTokenDataController.getServiceTokenDataKey -); - router.post( "/me/token", serviceTokenDataController.refreshToken diff --git a/backend/src/ee/services/EELicenseService.ts b/backend/src/ee/services/EELicenseService.ts index 6baea04dc..7052263c5 100644 --- a/backend/src/ee/services/EELicenseService.ts +++ b/backend/src/ee/services/EELicenseService.ts @@ -66,7 +66,7 @@ class EELicenseService { secretVersioning: true, pitRecovery: false, ipAllowlisting: false, - rbac: false, + rbac: true, customRateLimits: false, customAlerts: false, auditLogs: false, diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index 4e882d00f..0a0d22b28 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -12,7 +12,12 @@ import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js import picomatch from "picomatch"; import { AuthData } from "../../interfaces/middleware"; import { ActorType, IRole } from "../models"; -import { Membership, ServiceTokenData, ServiceTokenDataV3 } from "../../models"; +import { + IServiceTokenDataV3, + Membership, + ServiceMembership, + ServiceTokenData +} from "../../models"; import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; import { checkIPAgainstBlocklist } from "../../utils/ip"; @@ -265,10 +270,10 @@ export const getAuthDataProjectPermissions = async ({ user: authData.authPayload._id, workspace: workspaceId }) - .populate<{ - customRole: IRole & { permissions: RawRuleOf>[] }; - }>("customRole") - .exec(); + .populate<{ + customRole: IRole & { permissions: RawRuleOf>[] }; + }>("customRole") + .exec(); if (!membership || (membership.role === "custom" && !membership.customRole)) { throw UnauthorizedRequestError(); @@ -285,24 +290,27 @@ export const getAuthDataProjectPermissions = async ({ break; } case ActorType.SERVICE_V3: { - const serviceTokenData = await ServiceTokenDataV3 - .findById(authData.authPayload._id) - .populate<{ - customRole: IRole & { permissions: RawRuleOf>[] }; - }>("customRole") - .exec(); - - if (!serviceTokenData || (serviceTokenData.role === "custom" && !serviceTokenData.customRole)) { + const serviceMembership = await ServiceMembership.findOne({ + service: authData.authPayload._id, + workspace: workspaceId + }) + .populate<{ + customRole: IRole & { permissions: RawRuleOf>[] }; + service: IServiceTokenDataV3 + }>("customRole service") + .exec(); + + if (!serviceMembership || (serviceMembership.role === "custom" && !serviceMembership.customRole)) { throw UnauthorizedRequestError(); } checkIPAgainstBlocklist({ ipAddress: authData.ipAddress, - trustedIps: serviceTokenData.trustedIps + trustedIps: serviceMembership.service.trustedIps }); - role = serviceTokenData.role; - customRole = serviceTokenData.customRole; + role = serviceMembership.role; + customRole = serviceMembership.customRole; break; } default: diff --git a/backend/src/ee/services/RoleService.ts b/backend/src/ee/services/RoleService.ts index 8f8c4f315..8c14d9d57 100644 --- a/backend/src/ee/services/RoleService.ts +++ b/backend/src/ee/services/RoleService.ts @@ -1,6 +1,6 @@ import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability"; import { MembershipOrg } from "../../models"; -import { IRole } from "../models/role"; +import { IRole } from "../models"; import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; import { ACCEPTED } from "../../variables"; import { conditionsMatcher } from "./ProjectRoleService"; @@ -20,7 +20,8 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Billing = "billing", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + ServiceTokens = "service-tokens" // TODO: consider renaming } export type OrgPermissionSet = @@ -32,7 +33,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgPermissionActions, OrgPermissionSubjects.Billing]; + | [OrgPermissionActions, OrgPermissionSubjects.Billing] + | [OrgPermissionActions, OrgPermissionSubjects.ServiceTokens]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -75,6 +77,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Create, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.ServiceTokens); + return build({ conditionsMatcher }); }; @@ -98,6 +105,11 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Read, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Create, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.ServiceTokens); + return build({ conditionsMatcher }); }; @@ -105,6 +117,7 @@ export const memberPermissions = buildMemberPermission(); export const getUserOrgPermissions = async (userId: string, orgId: string) => { // TODO(akhilmhdh): speed this up by pulling from cache later + const membership = await MembershipOrg.findOne({ user: userId, organization: orgId, @@ -131,4 +144,4 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => { } throw BadRequestError({ message: "User role not found" }); -}; +}; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a437541b1..f4af92dd8 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -21,7 +21,9 @@ export * from "./userAction"; export * from "./workspace"; export * from "./serviceTokenData"; // TODO: deprecate export * from "./serviceTokenDataV3"; -export * from "./serviceTokenDataV3Key"; +export * from "./serviceMembershipOrg"; +export * from "./serviceMembership"; +export * from "./serviceTokenDataV3Key"; /// TODO: remove export * from "./apiKeyData"; // TODO: deprecate export * from "./apiKeyDataV2"; export * from "./loginSRPDetail"; diff --git a/backend/src/models/serviceMembership.ts b/backend/src/models/serviceMembership.ts new file mode 100644 index 000000000..0a68700c5 --- /dev/null +++ b/backend/src/models/serviceMembership.ts @@ -0,0 +1,38 @@ +import { Schema, Types, model } from "mongoose"; +import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables"; + +export interface IServiceMembership { + _id: Types.ObjectId; + service: Types.ObjectId; + workspace: Types.ObjectId; + role: "admin" | "member" | "viewer" | "custom"; + customRole: Types.ObjectId; +} + +const serviceMembershipSchema = new Schema( + { + service: { // TODO: consider renaming + type: Schema.Types.ObjectId, + ref: "ServiceTokenDataV3" + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + role: { + type: String, + enum: [ADMIN, MEMBER, VIEWER, CUSTOM], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } +); + +export const ServiceMembership = model("ServiceMembership", serviceMembershipSchema); \ No newline at end of file diff --git a/backend/src/models/serviceMembershipOrg.ts b/backend/src/models/serviceMembershipOrg.ts new file mode 100644 index 000000000..242b9ebd9 --- /dev/null +++ b/backend/src/models/serviceMembershipOrg.ts @@ -0,0 +1,37 @@ +import { Schema, Types, model } from "mongoose"; +import { ADMIN, CUSTOM, MEMBER } from "../variables"; + +export interface IServiceMembershipOrg { + _id: Types.ObjectId; + service: Types.ObjectId; + organization: Types.ObjectId; + role: "admin" | "member" | "viewer" | "custom"; + customRole: Types.ObjectId; +} + +const serviceMembershipOrgSchema = new Schema( + { + service: { // TODO: consider renaming + type: Schema.Types.ObjectId, + ref: "ServiceTokenDataV3" + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization" + }, + role: { + type: String, + enum: [ADMIN, MEMBER, CUSTOM], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } +); + +export const ServiceMembershipOrg = model("ServiceMembershipOrg", serviceMembershipOrgSchema); \ No newline at end of file diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts index fb39613d6..6c1518e22 100644 --- a/backend/src/models/serviceTokenDataV3.ts +++ b/backend/src/models/serviceTokenDataV3.ts @@ -1,6 +1,5 @@ import { Document, Schema, Types, model } from "mongoose"; import { IPType } from "../ee/models"; -import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables"; export interface IServiceTokenV3TrustedIp { ipAddress: string; @@ -11,9 +10,8 @@ export interface IServiceTokenV3TrustedIp { export interface IServiceTokenDataV3 extends Document { _id: Types.ObjectId; name: string; - workspace: Types.ObjectId; + organization: Types.ObjectId; user: Types.ObjectId; - publicKey: string; isActive: boolean; refreshTokenLastUsed?: Date; accessTokenLastUsed?: Date; @@ -23,8 +21,6 @@ export interface IServiceTokenDataV3 extends Document { isRefreshTokenRotationEnabled: boolean; expiresAt?: Date; accessTokenTTL: number; - role: "admin" | "member" | "viewer" | "custom"; - customRole: Types.ObjectId; trustedIps: Array; } @@ -34,9 +30,9 @@ const serviceTokenDataV3Schema = new Schema( type: String, required: true }, - workspace: { + organization: { type: Schema.Types.ObjectId, - ref: "Workspace", + ref: "Organization", required: true }, user: { @@ -44,10 +40,6 @@ const serviceTokenDataV3Schema = new Schema( ref: "User", required: true }, - publicKey: { - type: String, - required: true - }, isActive: { type: Boolean, default: true, @@ -91,15 +83,6 @@ const serviceTokenDataV3Schema = new Schema( default: 7200, required: true }, - role: { - type: String, - enum: [ADMIN, MEMBER, VIEWER, CUSTOM], - required: true - }, - customRole: { - type: Schema.Types.ObjectId, - ref: "Role" - }, trustedIps: { type: [ { diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index 6eea6fc5a..456635f75 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -54,4 +54,12 @@ router.delete( organizationsController.deleteOrganizationById ); +router.get( + "/:organizationId/service-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationServiceMemberships +); + export default router; diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index 304f69b34..095a0d1fc 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -93,4 +93,29 @@ router.patch( workspaceController.toggleAutoCapitalization ); +// TODO: consider endpoint restructuring +router.post( + "/:workspaceId/service-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.addWorkspaceServiceMembership +); + +router.delete( + "/:workspaceId/service-memberships/:serviceId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.deleteWorkspaceServiceMembership +); + +router.get( + "/:workspaceId/service-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceServiceMemberships +); + export default router; diff --git a/backend/src/routes/v3/workspaces.ts b/backend/src/routes/v3/workspaces.ts index dcae733fc..834d54cd1 100644 --- a/backend/src/routes/v3/workspaces.ts +++ b/backend/src/routes/v3/workspaces.ts @@ -34,12 +34,4 @@ router.post( // -- -router.get( - "/:workspaceId/service-token", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - workspacesController.getWorkspaceServiceTokenData -); - export default router; diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts index bddf76cb5..25c547f92 100644 --- a/backend/src/validation/organization.ts +++ b/backend/src/validation/organization.ts @@ -212,4 +212,12 @@ export const CreateOrgv2 = z.object({ export const DeleteOrgv2 = z.object({ params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgServiceMembersV2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgServiceTokenDataV3V2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) }); \ No newline at end of file diff --git a/backend/src/validation/serviceTokenDataV3.ts b/backend/src/validation/serviceTokenDataV3.ts index e4b2603b3..f025b5bfa 100644 --- a/backend/src/validation/serviceTokenDataV3.ts +++ b/backend/src/validation/serviceTokenDataV3.ts @@ -10,8 +10,7 @@ export const RefreshTokenV3 = z.object({ export const CreateServiceTokenV3 = z.object({ body: z.object({ name: z.string().trim(), - workspaceId: z.string().trim(), - publicKey: z.string().trim(), + organizationId: z.string().trim(), role: z.string().trim().min(1).default(MEMBER), trustedIps: z // TODO: provide default .object({ @@ -22,8 +21,6 @@ export const CreateServiceTokenV3 = z.object({ .default([{ ipAddress: "0.0.0.0/0" }]), expiresIn: z.number().optional(), accessTokenTTL: z.number().int().min(1), - encryptedKey: z.string().trim(), - nonce: z.string().trim(), isRefreshTokenRotationEnabled: z.boolean().default(false) }) }); diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index 5c4ae25c7..a341a9d22 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -8,6 +8,7 @@ import { AuthData } from "../interfaces/middleware"; import { z } from "zod"; import { EventType, UserAgentType } from "../ee/models"; import { UnauthorizedRequestError } from "../utils/errors"; +import { MEMBER } from "../variables"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -279,6 +280,29 @@ export const ToggleAutoCapitalizationV2 = z.object({ }) }); +export const AddWorkspaceServiceMemberV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + serviceId: z.string().trim(), // TODO: consider renaming + role: z.string().trim().min(1).default(MEMBER), + }) +}); + +export const DeleteWorkspaceServiceMemberV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + serviceId: z.string().trim() + }) +}); + +export const GetWorkspaceServiceMembersV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), +}); + export const GetWorkspaceBlinkIndexStatusV3 = z.object({ params: z.object({ workspaceId: z.string().trim() @@ -303,10 +327,4 @@ export const NameWorkspaceSecretsV3 = z.object({ }) .array() }) -}); - -export const GetWorkspaceServiceTokenDataV3 = z.object({ - params: z.object({ - workspaceId: z.string().trim() - }) }); \ No newline at end of file diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index a4f1ab5ef..8f2c95fbf 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -15,7 +15,8 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Billing = "billing", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + ServiceTokens = "service-tokens" } export type OrgPermissionSet = @@ -27,6 +28,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgPermissionActions, OrgPermissionSubjects.Billing]; + | [OrgPermissionActions, OrgPermissionSubjects.Billing] + | [OrgPermissionActions, OrgPermissionSubjects.ServiceTokens]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index e167de290..576d59292 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -14,6 +14,7 @@ export { useGetOrgPlansTable, useGetOrgPlanTable, useGetOrgPmtMethods, + useGetOrgServiceMemberships, useGetOrgTaxIds, useGetOrgTrialUrl, useRenameOrg, diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 1b075249c..77d6655ff 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { ServiceMembershipOrg } from "../serviceTokens/types"; import { BillingDetails, Invoice, @@ -15,7 +16,7 @@ import { TaxID } from "./types"; -const organizationKeys = { +export const organizationKeys = { getUserOrganizations: ["organization"] as const, getOrgPlanBillingInfo: (orgId: string) => [{ orgId }, "organization-plan-billing"] as const, getOrgPlanTable: (orgId: string) => [{ orgId }, "organization-plan-table"] as const, @@ -25,7 +26,8 @@ const organizationKeys = { getOrgPmtMethods: (orgId: string) => [{ orgId }, "organization-pmt-methods"] as const, getOrgTaxIds: (orgId: string) => [{ orgId }, "organization-tax-ids"] as const, getOrgInvoices: (orgId: string) => [{ orgId }, "organization-invoices"] as const, - getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const + getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const, + getOrgServiceMemberships: (orgId: string) => [{ orgId }, "organization-service-memberships"] as const, }; export const fetchOrganizations = async () => { @@ -349,6 +351,22 @@ export const useGetOrgLicenses = (organizationId: string) => { }); }; +export const useGetOrgServiceMemberships = (organizationId: string) => { + return useQuery({ + queryKey: organizationKeys.getOrgServiceMemberships(organizationId), + queryFn: async () => { + const { + data: { serviceMemberships } + } = await apiRequest.get<{ serviceMemberships: ServiceMembershipOrg[] }>( + `/api/v2/organizations/${organizationId}/service-memberships` + ); + + return serviceMemberships; + }, + enabled: true + }); +}; + export const useDeleteOrgById = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/serviceTokens/queries.tsx b/frontend/src/hooks/api/serviceTokens/queries.tsx index d0bf2ac42..ccb93f925 100644 --- a/frontend/src/hooks/api/serviceTokens/queries.tsx +++ b/frontend/src/hooks/api/serviceTokens/queries.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { workspaceKeys } from "../workspace/queries"; +import { organizationKeys } from "../organization/queries"; import { CreateServiceTokenDataV3DTO, CreateServiceTokenDataV3Res, @@ -74,7 +74,7 @@ export const useCreateServiceTokenV3 = () => { return data; }, onSuccess: ({ serviceTokenData }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(serviceTokenData.workspace)); + queryClient.invalidateQueries(organizationKeys.getOrgServiceMemberships(serviceTokenData.organization)); } }); }; @@ -104,8 +104,8 @@ export const useUpdateServiceTokenV3 = () => { return serviceTokenData; }, - onSuccess: ({ workspace }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(workspace)); + onSuccess: ({ organization }) => { + queryClient.invalidateQueries(organizationKeys.getOrgServiceMemberships(organization)); } }); }; @@ -119,8 +119,8 @@ export const useDeleteServiceTokenV3 = () => { const { data: { serviceTokenData } } = await apiRequest.delete(`/api/v3/service-token/${serviceTokenDataId}`); return serviceTokenData; }, - onSuccess: ({ workspace }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(workspace)); + onSuccess: ({ organization }) => { + queryClient.invalidateQueries(organizationKeys.getOrgServiceMemberships(organization)); } }); }; \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceTokens/types.ts b/frontend/src/hooks/api/serviceTokens/types.ts index 210ff7cd8..599ae950d 100644 --- a/frontend/src/hooks/api/serviceTokens/types.ts +++ b/frontend/src/hooks/api/serviceTokens/types.ts @@ -1,3 +1,5 @@ +import { TRole } from "../roles/types"; + export type ServiceTokenScope = { environment: string; secretPath: string; @@ -46,12 +48,7 @@ export type ServiceTokenV3TrustedIp = { export type ServiceTokenDataV3 = { _id: string; name: string; - role: string; - customRole?: { - name: string; - slug: string; - }; - workspace: string; + organization: string; isActive: boolean; refreshTokenLastUsed?: string; accessTokenLastUsed?: string; @@ -65,18 +62,35 @@ export type ServiceTokenDataV3 = { updatedAt: string; }; +export type ServiceMembershipOrg = { + _id: string; + service: ServiceTokenDataV3; + organization: string; + role: "admin" | "member" | "viewer" | "custom"; + customRole?: TRole; + createdAt: string; + updatedAt: string; +} + +export type ServiceMembership = { + _id: string; + service: ServiceTokenDataV3; + organization: string; + role: "admin" | "member" | "viewer" | "custom"; + customRole?: TRole; + createdAt: string; + updatedAt: string; +} + export type CreateServiceTokenDataV3DTO = { name: string; + organizationId: string; role?: string; - workspaceId: string; - publicKey: string; trustedIps: { ipAddress: string; }[]; expiresIn?: number; accessTokenTTL: number; - encryptedKey: string; - nonce: string; isRefreshTokenRotationEnabled: boolean; } diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index cef88fede..44fe58109 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,7 +1,9 @@ export { + useAddServiceToWorkspace, useAddUserToWorkspace, useCreateWorkspace, useCreateWsEnvironment, + useDeleteServiceFromWorkspace, useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, @@ -12,11 +14,12 @@ export { useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, useGetWorkspaceSecrets, - useGetWorkspaceServiceTokenDataV3, + useGetWorkspaceServiceMemberships, useGetWorkspaceUsers, useNameWorkspaceSecrets, useRenameWorkspace, useReorderWsEnvironment, useToggleAutoCapitalization, useUpdateUserWorkspaceRole, - useUpdateWsEnvironment} from "./queries"; + useUpdateWsEnvironment, +} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ede931de8..1ec2890e4 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { EncryptedSecret } from "../secrets/types"; -import { ServiceTokenDataV3 } from "../serviceTokens/types"; +import { ServiceMembership } from "../serviceTokens/types"; import { TWorkspaceUser } from "../users/types"; import { CreateEnvironmentDTO, @@ -32,7 +32,8 @@ export const workspaceKeys = { getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }] as const, getWorkspaceServiceTokenDataV3: (workspaceId: string) => - [{ workspaceId }, "workspace-service-token-data-v3"] as const + [{ workspaceId }, "workspace-service-token-data-v3"] as const, + getWorkspaceServiceMemberships: (workspaceId: string) => [{ workspaceId }, "organization-service-memberships"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -356,17 +357,69 @@ export const useUpdateUserWorkspaceRole = () => { }); }; -export const useGetWorkspaceServiceTokenDataV3 = (workspaceId: string) => { - return useQuery({ - queryKey: workspaceKeys.getWorkspaceServiceTokenDataV3(workspaceId), - queryFn: async () => { +export const useAddServiceToWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + serviceId, + workspaceId, + role + }: { + serviceId: string; + workspaceId: string; + role?: string; + }) => { + const { - data: { serviceTokenData } - } = await apiRequest.get<{ serviceTokenData: ServiceTokenDataV3[] }>( - `/api/v3/workspaces/${workspaceId}/service-token` + data: { serviceMembership } + } = await apiRequest.post(`/api/v2/workspace/${workspaceId}/service-memberships`, { + serviceId, + role + }); + + return serviceMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceMemberships(workspaceId)); + } + }); +}; + +export const useDeleteServiceFromWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + serviceId, + workspaceId, + }: { + serviceId: string; + workspaceId: string; + }) => { + + const { + data: { serviceMembership } + } = await apiRequest.delete(`/api/v2/workspace/${workspaceId}/service-memberships/${serviceId}`); + + return serviceMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceMemberships(workspaceId)); + } + }); +}; + +export const useGetWorkspaceServiceMemberships = (workspaceId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceServiceMemberships(workspaceId), + queryFn: async () => { + + const { + data: { serviceMemberships } + } = await apiRequest.get<{ serviceMemberships: ServiceMembership[] }>( + `/api/v2/workspace/${workspaceId}/service-memberships` ); - return serviceTokenData; + return serviceMemberships; }, enabled: true }); diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index b70cc442e..41c2a1f17 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -591,7 +591,7 @@ export const AppLayout = ({ children }: LayoutProps) => { isSelected={router.asPath === `/org/${currentOrg?._id}/members`} icon="system-outline-96-groups" > - Members + Access Control diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index f05b44f4b..2b0e51ad5 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -1,41 +1,32 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { withPermission } from "@app/hoc"; -import { useGetRoles } from "@app/hooks/api"; -import { TRole } from "@app/hooks/api/roles/types"; import { OrgMembersTable } from "./components/OrgMembersTable"; import { OrgRoleTabSection } from "./components/OrgRoleTabSection"; +import { OrgServiceTokenTab } from "./components/OrgServiceTokenTab"; enum TabSections { Member = "members", - Roles = "roles" + Roles = "roles", + ServiceTokens = "service-tokens" } export const MembersPage = withPermission( () => { - const { t } = useTranslation(); - const { currentOrg } = useOrganization(); - - const orgId = currentOrg?._id || ""; - - const { data: roles, isLoading: isRolesLoading } = useGetRoles({ - orgId - }); - return (

- {t("section.members.org-members")} + Access Control

Members + Service Accounts Roles @@ -46,17 +37,14 @@ export const MembersPage = withPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - []} - isRolesLoading={isRolesLoading} - /> + + + + - []} - isRolesLoading={isRolesLoading} - /> +
diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index c08a1cf1e..7e303554d 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -55,27 +55,21 @@ import { useAddUserToOrg, useDeleteOrgMembership, useGetOrgUsers, + useGetRoles, useGetSSOConfig, useGetUserWorkspaceMemberships, useGetUserWsKey, useUpdateOrgUserRole, - useUploadWsKey -} from "@app/hooks/api"; -import { TRole } from "@app/hooks/api/roles/types"; + useUploadWsKey} from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; -type Props = { - roles?: TRole[]; - isRolesLoading?: boolean; -}; - const addMemberFormSchema = yup.object({ email: yup.string().email().required().label("Email").trim().lowercase() }); type TAddMemberForm = yup.InferType; -export const OrgMembersTable = ({ roles = [], isRolesLoading }: Props) => { +export const OrgMembersTable = () => { const router = useRouter(); const { createNotification } = useNotificationContext(); @@ -86,6 +80,10 @@ export const OrgMembersTable = ({ roles = [], isRolesLoading }: Props) => { const orgId = currentOrg?._id || ""; const workspaceId = currentWorkspace?._id || ""; + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId + }); + const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(orgId); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const { data: serverDetails } = useFetchServerStatus(); @@ -211,7 +209,7 @@ export const OrgMembersTable = ({ roles = [], isRolesLoading }: Props) => { const findRoleFromId = useCallback( (roleId: string) => { - return roles.find(({ _id: id }) => id === roleId); + return (roles || []).find(({ _id: id }) => id === roleId); }, [roles] ); @@ -377,7 +375,7 @@ export const OrgMembersTable = ({ roles = [], isRolesLoading }: Props) => { onRoleChange(orgMembershipId, selectedRole) } > - {roles + {(roles || []) .filter(({ slug }) => slug === "owner" ? isIamOwner || role === "owner" : true ) diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx index 70b0813f3..e3ff11e6a 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx @@ -6,14 +6,8 @@ import { TRole } from "@app/hooks/api/roles/types"; import { OrgRoleModifySection } from "./OrgRoleModifySection"; import { OrgRoleTable } from "./OrgRoleTable"; -type Props = { - roles?: TRole[]; - isRolesLoading?: boolean; -}; - -export const OrgRoleTabSection = ({ roles = [], isRolesLoading }: Props) => { +export const OrgRoleTabSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); - return popUp.editRole.isOpen ? ( { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} > - handlePopUpOpen("editRole", role)} - /> + handlePopUpOpen("editRole", role)} /> ); }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx index dbd4331bf..6122309b4 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -20,22 +20,26 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteRole } from "@app/hooks/api"; +import { useDeleteRole, useGetRoles } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; type Props = { - isRolesLoading?: boolean; - roles?: TRole[]; onSelectRole: (role?: TRole) => void; }; -export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props) => { +export const OrgRoleTable = ({ + onSelectRole +}: Props) => { const [searchRoles, setSearchRoles] = useState(""); const { currentOrg } = useOrganization(); const orgId = currentOrg?._id || ""; const { createNotification } = useNotificationContext(); const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId + }); + const { mutateAsync: deleteRole } = useDeleteRole(); const handleRoleDelete = async () => { @@ -88,7 +92,7 @@ export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props {isRolesLoading && } - {roles?.map((role) => { + {(roles as TRole[])?.map((role) => { const { _id: id, name, slug } = role; const isNonMutatable = ["owner", "admin", "member"].includes(slug); diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/OrgServiceTokenTab.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/OrgServiceTokenTab.tsx new file mode 100644 index 000000000..a6977110e --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/OrgServiceTokenTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { ServiceTokenV3Section } from "./components"; + +export const OrgServiceTokenTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx similarity index 89% rename from frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx rename to frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx index cfe5937d7..87e36c53a 100644 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx @@ -4,15 +4,9 @@ import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-ico import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import { motion } from "framer-motion"; -import nacl from "tweetnacl"; -import { encodeBase64 } from "tweetnacl-util"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - decryptAssymmetric, - encryptAssymmetric -} from "@app/components/utilities/cryptography/crypto"; import { Button, FormControl, @@ -30,15 +24,14 @@ import { UpgradePlanModal} from "@app/components/v2"; import { useOrganization, - useSubscription, - useWorkspace + useSubscription } from "@app/context"; import { useToggle } from "@app/hooks"; import { useCreateServiceTokenV3, useGetRoles, - useGetUserWsKey, - useUpdateServiceTokenV3} from "@app/hooks/api"; + useUpdateServiceTokenV3 +} from "@app/hooks/api"; import { ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -101,17 +94,13 @@ export const AddServiceTokenV3Modal = ({ const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); const orgId = currentOrg?._id || ""; - const workspaceId = currentWorkspace?._id || ""; const { data: roles } = useGetRoles({ - orgId, - workspaceId + orgId }); - const { data: latestFileKey } = useGetUserWsKey(workspaceId); const { mutateAsync: createMutateAsync } = useCreateServiceTokenV3(); const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); const { createNotification } = useNotificationContext(); @@ -200,6 +189,7 @@ export const AddServiceTokenV3Modal = ({ isRefreshTokenRotationEnabled }: FormData) => { try { + const serviceTokenData = popUp?.serviceTokenV3?.data as { serviceTokenDataId: string; name: string; @@ -221,63 +211,22 @@ export const AddServiceTokenV3Modal = ({ handlePopUpToggle("serviceTokenV3", false); } else { - // create - if (!workspaceId) return; - if (!latestFileKey) return; - - const pair = nacl.box.keyPair(); - const secretKeyUint8Array = pair.secretKey; - const publicKeyUint8Array = pair.publicKey; - const privateKey = encodeBase64(secretKeyUint8Array); - const publicKey = encodeBase64(publicKeyUint8Array); - - const key = decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: localStorage.getItem("PRIVATE_KEY") as string - }); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: key, - publicKey, - privateKey: localStorage.getItem("PRIVATE_KEY") as string - }); const { refreshToken } = await createMutateAsync({ name, role, - workspaceId, - publicKey, + organizationId: orgId, trustedIps, expiresIn: expiresIn === "" ? undefined : Number(expiresIn), accessTokenTTL: Number(accessTokenTTL), - encryptedKey: ciphertext, - nonce, isRefreshTokenRotationEnabled }); - - const downloadData = { - public_key: publicKey, - private_key: privateKey, - refresh_token: refreshToken - }; - const serviceTokenJSON = JSON.stringify(downloadData, null, 2); - setNewServiceTokenJSON(serviceTokenJSON); - - const blob = new Blob([serviceTokenJSON], { type: "application/json" }); - const href = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = href; - link.download = `infisical_${name}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + setNewServiceTokenJSON(refreshToken); } createNotification({ - text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} service account`, type: "success" }); @@ -285,7 +234,7 @@ export const AddServiceTokenV3Modal = ({ } catch (err) { console.error(err); createNotification({ - text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} service account`, type: "error" }); } @@ -302,7 +251,7 @@ export const AddServiceTokenV3Modal = ({ setNewServiceTokenJSON(""); }} > - + {!hasServiceTokenJSON ? (
diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx similarity index 83% rename from frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx rename to frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx index 3b00202f9..a105a10d3 100644 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx @@ -2,13 +2,13 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { ProjectPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { withProjectPermission } from "@app/hoc"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; import { useDeleteServiceTokenV3 } from "@app/hooks/api"; @@ -17,7 +17,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal"; import { ServiceTokenV3Table } from "./ServiceTokenV3Table"; -export const ServiceTokenV3Section = withProjectPermission( +export const ServiceTokenV3Section = withPermission( () => { const { createNotification } = useNotificationContext(); const { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3(); @@ -51,11 +51,11 @@ export const ServiceTokenV3Section = withProjectPermission(

- Service Tokens V3 (Beta) + Service Accounts (Beta)

- {(isAllowed) => ( )} - +
); }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.ServiceTokens } ); \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx similarity index 79% rename from frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx rename to frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx index 2bfa23255..297e342fc 100644 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { ProjectPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -17,11 +17,13 @@ import { THead, Tr } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization} from "@app/context"; import { - useGetWorkspaceServiceTokenDataV3, - useUpdateServiceTokenV3 -} from "@app/hooks/api"; + useGetOrgServiceMemberships, + useUpdateServiceTokenV3} from "@app/hooks/api"; import { ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types" import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -47,9 +49,9 @@ export const ServiceTokenV3Table = ({ handlePopUpOpen }: Props) => { const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetWorkspaceServiceTokenDataV3(currentWorkspace?._id || ""); + const { currentOrg } = useOrganization(); const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); + const { data, isLoading } = useGetOrgServiceMemberships(currentOrg?._id || ""); const handleToggleServiceTokenDataStatus = async ({ serviceTokenDataId, @@ -85,9 +87,9 @@ export const ServiceTokenV3Table = ({ Name Status Role - Trusted IPs - Access Token TTL - Created At + {/* Trusted IPs */} + {/* Access Token TTL */} + {/* Created At */} Valid Until @@ -98,24 +100,26 @@ export const ServiceTokenV3Table = ({ data && data.length > 0 && data.map(({ - _id, - name, - isActive, + service: { + _id, + name, + isActive, + trustedIps, + // createdAt, + expiresAt, + accessTokenTTL, + isRefreshTokenRotationEnabled + }, role, - customRole, - trustedIps, - createdAt, - expiresAt, - accessTokenTTL, - isRefreshTokenRotationEnabled + customRole }) => { return ( {name} - {(isAllowed) => ( {isActive ? "Active" : "Inactive"}

)} -
+ {customRole?.slug ?? role} - + {/* {trustedIps.map(({ _id: trustedIpId, ipAddress, @@ -145,14 +149,14 @@ export const ServiceTokenV3Table = ({

); })} - - {accessTokenTTL} - {format(new Date(createdAt), "yyyy-MM-dd")} + */} + {/* {accessTokenTTL} */} + {/* {format(new Date(createdAt), "yyyy-MM-dd")} */} {expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} - {(isAllowed) => ( )} - - + {(isAllowed) => ( )} - + ); diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/index.tsx similarity index 100% rename from frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx rename to frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/ServiceTokenV3Section/index.tsx diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/index.tsx new file mode 100644 index 000000000..b6abc117c --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/components/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/index.tsx new file mode 100644 index 000000000..eab42b317 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgServiceTokenTab/index.tsx @@ -0,0 +1 @@ +export { OrgServiceTokenTab } from "./OrgServiceTokenTab"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index a9b10b607..aaa4d2f5a 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -7,11 +7,13 @@ import { withProjectPermission } from "@app/hoc"; import { MemberListTab } from "./components/MemberListTab"; import { ProjectRoleListTab } from "./components/ProjectRoleListTab"; +import { ServiceAccountTab } from "./components/ServiceAccountTab"; import { ServiceTokenTab } from "./components/ServiceTokenTab"; enum TabSections { Member = "members", Roles = "roles", + ServiceAccounts = "service-accounts", ServiceTokens = "service-tokens" } @@ -26,6 +28,7 @@ export const MembersPage = withProjectPermission( Members + Service Accounts Service Tokens Roles @@ -40,6 +43,9 @@ export const MembersPage = withProjectPermission( + + + diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 758970e2d..81e63d3ff 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -78,7 +78,7 @@ export const MemberListTab = () => { const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: orgUsers } = useGetOrgUsers(orgId); - + const [searchMemberFilter, setSearchMemberFilter] = useState(""); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ @@ -205,7 +205,7 @@ export const MemberListTab = () => { ({ status, user: u }) => status === "accepted" && !wsUserEmails.has(u.email) ); }, [orgUsers, members]); - + const onGrantAccess = async (grantedUserId: string, publicKey: string) => { try { const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index cca666ba8..402262f7a 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -64,8 +64,6 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { } }; - // roles={roles as TRole[]} - return (
diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/ServiceAccountTab.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/ServiceAccountTab.tsx new file mode 100644 index 000000000..53a666d69 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/ServiceAccountTab.tsx @@ -0,0 +1,19 @@ +import { motion } from "framer-motion"; + +import { + ServiceTokenV3Section +} from "./components"; + +export const ServiceAccountTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx new file mode 100644 index 000000000..fee5de2e5 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx @@ -0,0 +1,163 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Modal, + ModalContent, + Select, + SelectItem, +} from "@app/components/v2"; +import { + useOrganization, + useWorkspace +} from "@app/context"; +import { useAddServiceToWorkspace , + useGetOrgServiceMemberships, + useGetWorkspaceServiceMemberships +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + serviceTokenDataId: yup.string() +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["serviceTokenV3"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["serviceTokenV3"]>, state?: boolean) => void; +}; + +export const AddServiceTokenV3Modal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const orgId = currentOrg?._id || ""; + const workspaceId = currentWorkspace?._id || ""; + + const { data: orgServices } = useGetOrgServiceMemberships(orgId); + const { data: services } = useGetWorkspaceServiceMemberships(workspaceId); + const addServiceToWorkspace = useAddServiceToWorkspace(); + + const filteredOrgServices = useMemo(() => { + const wsServiceIds = new Map(); + + services?.forEach((service) => { + wsServiceIds.set(service.service._id, true); + }); + + return (orgServices || []).filter( + ({ service: s }) => !wsServiceIds.has(s._id) + ); + }, [orgServices, services]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const onFormSubmit = async ({ + serviceTokenDataId + }: FormData) => { + + try { + await addServiceToWorkspace.mutateAsync({ + workspaceId, + serviceId: serviceTokenDataId as string, + role: "member" + }); + + createNotification({ + text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + type: "success" + }); + + reset(); + handlePopUpToggle("serviceTokenV3", false); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("serviceTokenV3", isOpen); + reset(); + }} + > + + {filteredOrgServices.length ? ( + + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
All the service accounts in your organization are already added.
+ + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx new file mode 100644 index 000000000..e21fb5753 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx @@ -0,0 +1,109 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useWorkspace} from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { + useDeleteServiceFromWorkspace +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal"; +import { ServiceTokenV3Table } from "./ServiceTokenV3Table"; + +export const ServiceTokenV3Section = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?._id ?? ""; + + const { mutateAsync: deleteMutateAsync } = useDeleteServiceFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "serviceTokenV3", + "deleteServiceTokenV3", + "upgradePlan" + ] as const); + + const onRemoveServiceTokenDataSubmit = async (serviceTokenDataId: string) => { + try { + + await deleteMutateAsync({ + serviceId: serviceTokenDataId, + workspaceId + }); + + createNotification({ + text: "Successfully removed service account from project", + type: "success" + }); + + handlePopUpClose("deleteServiceTokenV3"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service account from project", + type: "error" + }); + } + } + + return ( +
+
+

+ Service Accounts (Beta) +

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteServiceTokenV3", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveServiceTokenDataSubmit( + (popUp?.deleteServiceTokenV3?.data as { serviceTokenDataId: string })?.serviceTokenDataId + ) + } + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx new file mode 100644 index 000000000..b4e8cd01e --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx @@ -0,0 +1,116 @@ +import { faKey, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context"; +import { useGetWorkspaceServiceMemberships } from "@app/hooks/api"; +import { ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types" +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteServiceTokenV3", "serviceTokenV3"]>, + data?: { + serviceTokenDataId?: string; + name?: string; + role?: string; + customRole?: { + name: string; + slug: string; + }; + trustedIps?: ServiceTokenV3TrustedIp[]; + accessTokenTTL?: number; + isRefreshTokenRotationEnabled?: boolean; + } + ) => void; + }; + +export const ServiceTokenV3Table = ({ + handlePopUpOpen +}: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetWorkspaceServiceMemberships(currentWorkspace?._id || ""); + + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + service: { + _id, + name + }, + role, + customRole, + createdAt + }) => { + return ( + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameRoleAdded on +
{name}{customRole?.slug ?? role}{format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + handlePopUpOpen("deleteServiceTokenV3", { + serviceTokenDataId: _id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/index.tsx new file mode 100644 index 000000000..b6abc117c --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/ServiceTokenV3Section/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/index.tsx new file mode 100644 index 000000000..b6abc117c --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/components/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/index.tsx new file mode 100644 index 000000000..a34f3c17b --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceAccountTab/index.tsx @@ -0,0 +1 @@ +export { ServiceAccountTab } from "./ServiceAccountTab"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx index 1a3c6e95c..bbb6262c0 100644 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx @@ -2,7 +2,6 @@ import { motion } from "framer-motion"; import { ServiceTokenSection, - ServiceTokenV3Section } from "./components"; export const ServiceTokenTab = () => { @@ -14,8 +13,7 @@ export const ServiceTokenTab = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - ); -} +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx index 18a8762cd..7d8b5ff15 100644 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx @@ -1,2 +1 @@ -export { ServiceTokenSection } from "./ServiceTokenSection"; -export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file +export { ServiceTokenSection } from "./ServiceTokenSection"; \ No newline at end of file