diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 428c1bda0..c27175bb1 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -3,15 +3,22 @@ import jwt from "jsonwebtoken"; import * as bigintConversion from "bigint-conversion"; // eslint-disable-next-line @typescript-eslint/no-var-requires const jsrp = require("jsrp"); -import { LoginSRPDetail, TokenVersion, User } from "../../models"; +import { + LoginSRPDetail, + TokenVersion, + User +} from "../../models"; import { clearTokens, createToken, issueAuthTokens } from "../../helpers/auth"; import { checkUserDevice } from "../../helpers/user"; import { AuthTokenType } from "../../variables"; -import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import { + BadRequestError, + UnauthorizedRequestError +} from "../../utils/errors"; import { getAuthSecret, getHttpsEnabled, - getJwtAuthLifetime + getJwtAuthLifetime, } from "../../config"; import { ActorType } from "../../ee/models"; import { validateRequest } from "../../helpers/validation"; @@ -25,10 +32,11 @@ declare module "jsonwebtoken" { userId: string; refreshVersion?: number; } - export interface ServiceRefreshTokenJwtPayload extends jwt.JwtPayload { - serviceTokenDataId: string; + export interface IdentityAccessTokenJwtPayload extends jwt.JwtPayload { + _id: string; + clientSecretId: string; + identityAccessTokenId: string; authTokenType: string; - tokenVersion: number; } } @@ -266,4 +274,4 @@ export const getNewToken = async (req: Request, res: Response) => { export const handleAuthProviderCallback = (req: Request, res: Response) => { res.redirect(`/login/provider/success?token=${encodeURIComponent(req.providerAuthToken)}`); -}; +}; \ No newline at end of file diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index fe171ae86..937936416 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -1,4 +1,5 @@ import * as authController from "./authController"; +import * as universalAuthController from "./universalAuthController"; import * as botController from "./botController"; import * as integrationAuthController from "./integrationAuthController"; import * as integrationController from "./integrationController"; @@ -20,6 +21,7 @@ import * as adminController from "./adminController"; export { authController, + universalAuthController, botController, integrationAuthController, integrationController, diff --git a/backend/src/controllers/v1/membershipController.ts b/backend/src/controllers/v1/membershipController.ts index cb11c4b74..350cddc5f 100644 --- a/backend/src/controllers/v1/membershipController.ts +++ b/backend/src/controllers/v1/membershipController.ts @@ -4,9 +4,9 @@ import { IUser, Key, Membership, MembershipOrg, User, Workspace } from "../../mo import { EventType, Role } from "../../ee/models"; import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership"; import { sendMail } from "../../helpers/nodemailer"; -import { ACCEPTED, ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; +import { ACCEPTED, ADMIN, CUSTOM, MEMBER, NO_ACCESS, VIEWER } from "../../variables"; import { getSiteURL } from "../../config"; -import { EEAuditLogService } from "../../ee/services"; +import { EEAuditLogService, EELicenseService } from "../../ee/services"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/membership"; import { @@ -129,7 +129,7 @@ export const changeMembershipRole = async (req: Request, res: Response) => { ProjectPermissionSub.Member ); - const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + const isCustomRole = ![ADMIN, MEMBER, VIEWER, NO_ACCESS].includes(role); if (isCustomRole) { const wsRole = await Role.findOne({ slug: role, @@ -137,6 +137,13 @@ export const changeMembershipRole = async (req: Request, res: Response) => { workspace: membershipToChangeRole.workspace }); if (!wsRole) throw BadRequestError({ message: "Role not found" }); + + const plan = await EELicenseService.getPlan(wsRole.organization); + + if (!plan.rbac) return res.status(400).send({ + message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." + }); + const membership = await Membership.findByIdAndUpdate(membershipId, { role: CUSTOM, customRole: wsRole diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index eeba96783..f212892ee 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -21,7 +21,7 @@ import { validateRequest } from "../../helpers/validation"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions } from "../../ee/services/RoleService"; import { ForbiddenError } from "@casl/ability"; @@ -44,11 +44,12 @@ export const deleteMembershipOrg = async (req: Request, _res: Response) => { if (!membershipOrgToDelete) { throw new Error("Failed to delete organization membership that doesn't exist"); } + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: membershipOrgToDelete.organization + }); - const { permission, membership: membershipOrg } = await getUserOrgPermissions( - req.user._id, - membershipOrgToDelete.organization.toString() - ); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Delete, OrgPermissionSubjects.Member @@ -60,7 +61,7 @@ export const deleteMembershipOrg = async (req: Request, _res: Response) => { }); await updateSubscriptionOrgQuantity({ - organizationId: membershipOrg.organization.toString() + organizationId: membershipOrgToDelete.organization.toString() }); return membershipOrgToDelete; @@ -96,7 +97,11 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { body: { inviteeEmail, organizationId } } = await validateRequest(reqValidator.InviteUserToOrgv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); + ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.Member diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index 2d216b6c5..676cb5572 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import { Types } from "mongoose"; import { IncidentContactOrg, Membership, @@ -14,7 +15,7 @@ import { ACCEPTED } from "../../variables"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions } from "../../ee/services/RoleService"; import { OrganizationNotFoundError } from "../../utils/errors"; import { ForbiddenError } from "@casl/ability"; @@ -44,7 +45,10 @@ export const getOrganization = async (req: Request, res: Response) => { } = await validateRequest(reqValidator.GetOrgv1, req); // ensure user has membership - await getUserOrgPermissions(req.user._id, organizationId); + await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }) const organization = await Organization.findById(organizationId); if (!organization) { @@ -68,8 +72,12 @@ export const getOrganizationMembers = async (req: Request, res: Response) => { const { params: { organizationId } } = await validateRequest(reqValidator.GetOrgMembersv1, req); - - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); + ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Member @@ -95,7 +103,10 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => params: { organizationId } } = await validateRequest(reqValidator.GetOrgWorkspacesv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }) ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Workspace @@ -137,7 +148,10 @@ export const changeOrganizationName = async (req: Request, res: Response) => { body: { name } } = await validateRequest(reqValidator.ChangeOrgNamev1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.Settings @@ -172,7 +186,10 @@ export const getOrganizationIncidentContacts = async (req: Request, res: Respons params: { organizationId } } = await validateRequest(reqValidator.GetOrgIncidentContactv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount @@ -199,7 +216,10 @@ export const addOrganizationIncidentContact = async (req: Request, res: Response body: { email } } = await validateRequest(reqValidator.CreateOrgIncideContact, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount @@ -228,7 +248,10 @@ export const deleteOrganizationIncidentContact = async (req: Request, res: Respo body: { email } } = await validateRequest(reqValidator.DelOrgIncideContact, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount @@ -257,7 +280,10 @@ export const createOrganizationPortalSession = async (req: Request, res: Respons params: { organizationId } } = await validateRequest(reqValidator.GetOrgPlanBillingInfov1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.Billing @@ -321,7 +347,10 @@ export const getOrganizationMembersAndTheirWorkspaces = async (req: Request, res params: { organizationId } } = await validateRequest(reqValidator.GetOrgMembersv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Member diff --git a/backend/src/controllers/v1/secretScanningController.ts b/backend/src/controllers/v1/secretScanningController.ts index 581a168b1..292ce94b3 100644 --- a/backend/src/controllers/v1/secretScanningController.ts +++ b/backend/src/controllers/v1/secretScanningController.ts @@ -21,7 +21,7 @@ import * as reqValidator from "../../validation/secretScanning"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions } from "../../ee/services/RoleService"; import { ForbiddenError } from "@casl/ability"; @@ -37,8 +37,11 @@ export const createInstallationSession = async (req: Request, res: Response) => message: "Failed to find organization" }); } - - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning @@ -70,11 +73,12 @@ export const linkInstallationToOrganization = async (req: Request, res: Response if (!installationSession) { throw UnauthorizedRequestError(); } + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: installationSession.organization + }); - const { permission } = await getUserOrgPermissions( - req.user._id, - installationSession.organization.toString() - ); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning @@ -142,7 +146,10 @@ export const getRisksForOrganization = async (req: Request, res: Response) => { params: { organizationId } } = await validateRequest(reqValidator.GetOrgRisksv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning @@ -162,7 +169,10 @@ export const updateRisksStatus = async (req: Request, res: Response) => { body: { status } } = await validateRequest(reqValidator.UpdateRiskStatusv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning diff --git a/backend/src/controllers/v1/universalAuthController.ts b/backend/src/controllers/v1/universalAuthController.ts new file mode 100644 index 000000000..e22c4e686 --- /dev/null +++ b/backend/src/controllers/v1/universalAuthController.ts @@ -0,0 +1,791 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import jwt from "jsonwebtoken"; +import crypto from "crypto"; +import bcrypt from "bcrypt"; +import { + IIdentity, + IIdentityTrustedIp, + IIdentityUniversalAuthClientSecret, + Identity, + IdentityAccessToken, + IdentityAuthMethod, + IdentityMembershipOrg, + IdentityUniversalAuth, + IdentityUniversalAuthClientSecret, +} from "../../models"; +import { createToken } from "../../helpers/auth"; +import { AuthTokenType } from "../../variables"; +import { + BadRequestError, + ForbiddenRequestError, + ResourceNotFoundError, + UnauthorizedRequestError +} from "../../utils/errors"; +import { + getAuthSecret, + getSaltRounds +} from "../../config"; +import { ActorType, EventType, IRole } from "../../ee/models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; +import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr } from "../../utils/ip"; +import { getUserAgentType } from "../../utils/posthog"; +import { EEAuditLogService, EELicenseService } from "../../ee/services"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getAuthDataOrgPermissions, + getOrgRolePermissions, + isAtLeastAsPrivilegedOrg +} from "../../ee/services/RoleService"; +import { ForbiddenError } from "@casl/ability"; + +const packageUniversalAuthClientSecretData = (identityUniversalAuthClientSecret: IIdentityUniversalAuthClientSecret) => ({ + _id: identityUniversalAuthClientSecret._id, + identityUniversalAuth: identityUniversalAuthClientSecret.identityUniversalAuth, + isClientSecretRevoked: identityUniversalAuthClientSecret.isClientSecretRevoked, + description: identityUniversalAuthClientSecret.description, + clientSecretPrefix: identityUniversalAuthClientSecret.clientSecretPrefix, + clientSecretNumUses: identityUniversalAuthClientSecret.clientSecretNumUses, + clientSecretNumUsesLimit: identityUniversalAuthClientSecret.clientSecretNumUsesLimit, + clientSecretTTL: identityUniversalAuthClientSecret.clientSecretTTL, + createdAt: identityUniversalAuthClientSecret.createdAt, + updatedAt: identityUniversalAuthClientSecret.updatedAt +}); + +/** + * Renews an access token by its TTL + * @param req + * @param res + */ +export const renewAccessToken = async (req: Request, res: Response) => { + const { + body: { + accessToken + } + } = await validateRequest(reqValidator.RenewAccessTokenV1, req); + + const decodedToken = ( + jwt.verify(accessToken, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN) throw UnauthorizedRequestError(); + + const identityAccessToken = await IdentityAccessToken.findOne({ + _id: decodedToken.identityAccessTokenId, + isAccessTokenRevoked: false + }); + + if (!identityAccessToken) throw UnauthorizedRequestError(); + + const { + accessTokenTTL, + accessTokenLastRenewedAt, + accessTokenMaxTTL, + createdAt: accessTokenCreatedAt + } = identityAccessToken; + + if (accessTokenTTL === accessTokenMaxTTL) throw UnauthorizedRequestError({ + message: "Failed to renew non-renewable access token" + }); + + // ttl check + if (accessTokenTTL > 0) { + const currentDate = new Date(); + if (accessTokenLastRenewedAt) { + // access token has been renewed + const accessTokenRenewed = new Date(accessTokenLastRenewedAt); + const ttlInMilliseconds = accessTokenTTL * 1000; + const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to renew MI access token due to TTL expiration" + }); + } else { + // access token has never been renewed + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = accessTokenTTL * 1000; + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to renew MI access token due to TTL expiration" + }); + } + } + + // max ttl checks + if (accessTokenMaxTTL > 0) { + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = accessTokenMaxTTL * 1000; + const currentDate = new Date(); + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to renew MI access token due to Max TTL expiration" + }); + + const extendToDate = new Date(currentDate.getTime() + accessTokenTTL); + if (extendToDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to renew MI access token past its Max TTL expiration" + }); + } + + await IdentityAccessToken.findByIdAndUpdate( + identityAccessToken._id, + { + accessTokenLastRenewedAt: new Date() + } + ); + + return res.status(200).send({ + accessToken, + expiresIn: identityAccessToken.accessTokenTTL, + tokenType: "Bearer" + }); +} + +/** + * Return access token for identity with client id [clientId] + * and client secret [clientSecret] + * @param req + * @param res + */ +export const loginIdentityUniversalAuth = async (req: Request, res: Response) => { + const { + body: { + clientId, + clientSecret + } + } = await validateRequest(reqValidator.LoginUniversalAuthV1, req); + + const identityUniversalAuth = await IdentityUniversalAuth.findOne({ + clientId + }).populate<{ identity: IIdentity }>("identity"); + + if (!identityUniversalAuth) throw UnauthorizedRequestError(); + + checkIPAgainstBlocklist({ + ipAddress: req.realIP, + trustedIps: identityUniversalAuth.clientSecretTrustedIps + }); + + const clientSecretData = await IdentityUniversalAuthClientSecret.find({ + identity: identityUniversalAuth.identity, + isClientSecretRevoked: false + }); + + let validatedClientSecretDatum: IIdentityUniversalAuthClientSecret | undefined; + + for (const clientSecretDatum of clientSecretData) { + const isSecretValid = await bcrypt.compare( + clientSecret, + clientSecretDatum.clientSecretHash + ); + + if (isSecretValid) { + validatedClientSecretDatum = clientSecretDatum; + break; + } + } + + if (!validatedClientSecretDatum) throw UnauthorizedRequestError(); + + const { + clientSecretTTL, + clientSecretNumUses, + clientSecretNumUsesLimit, + } = validatedClientSecretDatum; + + if (clientSecretTTL > 0) { + const clientSecretCreated = new Date(validatedClientSecretDatum.createdAt) + const ttlInMilliseconds = clientSecretTTL * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await IdentityUniversalAuthClientSecret.findByIdAndUpdate( + validatedClientSecretDatum._id, + { + isClientSecretRevoked: true + } + ); + + throw UnauthorizedRequestError({ + message: "Failed to authenticate identity credentials due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached + await IdentityUniversalAuthClientSecret.findByIdAndUpdate( + validatedClientSecretDatum._id, + { + isClientSecretRevoked: true + }, + { + new: true + } + ); + + throw UnauthorizedRequestError({ + message: "Failed to authenticate identity credentials due to client secret number of uses limit reached" + }); + } + + // increment usage count by 1 + await IdentityUniversalAuthClientSecret + .findByIdAndUpdate( + validatedClientSecretDatum._id, + { + clientSecretLastUsedAt: new Date(), + $inc: { clientSecretNumUses: 1 } + }, + { + new: true + } + ); + + const identityAccessToken = await new IdentityAccessToken({ + identity: identityUniversalAuth.identity, + identityUniversalAuthClientSecret: validatedClientSecretDatum._id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + accessTokenTTL: identityUniversalAuth.accessTokenTTL, + accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps, + isAccessTokenRevoked: false + }).save(); + + // token version + const accessToken = createToken({ + payload: { + identityId: identityUniversalAuth.identity.toString(), + clientSecretId: validatedClientSecretDatum._id.toString(), + identityAccessTokenId: identityAccessToken._id.toString(), + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + }, + secret: await getAuthSecret() + }); + + const userAgent = req.headers["user-agent"] ?? ""; + + await EEAuditLogService.createAuditLog( + { + actor: { + type: ActorType.IDENTITY, + metadata: { + identityId: identityUniversalAuth.identity._id.toString(), + name: identityUniversalAuth.identity.name + } + }, + authPayload: identityUniversalAuth.identity, + ipAddress: req.realIP, + userAgent, + userAgentType: getUserAgentType(userAgent) + }, + { + type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH, + metadata: { + identityId: identityUniversalAuth.identity._id.toString(), + identityUniversalAuthId: identityUniversalAuth._id.toString(), + clientSecretId: validatedClientSecretDatum._id.toString(), + identityAccessTokenId: identityAccessToken._id.toString() + } + } + ); + + return res.status(200).send({ + accessToken, + expiresIn: identityUniversalAuth.accessTokenTTL, + tokenType: "Bearer" + }); +} + +export const addIdentityUniversalAuth = async (req: Request, res: Response) => { + const { + params: { identityId }, + body: { + clientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + } + } = await validateRequest(reqValidator.AddUniversalAuthToIdentityV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg + .findOne({ + identity: new Types.ObjectId(identityId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + if (identityMembershipOrg.identity?.authMethod) throw BadRequestError({ + message: "Failed to add universal auth to already-configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }) + } + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Identity + ); + + const plan = await EELicenseService.getPlan(identityMembershipOrg.organization); + + // validate trusted ips + const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { + if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(clientSecretTrustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(clientSecretTrustedIp.ipAddress); + }); + + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityUniversalAuth = await new IdentityUniversalAuth({ + identity: identityMembershipOrg.identity._id, + clientId: crypto.randomUUID(), + clientSecretTrustedIps: reformattedClientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps, + }).save(); + + await Identity.findByIdAndUpdate( + identityMembershipOrg.identity._id, + { + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH + } + ); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH, + metadata: { + identityId: identityMembershipOrg.identity._id.toString(), + clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array + } + } + ); + + return res.status(200).send({ + identityUniversalAuth + }); +} + +export const updateIdentityUniversalAuth = async (req: Request, res: Response) => { + const { + params: { identityId }, + body: { + clientSecretTrustedIps, + accessTokenTTL, // TODO: validate this and max TTL + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + } + } = await validateRequest(reqValidator.UpdateUniversalAuthToIdentityV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg + .findOne({ + identity: new Types.ObjectId(identityId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.UNIVERSAL_AUTH) throw BadRequestError({ + message: "Failed to add universal auth to already-configured identity" + }); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Identity + ); + + const plan = await EELicenseService.getPlan(identityMembershipOrg.organization); + + // validate trusted ips + let reformattedClientSecretTrustedIps; + if (clientSecretTrustedIps) { + reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { + if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(clientSecretTrustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(clientSecretTrustedIp.ipAddress); + }); + } + + let reformattedAccessTokenTrustedIps; + if (accessTokenTrustedIps) { + reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + } + + const identityUniversalAuth = await IdentityUniversalAuth.findOneAndUpdate( + { + identity: identityMembershipOrg.identity._id, + }, + { + clientSecretTrustedIps: reformattedClientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps, + }, + { + new: true + } + ); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH, + metadata: { + identityId: identityMembershipOrg.identity._id.toString(), + clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array + } + } + ); + + return res.status(200).send({ + identityUniversalAuth + }); +} + +export const getIdentityUniversalAuth = async (req: Request, res: Response) => { + const { + params: { identityId } + } = await validateRequest(reqValidator.GetUniversalAuthForIdentityV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg + .findOne({ + identity: new Types.ObjectId(identityId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Identity + ); + + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.UNIVERSAL_AUTH) throw BadRequestError({ + message: "The identity does not have universal auth configured" + }); + + const identityUniversalAuth = await IdentityUniversalAuth.findOne({ + identity: identityMembershipOrg.identity._id, + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH, + metadata: { + identityId: identityMembershipOrg.identity._id.toString(), + } + } + ); + + return res.status(200).send({ + identityUniversalAuth + }); +} + +export const createUniversalAuthClientSecret = async (req: Request, res: Response) => { + const { + params: { identityId }, + body: { + description, + numUsesLimit, + ttl + } + } = await validateRequest(reqValidator.CreateUniversalAuthClientSecretV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg.findOne({ + identity: new Types.ObjectId(identityId) + }).populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Identity + ); + + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.UNIVERSAL_AUTH) throw BadRequestError({ + message: "The identity does not have universal auth configured" + }); + + const rolePermission = await getOrgRolePermissions( + identityMembershipOrg?.customRole?.slug ?? identityMembershipOrg.role, + identityMembershipOrg.organization.toString() + ); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to create client secret for more privileged identity" + }); + + const clientSecret = crypto.randomBytes(32).toString("hex"); + const clientSecretHash = await bcrypt.hash(clientSecret, await getSaltRounds()); + + const identityUniversalAuth = await IdentityUniversalAuth.findOne({ + identity: identityMembershipOrg.identity._id + }); + + if (!identityUniversalAuth) throw ResourceNotFoundError(); + + const identityUniversalAuthClientSecret = await new IdentityUniversalAuthClientSecret({ + identity: identityMembershipOrg.identity._id, + identityUniversalAuth: identityUniversalAuth._id, + description, + clientSecretPrefix: clientSecret.slice(0, 4), + clientSecretHash, + clientSecretNumUses: 0, + clientSecretNumUsesLimit: numUsesLimit, + clientSecretTTL: ttl, + isClientSecretRevoked: false + }).save(); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, + metadata: { + identityId: identityMembershipOrg.identity._id.toString(), + clientSecretId: identityUniversalAuthClientSecret._id.toString() + } + } + ); + + return res.status(200).send({ + clientSecret, + clientSecretData: packageUniversalAuthClientSecretData(identityUniversalAuthClientSecret) + }); +} + +export const getUniversalAuthClientSecrets = async (req: Request, res: Response) => { + const { + params: { identityId } + } = await validateRequest(reqValidator.GetUniversalAuthClientSecretsV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg.findOne({ + identity: new Types.ObjectId(identityId) + }).populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError(); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Identity + ); + + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.UNIVERSAL_AUTH) throw BadRequestError({ + message: "The identity does not have universal auth configured" + }); + + const rolePermission = await getOrgRolePermissions( + identityMembershipOrg?.customRole?.slug ?? identityMembershipOrg.role, + identityMembershipOrg.organization.toString() + ); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to get client secrets for more privileged MI" + }); + + const clientSecretData = await IdentityUniversalAuthClientSecret + .find({ + identity: identityMembershipOrg.identity, + isClientSecretRevoked: false + }) + .sort({ createdAt: -1 }) + .limit(5); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS, + metadata: { + identityId: identityMembershipOrg.identity._id.toString() + } + } + ); + + return res.status(200).send({ + clientSecretData: clientSecretData.map((clientSecretDatum) => packageUniversalAuthClientSecretData(clientSecretDatum)) + }); +} + +export const revokeUniversalAuthClientSecret = async (req: Request, res: Response) => { + const { + params: { identityId, clientSecretId } + } = await validateRequest(reqValidator.RevokeUniversalAuthClientSecretV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg + .findOne({ + identity: new Types.ObjectId(identityId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Identity + ); + + const rolePermission = await getOrgRolePermissions( + identityMembershipOrg?.customRole?.slug ?? identityMembershipOrg.role, + identityMembershipOrg.organization.toString() + ); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to delete client secrets for more privileged identity" + }); + + const clientSecretData = await IdentityUniversalAuthClientSecret.findOneAndUpdate( + { + _id: new Types.ObjectId(clientSecretId), + identity: identityMembershipOrg.identity._id + }, + { + isClientSecretRevoked: true + }, + { + new: true + } + ); + + if (!clientSecretData) throw ResourceNotFoundError(); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, + metadata: { + identityId: identityMembershipOrg.identity._id.toString(), + clientSecretId: clientSecretId + } + } + ); + + return res.status(200).send({ + clientSecretData: packageUniversalAuthClientSecretData(clientSecretData) + }) +} \ No newline at end of file diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index 9d3ae5b80..2d6e9776d 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -17,7 +17,7 @@ import { OrganizationNotFoundError } from "../../utils/errors"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions } from "../../ee/services/RoleService"; import { ForbiddenError } from "@casl/ability"; import { validateRequest } from "../../helpers/validation"; @@ -152,7 +152,10 @@ export const createWorkspace = async (req: Request, res: Response) => { }); } - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.Workspace diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index 5a14b5838..f8ffd46c8 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 { + IdentityMembershipOrg, + Membership, + MembershipOrg, + Workspace +} from "../../models"; import { Role } from "../../ee/models"; import { deleteMembershipOrg } from "../../helpers/membershipOrg"; import { @@ -9,15 +14,16 @@ import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; import { addMembershipsOrg } from "../../helpers/membershipOrg"; -import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; -import { ACCEPTED, ADMIN, CUSTOM } from "../../variables"; +import { BadRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../utils/errors"; +import { ACCEPTED, ADMIN, CUSTOM, MEMBER, NO_ACCESS } from "../../variables"; import * as reqValidator from "../../validation/organization"; import { validateRequest } from "../../helpers/validation"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions } from "../../ee/services/RoleService"; +import { EELicenseService } from "../../ee/services"; import { ForbiddenError } from "@casl/ability"; /** @@ -63,7 +69,10 @@ export const getOrganizationMemberships = async (req: Request, res: Response) => params: { organizationId } } = await validateRequest(reqValidator.GetOrgMembersv2, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Member @@ -141,16 +150,32 @@ export const updateOrganizationMembership = async (req: Request, res: Response) params: { organizationId, membershipId }, body: { role } } = await validateRequest(reqValidator.UpdateOrgMemberv2, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.Member ); - const isCustomRole = !["admin", "member"].includes(role); + const isCustomRole = ![ADMIN, MEMBER, NO_ACCESS].includes(role); if (isCustomRole) { - const orgRole = await Role.findOne({ slug: role, isOrgRole: true }); + const orgRole = await Role.findOne({ + slug: role, + isOrgRole: true, + organization: new Types.ObjectId(organizationId) + }); + if (!orgRole) throw BadRequestError({ message: "Role not found" }); + + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); + + if (!plan.rbac) return res.status(400).send({ + message: + "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." + }); const membership = await MembershipOrg.findByIdAndUpdate(membershipId, { role: CUSTOM, @@ -227,7 +252,18 @@ export const deleteOrganizationMembership = async (req: Request, res: Response) const { params: { organizationId, membershipId } } = await validateRequest(reqValidator.DeleteOrgMemberv2, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + + const membershipOrg = await MembershipOrg.findOne({ + _id: new Types.ObjectId(membershipId), + organization: new Types.ObjectId(organizationId) + }); + + if (!membershipOrg) throw ResourceNotFoundError(); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: membershipOrg.organization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Delete, OrgPermissionSubjects.Member @@ -291,7 +327,11 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => params: { organizationId } } = await validateRequest(reqValidator.GetOrgWorkspacesv2, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); + ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Workspace @@ -377,3 +417,32 @@ export const deleteOrganizationById = async (req: Request, res: Response) => { organization }); }; + +/** + * Return list of identity memberships for organization with id [organizationId] + * @param req + * @param res + * @returns + */ + export const getOrganizationIdentityMemberships = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgIdentityMembershipsV2, req); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Identity + ); + + const identityMemberships = await IdentityMembershipOrg.find({ + organization: new Types.ObjectId(organizationId) + }).populate("identity customRole"); + + return res.status(200).send({ + identityMemberships + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 8c5e70952..3ab41a38b 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -1,6 +1,15 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { Key, Membership, ServiceTokenData, Workspace } from "../../models"; +import { + IIdentity, + IdentityMembership, + IdentityMembershipOrg, + Key, + Membership, + ServiceTokenData, + Workspace +} from "../../models"; +import { IRole, Role } from "../../ee/models"; import { pullSecrets as pull, v2PushSecrets as push, @@ -16,9 +25,13 @@ import * as reqValidator from "../../validation"; import { ProjectPermissionActions, ProjectPermissionSub, - getAuthDataProjectPermissions + getAuthDataProjectPermissions, + getWorkspaceRolePermissions, + isAtLeastAsPrivilegedWorkspace } from "../../ee/services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; +import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError } from "../../utils/errors"; +import { ADMIN, CUSTOM, MEMBER, NO_ACCESS, VIEWER } from "../../variables"; interface V2PushSecret { type: string; // personal or shared @@ -491,3 +504,254 @@ export const toggleAutoCapitalization = async (req: Request, res: Response) => { workspace }); }; + +/** + * Add identity with id [identityId] to workspace + * with id [workspaceId] + * @param req + * @param res + */ + export const addIdentityToWorkspace = async (req: Request, res: Response) => { + const { + params: { workspaceId, identityId }, + body: { + role + } + } = await validateRequest(reqValidator.AddIdentityToWorkspaceV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Identity + ); + + let identityMembership = await IdentityMembership.findOne({ + identity: new Types.ObjectId(identityId), + workspace: new Types.ObjectId(workspaceId) + }); + + if (identityMembership) throw BadRequestError({ + message: `Identity with id ${identityId} already exists in project with id ${workspaceId}` + }); + + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw ResourceNotFoundError(); + + const identityMembershipOrg = await IdentityMembershipOrg.findOne({ + identity: new Types.ObjectId(identityId), + organization: workspace.organization + }); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + if (!identityMembershipOrg.organization.equals(workspace.organization)) throw BadRequestError({ + message: "Failed to add identity to project in another organization" + }); + + const rolePermission = await getWorkspaceRolePermissions(role, workspaceId); + const isAsPrivilegedAsIntendedRole = isAtLeastAsPrivilegedWorkspace(permission, rolePermission); + + if (!isAsPrivilegedAsIntendedRole) throw ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + + let customRole; + if (role) { + const isCustomRole = ![ADMIN, MEMBER, VIEWER, NO_ACCESS].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" }); + } + } + + identityMembership = await new IdentityMembership({ + identity: identityMembershipOrg.identity, + workspace: new Types.ObjectId(workspaceId), + role: customRole ? CUSTOM : role, + customRole + }).save(); + + return res.status(200).send({ + identityMembership + }); +} + +/** + * Update role of identity with id [identityId] in workspace + * with id [workspaceId] to [role] + * @param req + * @param res + */ + export const updateIdentityWorkspaceRole = async (req: Request, res: Response) => { + const { + params: { workspaceId, identityId }, + body: { + role + } + } = await validateRequest(reqValidator.UpdateIdentityWorkspaceRoleV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + let identityMembership = await IdentityMembership + .findOne({ + identity: new Types.ObjectId(identityId), + workspace: new Types.ObjectId(workspaceId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembership) throw BadRequestError({ + message: `Identity with id ${identityId} does not exist in project with id ${workspaceId}` + }); + + const identityRolePermission = await getWorkspaceRolePermissions( + identityMembership?.customRole?.slug ?? identityMembership.role, + identityMembership.workspace.toString() + ); + const isAsPrivilegedAsIdentity = isAtLeastAsPrivilegedWorkspace(permission, identityRolePermission); + if (!isAsPrivilegedAsIdentity) throw ForbiddenRequestError({ + message: "Failed to update role of more privileged identity" + }); + + const rolePermission = await getWorkspaceRolePermissions(role, workspaceId); + const isAsPrivilegedAsIntendedRole = isAtLeastAsPrivilegedWorkspace(permission, rolePermission); + + if (!isAsPrivilegedAsIntendedRole) throw ForbiddenRequestError({ + message: "Failed to update identity to a more privileged role" + }); + + let customRole; + if (role) { + const isCustomRole = ![ADMIN, MEMBER, VIEWER, NO_ACCESS].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" }); + } + } + + identityMembership = await IdentityMembership.findOneAndUpdate( + { + identity: identityMembership.identity._id, + workspace: new Types.ObjectId(workspaceId), + }, + { + role: customRole ? CUSTOM : role, + customRole + }, + { + new: true + } + ); + + return res.status(200).send({ + identityMembership + }); +} + +/** + * Delete identity with id [identityId] to workspace + * with id [workspaceId] + * @param req + * @param res + */ + export const deleteIdentityFromWorkspace = async (req: Request, res: Response) => { + const { + params: { workspaceId, identityId } + } = await validateRequest(reqValidator.DeleteIdentityFromWorkspaceV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Identity + ); + + const identityMembership = await IdentityMembership + .findOne({ + identity: new Types.ObjectId(identityId), + workspace: new Types.ObjectId(workspaceId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembership) throw ResourceNotFoundError({ + message: `Identity with id ${identityId} does not exist in project with id ${workspaceId}` + }); + + const identityRolePermission = await getWorkspaceRolePermissions( + identityMembership?.customRole?.slug ?? identityMembership.role, + identityMembership.workspace.toString() + ); + const isAsPrivilegedAsIdentity = isAtLeastAsPrivilegedWorkspace(permission, identityRolePermission); + if (!isAsPrivilegedAsIdentity) throw ForbiddenRequestError({ + message: "Failed to remove more privileged identity from project" + }); + + await IdentityMembership.findByIdAndDelete(identityMembership._id); + + return res.status(200).send({ + identityMembership + }); +} + +/** + * Return list of identity memberships for workspace with id [workspaceId] + * @param req + * @param res + * @returns + */ + export const getWorkspaceIdentityMemberships = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceIdentityMembersV2, req); + + const { permission } = await getAuthDataProjectPermissions({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Identity + ); + + const identityMemberships = await IdentityMembership.find({ + workspace: new Types.ObjectId(workspaceId) + }).populate("identity customRole"); + + return res.status(200).send({ + identityMemberships + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 28c4be1cf..88d5ab8d9 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -94,7 +94,7 @@ const checkSecretsPermission = async ({ }); return { authVerifier: () => true }; } - case ActorType.SERVICE_V3: { + case ActorType.IDENTITY: { const { permission } = await getAuthDataProjectPermissions({ authData, workspaceId: new Types.ObjectId(workspaceId) 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/v1/identitiesController.ts b/backend/src/ee/controllers/v1/identitiesController.ts new file mode 100644 index 000000000..c4e88f7c2 --- /dev/null +++ b/backend/src/ee/controllers/v1/identitiesController.ts @@ -0,0 +1,324 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import { + IIdentity, + Identity, + IdentityAccessToken, + IdentityMembership, + IdentityMembershipOrg, + IdentityUniversalAuth, + IdentityUniversalAuthClientSecret, + Organization +} from "../../../models"; +import { + EventType, + IRole, + Role +} from "../../models"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/identities"; +import { + getAuthDataOrgPermissions, + getOrgRolePermissions, + isAtLeastAsPrivilegedOrg +} from "../../services/RoleService"; +import { + BadRequestError, + ForbiddenRequestError, + ResourceNotFoundError, +} from "../../../utils/errors"; +import { ADMIN, CUSTOM, MEMBER, NO_ACCESS } from "../../../variables"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "../../services/RoleService"; +import { EEAuditLogService } from "../../services"; +import { ForbiddenError } from "@casl/ability"; + +/** + * Create identity + * @param req + * @param res + * @returns + */ +export const createIdentity = async (req: Request, res: Response) => { + const { + body: { + name, + organizationId, + role + } + } = await validateRequest(reqValidator.CreateIdentityV1, req); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Identity + ); + + const rolePermission = await getOrgRolePermissions(role, organizationId); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to create a more privileged identity" + }); + + const organization = await Organization.findById(organizationId); + if (!organization) throw BadRequestError({ message: `Organization with id ${organizationId} not found` }); + + const isCustomRole = ![ADMIN, MEMBER, NO_ACCESS].includes(role); + + let customRole; + if (isCustomRole) { + customRole = await Role.findOne({ + slug: role, + isOrgRole: true, + organization: new Types.ObjectId(organizationId) + }); + + if (!customRole) throw BadRequestError({ message: "Role not found" }); + } + + const identity = await new Identity({ + name + }).save(); + + await new IdentityMembershipOrg({ + identity: identity._id, + organization: new Types.ObjectId(organizationId), + role: isCustomRole ? CUSTOM : role, + customRole + }).save(); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.CREATE_IDENTITY, + metadata: { + identityId: identity._id.toString(), + name + } + }, + { + organizationId: new Types.ObjectId(organizationId) + } + ); + + return res.status(200).send({ + identity + }); +} + +/** + * Update identity with id [identityId] + * @param req + * @param res + * @returns + */ + export const updateIdentity = async (req: Request, res: Response) => { + const { + params: { identityId }, + body: { + name, + role + } + } = await validateRequest(reqValidator.UpdateIdentityV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg + .findOne({ + identity: new Types.ObjectId(identityId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Identity + ); + + const identityRolePermission = await getOrgRolePermissions( + identityMembershipOrg?.customRole?.slug ?? identityMembershipOrg.role, + identityMembershipOrg.organization.toString() + ); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, identityRolePermission); + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to update more privileged identity" + }); + + if (role) { + const rolePermission = await getOrgRolePermissions(role, identityMembershipOrg.organization.toString()); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to update identity to a more privileged role" + }); + } + + let customRole; + if (role) { + const isCustomRole = ![ADMIN, MEMBER, NO_ACCESS].includes(role); + if (isCustomRole) { + customRole = await Role.findOne({ + slug: role, + isOrgRole: true, + organization: identityMembershipOrg.organization + }); + + if (!customRole) throw BadRequestError({ message: "Role not found" }); + } + } + + const identity = await Identity.findByIdAndUpdate( + identityId, + { + name, + }, + { + new: true + } + ); + + if (!identity) throw BadRequestError({ + message: `Failed to update identity with id ${identityId}` + }); + + await IdentityMembershipOrg.findOneAndUpdate( + { + identity: identity._id + }, + { + role: customRole ? CUSTOM : role, + ...(customRole ? { + customRole + } : {}), + ...(role && !customRole ? { // non-custom role + $unset: { + customRole: 1 + } + } : {}) + }, + { + new: true + } + ); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.UPDATE_IDENTITY, + metadata: { + identityId: identity._id.toString(), + name: identity.name, + } + }, + { + organizationId: identityMembershipOrg.organization + } + ); + + return res.status(200).send({ + identity + }); +} + +/** + * Delete identity with id [identityId] + * @param req + * @param res + * @returns + */ + export const deleteIdentity = async (req: Request, res: Response) => { + const { + params: { identityId } + } = await validateRequest(reqValidator.DeleteIdentityV1, req); + + const identityMembershipOrg = await IdentityMembershipOrg + .findOne({ + identity: new Types.ObjectId(identityId) + }) + .populate<{ + identity: IIdentity, + customRole: IRole + }>("identity customRole"); + + if (!identityMembershipOrg) throw ResourceNotFoundError({ + message: `Failed to find identity with id ${identityId}` + }); + + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: identityMembershipOrg.organization + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Identity + ); + + const identityRolePermission = await getOrgRolePermissions( + identityMembershipOrg?.customRole?.slug ?? identityMembershipOrg.role, + identityMembershipOrg.organization.toString() + ); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, identityRolePermission); + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to delete more privileged identity" + }); + + const identity = await Identity.findByIdAndDelete(identityMembershipOrg.identity); + if (!identity) throw ResourceNotFoundError({ + message: `Identity with id ${identityId} not found` + }); + + await IdentityMembershipOrg.findByIdAndDelete(identityMembershipOrg._id); + + await IdentityMembership.deleteMany({ + identity: identityMembershipOrg.identity + }); + + await IdentityUniversalAuth.deleteMany({ + identity: identityMembershipOrg.identity + }); + + await IdentityUniversalAuthClientSecret.deleteMany({ + identity: identityMembershipOrg.identity + }); + + await IdentityAccessToken.deleteMany({ + identity: identityMembershipOrg.identity + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.DELETE_IDENTITY, + metadata: { + identityId: identity._id.toString() + } + }, + { + organizationId: identityMembershipOrg.organization + } + ); + + return res.status(200).send({ + identity + }); +} + + + + + diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index a80916733..0d17e5a06 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -1,3 +1,4 @@ +import * as identitiesController from "./identitiesController"; import * as secretController from "./secretController"; import * as secretSnapshotController from "./secretSnapshotController"; import * as organizationsController from "./organizationsController"; @@ -13,6 +14,7 @@ import * as secretRotationProviderController from "./secretRotationProviderContr import * as secretRotationController from "./secretRotationController"; export { + identitiesController, secretController, secretSnapshotController, organizationsController, diff --git a/backend/src/ee/controllers/v1/organizationsController.ts b/backend/src/ee/controllers/v1/organizationsController.ts index f64603e3b..2f0d5ec39 100644 --- a/backend/src/ee/controllers/v1/organizationsController.ts +++ b/backend/src/ee/controllers/v1/organizationsController.ts @@ -8,7 +8,7 @@ import * as reqValidator from "../../../validation/organization"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions, } from "../../services/RoleService"; import { ForbiddenError } from "@casl/ability"; import { Organization } from "../../../models"; @@ -20,7 +20,10 @@ export const getOrganizationPlansTable = async (req: Request, res: Response) => params: { organizationId } } = await validateRequest(reqValidator.GetOrgPlansTablev1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -42,7 +45,10 @@ export const getOrganizationPlan = async (req: Request, res: Response) => { params: { organizationId } } = await validateRequest(reqValidator.GetOrgPlanv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -70,7 +76,10 @@ export const startOrganizationTrial = async (req: Request, res: Response) => { body: { success_url } } = await validateRequest(reqValidator.StartOrgTrailv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.Billing @@ -116,7 +125,10 @@ export const getOrganizationPlanBillingInfo = async (req: Request, res: Response params: { organizationId } } = await validateRequest(reqValidator.GetOrgPlanBillingInfov1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -149,7 +161,10 @@ export const getOrganizationPlanTable = async (req: Request, res: Response) => { params: { organizationId } } = await validateRequest(reqValidator.GetOrgPlanTablev1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -176,7 +191,10 @@ export const getOrganizationBillingDetails = async (req: Request, res: Response) params: { organizationId } } = await validateRequest(reqValidator.GetOrgBillingDetailsv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -204,7 +222,10 @@ export const updateOrganizationBillingDetails = async (req: Request, res: Respon body: { name, email } } = await validateRequest(reqValidator.UpdateOrgBillingDetailsv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.Billing @@ -238,7 +259,10 @@ export const getOrganizationPmtMethods = async (req: Request, res: Response) => params: { organizationId } } = await validateRequest(reqValidator.GetOrgPmtMethodsv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -271,7 +295,10 @@ export const addOrganizationPmtMethod = async (req: Request, res: Response) => { body: { success_url, cancel_url } } = await validateRequest(reqValidator.CreateOrgPmtMethodv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.Billing @@ -312,7 +339,10 @@ export const deleteOrganizationPmtMethod = async (req: Request, res: Response) = params: { organizationId, pmtMethodId } } = await validateRequest(reqValidator.DelOrgPmtMethodv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Delete, OrgPermissionSubjects.Billing @@ -342,7 +372,10 @@ export const getOrganizationTaxIds = async (req: Request, res: Response) => { params: { organizationId } } = await validateRequest(reqValidator.GetOrgTaxIdsv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -375,7 +408,10 @@ export const addOrganizationTaxId = async (req: Request, res: Response) => { body: { type, value } } = await validateRequest(reqValidator.CreateOrgTaxId, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.Billing @@ -412,7 +448,10 @@ export const deleteOrganizationTaxId = async (req: Request, res: Response) => { params: { organizationId, taxId } } = await validateRequest(reqValidator.DelOrgTaxIdv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Delete, OrgPermissionSubjects.Billing @@ -445,7 +484,10 @@ export const getOrganizationInvoices = async (req: Request, res: Response) => { params: { organizationId } } = await validateRequest(reqValidator.GetOrgInvoicesv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing @@ -480,7 +522,10 @@ export const getOrganizationLicenses = async (req: Request, res: Response) => { params: { organizationId } } = await validateRequest(reqValidator.GetOrgLicencesv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Billing diff --git a/backend/src/ee/controllers/v1/roleController.ts b/backend/src/ee/controllers/v1/roleController.ts index cef14e79f..a4b3035e6 100644 --- a/backend/src/ee/controllers/v1/roleController.ts +++ b/backend/src/ee/controllers/v1/roleController.ts @@ -15,14 +15,17 @@ import { adminProjectPermissions, getAuthDataProjectPermissions, memberProjectPermissions, + noAccessProjectPermissions, viewerProjectPermission } from "../../services/ProjectRoleService"; import { OrgPermissionActions, OrgPermissionSubjects, adminPermissions, + getAuthDataOrgPermissions, getUserOrgPermissions, - memberPermissions + memberPermissions, + noAccessPermissions } from "../../services/RoleService"; import { BadRequestError } from "../../../utils/errors"; import { Role } from "../../models"; @@ -36,7 +39,11 @@ export const createRole = async (req: Request, res: Response) => { const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule if (isOrgRole) { - const { permission } = await getUserOrgPermissions(req.user.id, orgId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(orgId) + }); + if (permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "user doesn't have the permission." }); } @@ -80,9 +87,12 @@ export const updateRole = async (req: Request, res: Response) => { body: { name, description, slug, permissions, workspaceId, orgId } } = await validateRequest(UpdateRoleSchema, req); const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule - + if (isOrgRole) { - const { permission } = await getUserOrgPermissions(req.user.id, orgId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(orgId) + }); if (permission.cannot(OrgPermissionActions.Edit, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the org permission." }); } @@ -138,7 +148,10 @@ export const deleteRole = async (req: Request, res: Response) => { const isOrgRole = !role.workspace; if (isOrgRole) { - const { permission } = await getUserOrgPermissions(req.user.id, role.organization.toString()); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: role.organization + }); if (permission.cannot(OrgPermissionActions.Delete, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the org permission." }); } @@ -170,7 +183,10 @@ export const getRoles = async (req: Request, res: Response) => { const isOrgRole = !workspaceId; if (isOrgRole) { - const { permission } = await getUserOrgPermissions(req.user.id, orgId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(orgId) + }); if (permission.cannot(OrgPermissionActions.Read, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the org permission." }); } @@ -195,6 +211,13 @@ export const getRoles = async (req: Request, res: Response) => { description: "Complete administration access over the organization", permissions: isOrgRole ? adminPermissions.rules : adminProjectPermissions.rules }, + { + _id: "no-access", + name: "No Access", + slug: "no-access", + description: "No access to any resources in the organization", + permissions: isOrgRole ? noAccessPermissions.rules : noAccessProjectPermissions.rules + }, { _id: "member", name: isOrgRole ? "Member" : "Developer", @@ -229,7 +252,7 @@ export const getUserPermissions = async (req: Request, res: Response) => { const { params: { orgId } } = await validateRequest(GetUserPermission, req); - + const { permission, membership } = await getUserOrgPermissions(req.user._id, orgId); res.status(200).json({ diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index b7ae793ab..29ed9c18e 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -13,7 +13,7 @@ import { validateRequest } from "../../../helpers/validation"; import { OrgPermissionActions, OrgPermissionSubjects, - getUserOrgPermissions + getAuthDataOrgPermissions } from "../../services/RoleService"; import { ForbiddenError } from "@casl/ability"; @@ -47,7 +47,10 @@ export const getSSOConfig = async (req: Request, res: Response) => { query: { organizationId } } = await validateRequest(reqValidator.GetSsoConfigv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Read, OrgPermissionSubjects.Sso @@ -71,7 +74,10 @@ export const updateSSOConfig = async (req: Request, res: Response) => { body: { organizationId, authProvider, isActive, entryPoint, issuer, cert } } = await validateRequest(reqValidator.UpdateSsoConfigv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, OrgPermissionSubjects.Sso @@ -206,7 +212,10 @@ export const createSSOConfig = async (req: Request, res: Response) => { body: { organizationId, authProvider, isActive, entryPoint, issuer, cert } } = await validateRequest(reqValidator.CreateSsoConfigv1, req); - const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + const { permission } = await getAuthDataOrgPermissions({ + authData: req.authData, + organizationId: new Types.ObjectId(organizationId) + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Create, OrgPermissionSubjects.Sso diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index fd068373b..9f63de3d9 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -2,10 +2,11 @@ import { Request, Response } from "express"; import { PipelineStage, Types } from "mongoose"; import { Folder, + Identity, + IdentityMembership, Membership, Secret, ServiceTokenData, - ServiceTokenDataV3, TFolderSchema, User, Workspace @@ -17,10 +18,10 @@ import { FolderVersion, IPType, ISecretVersion, + IdentityActor, SecretSnapshot, SecretVersion, ServiceActor, - ServiceActorV3, TFolderRootVersionSchema, TrustedIP, UserActor @@ -669,6 +670,21 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { ProjectPermissionSub.AuditLogs ); + let actorMetadataQuery = ""; + if (actor) { + switch (actor?.split("-", 2)[0]) { + case ActorType.USER: + actorMetadataQuery = "actor.metadata.userId"; + break; + case ActorType.SERVICE: + actorMetadataQuery = "actor.metadata.serviceId"; + break; + case ActorType.IDENTITY: + actorMetadataQuery = "actor.metadata.identityId"; + break; + } + } + const query = { workspace: new Types.ObjectId(workspaceId), ...(eventType @@ -684,13 +700,9 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { ...(actor ? { "actor.type": actor.substring(0, actor.lastIndexOf("-")), - ...(actor.split("-", 2)[0] === ActorType.USER - ? { - "actor.metadata.userId": actor.substring(actor.lastIndexOf("-") + 1) - } - : { - "actor.metadata.serviceId": actor.substring(actor.lastIndexOf("-") + 1) - }) + ...({ + [actorMetadataQuery]: actor.substring(actor.lastIndexOf("-") + 1) + }) } : {}), ...(startDate || endDate @@ -702,7 +714,9 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { } : {}) }; + const auditLogs = await AuditLog.find(query).sort({ createdAt: -1 }).skip(offset).limit(limit); + return res.status(200).send({ auditLogs }); @@ -731,6 +745,7 @@ export const getWorkspaceAuditLogActorFilterOpts = async (req: Request, res: Res const userIds = await Membership.distinct("user", { workspace: new Types.ObjectId(workspaceId) }); + const userActors: UserActor[] = ( await User.find({ _id: { @@ -757,19 +772,25 @@ export const getWorkspaceAuditLogActorFilterOpts = async (req: Request, res: Res } })); - const serviceV3Actors: ServiceActorV3[] = ( - await ServiceTokenDataV3.find({ - workspace: new Types.ObjectId(workspaceId) + const identityIds = await IdentityMembership.distinct("identity", { + workspace: new Types.ObjectId(workspaceId) + }); + + const identityActors: IdentityActor[] = ( + await Identity.find({ + _id: { + $in: identityIds + } }) - ).map((serviceTokenData) => ({ - type: ActorType.SERVICE_V3, + ).map((identity) => ({ + type: ActorType.IDENTITY, metadata: { - serviceId: serviceTokenData._id.toString(), - name: serviceTokenData.name + identityId: identity._id.toString(), + name: identity.name } })); - const actors = [...userActors, ...serviceActors, ...serviceV3Actors]; + const actors = [...userActors, ...serviceActors, ...identityActors]; return res.status(200).send({ actors diff --git a/backend/src/ee/controllers/v3/index.ts b/backend/src/ee/controllers/v3/index.ts index 454e0c446..2a8f130dd 100644 --- a/backend/src/ee/controllers/v3/index.ts +++ b/backend/src/ee/controllers/v3/index.ts @@ -1,7 +1,5 @@ -import * as serviceTokenDataController from "./serviceTokenDataController"; import * as apiKeyDataController from "./apiKeyDataController"; export { - serviceTokenDataController, apiKeyDataController } \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts deleted file mode 100644 index 3e97c9afe..000000000 --- a/backend/src/ee/controllers/v3/serviceTokenDataController.ts +++ /dev/null @@ -1,469 +0,0 @@ -import jwt from "jsonwebtoken"; -import { Request, Response } from "express"; -import { Types } from "mongoose"; -import { - IServiceTokenDataV3, - IUser, - ServiceTokenDataV3, - ServiceTokenDataV3Key, - Workspace -} from "../../../models"; -import { IServiceTokenV3TrustedIp } from "../../../models/serviceTokenDataV3"; -import { - ActorType, - EventType, - Role -} from "../../models"; -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 - } - }); -} - -/** - * Return access and refresh token as per refresh operation - * @param req - * @param res - */ - export const refreshToken = async (req: Request, res: Response) => { - const { - body: { - refresh_token - } - } = await validateRequest(reqValidator.RefreshTokenV3, req); - - const decodedToken = ( - jwt.verify(refresh_token, await getAuthSecret()) - ); - - if (decodedToken.authTokenType !== AuthTokenType.SERVICE_REFRESH_TOKEN) throw UnauthorizedRequestError(); - - let serviceTokenData = await ServiceTokenDataV3.findOne({ - _id: new Types.ObjectId(decodedToken.serviceTokenDataId), - isActive: true - }); - - if (!serviceTokenData) throw UnauthorizedRequestError(); - - if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) { - // raise alarm - throw UnauthorizedRequestError(); - } - - const response: { - refresh_token?: string; - access_token: string; - expires_in: number; - token_type: string; - } = { - refresh_token, - access_token: "", - expires_in: 0, - token_type: "Bearer" - }; - - if (serviceTokenData.isRefreshTokenRotationEnabled) { - serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - $inc: { - tokenVersion: 1 - } - }, - { - new: true - } - ); - - if (!serviceTokenData) throw BadRequestError(); - - response.refresh_token = createToken({ - payload: { - serviceTokenDataId: serviceTokenData._id.toString(), - authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN, - tokenVersion: serviceTokenData.tokenVersion - }, - secret: await getAuthSecret() - }); - } - - response.access_token = createToken({ - payload: { - serviceTokenDataId: serviceTokenData._id.toString(), - authTokenType: AuthTokenType.SERVICE_ACCESS_TOKEN, - tokenVersion: serviceTokenData.tokenVersion - }, - expiresIn: serviceTokenData.accessTokenTTL, - secret: await getAuthSecret() - }); - - response.expires_in = serviceTokenData.accessTokenTTL; - - await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - refreshTokenLastUsed: new Date(), - $inc: { refreshTokenUsageCount: 1 } - }, - { - new: true - } - ); - - return res.status(200).send(response); -} - -/** - * Create service token data V3 - * @param req - * @param res - * @returns - */ -export const createServiceTokenData = async (req: Request, res: Response) => { - const { - body: { - name, - workspaceId, - publicKey, - role, - trustedIps, - expiresIn, - accessTokenTTL, - isRefreshTokenRotationEnabled, - encryptedKey, // for ServiceTokenDataV3Key - nonce, // for ServiceTokenDataV3Key - } - } = 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 workspace = await Workspace.findById(workspaceId); - if (!workspace) throw BadRequestError({ message: "Workspace not found" }); - - const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); - - let customRole; - if (isCustomRole) { - customRole = await Role.findOne({ - slug: role, - isOrgRole: false, - workspace: workspace._id - }); - - if (!customRole) throw BadRequestError({ message: "Role not found" }); - } - - const plan = await EELicenseService.getPlan(workspace.organization); - - // validate trusted ips - const reformattedTrustedIps = trustedIps.map((trustedIp) => { - if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ - message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." - }); - - const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); - - 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; - if (expiresIn) { - expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - } - - let user; - if (req.authData.actor.type === ActorType.USER) { - user = req.authData.authPayload._id; - } - - const isActive = true; - const serviceTokenData = await new ServiceTokenDataV3({ - name, - user, - workspace: new Types.ObjectId(workspaceId), - publicKey, - 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) - }).save(); - - const refreshToken = createToken({ - payload: { - serviceTokenDataId: serviceTokenData._id.toString(), - authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN, - tokenVersion: serviceTokenData.tokenVersion - }, - secret: await getAuthSecret() - }); - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.CREATE_SERVICE_TOKEN_V3, // TODO: update - metadata: { - name, - isActive, - role, - trustedIps: reformattedTrustedIps as Array, - expiresAt - } - }, - { - workspaceId: new Types.ObjectId(workspaceId) - } - ); - - return res.status(200).send({ - serviceTokenData, - refreshToken - }); -} - -/** - * Update service token V3 data with id [serviceTokenDataId] - * @param req - * @param res - * @returns - */ -export const updateServiceTokenData = async (req: Request, res: Response) => { - const { - params: { serviceTokenDataId }, - body: { - name, - isActive, - role, - trustedIps, - expiresIn, - accessTokenTTL, - isRefreshTokenRotationEnabled - } - } = await validateRequest(reqValidator.UpdateServiceTokenV3, req); - - let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId); - if (!serviceTokenData) throw ResourceNotFoundError({ - message: "Service token not found" - }); - - const { permission } = await getAuthDataProjectPermissions({ - authData: req.authData, - workspaceId: serviceTokenData.workspace - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.ServiceTokens - ); - - 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); - if (isCustomRole) { - customRole = await Role.findOne({ - slug: role, - isOrgRole: false, - workspace: workspace._id - }); - - if (!customRole) throw BadRequestError({ message: "Role not found" }); - } - } - - const plan = await EELicenseService.getPlan(workspace.organization); - - // validate trusted ips - let reformattedTrustedIps; - if (trustedIps) { - reformattedTrustedIps = trustedIps.map((trustedIp) => { - if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ - message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range." - }); - - const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); - - 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; - if (expiresIn) { - expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - } - - serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenDataId, - { - name, - isActive, - role: customRole ? CUSTOM : role, - ...(customRole ? { - customRole - } : {}), - ...(role && !customRole ? { // non-custom role - $unset: { - customRole: 1 - } - } : {}), - trustedIps: reformattedTrustedIps, - expiresAt, - accessTokenTTL, - isRefreshTokenRotationEnabled - }, - { - new: true - } - ); - - if (!serviceTokenData) throw BadRequestError({ - message: "Failed to update service token" - }); - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.UPDATE_SERVICE_TOKEN_V3, - metadata: { - name: serviceTokenData.name, - isActive, - role, - trustedIps: reformattedTrustedIps as Array, - expiresAt - } - }, - { - workspaceId: serviceTokenData.workspace - } - ); - - return res.status(200).send({ - serviceTokenData - }); -} - -/** - * Delete service token data with id [serviceTokenDataId] - * @param req - * @param res - * @returns - */ -export const deleteServiceTokenData = async (req: Request, res: Response) => { - const { - params: { serviceTokenDataId } - } = await validateRequest(reqValidator.DeleteServiceTokenV3, req); - - let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId); - if (!serviceTokenData) throw ResourceNotFoundError({ - message: "Service token not found" - }); - - const { permission } = await getAuthDataProjectPermissions({ - authData: req.authData, - workspaceId: serviceTokenData.workspace - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.ServiceTokens - ); - - serviceTokenData = await ServiceTokenDataV3.findByIdAndDelete(serviceTokenDataId); - - if (!serviceTokenData) throw BadRequestError({ - message: "Failed to delete service token" - }); - - await ServiceTokenDataV3Key.findOneAndDelete({ - serviceTokenData: serviceTokenData._id - }); - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.DELETE_SERVICE_TOKEN_V3, - metadata: { - name: serviceTokenData.name, - isActive: serviceTokenData.isActive, - role: serviceTokenData.role, - trustedIps: serviceTokenData.trustedIps as Array, - expiresAt: serviceTokenData.expiresAt - } - }, - { - workspaceId: serviceTokenData.workspace - } - ); - - return res.status(200).send({ - serviceTokenData - }); -} \ No newline at end of file diff --git a/backend/src/ee/models/auditLog/auditLog.ts b/backend/src/ee/models/auditLog/auditLog.ts index c2c5aba68..824e2f89f 100644 --- a/backend/src/ee/models/auditLog/auditLog.ts +++ b/backend/src/ee/models/auditLog/auditLog.ts @@ -10,7 +10,7 @@ export interface IAuditLog { event: Event; userAgent: string; userAgentType: UserAgentType; - expiresAt: Date; + expiresAt?: Date; } const auditLogSchema = new Schema( diff --git a/backend/src/ee/models/auditLog/enums.ts b/backend/src/ee/models/auditLog/enums.ts index 48147db08..0a12c4415 100644 --- a/backend/src/ee/models/auditLog/enums.ts +++ b/backend/src/ee/models/auditLog/enums.ts @@ -1,8 +1,7 @@ -export enum ActorType { - USER = "user", - SERVICE = "service", - SERVICE_V3 = "service-v3", - // Machine = "machine" +export enum ActorType { // would extend to AWS, Azure, ... + USER = "user", // userIdentity + SERVICE = "service", + IDENTITY = "identity" } export enum UserAgentType { @@ -32,9 +31,16 @@ export enum EventType { DELETE_TRUSTED_IP = "delete-trusted-ip", CREATE_SERVICE_TOKEN = "create-service-token", // v2 DELETE_SERVICE_TOKEN = "delete-service-token", // v2 - CREATE_SERVICE_TOKEN_V3 = "create-service-token-v3", // v3 - UPDATE_SERVICE_TOKEN_V3 = "update-service-token-v3", // v3 - DELETE_SERVICE_TOKEN_V3 = "delete-service-token-v3", // v3 + CREATE_IDENTITY = "create-identity", + UPDATE_IDENTITY = "update-identity", + DELETE_IDENTITY = "delete-identity", + LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", + ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", + UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", + GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", + CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", + REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index bae4e2f96..a4e470414 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -1,5 +1,5 @@ import { ActorType, EventType } from "./enums"; -import { IServiceTokenV3TrustedIp } from "../../../models/serviceTokenDataV3"; +import { IIdentityTrustedIp } from "../../../models"; interface UserActorMetadata { userId: string; @@ -11,6 +11,11 @@ interface ServiceActorMetadata { name: string; } +interface IdentityActorMetadata { + identityId: string; + name: string; +} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -21,16 +26,12 @@ export interface ServiceActor { metadata: ServiceActorMetadata; } -export interface ServiceActorV3 { - type: ActorType.SERVICE_V3; - metadata: ServiceActorMetadata; +export interface IdentityActor { + type: ActorType.IDENTITY; + metadata: IdentityActorMetadata; } -// export interface MachineActor { -// type: ActorType.Machine; -// } - -export type Actor = UserActor | ServiceActor | ServiceActorV3; +export type Actor = UserActor | ServiceActor | IdentityActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -220,36 +221,91 @@ interface DeleteServiceTokenEvent { }; } -interface CreateServiceTokenV3Event { - type: EventType.CREATE_SERVICE_TOKEN_V3; +interface CreateIdentityEvent { // note: currently not logging org-role + type: EventType.CREATE_IDENTITY; metadata: { + identityId: string; name: string; - isActive: boolean; - role: string; - trustedIps: Array; - expiresAt?: Date; }; } -interface UpdateServiceTokenV3Event { - type: EventType.UPDATE_SERVICE_TOKEN_V3; +interface UpdateIdentityEvent { + type: EventType.UPDATE_IDENTITY; metadata: { + identityId: string; name?: string; - isActive?: boolean; - role?: string; - trustedIps?: Array; - expiresAt?: Date; }; } -interface DeleteServiceTokenV3Event { - type: EventType.DELETE_SERVICE_TOKEN_V3; +interface DeleteIdentityEvent { + type: EventType.DELETE_IDENTITY; metadata: { - name: string; - isActive: boolean; - role: string; - expiresAt?: Date; - trustedIps: Array; + identityId: string; + }; +} + +interface LoginIdentityUniversalAuthEvent { + type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH ; + metadata: { + identityId: string; + identityUniversalAuthId: string; + clientSecretId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityUniversalAuthEvent { + type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + clientSecretTrustedIps: Array; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityUniversalAuthEvent { + type: EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + clientSecretTrustedIps?: Array; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityUniversalAuthEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + }; +} + +interface CreateIdentityUniversalAuthClientSecretEvent { + type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET ; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + +interface GetIdentityUniversalAuthClientSecretsEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS; + metadata: { + identityId: string; + }; +} + + +interface RevokeIdentityUniversalAuthClientSecretEvent { + type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET ; + metadata: { + identityId: string; + clientSecretId: string; }; } @@ -495,9 +551,16 @@ export type Event = | DeleteTrustedIPEvent | CreateServiceTokenEvent | DeleteServiceTokenEvent - | CreateServiceTokenV3Event - | UpdateServiceTokenV3Event - | DeleteServiceTokenV3Event + | CreateIdentityEvent + | UpdateIdentityEvent + | DeleteIdentityEvent + | LoginIdentityUniversalAuthEvent + | AddIdentityUniversalAuthEvent + | UpdateIdentityUniversalAuthEvent + | GetIdentityUniversalAuthEvent + | CreateIdentityUniversalAuthClientSecretEvent + | GetIdentityUniversalAuthClientSecretsEvent + | RevokeIdentityUniversalAuthClientSecretEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/ee/routes/v1/identities.ts b/backend/src/ee/routes/v1/identities.ts new file mode 100644 index 000000000..c78a7d8c8 --- /dev/null +++ b/backend/src/ee/routes/v1/identities.ts @@ -0,0 +1,31 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; +import { identitiesController } from "../../controllers/v1"; + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN] + }), + identitiesController.createIdentity +); + +router.patch( + "/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + identitiesController.updateIdentity +); + +router.delete( + "/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + identitiesController.deleteIdentity +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index d5168e402..b22c61629 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,3 +1,4 @@ +import identities from "./identities"; import secret from "./secret"; import secretSnapshot from "./secretSnapshot"; import organizations from "./organizations"; @@ -13,6 +14,7 @@ import secretRotationProvider from "./secretRotationProvider"; import secretRotation from "./secretRotation"; export { + identities, secret, secretSnapshot, organizations, diff --git a/backend/src/ee/routes/v3/index.ts b/backend/src/ee/routes/v3/index.ts index dd8c13427..c534640e3 100644 --- a/backend/src/ee/routes/v3/index.ts +++ b/backend/src/ee/routes/v3/index.ts @@ -1,7 +1,5 @@ -import serviceTokenData from "./serviceTokenData"; import apiKeyData from "./apiKeyData"; export { - serviceTokenData, apiKeyData } \ No newline at end of file diff --git a/backend/src/ee/routes/v3/serviceTokenData.ts b/backend/src/ee/routes/v3/serviceTokenData.ts deleted file mode 100644 index ba1d04854..000000000 --- a/backend/src/ee/routes/v3/serviceTokenData.ts +++ /dev/null @@ -1,44 +0,0 @@ -import express from "express"; -const router = express.Router(); -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 -); - -router.post( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - serviceTokenDataController.createServiceTokenData -); - -router.patch( - "/:serviceTokenDataId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - serviceTokenDataController.updateServiceTokenData -); - -router.delete( - "/:serviceTokenDataId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - serviceTokenDataController.deleteServiceTokenData -); - -export default router; \ No newline at end of file diff --git a/backend/src/ee/services/EEAuditLogService.ts b/backend/src/ee/services/EEAuditLogService.ts index eb5c1bbb3..9d220feee 100644 --- a/backend/src/ee/services/EEAuditLogService.ts +++ b/backend/src/ee/services/EEAuditLogService.ts @@ -3,7 +3,6 @@ import { AuditLog, Event } from "../models"; import { AuthData } from "../../interfaces/middleware"; import EELicenseService from "./EELicenseService"; import { Workspace } from "../../models"; -import { OrganizationNotFoundError } from "../../utils/errors"; interface EventScope { workspaceId?: Types.ObjectId; @@ -14,31 +13,42 @@ type ValidEventScope = | Required> | Required> | Required + | Record; export default class EEAuditLogService { - static async createAuditLog(authData: AuthData, event: Event, eventScope: ValidEventScope, shouldSave = true) { + static async createAuditLog(authData: AuthData, event: Event, eventScope: ValidEventScope = {}, shouldSave = true) { const MS_IN_DAY = 24 * 60 * 60 * 1000; - const organizationId = ("organizationId" in eventScope) - ? eventScope.organizationId - : (await Workspace.findById(eventScope.workspaceId).select("organization").lean())?.organization; + let organizationId; + if ("organizationId" in eventScope) { + organizationId = eventScope.organizationId; + } - if (!organizationId) throw OrganizationNotFoundError({ - message: "createAuditLog: Failed to create audit log due to missing organizationId" - }); - - const ttl = (await EELicenseService.getPlan(organizationId)).auditLogsRetentionDays * MS_IN_DAY; + let workspaceId; + if ("workspaceId" in eventScope) { + workspaceId = eventScope.workspaceId; + + if (!organizationId) { + organizationId = (await Workspace.findById(workspaceId).select("organization").lean())?.organization; + } + } + + let expiresAt; + if (organizationId) { + const ttl = (await EELicenseService.getPlan(organizationId)).auditLogsRetentionDays * MS_IN_DAY; + expiresAt = new Date(Date.now() + ttl); + } const auditLog = await new AuditLog({ actor: authData.actor, organization: organizationId, - workspace: ("workspaceId" in eventScope) ? eventScope.workspaceId : undefined, + workspace: workspaceId, ipAddress: authData.ipAddress, event, userAgent: authData.userAgent, userAgentType: authData.userAgentType, - expiresAt: new Date(Date.now() + ttl) + expiresAt }); if (shouldSave) { diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index 4e882d00f..18511948b 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -11,10 +11,15 @@ import { UnauthorizedRequestError } from "../../utils/errors"; 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 { ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; -import { checkIPAgainstBlocklist } from "../../utils/ip"; +import { ActorType, IRole, Role } from "../models"; +import { + IIdentity, + IdentityMembership, + Membership, + ServiceTokenData +} from "../../models"; +import { ADMIN, CUSTOM, MEMBER, NO_ACCESS, VIEWER } from "../../variables"; +import { BadRequestError } from "../../utils/errors"; const $glob: FieldInstruction = { type: "field", @@ -55,7 +60,8 @@ export enum ProjectPermissionSub { Secrets = "secrets", SecretRollback = "secret-rollback", SecretApproval = "secret-approval", - SecretRotation = "secret-rotation" + SecretRotation = "secret-rotation", + Identity = "identity" } type SubjectFields = { @@ -80,6 +86,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.Identity] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -126,6 +133,11 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); @@ -191,6 +203,11 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); @@ -231,6 +248,7 @@ const buildViewerPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); @@ -243,6 +261,13 @@ const buildViewerPermission = () => { export const viewerProjectPermission = buildViewerPermission(); +const buildNoAccessProjectPermission = () => { + const { build } = new AbilityBuilder>(createMongoAbility); + return build({ conditionsMatcher }); +} + +export const noAccessProjectPermissions = buildNoAccessProjectPermission(); + /** * Return permissions for user/service pertaining to workspace with id [workspaceId] * @@ -256,7 +281,7 @@ export const getAuthDataProjectPermissions = async ({ authData: AuthData; workspaceId: Types.ObjectId; }) => { - let role: "admin" | "member" | "viewer" | "custom"; + let role: "admin" | "member" | "viewer" | "no-access" | "custom"; let customRole; switch (authData.actor.type) { @@ -265,10 +290,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(); @@ -284,25 +309,24 @@ export const getAuthDataProjectPermissions = async ({ role = "viewer"; 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)) { + case ActorType.IDENTITY: { + const identityMembership = await IdentityMembership.findOne({ + identity: authData.authPayload._id, + workspace: workspaceId + }) + .populate<{ + customRole: IRole & { permissions: RawRuleOf>[] }; + identity: IIdentity + }>("customRole identity") + .exec(); + + if (!identityMembership || (identityMembership.role === "custom" && !identityMembership.customRole)) { throw UnauthorizedRequestError(); } - - checkIPAgainstBlocklist({ - ipAddress: authData.ipAddress, - trustedIps: serviceTokenData.trustedIps - }); - role = serviceTokenData.role; - customRole = serviceTokenData.customRole; + role = identityMembership.role; + customRole = identityMembership.customRole; + break; } default: @@ -316,6 +340,8 @@ export const getAuthDataProjectPermissions = async ({ return { permission: memberProjectPermissions }; case VIEWER: return { permission: viewerProjectPermission }; + case NO_ACCESS: + return { permission: noAccessProjectPermissions }; case CUSTOM: { if (!customRole) throw UnauthorizedRequestError(); return { @@ -329,3 +355,61 @@ export const getAuthDataProjectPermissions = async ({ throw UnauthorizedRequestError(); } } + +export const getWorkspaceRolePermissions = async (role: string, workspaceId: string) => { + const isCustomRole = ![ADMIN, MEMBER, VIEWER, NO_ACCESS].includes(role); + if (isCustomRole) { + const workspaceRole = await Role.findOne({ + slug: role, + isOrgRole: false, + workspace: new Types.ObjectId(workspaceId) + }); + + if (!workspaceRole) throw BadRequestError({ message: "Role not found" }); + + return createMongoAbility(workspaceRole.permissions as RawRuleOf>[], { + conditionsMatcher + }); + } + + switch (role) { + case ADMIN: + return adminProjectPermissions; + case MEMBER: + return memberProjectPermissions; + case VIEWER: + return viewerProjectPermission; + case NO_ACCESS: + return noAccessProjectPermissions; + default: + throw BadRequestError({ message: "Role not found" }); + } +} + +/** + * Extracts and formats permissions from a CASL Ability object or a raw permission set. + * @param ability + * @returns + */ + const extractPermissions = (ability: any) => { + return ability.A.map((permission: any) => `${permission.action}_${permission.subject}`); +} + +/** + * Compares two sets of permissions to determine if the first set is at least as privileged as the second set. + * The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions. + * +*/ +export const isAtLeastAsPrivilegedWorkspace = (permissions1: MongoAbility | ProjectPermissionSet, permissions2: MongoAbility | ProjectPermissionSet) => { + + const set1 = new Set(extractPermissions(permissions1)); + const set2 = new Set(extractPermissions(permissions2)); + + for (const perm of set2) { + if (!set1.has(perm)) { + return false; + } + } + + return set1.size >= set2.size; +} \ No newline at end of file diff --git a/backend/src/ee/services/RoleService.ts b/backend/src/ee/services/RoleService.ts index 8f8c4f315..1822c1d71 100644 --- a/backend/src/ee/services/RoleService.ts +++ b/backend/src/ee/services/RoleService.ts @@ -1,9 +1,15 @@ +import { Types } from "mongoose"; import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability"; -import { MembershipOrg } from "../../models"; -import { IRole } from "../models/role"; +import { + IIdentity, + IdentityMembershipOrg, + MembershipOrg +} from "../../models"; +import { ActorType, IRole, Role } from "../models"; import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; -import { ACCEPTED } from "../../variables"; +import { ACCEPTED, ADMIN, CUSTOM, MEMBER, NO_ACCESS} from "../../variables"; import { conditionsMatcher } from "./ProjectRoleService"; +import { AuthData } from "../../interfaces/middleware"; export enum OrgPermissionActions { Read = "read", @@ -20,7 +26,8 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Billing = "billing", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + Identity = "identity" } export type OrgPermissionSet = @@ -32,7 +39,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgPermissionActions, OrgPermissionSubjects.Billing]; + | [OrgPermissionActions, OrgPermissionSubjects.Billing] + | [OrgPermissionActions, OrgPermissionSubjects.Identity]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -75,6 +83,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + return build({ conditionsMatcher }); }; @@ -98,13 +111,26 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + return build({ conditionsMatcher }); }; export const memberPermissions = buildMemberPermission(); +const buildNoAccessPermission = () => { + const { build } = new AbilityBuilder>(createMongoAbility); + return build({ conditionsMatcher }); +} + +export const noAccessPermissions = buildNoAccessPermission(); + 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, @@ -119,11 +145,13 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => { throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); } - if (membership.role === "admin") return { permission: adminPermissions, membership }; + if (membership.role === ADMIN) return { permission: adminPermissions, membership }; - if (membership.role === "member") return { permission: memberPermissions, membership }; + if (membership.role === MEMBER) return { permission: memberPermissions, membership }; + + if (membership.role === NO_ACCESS) return { permission: noAccessPermissions, membership } - if (membership.role === "custom") { + if (membership.role === CUSTOM) { const permission = createMongoAbility(membership.customRole.permissions, { conditionsMatcher }); @@ -132,3 +160,142 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => { throw BadRequestError({ message: "User role not found" }); }; + +/** + * Return permissions for user/service pertaining to organization with id [organizationId] + * + * Note: should not rely on this function for ST V2 authorization logic + * b/c ST V2 does not support role-based access control but also not organization-level resources + */ + export const getAuthDataOrgPermissions = async ({ + authData, + organizationId +}: { + authData: AuthData; + organizationId: Types.ObjectId; +}) => { + let role: "admin" | "member" | "no-access" | "custom"; + let customRole; + + switch (authData.actor.type) { + case ActorType.USER: { + const membershipOrg = await MembershipOrg.findOne({ + user: authData.authPayload._id, + organization: organizationId, + status: ACCEPTED + }) + .populate<{ customRole: IRole & { permissions: RawRuleOf>[] } }>( + "customRole" + ) + .exec(); + + if (!membershipOrg || (membershipOrg.role === "custom" && !membershipOrg.customRole)) { + throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); + } + + role = membershipOrg.role; + customRole = membershipOrg.customRole; + break; + } + case ActorType.SERVICE: { + throw UnauthorizedRequestError({ + message: "Failed to access organization-level resources with service token" + }); + } + case ActorType.IDENTITY: { + const identityMembershipOrg = await IdentityMembershipOrg.findOne({ + identity: authData.authPayload._id, + organization: organizationId + }) + .populate<{ + customRole: IRole & { permissions: RawRuleOf>[] }; + identity: IIdentity + }>("customRole identity") + .exec(); + + if (!identityMembershipOrg || (identityMembershipOrg.role === "custom" && !identityMembershipOrg.customRole)) { + throw UnauthorizedRequestError(); + } + + role = identityMembershipOrg.role; + customRole = identityMembershipOrg.customRole; + break; + } + default: + throw UnauthorizedRequestError(); + } + + switch (role) { + case ADMIN: + return { permission: adminPermissions }; + case MEMBER: + return { permission: memberPermissions }; + case NO_ACCESS: + return { permission: noAccessPermissions }; + case CUSTOM: { + if (!customRole) throw UnauthorizedRequestError(); + return { + permission: createMongoAbility( + customRole.permissions, + { conditionsMatcher } + ) + }; + } + } +} + +export const getOrgRolePermissions = async (role: string, orgId: string) => { + const isCustomRole = ![ADMIN, MEMBER, NO_ACCESS].includes(role); + if (isCustomRole) { + const orgRole = await Role.findOne({ + slug: role, + isOrgRole: true, + organization: new Types.ObjectId(orgId) + }); + + if (!orgRole) throw BadRequestError({ message: "Org Role not found" }); + + return createMongoAbility(orgRole.permissions as RawRuleOf>[], { + conditionsMatcher + }); + } + + switch (role) { + case ADMIN: + return adminPermissions; + case MEMBER: + return memberPermissions; + case NO_ACCESS: + return noAccessPermissions; + default: + throw BadRequestError({ message: "User org role not found" }); + } +} + +/** + * Extracts and formats permissions from a CASL Ability object or a raw permission set. + * @param ability + * @returns + */ +const extractPermissions = (ability: any) => { + return ability.A.map((permission: any) => `${permission.action}_${permission.subject}`); +} + +/** + * Compares two sets of permissions to determine if the first set is at least as privileged as the second set. + * The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions. + * +*/ +export const isAtLeastAsPrivilegedOrg = (permissions1: MongoAbility | OrgPermissionSet, permissions2: MongoAbility | OrgPermissionSet) => { + + const set1 = new Set(extractPermissions(permissions1)); + const set2 = new Set(extractPermissions(permissions2)); + + for (const perm of set2) { + if (!set1.has(perm)) { + return false; + } + } + + return set1.size >= set2.size; +} \ No newline at end of file diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 7700c21ee..d08d07a45 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -17,7 +17,7 @@ export const validateMembership = async ({ }: { userId: Types.ObjectId | string; workspaceId: Types.ObjectId | string; - acceptedRoles?: Array<"admin" | "member" | "custom" | "viewer">; + acceptedRoles?: Array<"admin" | "member" | "custom" | "viewer" | "no-access">; }) => { const membership = await Membership.findOne({ user: userId, diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index 46f5fbf56..9f2e8d93c 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -18,7 +18,7 @@ export const validateMembershipOrg = async ({ }: { userId: Types.ObjectId; organizationId: Types.ObjectId; - acceptedRoles?: Array<"owner" | "admin" | "member" | "custom">; + acceptedRoles?: Array<"owner" | "admin" | "member" | "custom" | "no-access">; acceptedStatuses?: Array<"invited" | "accepted">; }) => { const membershipOrg = await MembershipOrg.findOne({ diff --git a/backend/src/helpers/organization.ts b/backend/src/helpers/organization.ts index 4b2ac369b..36d1baeb6 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -4,6 +4,11 @@ import { BotKey, BotOrg, Folder, + Identity, + IdentityMembership, + IdentityMembershipOrg, + IdentityUniversalAuth, + IdentityUniversalAuthClientSecret, IncidentContactOrg, Integration, IntegrationAuth, @@ -16,8 +21,6 @@ import { SecretImport, ServiceToken, ServiceTokenData, - ServiceTokenDataV3, - ServiceTokenDataV3Key, Tag, Webhook, Workspace @@ -123,6 +126,32 @@ export const deleteOrganization = async ({ await MembershipOrg.deleteMany({ organization: organization._id }); + + const identityIds = await IdentityMembershipOrg.distinct("identity", { + organization: organization._id + }); + + await IdentityMembershipOrg.deleteMany({ + organization: organization._id + }); + + await Identity.deleteMany({ + _id: { + $in: identityIds + } + }); + + await IdentityUniversalAuth.deleteMany({ + identity: { + $in: identityIds + } + }); + + await IdentityUniversalAuthClientSecret.deleteMany({ + identity: { + $in: identityIds + } + }); await BotOrg.deleteMany({ organization: organization._id @@ -268,13 +297,7 @@ export const deleteOrganization = async ({ } }); - await ServiceTokenDataV3.deleteMany({ - workspace: { - $in: workspaceIds - } - }); - - await ServiceTokenDataV3Key.deleteMany({ + await IdentityMembership.deleteMany({ workspace: { $in: workspaceIds } diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 60e4955b5..3a031a066 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -3,6 +3,7 @@ import { Bot, BotKey, Folder, + IdentityMembership, Integration, IntegrationAuth, Key, @@ -12,8 +13,6 @@ import { SecretImport, ServiceToken, ServiceTokenData, - ServiceTokenDataV3, - ServiceTokenDataV3Key, Tag, Webhook, Workspace @@ -178,12 +177,8 @@ export const deleteWorkspace = async ({ await ServiceTokenData.deleteMany({ workspace: workspace._id }); - - await ServiceTokenDataV3.deleteMany({ - workspace: workspace._id - }); - - await ServiceTokenDataV3Key.deleteMany({ + + await IdentityMembership.deleteMany({ workspace: workspace._id }); diff --git a/backend/src/index.ts b/backend/src/index.ts index ad25f7821..cdf2ac8e5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,7 @@ import { secretSnapshot as eeSecretSnapshotRouter, users as eeUsersRouter, workspace as eeWorkspaceRouter, + identities as v1IdentitiesRouter, roles as v1RoleRouter, secretApprovalPolicy as v1SecretApprovalPolicyRouter, secretApprovalRequest as v1SecretApprovalRequestRouter, @@ -33,7 +34,6 @@ import { secretScanning as v1SecretScanningRouter } from "./ee/routes/v1"; import { apiKeyData as v3apiKeyDataRouter } from "./ee/routes/v3"; -import { serviceTokenData as v3ServiceTokenDataRouter } from "./ee/routes/v3"; import { admin as v1AdminRouter, auth as v1AuthRouter, @@ -52,6 +52,7 @@ import { secretsFolder as v1SecretsFolder, serviceToken as v1ServiceTokenRouter, signup as v1SignupRouter, + universalAuth as v1UniversalAuthRouter, userAction as v1UserActionRouter, user as v1UserRouter, webhooks as v1WebhooksRouter, @@ -198,6 +199,7 @@ const main = async () => { } // (EE) routes + app.use("/api/v1/identities", v1IdentitiesRouter); app.use("/api/v1/secret", eeSecretRouter); app.use("/api/v1/secret-snapshot", eeSecretSnapshotRouter); app.use("/api/v1/users", eeUsersRouter); @@ -205,14 +207,14 @@ const main = async () => { app.use("/api/v1/organizations", eeOrganizationsRouter); app.use("/api/v1/sso", eeSSORouter); app.use("/api/v1/cloud-products", eeCloudProductsRouter); - app.use("/api/v3/api-key", v3apiKeyDataRouter); // new - app.use("/api/v3/service-token", v3ServiceTokenDataRouter); // new + app.use("/api/v3/api-key", v3apiKeyDataRouter); app.use("/api/v1/secret-rotation-providers", v1SecretRotationProviderRouter); app.use("/api/v1/secret-rotations", v1SecretRotation); // v1 routes app.use("/api/v1/signup", v1SignupRouter); app.use("/api/v1/auth", v1AuthRouter); + app.use("/api/v1/auth", v1UniversalAuthRouter); // new app.use("/api/v1/admin", v1AdminRouter); app.use("/api/v1/bot", v1BotRouter); app.use("/api/v1/user", v1UserRouter); @@ -220,7 +222,7 @@ const main = async () => { app.use("/api/v1/organization", v1OrganizationRouter); app.use("/api/v1/workspace", v1WorkspaceRouter); app.use("/api/v1/membership-org", v1MembershipOrgRouter); - app.use("/api/v1/membership", v1MembershipRouter); // + app.use("/api/v1/membership", v1MembershipRouter); app.use("/api/v1/key", v1KeyRouter); app.use("/api/v1/invite-org", v1InviteOrgRouter); app.use("/api/v1/secret", v1SecretRouter); // deprecate @@ -247,7 +249,7 @@ const main = async () => { app.use("/api/v2/workspace", v2TagsRouter); app.use("/api/v2/workspace", v2WorkspaceRouter); app.use("/api/v2/secret", v2SecretRouter); // deprecate - app.use("/api/v2/secrets", v2SecretsRouter); // note: in the process of moving to v3/secrets + app.use("/api/v2/secrets", v2SecretsRouter); app.use("/api/v2/service-token", v2ServiceTokenDataRouter); // v3 routes (experimental) diff --git a/backend/src/interfaces/middleware/index.ts b/backend/src/interfaces/middleware/index.ts index 0100d3ff6..acd992162 100644 --- a/backend/src/interfaces/middleware/index.ts +++ b/backend/src/interfaces/middleware/index.ts @@ -1,6 +1,6 @@ import { Types } from "mongoose"; -import { IServiceTokenData, IServiceTokenDataV3, IUser } from "../../models"; -import { ServiceActor, ServiceActorV3, UserActor, UserAgentType } from "../../ee/models"; +import { IIdentity, IServiceTokenData, IUser } from "../../models"; +import { IdentityActor, ServiceActor, UserActor, UserAgentType } from "../../ee/models"; interface BaseAuthData { ipAddress: string; @@ -14,9 +14,9 @@ export interface UserAuthData extends BaseAuthData { authPayload: IUser; } -export interface ServiceTokenV3AuthData extends BaseAuthData { - actor: ServiceActorV3; - authPayload: IServiceTokenDataV3; +export interface IdentityAuthData extends BaseAuthData { + actor: IdentityActor; + authPayload: IIdentity; } export interface ServiceTokenAuthData extends BaseAuthData { @@ -24,4 +24,4 @@ export interface ServiceTokenAuthData extends BaseAuthData { authPayload: IServiceTokenData; } -export type AuthData = UserAuthData | ServiceTokenV3AuthData | ServiceTokenAuthData; +export type AuthData = UserAuthData | IdentityAuthData | ServiceTokenAuthData; \ No newline at end of file diff --git a/backend/src/middleware/requestErrorHandler.ts b/backend/src/middleware/requestErrorHandler.ts index 725fc4955..99417d030 100644 --- a/backend/src/middleware/requestErrorHandler.ts +++ b/backend/src/middleware/requestErrorHandler.ts @@ -39,8 +39,9 @@ export const requestErrorHandler: ErrorRequestHandler = async ( Sentry.captureException(error); - delete (error).stacktrace // remove stack trace from being sent to client - res.status((error).statusCode).json(error); // revise json part here + res.status((error).statusCode).send( + await error.format(req) + ); next(); }; diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 929d22fea..e68de3133 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -50,7 +50,7 @@ const requireAuth = ({ case AuthMode.SERVICE_TOKEN: req.serviceTokenData = authData.authPayload; break; - case AuthMode.SERVICE_ACCESS_TOKEN: + case AuthMode.IDENTITY_ACCESS_TOKEN: req.serviceTokenData = authData.authPayload; break; case AuthMode.API_KEY: diff --git a/backend/src/models/identity.ts b/backend/src/models/identity.ts new file mode 100644 index 000000000..ec4948e1b --- /dev/null +++ b/backend/src/models/identity.ts @@ -0,0 +1,38 @@ +import { Document, Schema, Types, model } from "mongoose"; +import { IPType } from "../ee/models"; + +export interface IIdentityTrustedIp { + ipAddress: string; + type: IPType; + prefix: number; +} + +export enum IdentityAuthMethod { + UNIVERSAL_AUTH = "universal-auth" +} + +export interface IIdentity extends Document { + _id: Types.ObjectId; + name: string; + authMethod?: IdentityAuthMethod; +} + +const identitySchema = new Schema( + { + name: { + type: String, + required: true + }, + authMethod: { + type: String, + enum: IdentityAuthMethod, + required: false, + }, + + }, + { + timestamps: true + } +); + +export const Identity = model("Identity", identitySchema); diff --git a/backend/src/models/identityAccessToken.ts b/backend/src/models/identityAccessToken.ts new file mode 100644 index 000000000..7acdb2dbe --- /dev/null +++ b/backend/src/models/identityAccessToken.ts @@ -0,0 +1,104 @@ +import { Document, Schema, Types, model } from "mongoose"; +import { IIdentityTrustedIp } from "./identity"; +import { IPType } from "../ee/models/trustedIp"; + +export interface IIdentityAccessToken extends Document { + _id: Types.ObjectId; + identity: Types.ObjectId; + identityUniversalAuthClientSecret?: Types.ObjectId; + accessTokenLastUsedAt?: Date; + accessTokenLastRenewedAt?: Date; + accessTokenNumUses: number; + accessTokenNumUsesLimit: number; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenTrustedIps: Array; + isAccessTokenRevoked: boolean; + updatedAt: Date; + createdAt: Date; +} + +const identityAccessTokenSchema = new Schema( + { + identity: { + type: Schema.Types.ObjectId, + ref: "Identity", + required: false + }, + identityUniversalAuthClientSecret: { + type: Schema.Types.ObjectId, + ref: "IdentityUniversalAuthClientSecret", + required: false + }, + accessTokenLastUsedAt: { + type: Date, + required: false + }, + accessTokenLastRenewedAt: { + type: Date, + required: false + }, + accessTokenNumUses: { + // number of times access token has been used + type: Number, + default: 0, + required: true + }, + accessTokenNumUsesLimit: { + // number of times access token can be used for + type: Number, + default: 0, // default: used as many times as needed + required: true + }, + accessTokenTTL: { // seconds + // incremental lifetime + type: Number, + default: 7200, + required: true + }, + accessTokenMaxTTL: { // seconds + // max lifetime + type: Number, + default: 7200, + required: true + }, + accessTokenTrustedIps: { + 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 + }, + isAccessTokenRevoked: { + type: Boolean, + default: false, + required: true + }, + }, + { + timestamps: true + } +); + +export const IdentityAccessToken = model("IdentityAccessToken", identityAccessTokenSchema); diff --git a/backend/src/models/identityMembership.ts b/backend/src/models/identityMembership.ts new file mode 100644 index 000000000..4fedfe909 --- /dev/null +++ b/backend/src/models/identityMembership.ts @@ -0,0 +1,39 @@ +import { Schema, Types, model } from "mongoose"; +import { ADMIN, CUSTOM, MEMBER, NO_ACCESS, VIEWER } from "../variables"; + +export interface IIdentityMembership { + _id: Types.ObjectId; + identity: Types.ObjectId; + workspace: Types.ObjectId; + role: "admin" | "member" | "viewer" | "no-access" | "custom"; + customRole: Types.ObjectId; +} + +const identityMembershipSchema = new Schema( + { + identity: { + type: Schema.Types.ObjectId, + ref: "Identity" + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true, + index: true, + }, + role: { + type: String, + enum: [ADMIN, MEMBER, VIEWER, CUSTOM, NO_ACCESS], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } +); + +export const IdentityMembership = model("IdentityMembership", identityMembershipSchema); \ No newline at end of file diff --git a/backend/src/models/identityMembershipOrg.ts b/backend/src/models/identityMembershipOrg.ts new file mode 100644 index 000000000..8da8693c4 --- /dev/null +++ b/backend/src/models/identityMembershipOrg.ts @@ -0,0 +1,37 @@ +import { Schema, Types, model } from "mongoose"; +import { ADMIN, CUSTOM, MEMBER, NO_ACCESS} from "../variables"; + +export interface IIdentityMembershipOrg { + _id: Types.ObjectId; + identity: Types.ObjectId; + organization: Types.ObjectId; + role: "admin" | "member" | "no-access" | "custom"; + customRole: Types.ObjectId; +} + +const identityMembershipOrgSchema = new Schema( + { + identity: { + type: Schema.Types.ObjectId, + ref: "Identity" + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization" + }, + role: { + type: String, + enum: [ADMIN, MEMBER, NO_ACCESS, CUSTOM], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } +); + +export const IdentityMembershipOrg = model("IdentityMembershipOrg", identityMembershipOrgSchema); \ No newline at end of file diff --git a/backend/src/models/identityUniversalAuth.ts b/backend/src/models/identityUniversalAuth.ts new file mode 100644 index 000000000..89fb46a95 --- /dev/null +++ b/backend/src/models/identityUniversalAuth.ts @@ -0,0 +1,107 @@ +import { Document, Schema, Types, model } from "mongoose"; +import { IPType } from "../ee/models"; +import { IIdentityTrustedIp } from "./identity"; + +export interface IIdentityUniversalAuth extends Document { + _id: Types.ObjectId; + identity: Types.ObjectId; + clientId: string; + clientSecretTrustedIps: Array; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; +} + +const identityUniversalAuthSchema = new Schema( + { + identity: { + type: Schema.Types.ObjectId, + ref: "Identity", + required: true + }, + clientId: { + type: String, + required: true + }, + clientSecretTrustedIps: { + type: [ + { + ipAddress: { + type: String, + required: true + }, + type: { + type: String, + enum: [ + IPType.IPV4, + IPType.IPV6 + ], + required: true + }, + prefix: { + type: Number, + required: false + } + } + ], + default: [{ + ipAddress: "0.0.0.0", + type: IPType.IPV4.toString(), + prefix: 0 + }], + required: true + }, + accessTokenTTL: { // seconds + // incremental lifetime + type: Number, + default: 7200, + required: true + }, + accessTokenMaxTTL: { // seconds + // max lifetime + type: Number, + default: 7200, + required: true + }, + accessTokenNumUsesLimit: { + // number of times access token can be used for + type: Number, + default: 0, // default: used as many times as needed + required: true + }, + accessTokenTrustedIps: { + 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 + } + }, + { + timestamps: true + } +); + +export const IdentityUniversalAuth = model("IdentityUniversalAuth", identityUniversalAuthSchema); \ No newline at end of file diff --git a/backend/src/models/identityUniversalAuthClientSecret.ts b/backend/src/models/identityUniversalAuthClientSecret.ts new file mode 100644 index 000000000..af9cc08a4 --- /dev/null +++ b/backend/src/models/identityUniversalAuthClientSecret.ts @@ -0,0 +1,81 @@ +import { Document, Schema, Types, model } from "mongoose"; + +export interface IIdentityUniversalAuthClientSecret extends Document { + _id: Types.ObjectId; + identity: Types.ObjectId; + identityUniversalAuth : Types.ObjectId; + description: string; + clientSecretPrefix: string; + clientSecretHash: string; + clientSecretLastUsedAt?: Date; + clientSecretNumUses: number; + clientSecretNumUsesLimit: number; + clientSecretTTL: number; + updatedAt: Date; + createdAt: Date; + isClientSecretRevoked: boolean; +} + +const identityUniversalAuthClientSecretSchema = new Schema( + { + identity: { + type: Schema.Types.ObjectId, + ref: "Identity", + required: true + }, + identityUniversalAuth: { + type: Schema.Types.ObjectId, + ref: "IdentityUniversalAuth", + required: true + }, + description: { + type: String, + required: true + }, + clientSecretPrefix: { + type: String, + required: true + }, + clientSecretHash: { + type: String, + required: true + }, + clientSecretLastUsedAt: { + type: Date, + required: false + }, + clientSecretNumUses: { + // number of times client secret has been used + // in login operation + type: Number, + default: 0, + required: true + }, + clientSecretNumUsesLimit: { + // number of times client secret can be used for + // a login operation + type: Number, + default: 0, // default: used as many times as needed + required: true + }, + clientSecretTTL: { + type: Number, + default: 0, // default: does not expire + required: true + }, + isClientSecretRevoked: { + type: Boolean, + default: false, + required: true + } + }, + { + timestamps: true + } +); + +identityUniversalAuthClientSecretSchema.index( + { identityUniversalAuth: 1, isClientSecretRevoked: 1 } +); + +export const IdentityUniversalAuthClientSecret = model("IdentityUniversalAuthClientSecret", identityUniversalAuthClientSecretSchema); \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a437541b1..9d20ea67a 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -20,8 +20,15 @@ export * from "./user"; export * from "./userAction"; export * from "./workspace"; export * from "./serviceTokenData"; // TODO: deprecate -export * from "./serviceTokenDataV3"; -export * from "./serviceTokenDataV3Key"; + +// new +export * from "./identity"; +export * from "./identityMembership"; +export * from "./identityMembershipOrg"; +export * from "./identityUniversalAuth"; +export * from "./identityUniversalAuthClientSecret"; +export * from "./identityAccessToken"; + export * from "./apiKeyData"; // TODO: deprecate export * from "./apiKeyDataV2"; export * from "./loginSRPDetail"; diff --git a/backend/src/models/membership.ts b/backend/src/models/membership.ts index 22a3819e2..c09fa2779 100644 --- a/backend/src/models/membership.ts +++ b/backend/src/models/membership.ts @@ -1,5 +1,5 @@ import { Schema, Types, model } from "mongoose"; -import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables"; +import { ADMIN, CUSTOM, MEMBER, NO_ACCESS, VIEWER } from "../variables"; export interface IMembershipPermission { environmentSlug: string; @@ -11,7 +11,7 @@ export interface IMembership { user: Types.ObjectId; inviteEmail?: string; workspace: Types.ObjectId; - role: "admin" | "member" | "viewer" | "custom"; + role: "admin" | "member" | "viewer" | "no-access" | "custom"; customRole: Types.ObjectId; deniedPermissions: IMembershipPermission[]; } @@ -44,7 +44,7 @@ const membershipSchema = new Schema( }, role: { type: String, - enum: [ADMIN, MEMBER, VIEWER, CUSTOM], + enum: [ADMIN, MEMBER, VIEWER, NO_ACCESS, CUSTOM], required: true }, customRole: { diff --git a/backend/src/models/membershipOrg.ts b/backend/src/models/membershipOrg.ts index 09b16be84..0d4a2f6b7 100644 --- a/backend/src/models/membershipOrg.ts +++ b/backend/src/models/membershipOrg.ts @@ -1,12 +1,12 @@ import { Document, Schema, Types, model } from "mongoose"; -import { ACCEPTED, ADMIN, CUSTOM, INVITED, MEMBER } from "../variables"; +import { ACCEPTED, ADMIN, CUSTOM, INVITED, MEMBER, NO_ACCESS } from "../variables"; export interface IMembershipOrg extends Document { _id: Types.ObjectId; user: Types.ObjectId; inviteEmail: string; organization: Types.ObjectId; - role: "owner" | "admin" | "member" | "custom"; + role: "admin" | "member" | "no-access" | "custom"; customRole: Types.ObjectId; status: "invited" | "accepted"; } @@ -26,7 +26,7 @@ const membershipOrgSchema = new Schema( }, role: { type: String, - enum: [ADMIN, MEMBER, CUSTOM], + enum: [ADMIN, MEMBER, NO_ACCESS, CUSTOM], required: true }, status: { diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts deleted file mode 100644 index fb39613d6..000000000 --- a/backend/src/models/serviceTokenDataV3.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; -import { IPType } from "../ee/models"; -import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables"; - -export interface IServiceTokenV3TrustedIp { - ipAddress: string; - type: IPType; - prefix: number; -} - -export interface IServiceTokenDataV3 extends Document { - _id: Types.ObjectId; - name: string; - workspace: Types.ObjectId; - user: Types.ObjectId; - publicKey: string; - isActive: boolean; - refreshTokenLastUsed?: Date; - accessTokenLastUsed?: Date; - refreshTokenUsageCount: number; - accessTokenUsageCount: number; - tokenVersion: number; - isRefreshTokenRotationEnabled: boolean; - expiresAt?: Date; - accessTokenTTL: number; - role: "admin" | "member" | "viewer" | "custom"; - customRole: Types.ObjectId; - trustedIps: Array; -} - -const serviceTokenDataV3Schema = new Schema( - { - name: { - type: String, - required: true - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true - }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true - }, - publicKey: { - type: String, - required: true - }, - isActive: { - type: Boolean, - default: true, - required: true - }, - refreshTokenLastUsed: { - type: Date, - required: false - }, - accessTokenLastUsed: { - type: Date, - required: false - }, - refreshTokenUsageCount: { - type: Number, - default: 0, - required: true - }, - accessTokenUsageCount: { - type: Number, - default: 0, - required: true - }, - tokenVersion: { - type: Number, - default: 1, - required: true - }, - isRefreshTokenRotationEnabled: { - type: Boolean, - default: false, - required: true - }, - expiresAt: { // consider revising field name - type: Date, - required: false, - // expires: 0 - }, - accessTokenTTL: { // seconds - type: Number, - default: 7200, - required: true - }, - role: { - type: String, - enum: [ADMIN, MEMBER, VIEWER, CUSTOM], - required: true - }, - customRole: { - type: Schema.Types.ObjectId, - ref: "Role" - }, - 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 - } - }, - { - timestamps: true - } -); - -export const ServiceTokenDataV3 = model("ServiceTokenDataV3", serviceTokenDataV3Schema); \ No newline at end of file diff --git a/backend/src/models/serviceTokenDataV3Key.ts b/backend/src/models/serviceTokenDataV3Key.ts deleted file mode 100644 index 7a69abc1e..000000000 --- a/backend/src/models/serviceTokenDataV3Key.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface IServiceTokenDataV3Key extends Document { - _id: Types.ObjectId; - encryptedKey: string; - nonce: string; - sender: Types.ObjectId; - serviceTokenData: Types.ObjectId; - workspace: Types.ObjectId; -} - -const serviceTokenDataV3KeySchema = new Schema( - { - encryptedKey: { - type: String, - required: true - }, - nonce: { - type: String, - required: true - }, - sender: { - type: Schema.Types.ObjectId, - ref: "User", - required: true - }, - serviceTokenData: { - type: Schema.Types.ObjectId, - ref: "ServiceTokenDataV3", - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - } - }, - { - timestamps: true - } -); - -export const ServiceTokenDataV3Key = model("ServiceTokenDataV3Key", serviceTokenDataV3KeySchema); \ No newline at end of file diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index c19ead569..8bc4642af 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -1,6 +1,7 @@ import signup from "./signup"; import bot from "./bot"; import auth from "./auth"; +import universalAuth from "./universalAuth"; import user from "./user"; import userAction from "./userAction"; import organization from "./organization"; @@ -23,6 +24,7 @@ import admin from "./admin"; export { signup, auth, + universalAuth, bot, user, userAction, diff --git a/backend/src/routes/v1/universalAuth.ts b/backend/src/routes/v1/universalAuth.ts new file mode 100644 index 000000000..d232f0ee8 --- /dev/null +++ b/backend/src/routes/v1/universalAuth.ts @@ -0,0 +1,66 @@ + +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; +import { universalAuthController } from "../../controllers/v1"; +import { AuthMode } from "../../variables"; + +router.post( + "/token/renew", + universalAuthController.renewAccessToken +); + +router.post( + "/universal-auth/login", + universalAuthController.loginIdentityUniversalAuth +); + +router.post( + "/universal-auth/identities/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + universalAuthController.addIdentityUniversalAuth +); + +router.patch( + "/universal-auth/identities/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + universalAuthController.updateIdentityUniversalAuth +); + +router.get( + "/universal-auth/identities/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + universalAuthController.getIdentityUniversalAuth +); + +router.post( + "/universal-auth/identities/:identityId/client-secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + universalAuthController.createUniversalAuthClientSecret +); + +router.get( + "/universal-auth/identities/:identityId/client-secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + universalAuthController.getUniversalAuthClientSecrets +); + +router.post( + "/universal-auth/identities/:identityId/client-secrets/:clientSecretId/revoke", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + universalAuthController.revokeUniversalAuthClientSecret +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index 6eea6fc5a..cfe1c4d6e 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/identity-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationIdentityMemberships +); + export default router; diff --git a/backend/src/routes/v2/serviceTokenData.ts b/backend/src/routes/v2/serviceTokenData.ts index c959cfa56..2a0760f50 100644 --- a/backend/src/routes/v2/serviceTokenData.ts +++ b/backend/src/routes/v2/serviceTokenData.ts @@ -6,7 +6,7 @@ import { import { AuthMode } from "../../variables"; import { serviceTokenDataController } from "../../controllers/v2"; -router.get( // TODO: deprecate (moving to ST V3) +router.get( // TODO: deprecate (moving to identity) "/", requireAuth({ acceptedAuthModes: [AuthMode.SERVICE_TOKEN] @@ -14,7 +14,7 @@ router.get( // TODO: deprecate (moving to ST V3) serviceTokenDataController.getServiceTokenData ); -router.post( // TODO: deprecate (moving to ST V3) +router.post( // TODO: deprecate (moving to identity) "/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] @@ -22,7 +22,7 @@ router.post( // TODO: deprecate (moving to ST V3) serviceTokenDataController.createServiceTokenData ); -router.delete( // TODO: deprecate (moving to ST V3) +router.delete( // TODO: deprecate (moving to identity) "/:serviceTokenDataId", requireAuth({ acceptedAuthModes: [AuthMode.JWT] diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index 304f69b34..59b7647e1 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -93,4 +93,37 @@ router.patch( workspaceController.toggleAutoCapitalization ); +router.post( + "/:workspaceId/identity-memberships/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.addIdentityToWorkspace +); + +router.patch( + "/:workspaceId/identity-memberships/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.updateIdentityWorkspaceRole +); + +router.delete( + "/:workspaceId/identity-memberships/:identityId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.deleteIdentityFromWorkspace +); + +router.get( + "/:workspaceId/identity-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceIdentityMemberships +); + + export default router; diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 81e0cb4c0..be6daba2c 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -7,7 +7,7 @@ import { AuthMode } from "../../variables"; router.get( "/raw", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN] }), secretsController.getSecretsRaw ); @@ -15,7 +15,7 @@ router.get( router.get( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -29,7 +29,7 @@ router.get( router.post( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -43,7 +43,7 @@ router.post( router.patch( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -57,7 +57,7 @@ router.patch( router.delete( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -71,7 +71,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -116,7 +116,7 @@ router.delete( router.post( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -127,7 +127,7 @@ router.post( router.get( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -138,7 +138,7 @@ router.get( router.patch( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -149,7 +149,7 @@ router.patch( router.delete( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" 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/utils/authn/authModeValidators/identity.ts b/backend/src/utils/authn/authModeValidators/identity.ts new file mode 100644 index 000000000..c85227e32 --- /dev/null +++ b/backend/src/utils/authn/authModeValidators/identity.ts @@ -0,0 +1,104 @@ +import jwt from "jsonwebtoken"; +import { IIdentity, IdentityAccessToken } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { UnauthorizedRequestError } from "../../errors"; +import { checkIPAgainstBlocklist } from "../../../utils/ip"; + +interface ValidateIdentityParams { + authTokenValue: string; + ipAddress: string; +} + +export const validateIdentity = async ({ + authTokenValue, + ipAddress +}: ValidateIdentityParams) => { + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN) throw UnauthorizedRequestError(); + + const identityAccessToken = await IdentityAccessToken + .findOne({ + _id: decodedToken.identityAccessTokenId, + isAccessTokenRevoked: false + }) + .populate<{ identity: IIdentity }>("identity"); + + if (!identityAccessToken || !identityAccessToken?.identity) throw UnauthorizedRequestError(); + + const { + accessTokenNumUsesLimit, + accessTokenNumUses, + accessTokenTTL, + accessTokenLastRenewedAt, + accessTokenMaxTTL, + createdAt: accessTokenCreatedAt + } = identityAccessToken; + + checkIPAgainstBlocklist({ + ipAddress, + trustedIps: identityAccessToken.accessTokenTrustedIps + }); + + // ttl check + if (accessTokenTTL > 0) { + const currentDate = new Date(); + if (accessTokenLastRenewedAt) { + // access token has been renewed + const accessTokenRenewed = new Date(accessTokenLastRenewedAt); + const ttlInMilliseconds = accessTokenTTL * 1000; + const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to authenticate identity access token due to TTL expiration" + }); + } else { + // access token has never been renewed + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = accessTokenTTL * 1000; + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to authenticate identity access token due to TTL expiration" + }); + } + } + + // max ttl check + if (accessTokenMaxTTL > 0) { + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = accessTokenMaxTTL * 1000; + const currentDate = new Date(); + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) throw UnauthorizedRequestError({ + message: "Failed to authenticate identity access token due to Max TTL expiration" + }); + } + + // num uses check + if ( + accessTokenNumUsesLimit > 0 + && accessTokenNumUses === accessTokenNumUsesLimit + ) { + throw UnauthorizedRequestError({ + message: "Failed to authenticate MI access token due to access token number of uses limit reached" + }); + } + + await IdentityAccessToken.findByIdAndUpdate( + identityAccessToken._id, + { + accessTokenLastUsedAt: new Date(), + $inc: { accessTokenNumUses: 1 } + }, + { + new: true + } + ); + + return identityAccessToken.identity; +} \ No newline at end of file diff --git a/backend/src/utils/authn/authModeValidators/index.ts b/backend/src/utils/authn/authModeValidators/index.ts index 0ac4c3c1c..170a8ce59 100644 --- a/backend/src/utils/authn/authModeValidators/index.ts +++ b/backend/src/utils/authn/authModeValidators/index.ts @@ -2,4 +2,4 @@ export * from "./apiKey"; export * from "./apiKeyV2"; export * from "./jwt"; export * from "./serviceTokenV2"; -export * from "./serviceTokenV3"; \ No newline at end of file +export * from "./identity"; \ No newline at end of file diff --git a/backend/src/utils/authn/authModeValidators/serviceTokenV3.ts b/backend/src/utils/authn/authModeValidators/serviceTokenV3.ts deleted file mode 100644 index 330f60c1c..000000000 --- a/backend/src/utils/authn/authModeValidators/serviceTokenV3.ts +++ /dev/null @@ -1,64 +0,0 @@ -import jwt from "jsonwebtoken"; -import { Types } from "mongoose"; -import { ServiceTokenDataV3 } from "../../../models"; -import { getAuthSecret } from "../../../config"; -import { AuthTokenType } from "../../../variables"; -import { UnauthorizedRequestError } from "../../errors"; - -interface ValidateServiceTokenV3Params { - authTokenValue: string; -} - -export const validateServiceTokenV3 = async ({ - authTokenValue -}: ValidateServiceTokenV3Params) => { - const decodedToken = ( - jwt.verify(authTokenValue, await getAuthSecret()) - ); - - if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError(); - - const serviceTokenData = await ServiceTokenDataV3.findOne({ - _id: new Types.ObjectId(decodedToken.serviceTokenDataId), - isActive: true - }); - - if (!serviceTokenData) { - throw UnauthorizedRequestError({ - message: "Failed to authenticate" - }); - } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { - // case: service token expired - await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - isActive: false - }, - { - new: true - } - ); - - throw UnauthorizedRequestError({ - message: "Failed to authenticate", - }); - } else if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) { - // TODO: raise alarm - throw UnauthorizedRequestError({ - message: "Failed to authenticate", - }); - } - - await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - accessTokenLastUsed: new Date(), - $inc: { accessTokenUsageCount: 1 } - }, - { - new: true - } - ); - - return serviceTokenData; -} \ No newline at end of file diff --git a/backend/src/utils/authn/helpers/authDataExtractors.ts b/backend/src/utils/authn/helpers/authDataExtractors.ts index d35163e03..e928108c3 100644 --- a/backend/src/utils/authn/helpers/authDataExtractors.ts +++ b/backend/src/utils/authn/helpers/authDataExtractors.ts @@ -1,7 +1,7 @@ import { AuthData } from "../../../interfaces/middleware"; import { + Identity, ServiceTokenData, - ServiceTokenDataV3, User } from "../../../models"; @@ -19,7 +19,7 @@ import { return { serviceTokenDataId: authData.authPayload._id }; } - if (authData.authPayload instanceof ServiceTokenDataV3) { + if (authData.authPayload instanceof Identity) { return { serviceTokenDataId: authData.authPayload._id }; } }; @@ -38,7 +38,7 @@ export const getAuthDataPayloadUserObj = (authData: AuthData) => { return { user: authData.authPayload.user }; } - if (authData.authPayload instanceof ServiceTokenDataV3) { - return { user: authData.authPayload.user }; + if (authData.authPayload instanceof Identity) { + return {}; } } \ No newline at end of file diff --git a/backend/src/utils/authn/helpers/index.ts b/backend/src/utils/authn/helpers/index.ts index 064efe133..7f3998e57 100644 --- a/backend/src/utils/authn/helpers/index.ts +++ b/backend/src/utils/authn/helpers/index.ts @@ -7,9 +7,9 @@ import { UnauthorizedRequestError } from "../../errors"; import { validateAPIKey, validateAPIKeyV2, + validateIdentity, validateJWT, - validateServiceTokenV2, - validateServiceTokenV3 + validateServiceTokenV2 } from "../authModeValidators"; import { getUserAgentType } from "../../posthog"; @@ -36,7 +36,7 @@ interface GetAuthDataParams { * - SERVICE_TOKEN * - API_KEY * - JWT - * - SERVICE_ACCESS_TOKEN (from ST V3) + * - IDENTITY_ACCESS_TOKEN (from identity) * - API_KEY_V2 * @param {Object} params * @param {Object.} params.headers - The HTTP request headers, usually from Express's `req.headers`. @@ -77,8 +77,8 @@ export const extractAuthMode = async ({ return { authMode: AuthMode.JWT, authTokenValue }; case AuthTokenType.API_KEY: return { authMode: AuthMode.API_KEY_V2, authTokenValue }; - case AuthTokenType.SERVICE_ACCESS_TOKEN: - return { authMode: AuthMode.SERVICE_ACCESS_TOKEN, authTokenValue }; + case AuthTokenType.IDENTITY_ACCESS_TOKEN: + return { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, authTokenValue }; default: throw UnauthorizedRequestError({ message: "Failed to authenticate unknown authentication method" @@ -115,20 +115,21 @@ export const getAuthData = async ({ userAgentType } } - case AuthMode.SERVICE_ACCESS_TOKEN: { - const serviceTokenData = await validateServiceTokenV3({ - authTokenValue + case AuthMode.IDENTITY_ACCESS_TOKEN: { + const identity = await validateIdentity({ + authTokenValue, + ipAddress }); return { actor: { - type: ActorType.SERVICE_V3, + type: ActorType.IDENTITY, metadata: { - serviceId: serviceTokenData._id.toString(), - name: serviceTokenData.name + identityId: identity._id.toString(), + name: identity.name } }, - authPayload: serviceTokenData, + authPayload: identity, ipAddress, userAgent, userAgentType diff --git a/backend/src/utils/requestError.ts b/backend/src/utils/requestError.ts index f5f320b8d..7e4625e30 100644 --- a/backend/src/utils/requestError.ts +++ b/backend/src/utils/requestError.ts @@ -53,9 +53,10 @@ export default class RequestError extends Error { ){ super(message) - this._logLevel = logLevel || LogLevel.INFO + this._logLevel = logLevel || LogLevel.INFO; this._logName = LogLevel[this._logLevel]; - this.statusCode = statusCode + this.statusCode = statusCode; + this.message = message; this.type = type this.context = context || {} this.extra = [] diff --git a/backend/src/validation/auth.ts b/backend/src/validation/auth.ts index 071ad5e49..84fea54b6 100644 --- a/backend/src/validation/auth.ts +++ b/backend/src/validation/auth.ts @@ -84,6 +84,99 @@ export const ResetPasswordV1 = z.object({ }) }); +export const RenewAccessTokenV1 = z.object({ + body: z.object({ + accessToken: z.string().trim(), + }) +}); + +export const LoginUniversalAuthV1 = z.object({ + body: z.object({ + clientId: z.string().trim(), + clientSecret: z.string().trim() + }) +}); + +export const AddUniversalAuthToIdentityV1 = z.object({ + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + clientSecretTrustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }]), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }]), + accessTokenTTL: z.number().int().min(0).default(7200), + accessTokenMaxTTL: z.number().int().min(0).default(0), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }) +}); + +export const UpdateUniversalAuthToIdentityV1 = z.object({ + params: z.object({ + identityId: z.string() + }), + body: z.object({ + clientSecretTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z.number().int().min(0).default(0), + }), +}); + +export const GetUniversalAuthForIdentityV1 = z.object({ + params: z.object({ + identityId: z.string().trim() + }) +}); + +export const CreateUniversalAuthClientSecretV1 = z.object({ + params: z.object({ + identityId: z.string() + }), + body: z.object({ + description: z.string().trim().default(""), + numUsesLimit: z.number().min(0).default(0), + ttl: z.number().min(0).default(0), + }), +}); + +export const GetUniversalAuthClientSecretsV1 = z.object({ + params: z.object({ + identityId: z.string() + }) +}); + +export const RevokeUniversalAuthClientSecretV1 = z.object({ + params: z.object({ + identityId: z.string(), + clientSecretId: z.string() + }) +}); + export const VerifyMfaTokenV2 = z.object({ body: z.object({ mfaToken: z.string().trim() diff --git a/backend/src/validation/identities.ts b/backend/src/validation/identities.ts new file mode 100644 index 000000000..fa22fde34 --- /dev/null +++ b/backend/src/validation/identities.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { NO_ACCESS } from "../variables"; + +export const CreateIdentityV1 = z.object({ + body: z.object({ + name: z.string().trim(), + organizationId: z.string().trim(), + role: z.string().trim().min(1).default(NO_ACCESS) + }) +}); + +export const UpdateIdentityV1 = z.object({ + params: z.object({ + identityId: z.string() + }), + body: z.object({ + name: z.string().trim().optional(), + role: z.string().trim().min(1).optional() + }), +}); + +export const DeleteIdentityV1 = z.object({ + params: z.object({ + identityId: z.string() + }), +}); diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 002eda34d..e2027d41b 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -8,5 +8,5 @@ export * from "./membershipOrg"; export * from "./organization"; export * from "./secrets"; export * from "./serviceTokenData"; -export * from "./serviceTokenDataV3"; +export * from "./identities"; export * from "./apiKeyDataV3"; diff --git a/backend/src/validation/integrationAuth.ts b/backend/src/validation/integrationAuth.ts index 3eb51d833..7488109d0 100644 --- a/backend/src/validation/integrationAuth.ts +++ b/backend/src/validation/integrationAuth.ts @@ -58,9 +58,9 @@ const validateClientForIntegrationAuth = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for integration authorization" }); - case ActorType.SERVICE_V3: + case ActorType.IDENTITY: throw UnauthorizedRequestError({ - message: "Failed service token authorization for integration authorization" + message: "Failed identity authorization for integration authorization" }); } }; diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts index bddf76cb5..d0ab37057 100644 --- a/backend/src/validation/organization.ts +++ b/backend/src/validation/organization.ts @@ -46,9 +46,9 @@ export const validateClientForOrganization = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for organization" }); - case ActorType.SERVICE_V3: + case ActorType.IDENTITY: throw UnauthorizedRequestError({ - message: "Failed service token authorization for organization" + message: "Failed identity authorization for organization" }); } }; @@ -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 GetOrgIdentityMembershipsV2 = 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 deleted file mode 100644 index e4b2603b3..000000000 --- a/backend/src/validation/serviceTokenDataV3.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { z } from "zod"; -import { MEMBER } from "../variables"; - -export const RefreshTokenV3 = z.object({ - body: z.object({ - refresh_token: z.string().trim() - }) -}); - -export const CreateServiceTokenV3 = z.object({ - body: z.object({ - name: z.string().trim(), - workspaceId: z.string().trim(), - publicKey: z.string().trim(), - role: z.string().trim().min(1).default(MEMBER), - trustedIps: z // TODO: provide default - .object({ - ipAddress: z.string().trim(), - }) - .array() - .min(1) - .default([{ ipAddress: "0.0.0.0/0" }]), - expiresIn: z.number().optional(), - accessTokenTTL: z.number().int().min(1), - encryptedKey: z.string().trim(), - nonce: z.string().trim(), - isRefreshTokenRotationEnabled: z.boolean().default(false) - }) -}); - -export const UpdateServiceTokenV3 = z.object({ - params: z.object({ - serviceTokenDataId: z.string() - }), - body: z.object({ - name: z.string().trim().optional(), - isActive: z.boolean().optional(), - role: z.string().trim().min(1).optional(), - trustedIps: z - .object({ - ipAddress: z.string().trim() - }) - .array() - .min(1) - .optional(), - expiresIn: z.number().optional(), - accessTokenTTL: z.number().int().min(1).optional(), - isRefreshTokenRotationEnabled: z.boolean().optional() - }), -}); - -export const DeleteServiceTokenV3 = z.object({ - params: z.object({ - serviceTokenDataId: z.string() - }), -}); \ No newline at end of file diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index fe13ea4db..4c9a2183d 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 { NO_ACCESS } from "../variables"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -59,9 +60,9 @@ export const validateClientForWorkspace = async ({ requiredPermissions }); return { membership, workspace }; - case ActorType.SERVICE_V3: + case ActorType.IDENTITY: throw UnauthorizedRequestError({ - message: "Failed service token authorization for organization" + message: "Failed identity authorization for organization" }); } }; @@ -279,6 +280,39 @@ export const ToggleAutoCapitalizationV2 = z.object({ }) }); +export const AddIdentityToWorkspaceV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + identityId: z.string().trim() + }), + body: z.object({ + role: z.string().trim().min(1).default(NO_ACCESS), + }) +}); + +export const UpdateIdentityWorkspaceRoleV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + identityId: z.string().trim() + }), + body: z.object({ + role: z.string().trim().min(1).default(NO_ACCESS), + }) +}); + +export const DeleteIdentityFromWorkspaceV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + identityId: z.string().trim() + }) +}); + +export const GetWorkspaceIdentityMembersV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), +}); + export const GetWorkspaceBlinkIndexStatusV3 = z.object({ params: z.object({ workspaceId: z.string().trim() @@ -304,9 +338,3 @@ export const NameWorkspaceSecretsV3 = z.object({ .array() }) }); - -export const GetWorkspaceServiceTokenDataV3 = z.object({ - params: z.object({ - workspaceId: z.string().trim() - }) -}); diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index bdad84ff6..5eec0ba22 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -7,14 +7,13 @@ export enum AuthTokenType { MFA_TOKEN = "mfaToken", // TODO: remove in favor of claim PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim API_KEY = "apiKey", - SERVICE_ACCESS_TOKEN = "serviceAccessToken", - SERVICE_REFRESH_TOKEN = "serviceRefreshToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", } export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", - SERVICE_ACCESS_TOKEN = "serviceAccessToken", + IDENTITY_ACCESS_TOKEN = "identityAccessToken", API_KEY = "apiKey", API_KEY_V2 = "apiKeyV2" } diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 5e796e357..c8eb52863 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -3,6 +3,7 @@ export const OWNER = "owner"; // depreciated export const ADMIN = "admin"; export const MEMBER = "member"; export const VIEWER = "viewer"; +export const NO_ACCESS = "no-access"; export const CUSTOM = "custom"; // membership statuses diff --git a/docs/documentation/platform/identity.mdx b/docs/documentation/platform/identity.mdx new file mode 100644 index 000000000..c9c85e78c --- /dev/null +++ b/docs/documentation/platform/identity.mdx @@ -0,0 +1,168 @@ +--- +title: Identity +description: "Programmatically interact with Infisical" +--- + +A (machine) identity is an entity that you can create in Infisical. +Each identity represents a workload that wishes to access the Infisical API via an authentication method; this is similar to an IAM user in AWS or service account in GCP. + +An identity can be provisioned scoped access to resources at the organization or project-level via [role-based access controls (RBAC)](/documentation/platform/role-based-access-controls). For instance, you may create a identity with scoped access to +fetch secrets back from the `/` path of the `development` environment in some project. + + + The identity feature is in beta. + + Currently, an identity can only be used to make authenticated requests to the Infisical API and does not work with any clients such as [Node SDK](https://github.com/Infisical/infisical-node) + , [Python SDK](https://github.com/Infisical/infisical-python), CLI, K8s operator, Terraform Provider, etc. + + We will be releasing compatibility with it across clients in the coming quarter. + + +Each identity can be configured an authentication method. The only supported method at the moment is **Universal Auth (UA)** +which has the following properties: + +- In UA, each identity is assigned a **Client ID** for which you can generate one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for an access token (i.e. login operation) to authenticate with the Infisical API. +- UA supports restrictions on the number of times that the **Client Secret(s)** and access token(s) can be used. +- UA supports token renewal that is the ability to extend the lifetime of a token by its TTL up to its maximum TTL since its creation. +- UA supports IP allowlisting; this means you can restrict the usage of **Client Secret(s)** and access token to a specific IP or CIDR range. +- UA support expiration, so, if specified, the client secret of the identity will automatically be defunct after a period of time. +- UA tracks most recent usage of their client secrets and access tokens; it also keeps track of each token's usage count. + +## Using identities + +In the following steps, we explore how to create and use identities for your applications to access the Infisical API. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![machine identities organization](../../images/platform/machine-identity/machine-identity-org.png) + + ![machine identities organization create](../../images/platform/machine-identity/machine-identity-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab to permit the identity to access certain resources. + + Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. + + - Access Token TTL (default is `7200`): The incremental lifetime for an acccess token in seconds; a value of `0` implies an infinite incremental lifetime. + - Access Token Max TTL (default is `7200`): The maximum lifetime for an acccess token in seconds; a value of `0` implies an infinite maximum lifetime. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Client Secret Trusted IPs: The IPs or CIDR ranges that the **Client Secret** can be used from together with the **Client ID** to get back an access token. By default, **Client Secrets** are given the `0.0.0.0/0` entry representing all possible IPv4 addresses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0` entry representing all possible IPv4 addresses. + + + Restricting **Client Secret** and access token usage to specific trusted IPs is a paid feature. + + If you’re using Infisical Cloud, then it is available under the Pro Tier. If you’re self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. + + + + + In order to use the identity, you'll need the non-sensitive **Client ID** + of the identity and a **Client Secret** for it; you can think of these credentials akin to a username + and password used to authenticate with the Infisical API. With that, press on the key icon on the identity to generate a **Client Secret** + for it. + + ![machine identities client secret create](../../images/platform/machine-identity/machine-identity-org-client-secret.png) + ![machine identities client secret create](../../images/platform/machine-identity/machine-identity-org-client-secret-create-1.png) + ![machine identities client secret create](../../images/platform/machine-identity/machine-identity-org-client-secret-create-2.png) + + Feel free to input any (optional) details for the **Client Secret** configuration: + + - Description: A description for the **Client Secret**. + - TTL (default is `0`): The time-to-live for the **Client Secret**. By default, the TTL will be set to 0 which implies that the **Client Secret** will never expire; a value of `0` implies an infinite lifetime. + - Max Number of Uses (default is `0`): The maximum number of times that the **Client Secret** can be used together with the **Client ID** to get back an access token; a value of `0` implies infinite number of uses. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the role you want to assign it. + + ![machine identities project](../../images/platform/machine-identity/machine-identity-project.png) + + ![machine identities project create](../../images/platform/machine-identity/machine-identity-project-create.png) + + + To access the Infisical API as the identity, you should first perform a login operation + that is to exchange the **Client ID** and **Client Secret** of the MI for an access token + by making a request to the `/api/v1/auth/universal-auth/login` endpoint. + + #### Sample request + + ``` + curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'clientSecret=...' \ + --data-urlencode 'clientId=...' + ``` + + #### Sample response + + ``` + { + "accessToken": "...", + "expiresIn": 7200, + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained from the aforementioned login operation. + + + + +**FAQ** + + + + A service token is a project-level authentication method that is being phased out in favor of identities. + + Amongst many differences, identities provide broader access over the Infisical API, utilizes the same role-based + permission system used by users, and comes with ample more configurable security measures. + + + There are a few reasons for why this might happen: + + - The client secret or access token has expired. + - The identity is insufficently permissioned to interact with the resources you wish to access. + - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. + - The client secret/access token is being used from an untrusted IP. + + + A identity access token can have a time-to-live (TTL) or incremental lifetime afterwhich it expires. + + In certain cases, you may want to extend the lifespan of an access token; to do so, you must use the max TTL parameter. + When TTL and max TTL are equal, a token is not renewable; when max TTL is greater than TTL, a token is renewable. + In the latter case, a token still expires at its TTL but its lifetime can be extended/renewed up until its max TLL. + + Note that the max TTL cannot be less than the TTL for an access token. + + + There are a few reasons for why this might happen: + + - You have insufficient organization permissions to create, read, update, delete identities. + - The identity you are trying to read, update, or delete is more privileged than yourself. + - The role you are trying to create an identity for or update an identity to is more privileged than yours. + + + 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. + + 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. + + 3. `/*/*`: This pattern matches all subfolders at a depth of two levels in the current directory. It does not match any folders at a shallower or deeper level. For example, it would match folders like `/folder1/subfolder/`, `/folder2/subfolder/`, but not `/folder1/` or `/folder1/subfolder/subsubfolder/`. + + 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. + + \ No newline at end of file diff --git a/docs/documentation/platform/token.mdx b/docs/documentation/platform/token.mdx index 9a733445e..31efdbce1 100644 --- a/docs/documentation/platform/token.mdx +++ b/docs/documentation/platform/token.mdx @@ -14,10 +14,6 @@ You can manage service tokens in Project Settings > Service Tokens. Service Token (ST) is the current widely-used authentication method for managing secrets. - - We're soon releasing ST V3, a revised version of this Service Token, so stay tuned. - - Here's a few pointers to get you acquainted with it: - When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything diff --git a/docs/documentation/platform/token3.mdx b/docs/documentation/platform/token3.mdx deleted file mode 100644 index a04c9b853..000000000 --- a/docs/documentation/platform/token3.mdx +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: "Service token" -description: "Infisical service tokens allows you to programmatically interact with Infisical" ---- - -Service tokens are authentication credentials that services can use to access designated endpoints in the Infisical API to manage project resources like secrets. -Each service token can be provisioned scoped access to select environment(s) and path(s) within them. - -## Service Tokens - -Infisical currently offers Service Token V3 and Service Token; you can manage both types of tokens in Project Settings > Service Tokens. - -### Service Token V3 (Beta) - -Service Token V3 (ST V3) is a new and improved authentication method that is in beta. - - - Currently, the Service Token V3 authentication method can only be used with the latest [Node SDK](https://github.com/Infisical/infisical-node) and [Python SDK](https://github.com/Infisical/infisical-python). - You can also make an API call with it to create, read, update, or delete secrets. - - We will be releasing compatibility for it with the CLI and K8s operator in the coming month. - - That said, we recommend using ST V3 whenever possible. - - -Here's a few pointers to get you acquainted with it: - -- When you create a ST V3, you export a `JSON` file containing 3 components: `publicKey`, `privateKey`, and `serviceToken` where -`serviceToken` is a JWT token prefixed with `stv3`. The token provides access to the Infisical API and the public-private key -pairs are to support cryptographic operations for the client whenever E2EE is needed. -- ST V3 supports IP allowlisting; this means you can restrict the usage of a ST V3 to a specific IP or CIDR range. -- ST V3 supports provisioning granular `read` or `readWrite` access down to each path. -- ST V3 supports toggling on/off active states, so you can render a ST V3 inactive without deleting it. -- ST V3 supports expiration, so, if specified, a token will automatically turn inactive after a period of time. -- ST V3 tracks most recent usage; it also keeps track of each token's usage count. -- ST V3 is editable. - -### Service Token (Current) - -Service Token (ST) is the current widely-used authentication method. - - - We recently released ST V3, a revised version of this Service Token, which you can read about above. - - Whenever possible, you should use ST V3 because we will be deprecating ST sometime Q4 2023. - - -Here's a few pointers to get you acquainted with it: - -- When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything -before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion -of the token. -- ST supports expiration; it gets deleted automatically upon expiration. -- ST supports provisioning `read` and/or `write` permissions broadly applied to all accessible environment(s) and path(s). -- ST is not editable. - -## Creating a service token - -To create a service token, head to Project Settings > Service Tokens as shown below and press **Create token**. - -![token add](../../images/project-token-add.png) - -Now input any token configuration details such as which environment(s) and path(s) you'd like to provision -the token access to. Here's some guidance for each field: - -- Name: A friendly name for the token. -- Scopes: The environment(s) and path(s) the token should have access to. -If using ST V3, you can also indicate whether or not the token should have `read` or `readWrite` access to each path. -Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). -- Trusted IPs: The IPs or CIDR ranges that the token can be used from. By default, each token is given the `0.0.0.0/0` entry representing all possible IPv4 addresses. -- Expiration: The time when this token should be rendered inactive. - - - Restricting token usage to specific trusted IPs is a paid feature. - - If you’re using Infisical Cloud, then it is available under the Pro Tier. If you’re self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. - - -![token add](../../images/project-token-permissions.png) - -In the above screenshot, you can see that we are creating a token token with `read` access to all subfolders at any depth -of the `/common` path within the development environment of the project; the token expires in 6 months and can be used from any IP address. - -**FAQ** - - - - There are a few reasons for why this might happen: - - - The service token has expired. - - The service token is insufficently permissioned to interact with the secrets in the given environment and path. - - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. - - (If using ST V3) The service token has not been activated yet. - - (If using ST V3) The service token is being used from an untrusted IP. - - - 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. - - 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. - - 3. `/*/*`: This pattern matches all subfolders at a depth of two levels in the current directory. It does not match any folders at a shallower or deeper level. For example, it would match folders like `/folder1/subfolder/`, `/folder2/subfolder/`, but not `/folder1/` or `/folder1/subfolder/subsubfolder/`. - - 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. - - \ No newline at end of file diff --git a/docs/images/platform/machine-identity/machine-identity-org-client-secret-create-1.png b/docs/images/platform/machine-identity/machine-identity-org-client-secret-create-1.png new file mode 100644 index 000000000..5e8970dc2 Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-org-client-secret-create-1.png differ diff --git a/docs/images/platform/machine-identity/machine-identity-org-client-secret-create-2.png b/docs/images/platform/machine-identity/machine-identity-org-client-secret-create-2.png new file mode 100644 index 000000000..5c50589b5 Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-org-client-secret-create-2.png differ diff --git a/docs/images/platform/machine-identity/machine-identity-org-client-secret.png b/docs/images/platform/machine-identity/machine-identity-org-client-secret.png new file mode 100644 index 000000000..71c6d4334 Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-org-client-secret.png differ diff --git a/docs/images/platform/machine-identity/machine-identity-org-create.png b/docs/images/platform/machine-identity/machine-identity-org-create.png new file mode 100644 index 000000000..947a0b552 Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-org-create.png differ diff --git a/docs/images/platform/machine-identity/machine-identity-org.png b/docs/images/platform/machine-identity/machine-identity-org.png new file mode 100644 index 000000000..885e51e1c Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-org.png differ diff --git a/docs/images/platform/machine-identity/machine-identity-project-create.png b/docs/images/platform/machine-identity/machine-identity-project-create.png new file mode 100644 index 000000000..084c2b4c4 Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-project-create.png differ diff --git a/docs/images/platform/machine-identity/machine-identity-project.png b/docs/images/platform/machine-identity/machine-identity-project.png new file mode 100644 index 000000000..a35e957cc Binary files /dev/null and b/docs/images/platform/machine-identity/machine-identity-project.png differ diff --git a/docs/internals/service-tokens-new.mdx b/docs/internals/service-tokens-new.mdx index 4fc8a29cf..7f4145f95 100644 --- a/docs/internals/service-tokens-new.mdx +++ b/docs/internals/service-tokens-new.mdx @@ -1,72 +1,57 @@ --- -title: "Service tokens" -description: "Understanding service tokens and their best practices" +title: "Machine identities" +description: "Understanding machine identities and their best practices" --- ​ -Many clients use service tokens to authenticate and read/write secrets from/to Infisical; they can be created in your project settings. +Many clients use machine identities (MIs) to authenticate and read/write secrets from/to Infisical; they can be created in your organization settings. -On this page, we discuss Service Token V3, the new and improved authentication method. +On this page, we discuss MIs, the new and improved authentication method. ## Anatomy -A service token in Infisical exports a `JSON` file containing 3 components: `publicKey`, `privateKey`, and `serviceToken` where -`serviceToken` is a JWT token prefixed with `proj_token`. The token provides access to the Infisical API and the public-private key -pairs are to support cryptographic operations for the client whenever E2EE is needed. +A MI in Infisical comes with a JWT-based refresh token authentication credential. The refresh token can be exchanged for an access token +with a time-to-live (TTL) to access the Infisical API. ### Database model -The storage backend model for a token contains the following information: +The storage backend model for a MI contains the following notable data: -- ID: The token identifier. -- Expiration: The date at which point the token is invalid. -- Project: The project that the token is part of. -- Status: The active/inactive state of a token. -- Scopes: The project environment(s) and path(s) that the token has access to as well as `read` or `readWrite` permissions for them. +- ID: The internal ID of the MI. +- Name: The name of the MI. +- Organization: The organization that the MI belongs to. +- Refresh/Access Token last used: The last used dates of the MI refresh and access tokens. +- Refresh/Access Token usage count: The number of times the MI refresh and access tokens have been used. +- Refresh Token Rotation Enabled: Whether or not a new MI refresh token should be returned when exchanging an existing refresh token for an access token; if enabled, the old refresh token is invalidated at each refresh operation. +- Token Version: The token version used to keep track of old/current refresh and access tokens. +- Expiration: The date at which point the MI refresh token credential can no longer be used. +- Access Token TTL: the time-to-live of each access token issued at each refresh token exchange. - Trusted IPs: The specific (IPv4 or IPv6) IPs or CIDR ranges that the token can be used from. -- Last used: The date at which point the token was last used. -- Usage count: The number of times that the token has been used. -### Token - -As mentioned before, a service token consists of three components, exported as a `JSON`, used for authentication and cryptographic purposes. - -Consider the following `JSON`: - -``` -{ - "publicKey": "...", - "privateKey": "...", - "serviceToken": "stv3..." -} -``` - -Here, the `serviceToken` component can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer ` and retrieve (encrypted) secrets as well as a project key back. Meanwhile, the `privateKey` (in the `JSON`), and `publicKey` (returned in the encrypted project key response) can be used to decrypt the project key used to decrypt the secrets. - -Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. +Separately, another model stores the mapping of a MI and the organization or project-role it is bound to. ​ ## Recommendations ### Permissions -You should consider the [principle of least privilege(PoLP)](https://en.wikipedia.org/wiki/Principle_of_least_privilege) when setting which environment(s) and path(s) -should be accessible by a service token; you should also consider whether or not it needs `read` or `readWrite` access. +You should consider the [principle of least privilege(PoLP)](https://en.wikipedia.org/wiki/Principle_of_least_privilege) when +creating and assigning roles to MIs. -For example, if the client using the token only requires `read` access to the secrets in the `/config` path of the staging environment, then you should scope the token to the `/config` path of that environment only with `read` permission. +For example, if an MI only requires `read` access to the secrets in the `/config` path of the staging environment, then you should scope the role of the MI to the `/config` path of that environment only with `read` permission. ### Status & Expiration -We recommend considering whether or not a service token should be able to access secrets indefinitely or within a finite lifetime such as until 6 months or 1 year from now +We recommend considering whether or not a MI should be able to access secrets indefinitely or within a finite lifetime such as until 6 months or 1 year from now ### Network access -We recommend configuring the IP allowlist configuration of each service token to restrict its usage to specific IP addresses or CIDR-notated range of addresses. +We recommend configuring the IP allowlist configuration of each MI to restrict its usage to specific IP addresses or CIDR-notated range of addresses. ### Storage -Since service tokens grant access to your secrets, we recommend storing them securely across your development cycle whether it be in a .env file in local development or as an environment variable of your deployment platform. +Since MIs grant access to your secrets, we recommend storing the refresh token credential securely across your development cycle whether it be in a .env file in local development or as an environment variable of your deployment platform. ### Rotation -We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by [NIST publication 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final). - -Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. \ No newline at end of file +We recommend periodically rotating the MI refresh token, even in the absence of compromise. If using the Infisical Agent, we recommend enabling the **Refresh Token Rotation** option +on your MI; this will issue a new refresh token and invalidate the old one upon a refresh token exchange operation — In doing so, the refresh token is kept as a moving target +and secret zero risk is mitigated. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index f196424f9..80f4d8226 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -118,6 +118,7 @@ "documentation/platform/pit-recovery", "documentation/platform/audit-logs", "documentation/platform/token", + "documentation/platform/identity", "documentation/platform/mfa", "documentation/platform/pr-workflows", "documentation/platform/role-based-access-controls", diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index a4f1ab5ef..f0fbd23eb 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", + Identity = "identity" } 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.Identity]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 5ae91e756..608419f4f 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -22,7 +22,8 @@ export enum ProjectPermissionSub { Secrets = "secrets", SecretRollback = "secret-rollback", SecretApproval = "secret-approval", - SecretRotation = "secret-rotation" + SecretRotation = "secret-rotation", + Identity = "identity" } type SubjectFields = { @@ -44,6 +45,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] + | [ProjectPermissionActions, ProjectPermissionSub.Identity] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 9d31f812d..01674d2ee 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -16,9 +16,17 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_TRUSTED_IP]: "Delete trusted IP", [EventType.CREATE_SERVICE_TOKEN]: "Create service token", [EventType.DELETE_SERVICE_TOKEN]: "Delete service token", - [EventType.CREATE_SERVICE_TOKEN_V3]: "Create (new) service token", - [EventType.UPDATE_SERVICE_TOKEN_V3]: "Update (new) service token", - [EventType.DELETE_SERVICE_TOKEN_V3]: "Delete (new) service token", + [EventType.CREATE_IDENTITY]: "Create identity", + [EventType.UPDATE_IDENTITY]: "Update identity", + [EventType.DELETE_IDENTITY]: "Delete identity", + [EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH]: "Login via universal auth", + [EventType.ADD_IDENTITY_UNIVERSAL_AUTH]: "Add universal auth", + [EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH]: "Update universal auth", + [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", + [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", + [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", + [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", + [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index c292fdc24..ea1affa87 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -1,7 +1,7 @@ export enum ActorType { USER = "user", SERVICE = "service", - SERVICE_V3 = "service-v3" + IDENTITY = "identity" } export enum UserAgentType { @@ -27,9 +27,16 @@ export enum EventType { DELETE_TRUSTED_IP = "delete-trusted-ip", CREATE_SERVICE_TOKEN = "create-service-token", // v2 DELETE_SERVICE_TOKEN = "delete-service-token", // v2 - CREATE_SERVICE_TOKEN_V3 = "create-service-token-v3", // v3 - UPDATE_SERVICE_TOKEN_V3 = "update-service-token-v3", // v3 - DELETE_SERVICE_TOKEN_V3 = "delete-service-token-v3", // v3 + CREATE_IDENTITY = "create-identity", + UPDATE_IDENTITY = "update-identity", + DELETE_IDENTITY = "delete-identity", + LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", + ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", + UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", + GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", + CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", + REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 579dd8fc5..54f4882b3 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -1,3 +1,4 @@ +import { IdentityTrustedIp } from "../identities/types"; import { ActorType, EventType, UserAgentType } from "./enums"; interface UserActorMetadata { @@ -10,6 +11,11 @@ interface ServiceActorMetadata { name: string; } +interface IdentityActorMetadata { + identityId: string; + name: string; +} + interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -20,12 +26,12 @@ export interface ServiceActor { metadata: ServiceActorMetadata; } -export interface ServiceActorV3 { - type: ActorType.SERVICE_V3; - metadata: ServiceActorMetadata; +export interface IdentityActor { + type: ActorType.IDENTITY; + metadata: IdentityActorMetadata; } -export type Actor = UserActor | ServiceActor | ServiceActorV3; +export type Actor = UserActor | ServiceActor | IdentityActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -188,34 +194,92 @@ interface DeleteServiceTokenEvent { }; } -interface CreateServiceTokenV3Event { - type: EventType.CREATE_SERVICE_TOKEN_V3; - metadata: { - name: string; - isActive: boolean; - role: string; - expiresAt?: Date; - } +interface CreateIdentityEvent { // note: currently not logging org-role + type: EventType.CREATE_IDENTITY; + metadata: { + identityId: string; + name: string; + }; } -interface UpdateServiceTokenV3Event { - type: EventType.UPDATE_SERVICE_TOKEN_V3; - metadata: { - name?: string; - isActive?: boolean; - role?: string; - expiresAt?: Date; - } +interface UpdateIdentityEvent { + type: EventType.UPDATE_IDENTITY; + metadata: { + identityId: string; + name?: string; + }; } -interface DeleteServiceTokenV3Event { - type: EventType.DELETE_SERVICE_TOKEN_V3; - metadata: { - name: string; - isActive: boolean; - role?: string; - expiresAt?: Date; - } +interface DeleteIdentityEvent { + type: EventType.DELETE_IDENTITY; + metadata: { + identityId: string; + }; +} + +interface LoginIdentityUniversalAuthEvent { + type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH ; + metadata: { + identityId: string; + identityUniversalAuthId: string; + clientSecretId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityUniversalAuthEvent { + type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + clientSecretTrustedIps: Array; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityUniversalAuthEvent { + type: EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + clientSecretTrustedIps?: Array; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityUniversalAuthEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH; + metadata: { + identityId: string; + }; +} + +interface CreateIdentityUniversalAuthClientSecretEvent { + type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET ; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + +interface GetIdentityUniversalAuthClientSecretsEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS; + metadata: { + identityId: string; + }; +} + + +interface RevokeIdentityUniversalAuthClientSecretEvent { + type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET ; + metadata: { + identityId: string; + clientSecretId: string; + }; } interface CreateEnvironmentEvent { @@ -414,9 +478,16 @@ export type Event = | DeleteTrustedIPEvent | CreateServiceTokenEvent | DeleteServiceTokenEvent - | CreateServiceTokenV3Event - | UpdateServiceTokenV3Event - | DeleteServiceTokenV3Event + | CreateIdentityEvent + | UpdateIdentityEvent + | DeleteIdentityEvent + | LoginIdentityUniversalAuthEvent + | AddIdentityUniversalAuthEvent + | UpdateIdentityUniversalAuthEvent + | GetIdentityUniversalAuthEvent + | CreateIdentityUniversalAuthClientSecretEvent + | GetIdentityUniversalAuthClientSecretsEvent + | RevokeIdentityUniversalAuthClientSecretEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx new file mode 100644 index 000000000..6cce84b53 --- /dev/null +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -0,0 +1,5 @@ +import { IdentityAuthMethod } from "./enums"; + +export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { + [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth" +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx new file mode 100644 index 000000000..71ef886d6 --- /dev/null +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -0,0 +1,3 @@ +export enum IdentityAuthMethod { + UNIVERSAL_AUTH = "universal-auth" +} \ No newline at end of file diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx new file mode 100644 index 000000000..61b3035eb --- /dev/null +++ b/frontend/src/hooks/api/identities/index.tsx @@ -0,0 +1,14 @@ +export { identityAuthToNameMap } from "./constants"; +export { IdentityAuthMethod } from "./enums"; +export { + useAddIdentityUniversalAuth, + useCreateIdentity, + useCreateIdentityUniversalAuthClientSecret, + useDeleteIdentity, + useRevokeIdentityUniversalAuthClientSecret, + useUpdateIdentity, + useUpdateIdentityUniversalAuth} from "./mutations"; +export { + useGetIdentityUniversalAuth, + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx new file mode 100644 index 000000000..3711fd459 --- /dev/null +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -0,0 +1,164 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { organizationKeys } from "../organization/queries"; +import { identitiesKeys } from "./queries"; +import { + AddIdentityUniversalAuthDTO, + ClientSecretData, + CreateIdentityDTO, + CreateIdentityUniversalAuthClientSecretDTO, + CreateIdentityUniversalAuthClientSecretRes, + DeleteIdentityDTO, + DeleteIdentityUniversalAuthClientSecretDTO, + Identity, + IdentityUniversalAuth, + UpdateIdentityDTO, + UpdateIdentityUniversalAuthDTO} from "./types"; + +export const useCreateIdentity = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: { identity } } = await apiRequest.post("/api/v1/identities/", body); + return identity; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentity = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + name, + role + }) => { + + const { data: { identity } } = await apiRequest.patch(`/api/v1/identities/${identityId}`, { + name, + role + }); + + return identity; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +} + +export const useDeleteIdentity = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + }) => { + const { data: { identity } } = await apiRequest.delete(`/api/v1/identities/${identityId}`); + return identity; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +// TODO: move these to /auth + +export const useAddIdentityUniversalAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + clientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + }) => { + const { data: { identityUniversalAuth } } = await apiRequest.post(`/api/v1/auth/universal-auth/identities/${identityId}`, + { + clientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + } + ); + return identityUniversalAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityUniversalAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + clientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + }) => { + const { data: { identityUniversalAuth } } = await apiRequest.patch(`/api/v1/auth/universal-auth/identities/${identityId}`, + { + clientSecretTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + } + ); + return identityUniversalAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useCreateIdentityUniversalAuthClientSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + description, + ttl, + numUsesLimit + }) => { + const { data } = await apiRequest.post(`/api/v1/auth/universal-auth/identities/${identityId}/client-secrets`, { + description, + ttl, + numUsesLimit + }); + return data; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries(identitiesKeys.getIdentityUniversalAuthClientSecrets(identityId)); + } + }); +}; + +export const useRevokeIdentityUniversalAuthClientSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + clientSecretId + }) => { + const { data: { clientSecretData } } = await apiRequest.post<{ clientSecretData: ClientSecretData }>(`/api/v1/auth/universal-auth/identities/${identityId}/client-secrets/${clientSecretId}/revoke`); + return clientSecretData; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries(identitiesKeys.getIdentityUniversalAuthClientSecrets(identityId)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx new file mode 100644 index 000000000..e43073f40 --- /dev/null +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { ClientSecretData , IdentityUniversalAuth } from "./types"; + +export const identitiesKeys = { + getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, + getIdentityUniversalAuthClientSecrets: (identityId: string) => [{ identityId }, "identity-universal-auth-client-secrets"] as const +} + +export const useGetIdentityUniversalAuth = (identityId: string) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityUniversalAuth(identityId), + queryFn: async () => { + if (identityId === "") throw new Error("Identity ID is required"); + + const { data: { identityUniversalAuth } } = await apiRequest.get<{ identityUniversalAuth: IdentityUniversalAuth }>( + `/api/v1/auth/universal-auth/identities/${identityId}` + ); + + return identityUniversalAuth; + } + }); +} + +export const useGetIdentityUniversalAuthClientSecrets = (identityId: string) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityUniversalAuthClientSecrets(identityId), + queryFn: async () => { + if (identityId === "") return []; + + const { data: { clientSecretData } } = await apiRequest.get<{ clientSecretData: ClientSecretData[] }>( + `/api/v1/auth/universal-auth/identities/${identityId}/client-secrets` + ); + + return clientSecretData; + } + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts new file mode 100644 index 000000000..0a9a41fc3 --- /dev/null +++ b/frontend/src/hooks/api/identities/types.ts @@ -0,0 +1,123 @@ +import { TRole } from "../roles/types"; +import { IdentityAuthMethod } from "./enums"; + +export type IdentityTrustedIp = { + _id: string; + ipAddress: string; + type: "ipv4" | "ipv6"; + prefix?: number; +} + +export type Identity = { + _id: string; + name: string; + authMethod?: IdentityAuthMethod; + createdAt: string; + updatedAt: string; +}; + +export type IdentityMembershipOrg = { + _id: string; + identity: Identity; + organization: string; + role: "admin" | "member" | "viewer" | "no-access" | "custom"; + customRole?: TRole; + createdAt: string; + updatedAt: string; +} + +export type IdentityMembership = { + _id: string; + identity: Identity; + organization: string; + role: "admin" | "member" | "viewer" | "no-access" | "custom"; + customRole?: TRole; + createdAt: string; + updatedAt: string; +} + +export type CreateIdentityDTO = { + name: string; + organizationId: string; + role?: string; +} + +export type UpdateIdentityDTO = { + identityId: string; + name?: string; + role?: string; + organizationId: string; +} + +export type DeleteIdentityDTO = { + identityId: string; + organizationId: string; +} + +export type IdentityUniversalAuth = { + identityId: string; + clientId: string; + clientSecretTrustedIps: IdentityTrustedIp[]; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +} + +export type AddIdentityUniversalAuthDTO = { + organizationId: string; + identityId: string; + clientSecretTrustedIps: { + ipAddress: string; + }[]; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +} + +export type UpdateIdentityUniversalAuthDTO = { + organizationId: string; + identityId: string; + clientSecretTrustedIps?: { + ipAddress: string; + }[]; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +} + +export type CreateIdentityUniversalAuthClientSecretDTO = { + identityId: string; + description?: string; + ttl?: number; + numUsesLimit?: number; +} + +export type ClientSecretData = { + _id: string; + identityUniversalAuth: string; + isClientSecretRevoked: boolean; + description: string; + clientSecretPrefix: string; + clientSecretNumUses: number; + clientSecretNumUsesLimit: number; + clientSecretTTL: number; + createdAt: string; + updatedAt: string; +} + +export type CreateIdentityUniversalAuthClientSecretRes = { + clientSecret: string; + clientSecretData: ClientSecretData; +} + +export type DeleteIdentityUniversalAuthClientSecretDTO = { + identityId: string; + clientSecretId: string; +} \ No newline at end of file diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index c5e719218..4caeaea48 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,8 +1,9 @@ -export * from "./apiKeys"; export * from "./admin" +export * from "./apiKeys"; export * from "./auditLogs"; export * from "./auth"; export * from "./bots"; +export * from "./identities"; export * from "./incidentContacts"; export * from "./integrationAuth"; export * from "./integrations"; @@ -16,6 +17,7 @@ export * from "./secretImports"; export * from "./secretRotation"; export * from "./secrets"; export * from "./secretSnapshots"; +export * from "./serverDetails"; export * from "./serviceTokens"; export * from "./ssoConfig"; export * from "./subscriptions"; @@ -23,4 +25,4 @@ export * from "./tags"; export * from "./trustedIps"; export * from "./users"; export * from "./webhooks"; -export * from "./workspace"; +export * from "./workspace"; \ No newline at end of file diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index e167de290..c8a284836 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -6,6 +6,7 @@ export { useDeleteOrgById, useDeleteOrgPmtMethod, useDeleteOrgTaxId, + useGetIdentityMembershipOrgs, useGetOrganizations, useGetOrgBillingDetails, useGetOrgInvoices, @@ -17,4 +18,5 @@ export { useGetOrgTaxIds, useGetOrgTrialUrl, useRenameOrg, - useUpdateOrgBillingDetails} from "./queries"; + useUpdateOrgBillingDetails +} from "./queries"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 1b075249c..5b8b145c2 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 { IdentityMembershipOrg } from "../identities/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, + getOrgIdentityMemberships: (orgId: string) => [{ orgId }, "organization-identity-memberships"] as const, }; export const fetchOrganizations = async () => { @@ -349,6 +351,22 @@ export const useGetOrgLicenses = (organizationId: string) => { }); }; +export const useGetIdentityMembershipOrgs = (organizationId: string) => { + return useQuery({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId), + queryFn: async () => { + const { + data: { identityMemberships } + } = await apiRequest.get<{ identityMemberships: IdentityMembershipOrg[] }>( + `/api/v2/organizations/${organizationId}/identity-memberships` + ); + + return identityMemberships; + }, + enabled: true + }); +}; + export const useDeleteOrgById = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/serviceTokens/index.ts b/frontend/src/hooks/api/serviceTokens/index.ts index 01422d833..033e6ff1a 100644 --- a/frontend/src/hooks/api/serviceTokens/index.ts +++ b/frontend/src/hooks/api/serviceTokens/index.ts @@ -1,8 +1,5 @@ export { useCreateServiceToken, - useCreateServiceTokenV3, useDeleteServiceToken, - useDeleteServiceTokenV3, useGetUserWsServiceTokens, - useUpdateServiceTokenV3 } from "./queries"; diff --git a/frontend/src/hooks/api/serviceTokens/queries.tsx b/frontend/src/hooks/api/serviceTokens/queries.tsx index d0bf2ac42..0f67af47b 100644 --- a/frontend/src/hooks/api/serviceTokens/queries.tsx +++ b/frontend/src/hooks/api/serviceTokens/queries.tsx @@ -2,17 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { workspaceKeys } from "../workspace/queries"; import { - CreateServiceTokenDataV3DTO, - CreateServiceTokenDataV3Res, CreateServiceTokenDTO, CreateServiceTokenRes, - DeleteServiceTokenDataV3DTO, DeleteServiceTokenRes, - ServiceToken, - ServiceTokenDataV3, - UpdateServiceTokenDataV3DTO} from "./types"; + ServiceToken +} from "./types"; const serviceTokenKeys = { getAllWorkspaceServiceToken: (workspaceID: string) => [{ workspaceID }, "service-tokens"] as const @@ -64,63 +59,4 @@ export const useDeleteServiceToken = () => { queryClient.invalidateQueries(serviceTokenKeys.getAllWorkspaceServiceToken(workspace)); } }); -}; - -export const useCreateServiceTokenV3 = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (body) => { - const { data } = await apiRequest.post("/api/v3/service-token/", body); - return data; - }, - onSuccess: ({ serviceTokenData }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(serviceTokenData.workspace)); - } - }); -}; - -export const useUpdateServiceTokenV3 = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - serviceTokenDataId, - name, - role, - isActive, - trustedIps, - expiresIn, - accessTokenTTL, - isRefreshTokenRotationEnabled - }) => { - const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, { - name, - role, - isActive, - trustedIps, - expiresIn, - accessTokenTTL, - isRefreshTokenRotationEnabled - }); - - return serviceTokenData; - }, - onSuccess: ({ workspace }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(workspace)); - } - }); -}; - -export const useDeleteServiceTokenV3 = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - serviceTokenDataId - }) => { - const { data: { serviceTokenData } } = await apiRequest.delete(`/api/v3/service-token/${serviceTokenDataId}`); - return serviceTokenData; - }, - onSuccess: ({ workspace }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(workspace)); - } - }); }; \ 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..ea3223660 100644 --- a/frontend/src/hooks/api/serviceTokens/types.ts +++ b/frontend/src/hooks/api/serviceTokens/types.ts @@ -32,72 +32,4 @@ export type CreateServiceTokenRes = { serviceTokenData: ServiceToken; }; -export type DeleteServiceTokenRes = { serviceTokenData: ServiceToken }; - -// --- v3 - -export type ServiceTokenV3TrustedIp = { - _id: string; - ipAddress: string; - type: "ipv4" | "ipv6"; - prefix?: number; -} - -export type ServiceTokenDataV3 = { - _id: string; - name: string; - role: string; - customRole?: { - name: string; - slug: string; - }; - workspace: string; - isActive: boolean; - refreshTokenLastUsed?: string; - accessTokenLastUsed?: string; - refreshTokenUsageCount: number; - accessTokenUsageCount: number; - trustedIps: ServiceTokenV3TrustedIp[]; - expiresAt?: string; - accessTokenTTL: number; - isRefreshTokenRotationEnabled: boolean; - createdAt: string; - updatedAt: string; -}; - -export type CreateServiceTokenDataV3DTO = { - name: string; - role?: string; - workspaceId: string; - publicKey: string; - trustedIps: { - ipAddress: string; - }[]; - expiresIn?: number; - accessTokenTTL: number; - encryptedKey: string; - nonce: string; - isRefreshTokenRotationEnabled: boolean; -} - -export type CreateServiceTokenDataV3Res = { - refreshToken: string; - serviceTokenData: ServiceTokenDataV3; -} - -export type UpdateServiceTokenDataV3DTO = { - serviceTokenDataId: string; - isActive?: boolean; - name?: string; - role?: string; - trustedIps?: { - ipAddress: string; - }[]; - expiresIn?: number; - accessTokenTTL?: number; - isRefreshTokenRotationEnabled?: boolean; -} - -export type DeleteServiceTokenDataV3DTO = { - serviceTokenDataId: string; -} \ No newline at end of file +export type DeleteServiceTokenRes = { serviceTokenData: ServiceToken }; \ No newline at end of file diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 3b57e8953..31169a3fc 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -44,7 +44,7 @@ export type OrgUser = { }; inviteEmail: string; organization: string; - role: "owner" | "admin" | "member" | "custom"; + role: "owner" | "admin" | "member" | "no-access" | "custom"; status: "invited" | "accepted" | "verified" | "completed"; deniedPermissions: any[]; customRole: string; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index cef88fede..6a678a432 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,7 +1,9 @@ export { + useAddIdentityToWorkspace, useAddUserToWorkspace, useCreateWorkspace, useCreateWsEnvironment, + useDeleteIdentityFromWorkspace, useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, @@ -9,14 +11,15 @@ export { useGetUserWorkspaces, useGetWorkspaceAuthorizations, useGetWorkspaceById, + useGetWorkspaceIdentityMemberships, useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, useGetWorkspaceSecrets, - useGetWorkspaceServiceTokenDataV3, useGetWorkspaceUsers, useNameWorkspaceSecrets, useRenameWorkspace, useReorderWsEnvironment, useToggleAutoCapitalization, + useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, useUpdateWsEnvironment} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ede931de8..86f5206c7 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,10 +2,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { EncryptedSecret } from "../secrets/types"; -import { ServiceTokenDataV3 } from "../serviceTokens/types"; import { TWorkspaceUser } from "../users/types"; import { CreateEnvironmentDTO, @@ -31,8 +31,7 @@ export const workspaceKeys = { getAllUserWorkspace: ["workspaces"] as const, getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }] as const, - getWorkspaceServiceTokenDataV3: (workspaceId: string) => - [{ workspaceId }, "workspace-service-token-data-v3"] as const + getWorkspaceIdentityMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-identity-memberships"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -97,6 +96,7 @@ const fetchUserWorkspaceMemberships = async (orgId: string) => { const { data } = await apiRequest.get>( `/api/v1/organization/${orgId}/workspace-memberships` ); + return data; }; @@ -356,18 +356,92 @@ export const useUpdateUserWorkspaceRole = () => { }); }; -export const useGetWorkspaceServiceTokenDataV3 = (workspaceId: string) => { +export const useAddIdentityToWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + workspaceId, + role + }: { + identityId: string; + workspaceId: string; + role?: string; + }) => { + + const { + data: { identityMembership } + } = await apiRequest.post(`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, { + role + }); + + return identityMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(workspaceId)); + } + }); +}; + +export const useUpdateIdentityWorkspaceRole = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + workspaceId, + role + }: { + identityId: string; + workspaceId: string; + role?: string; + }) => { + + const { + data: { identityMembership } + } = await apiRequest.patch(`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, { + role + }); + + return identityMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(workspaceId)); + } + }); +}; + +export const useDeleteIdentityFromWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + workspaceId, + }: { + identityId: string; + workspaceId: string; + }) => { + const { + data: { identityMembership } + } = await apiRequest.delete(`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`); + return identityMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(workspaceId)); + } + }); +}; + +export const useGetWorkspaceIdentityMemberships = (workspaceId: string) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceServiceTokenDataV3(workspaceId), + queryKey: workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), queryFn: async () => { const { - data: { serviceTokenData } - } = await apiRequest.get<{ serviceTokenData: ServiceTokenDataV3[] }>( - `/api/v3/workspaces/${workspaceId}/service-token` + data: { identityMemberships } + } = await apiRequest.get<{ identityMemberships: IdentityMembership[] }>( + `/api/v2/workspace/${workspaceId}/identity-memberships` ); - - return serviceTokenData; + return identityMemberships; }, enabled: true }); -}; +}; \ No newline at end of file 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..867b0a61c 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -1,62 +1,49 @@ /* 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 { + OrgIdentityTab, + OrgMembersTab, + OrgRoleTabSection +} from "./components"; enum TabSections { Member = "members", - Roles = "roles" + Roles = "roles", + Identities = "identities" } 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")} + Organization Access Control

- Members - Roles + People + +
+

Machine Identities

+
+ New +
+
+
+ Organization Roles
- - []} - isRolesLoading={isRolesLoading} - /> - + + + + - []} - isRolesLoading={isRolesLoading} - /> +
diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/OrgIdentityTab.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/OrgIdentityTab.tsx new file mode 100644 index 000000000..74b710374 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/OrgIdentityTab.tsx @@ -0,0 +1,19 @@ +import { motion } from "framer-motion"; + +import { + IdentitySection +} from "./components"; + +export const OrgIdentityTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx new file mode 100644 index 000000000..c10760496 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -0,0 +1,108 @@ +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { + FormControl, + Modal, + ModalContent, + Select, + SelectItem, + UpgradePlanModal +} from "@app/components/v2"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; + +type Props = { + popUp: UsePopUpState<["identityAuthMethod", "upgradePlan"]>; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["identityAuthMethod", "upgradePlan"]>, state?: boolean) => void; +} + +const identityAuthMethods = [ + { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH } +]; + +const schema = yup.object({ + authMethod: yup.string().required("Auth method is required"), // TODO: better enforcement here +}).required(); + +export type FormData = yup.InferType; + +export const IdentityAuthMethodModal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + + const { + control, + // watch, + } = useForm({ + resolver: yupResolver(schema) + }); + + const identityAuthMethodData = popUp?.identityAuthMethod?.data as { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + } + + // const authMethod = watch("authMethod"); + + const renderIdentityAuthForm = () => { + return ( + + ); + } + + return ( + { + handlePopUpToggle("identityAuthMethod", isOpen); + }} + > + + ( + + + + )} + /> + {renderIdentityAuthForm()} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> + + + ); +} + diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx new file mode 100644 index 000000000..28a4e4dfe --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -0,0 +1,239 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useCreateIdentity, + useGetRoles, + useUpdateIdentity +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + name: yup.string().required("MI name is required"), + role: yup.string(), +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["identity"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + data: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + } + ) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; +}; + +export const IdentityModal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + + const { data: roles } = useGetRoles({ + orgId + }); + + const { mutateAsync: createMutateAsync } = useCreateIdentity(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: "" + } + }); + + useEffect(() => { + + const identity = popUp?.identity?.data as { + identityId: string; + name: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (identity) { + reset({ + name: identity.name, + role: identity?.customRole?.slug ?? identity.role + }); + } else { + reset({ + name: "", + role: roles[0].slug + }); + } + }, [popUp?.identity?.data, roles]); + + const onFormSubmit = async ({ + name, + role, + }: FormData) => { + try { + + const identity = popUp?.identity?.data as { + identityId: string; + name: string; + role: string; + }; + + if (identity) { + // update + + await updateMutateAsync({ + identityId: identity.identityId, + name, + role: role || undefined, + organizationId: orgId + }); + + handlePopUpToggle("identity", false); + } else { + // create + + const { + _id: createdId, + name: createdName, + authMethod + } = await createMutateAsync({ + name, + role: role || undefined, + organizationId: orgId + }); + + handlePopUpToggle("identity", false); + handlePopUpOpen("identityAuthMethod", { + identityId: createdId, + name: createdName, + authMethod + }); + } + + createNotification({ + text: `Successfully ${popUp?.identity?.data ? "updated" : "created"} identity`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message + ?? `Failed to ${popUp?.identity?.data ? "updated" : "created"} identity`; + + createNotification({ + text, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("identity", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx new file mode 100644 index 000000000..c9809500c --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -0,0 +1,122 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization} from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteIdentity } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { IdentityAuthMethodModal } from "./IdentityAuthMethodModal"; +import { IdentityModal } from "./IdentityModal"; +import { IdentityTable } from "./IdentityTable"; +import { IdentityUniversalAuthClientSecretModal } from "./IdentityUniversalAuthClientSecretModal"; + +export const IdentitySection = withPermission( + () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + + const { createNotification } = useNotificationContext(); + const { mutateAsync: deleteMutateAsync } = useDeleteIdentity(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "identity", + "identityAuthMethod", + "deleteIdentity", + "universalAuthClientSecret", + "deleteUniversalAuthClientSecret", + "upgradePlan" + ] as const); + + const onDeleteIdentitySubmit = async (identityId: string) => { + try { + + await deleteMutateAsync({ + identityId, + organizationId: orgId + }); + + createNotification({ + text: "Successfully deleted identity", + type: "success" + }); + + handlePopUpClose("deleteIdentity"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message + ?? "Failed to delete identity" + + createNotification({ + text, + type: "error" + }); + } + } + + return ( +
+
+

+ Identities +

+ + {(isAllowed) => ( + + )} + +
+ + + + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Identity } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx new file mode 100644 index 000000000..b9dca1ad1 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -0,0 +1,267 @@ +import { faKey, faLock,faPencil, faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization} from "@app/context"; +import { + useGetIdentityMembershipOrgs, + useGetRoles, + useUpdateIdentity} from "@app/hooks/api"; +import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +// TODO: some kind of map + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteIdentity", "identity", "universalAuthClientSecret", "identityAuthMethod"]>, + data?: { + identityId?: string; + name?: string; + authMethod?: string; + role?: string; + customRole?: { + name: string; + slug: string; + }; + } + ) => void; + }; + +export const IdentityTable = ({ + handlePopUpOpen +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + + const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); + const { data, isLoading } = useGetIdentityMembershipOrgs(orgId); + + const { data: roles } = useGetRoles({ + orgId + }); + + const handleChangeRole = async ({ + identityId, + role + }: { + identityId: string; + role: string; + }) => { + try { + + await updateMutateAsync({ + identityId, + role, + organizationId: orgId + }); + + createNotification({ + text: "Successfully updated identity role", + type: "success" + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to update identity role" + + createNotification({ + text, + type: "error" + }); + } + } + + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + identity: { + _id, + name, + authMethod + }, + role, + customRole + }) => { + return ( + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameRoleAuth Method +
{name} + + {(isAllowed) => { + return ( + + ); + }} + + {authMethod ? identityAuthToNameMap[authMethod] : "Not configured"} +
+ {authMethod === IdentityAuthMethod.UNIVERSAL_AUTH && ( + + { + handlePopUpOpen("universalAuthClientSecret", { + identityId: _id, + name + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + // isDisabled={!isAllowed} + > + + + + )} + + {(isAllowed) => ( + + { + handlePopUpOpen("identityAuthMethod", { + identityId: _id, + name, + authMethod + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("identity", { + identityId: _id, + name, + role, + customRole + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity", { + identityId: _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/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthClientSecretModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthClientSecretModal.tsx new file mode 100644 index 000000000..45a921a9f --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthClientSecretModal.tsx @@ -0,0 +1,411 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { faCheck, faCopy, faKey,faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { format } from "date-fns"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { + useCreateIdentityUniversalAuthClientSecret, + useGetIdentityUniversalAuth, + useGetIdentityUniversalAuthClientSecrets, useRevokeIdentityUniversalAuthClientSecret} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + description: yup.string(), + ttl: yup.string(), + numUsesLimit: yup.string() +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["universalAuthClientSecret", "deleteUniversalAuthClientSecret"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteUniversalAuthClientSecret"]>, + data?: { + clientSecretPrefix: string; + clientSecretId: string; + } + ) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["universalAuthClientSecret", "deleteUniversalAuthClientSecret"]>, state?: boolean) => void; +}; + +export const IdentityUniversalAuthClientSecretModal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const [token, setToken] = useState(""); + const [isClientSecretCopied, setIsClientSecretCopied] = useToggle(false); + const [isClientIdCopied, setIsClientIdCopied] = useToggle(false); + + const popUpData = (popUp?.universalAuthClientSecret?.data as { + identityId?: string; + name?: string; + }); + + const { data, isLoading } = useGetIdentityUniversalAuthClientSecrets(popUpData?.identityId ?? ""); + const { data: identityUniversalAuth } = useGetIdentityUniversalAuth(popUpData?.identityId ?? ""); + + const { mutateAsync: createClientSecretMutateAsync } = useCreateIdentityUniversalAuthClientSecret(); + const { mutateAsync: revokeClientSecretMutateAsync } = useRevokeIdentityUniversalAuthClientSecret(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + description: "", + ttl: "", + numUsesLimit: "" + } + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isClientSecretCopied) { + timer = setTimeout(() => setIsClientSecretCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isClientSecretCopied]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isClientIdCopied) { + timer = setTimeout(() => setIsClientIdCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isClientIdCopied]); + + const onFormSubmit = async ({ + description, + ttl, + numUsesLimit + }: FormData) => { + try { + + if (!popUpData?.identityId) return; + + const { clientSecret } = await createClientSecretMutateAsync({ + identityId: popUpData.identityId, + description, + ttl: Number(ttl), + numUsesLimit: Number(numUsesLimit) + }); + + setToken(clientSecret); + + createNotification({ + text: "Successfully created client secret", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create client secret", + type: "error" + }); + } + } + + const onDeleteClientSecretSubmit = async ({ + clientSecretId, + clientSecretPrefix + }: { + clientSecretId: string; + clientSecretPrefix: string; + }) => { + try { + + if (!popUpData?.identityId) return; + + await revokeClientSecretMutateAsync({ + identityId: popUpData.identityId, + clientSecretId + }); + + if (token.startsWith(clientSecretPrefix)) { + reset(); + setToken(""); + } + + handlePopUpToggle("deleteUniversalAuthClientSecret", false); + + createNotification({ + text: "Successfully deleted client secret", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete client secret", + type: "error" + }); + + } + } + + const hasToken = Boolean(token); + + return ( + { + handlePopUpToggle("universalAuthClientSecret", isOpen); + reset(); + setToken(""); + }} + > + +

Client ID

+
+

{identityUniversalAuth?.clientId ?? ""}

+ { + navigator.clipboard.writeText(identityUniversalAuth?.clientId ?? "") + setIsClientIdCopied.on(); + }} + > + + + {t("common.click-to-copy")} + + +
+

New Client Secret

+ {hasToken ? ( +
+
+

We will only show this secret once

+ +
+
+

{token}

+ { + navigator.clipboard.writeText(token); + setIsClientSecretCopied.on(); + }} + > + + + {t("common.click-to-copy")} + + +
+
+ ) : ( +
+ ( + + + + )} + /> +
+ ( + +
+ + +
+
+ )} + /> + ( + +
+ + +
+
+ )} + /> +
+ + )} +

Client Secrets

+ + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + _id, + description, + clientSecretTTL, + clientSecretPrefix, + clientSecretNumUses, + clientSecretNumUsesLimit, + createdAt + }) => { + let expiresAt; + if (clientSecretTTL > 0) { + expiresAt = new Date(new Date(createdAt).getTime() + clientSecretTTL * 1000); + } + + return ( + + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
DescriptionNum UsesExpires AtClient Secret +
{description === "" ? "-" : description}{`${clientSecretNumUses}${clientSecretNumUsesLimit ? `/${clientSecretNumUsesLimit}` : ""}`}{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}{`${clientSecretPrefix}****`} + { + handlePopUpOpen("deleteUniversalAuthClientSecret", { + clientSecretPrefix, + clientSecretId: _id + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ handlePopUpToggle("deleteUniversalAuthClientSecret", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const deleteClientSecretData = (popUp?.deleteUniversalAuthClientSecret?.data as { + clientSecretId: string; + clientSecretPrefix: string; + }); + + return onDeleteClientSecretSubmit({ + clientSecretId: deleteClientSecretData.clientSecretId, + clientSecretPrefix: deleteClientSecretData.clientSecretPrefix + }); + }} + /> +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx new file mode 100644 index 000000000..a1d0a0bd2 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -0,0 +1,428 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + IconButton, + Input, +} from "@app/components/v2"; +import { + useOrganization, + useSubscription +} from "@app/context"; +import { + useAddIdentityUniversalAuth, + useGetIdentityUniversalAuth, + useUpdateIdentityUniversalAuth +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + accessTokenTTL: yup + .string() + .required("Access Token TTL is required"), + accessTokenMaxTTL: yup + .string() + .required("Access Max Token TTL is required"), + accessTokenNumUsesLimit: yup + .string() + .required("Access Token Max Number of Uses is required"), + clientSecretTrustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Client Secret Trusted IP"), + accessTokenTrustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Access Token Trusted IP") +}).required(); + +export type FormData = yup.InferType; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + } +} + +export const IdentityUniversalAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + const { subscription } = useSubscription(); + const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityUniversalAuth(); + const { data } = useGetIdentityUniversalAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + accessTokenTTL: "7200", + accessTokenMaxTTL: "0", + accessTokenNumUsesLimit: "0", + clientSecretTrustedIps: [{ + ipAddress: "0.0.0.0/0" + }], + accessTokenTrustedIps: [{ + ipAddress: "0.0.0.0/0" + }], + } + }); + + const { + fields: clientSecretTrustedIpsFields, + append: appendClientSecretTrustedIp, + remove: removeClientSecretTrustedIp + } = useFieldArray({ control, name: "clientSecretTrustedIps" }); + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { // TODO: fix data type + reset({ + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + clientSecretTrustedIps: data.clientSecretTrustedIps.map(({ + ipAddress, + prefix + }: IdentityTrustedIp) => { + return ({ + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }); + }), + accessTokenTrustedIps: data.accessTokenTrustedIps.map(({ + ipAddress, + prefix + }: IdentityTrustedIp) => { + return ({ + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }); + }) + }); + } else { + reset({ + accessTokenTTL: "7200", + accessTokenMaxTTL: "0", + accessTokenNumUsesLimit: "0", + clientSecretTrustedIps: [{ + ipAddress: "0.0.0.0/0" + }], + accessTokenTrustedIps: [{ + ipAddress: "0.0.0.0/0" + }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + clientSecretTrustedIps, + accessTokenTrustedIps + }: FormData) => { + try { + + if (!identityAuthMethodData) return; + + if (data) { + // update universal auth configuration + await updateMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + clientSecretTrustedIps, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps, + }); + + } else { + // create new universal auth configuration + + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + clientSecretTrustedIps, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps, + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${identityAuthMethodData?.authMethod ? "updated" : "configured"} auth method`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message + ?? `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`; + + createNotification({ + text, + type: "error" + }); + } + } + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {clientSecretTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeClientSecretTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/index.tsx new file mode 100644 index 000000000..3aa8cee58 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/index.tsx @@ -0,0 +1 @@ +export { IdentitySection } from "./IdentitySection"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/index.tsx new file mode 100644 index 000000000..3aa8cee58 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/index.tsx @@ -0,0 +1 @@ +export { IdentitySection } from "./IdentitySection"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/index.tsx new file mode 100644 index 000000000..38c7a320e --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/index.tsx @@ -0,0 +1 @@ +export { OrgIdentityTab } from "./OrgIdentityTab"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx new file mode 100644 index 000000000..0455b4f7d --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { OrgMembersSection } from "./components"; + +export const OrgMembersTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx new file mode 100644 index 000000000..3465dd0c0 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -0,0 +1,176 @@ +import { Controller, useForm } from "react-hook-form"; +import { + faCheck, + faCopy, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalContent, +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + useAddUserToOrg, + useFetchServerStatus +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const addMemberFormSchema = yup.object({ + email: yup.string().email().required().label("Email").trim().lowercase() +}); + +type TAddMemberForm = yup.InferType; + +type Props = { + popUp: UsePopUpState<["addMember"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void; + completeInviteLink: string; + setCompleteInviteLink: (link: string) => void; +}; + +export const AddOrgMemberModal = ({ + popUp, + handlePopUpToggle, + completeInviteLink, + setCompleteInviteLink +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + + const { data: serverDetails } = useFetchServerStatus(); + const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); + + const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addMemberFormSchema) }); + + const onAddMember = async ({ email }: TAddMemberForm) => { + if (!currentOrg?._id) return; + + try { + const { data } = await addUserMutateAsync({ + organizationId: currentOrg?._id, + inviteeEmail: email + }); + + setCompleteInviteLink(data?.completeInviteLink ??""); + + // only show this notification when email is configured. + // A [completeInviteLink] will not be sent if smtp is configured + + if (!data.completeInviteLink) { + createNotification({ + text: "Successfully invited user to the organization.", + type: "success" + }); + } + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to invite user to org", + type: "error" + }); + } + + if (serverDetails?.emailConfigured) { + handlePopUpToggle("addMember", false); + } + + reset(); + }; + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(completeInviteLink as string); + setInviteLinkCopied.on(); + }; + + return ( + { + handlePopUpToggle("addMember", isOpen); + setCompleteInviteLink(""); + }} + > + + {!completeInviteLink && ( +
+ An invite is specific to an email address and expires after 1 day. +
+ For security reasons, you will need to separately add members to projects. +
+ )} + {completeInviteLink && + "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} +
+ } + > + {!completeInviteLink && ( +
+ ( + + + + )} + /> +
+ + +
+ + )} + {completeInviteLink && ( +
+

{completeInviteLink}

+ + + + click to copy + + +
+ )} + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx new file mode 100644 index 000000000..d96e0d7c4 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmailServiceSetupModal, + UpgradePlanModal +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription, +} from "@app/context"; +import { + useDeleteOrgMembership, + useGetSSOConfig, +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddOrgMemberModal } from "./AddOrgMemberModal"; +import { OrgMembersTable } from "./OrgMembersTable"; + +export const OrgMembersSection = () => { + const { createNotification } = useNotificationContext(); + const { subscription } = useSubscription(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id ?? ""; + + const [completeInviteLink, setCompleteInviteLink] = useState(""); + + const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(orgId); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addMember", + "removeMember", + "upgradePlan", + "setUpEmail" + ] as const); + + const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); + + const isMoreUsersNotAllowed = subscription?.memberLimit + ? subscription.membersUsed >= subscription.memberLimit + : false; + + const handleAddMemberModal = () => { + if (!isLoadingSSOConfig && ssoConfig && ssoConfig.isActive) { + createNotification({ + text: "You cannot invite users when SAML SSO is configured for your organization", + type: "error" + }); + return; + } + + if (isMoreUsersNotAllowed) { + handlePopUpOpen("upgradePlan", { + description: "You can add more members if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("addMember"); + } + } + + const onRemoveMemberSubmit = async (orgMembershipId: string) => { + try { + await deleteMutateAsync({ + orgId, + membershipId: orgMembershipId + }); + + createNotification({ + text: "Successfully removed user from org", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove user from the organization", + type: "error" + }); + } + + handlePopUpClose("removeMember"); + } + + return ( +
+
+

+ Members +

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("removeMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveMemberSubmit( + (popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + handlePopUpToggle("setUpEmail", isOpen)} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx new file mode 100644 index 000000000..dfd6169cd --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -0,0 +1,272 @@ +import { useCallback,useMemo, useState } from "react"; +import { + faMagnifyingGlass, + faUsers, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription, + useUser} from "@app/context"; +import { + useAddUserToOrg, + useFetchServerStatus, + useGetOrgUsers, + useGetRoles, + useUpdateOrgUserRole +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>, + data?: { + orgMembershipId?: string; + email?: string; + description?: string; + } + ) => void; + setCompleteInviteLink: (link: string) => void; +}; + +export const OrgMembersTable = ({ + handlePopUpOpen, + setCompleteInviteLink +}: Props) => { + const { createNotification } = useNotificationContext(); + const { subscription } = useSubscription(); + const { currentOrg } = useOrganization(); + const { user } = useUser(); + const userId = user?._id || ""; + const orgId = currentOrg?._id || ""; + + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId + }); + + const [searchMemberFilter, setSearchMemberFilter] = useState(""); + + const { data: serverDetails } = useFetchServerStatus(); + const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); + + const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); + const { mutateAsync: updateUserOrgRole } = useUpdateOrgUserRole(); + + const onRoleChange = async (membershipId: string, role: string) => { + if (!currentOrg?._id) return; + + try { + // TODO: replace hardcoding default role + const isCustomRole = !["admin", "member"].includes(role); + + if (isCustomRole && subscription && !subscription?.rbac) { + handlePopUpOpen("upgradePlan", { + description: "You can assign custom roles to members if you upgrade your Infisical plan." + }); + return; + } + + await updateUserOrgRole({ + organizationId: currentOrg?._id, + membershipId, role + }); + + createNotification({ + text: "Successfully updated user role", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to update user role", + type: "error" + }); + } + }; + + const onResendInvite = async (email: string) => { + try { + + const { data } = await addUserMutateAsync({ + organizationId: orgId, + inviteeEmail: email + }); + + setCompleteInviteLink(data?.completeInviteLink || ""); + + if (!data.completeInviteLink) { + createNotification({ + text: `Successfully resent invite to ${email}`, + type: "success" + }); + } + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to resend invite to ${email}`, + type: "error" + }); + } + } + + const isLoading = isMembersLoading || isRolesLoading; + + const isIamOwner = useMemo( + () => members?.find(({ user: u }) => userId === u?._id)?.role === "owner", + [userId, members] + ); + + const findRoleFromId = useCallback( + (roleId: string) => { + return (roles || []).find(({ _id: id }) => id === roleId); + }, + [roles] + ); + + const filterdUser = useMemo( + () => + members?.filter( + ({ user: u, inviteEmail }) => + u?.firstName?.toLowerCase().includes(searchMemberFilter) || + u?.lastName?.toLowerCase().includes(searchMemberFilter) || + u?.email?.toLowerCase().includes(searchMemberFilter) || + inviteEmail?.includes(searchMemberFilter) + ), + [members, searchMemberFilter] + ); + + return ( +
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && + filterdUser?.map( + ({ user: u, inviteEmail, role, customRole, _id: orgMembershipId, status }) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + return ( + + + + + + + ); + } + )} + +
NameEmailRole +
{name}{email} + + {(isAllowed) => ( + <> + {status === "accepted" && ( + + )} + {(status === "invited" || status === "verified") && + serverDetails?.emailConfigured && ( + + )} + + )} + + + {userId !== u?._id && ( + + {(isAllowed) => ( + { + handlePopUpOpen("removeMember", { orgMembershipId, email }) + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + + )} +
+ {!isLoading && filterdUser?.length === 0 && ( + + )} +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/index.tsx new file mode 100644 index 000000000..306158a16 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/index.tsx @@ -0,0 +1 @@ +export { OrgMembersSection } from "./OrgMembersSection"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/index.tsx new file mode 100644 index 000000000..306158a16 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/index.tsx @@ -0,0 +1 @@ +export { OrgMembersSection } from "./OrgMembersSection"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/index.tsx new file mode 100644 index 000000000..8b20853b9 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/index.tsx @@ -0,0 +1 @@ +export { OrgMembersTab } from "./OrgMembersTab"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx deleted file mode 100644 index c08a1cf1e..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ /dev/null @@ -1,577 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useRouter } from "next/router"; -import { - faCheck, - faCopy, - faMagnifyingGlass, - faPlus, - faTrash, - faUsers -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; - -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { OrgPermissionCan } from "@app/components/permissions"; -import { - decryptAssymmetric, - encryptAssymmetric -} from "@app/components/utilities/cryptography/crypto"; -import { - Button, - DeleteActionModal, - EmailServiceSetupModal, - EmptyState, - FormControl, - IconButton, - Input, - Modal, - ModalContent, - Select, - SelectItem, - Table, - TableContainer, - TableSkeleton, - Tag, - TBody, - Td, - Th, - THead, - Tr, - UpgradePlanModal -} from "@app/components/v2"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - useOrganization, - useSubscription, - useUser, - useWorkspace -} from "@app/context"; -import { usePopUp, useToggle } from "@app/hooks"; -import { - useAddUserToOrg, - useDeleteOrgMembership, - useGetOrgUsers, - useGetSSOConfig, - useGetUserWorkspaceMemberships, - useGetUserWsKey, - useUpdateOrgUserRole, - useUploadWsKey -} from "@app/hooks/api"; -import { TRole } from "@app/hooks/api/roles/types"; -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) => { - const router = useRouter(); - const { createNotification } = useNotificationContext(); - - const { currentOrg } = useOrganization(); - const { workspaces, currentWorkspace } = useWorkspace(); - const { user } = useUser(); - const userId = user?._id || ""; - const orgId = currentOrg?._id || ""; - const workspaceId = currentWorkspace?._id || ""; - - const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(orgId); - const [searchMemberFilter, setSearchMemberFilter] = useState(""); - const { data: serverDetails } = useFetchServerStatus(); - - const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "addMember", - "removeMember", - "upgradePlan", - "setUpEmail" - ] as const); - const { subscription } = useSubscription(); - - const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); - const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = - useGetUserWorkspaceMemberships(orgId); - const { data: wsKey } = useGetUserWsKey(workspaceId); - - const removeUserOrgMembership = useDeleteOrgMembership(); - const addUserToOrg = useAddUserToOrg(); - const updateOrgUserRole = useUpdateOrgUserRole(); - const uploadWsKey = useUploadWsKey(); - - const [completeInviteLink, setCompleteInviteLink] = useState(""); - - const isMoreUsersNotAllowed = subscription?.memberLimit - ? subscription.membersUsed >= subscription.memberLimit - : false; - - useEffect(() => { - if (router.query.action === "invite") { - handlePopUpOpen("addMember"); - } - }, []); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ resolver: yupResolver(addMemberFormSchema) }); - - const onAddMember = async ({ email }: TAddMemberForm) => { - if (!currentOrg?._id) return; - - try { - const { data } = await addUserToOrg.mutateAsync({ - organizationId: currentOrg?._id, - inviteeEmail: email - }); - setCompleteInviteLink(data?.completeInviteLink); - // only show this notification when email is configured. - // A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLink) { - createNotification({ - text: "Successfully invited user to the organization.", - type: "success" - }); - } - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to invite user to org", - type: "error" - }); - } - if (serverDetails?.emailConfigured) { - handlePopUpClose("addMember"); - } - reset(); - }; - - const onAddUserToOrg = async (email: string) => { - if (!currentOrg?._id) return; - - try { - const { data } = await addUserToOrg.mutateAsync({ - organizationId: currentOrg?._id, - inviteeEmail: email - }); - setCompleteInviteLink(data?.completeInviteLink); - - // only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLink) { - createNotification({ - text: "Successfully invited user to the organization.", - type: "success" - }); - } - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to invite user to org", - type: "error" - }); - } - }; - - const onRemoveOrgMemberApproved = async () => { - const membershipId = (popUp?.removeMember?.data as { id: string })?.id; - if (!currentOrg?._id) return; - - try { - await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId }); - createNotification({ - text: "Successfully removed user from org", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to remove user from the organization", - type: "error" - }); - } - handlePopUpClose("removeMember"); - }; - - const isIamOwner = useMemo( - () => members?.find(({ user: u }) => userId === u?._id)?.role === "owner", - [userId, members] - ); - - const findRoleFromId = useCallback( - (roleId: string) => { - return roles.find(({ _id: id }) => id === roleId); - }, - [roles] - ); - - const filterdUser = useMemo( - () => - members?.filter( - ({ user: u, inviteEmail }) => - u?.firstName?.toLowerCase().includes(searchMemberFilter) || - u?.lastName?.toLowerCase().includes(searchMemberFilter) || - u?.email?.toLowerCase().includes(searchMemberFilter) || - inviteEmail?.includes(searchMemberFilter) - ), - [members, searchMemberFilter] - ); - - useEffect(() => { - let timer: NodeJS.Timeout; - if (isInviteLinkCopied) { - timer = setTimeout(() => setInviteLinkCopied.off(), 2000); - } - return () => clearTimeout(timer); - }, [isInviteLinkCopied]); - - const onRoleChange = async (membershipId: string, role: string) => { - if (!currentOrg?._id) return; - - try { - await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role }); - createNotification({ - text: "Successfully updated user role", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update user role", - type: "error" - }); - } - }; - - const onGrantAccess = async (grantedUserId: string, publicKey: string) => { - try { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - if (!PRIVATE_KEY || !wsKey) return; - - // assymmetrically decrypt symmetric key with local private key - const key = decryptAssymmetric({ - ciphertext: wsKey.encryptedKey, - nonce: wsKey.nonce, - publicKey: wsKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: key, - publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKey.mutateAsync({ - userId: grantedUserId, - nonce, - encryptedKey: ciphertext, - workspaceId: currentWorkspace?._id || "" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to grant access to user", - type: "error" - }); - } - }; - - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(completeInviteLink as string); - setInviteLinkCopied.on(); - }; - - const isLoading = isMembersLoading || IsWsMembershipLoading || isRolesLoading; - - return ( -
-
-
- setSearchMemberFilter(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> -
- - {(isAllowed) => ( - - )} - -
-
- - - - - - - - - - - - {isLoading && } - {!isLoading && - filterdUser?.map( - ({ user: u, inviteEmail, role, customRole, _id: orgMembershipId, status }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const email = u?.email || inviteEmail; - const userWs = workspaceMemberships?.[u?._id]; - - return ( - - - - - - - - ); - } - )} - -
NameEmailRoleProjects -
{name}{email} - - {(isAllowed) => ( - <> - {status === "accepted" && ( - - )} - {(status === "invited" || status === "verified") && - serverDetails?.emailConfigured && ( - - )} - {status === "completed" && ( - - )} - - )} - - - {userWs ? ( - userWs?.map(({ name: wsName, _id }) => ( - - {wsName} - - )) - ) : ( -
- {(status === "invited" || status === "verified") && - serverDetails?.emailConfigured ? ( - - This user hasn't accepted the invite yet - - ) : ( - - This user isn't part of any projects yet - - )} - {router.query.id !== "undefined" && - !( - (status === "invited" || status === "verified") && - serverDetails?.emailConfigured - ) && ( - - )} -
- )} -
- {userId !== u?._id && ( - - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { id: orgMembershipId }) - } - > - - - )} - - )} -
- {!isLoading && filterdUser?.length === 0 && ( - - )} -
-
- { - handlePopUpToggle("addMember", isOpen); - setCompleteInviteLink(undefined); - }} - > - - {!completeInviteLink && ( -
- An invite is specific to an email address and expires after 1 day. -
- For security reasons, you will need to separately add members to projects. -
- )} - {completeInviteLink && - "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} -
- } - > - {!completeInviteLink && ( -
- ( - - - - )} - /> -
- - -
- - )} - {completeInviteLink && ( -
-

{completeInviteLink}

- - - - click to copy - - -
- )} - - - handlePopUpToggle("removeMember", isOpen)} - onDeleteApproved={onRemoveOrgMemberApproved} - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You can add custom environments if you switch to Infisical's Team plan." - /> - handlePopUpToggle("setUpEmail", isOpen)} - /> - - ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/index.tsx deleted file mode 100644 index a3d56f438..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgMembersTable } from "./OrgMembersTable"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index 4caeb4553..0b63986aa 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -5,17 +5,16 @@ import { faContactCard, faMagnifyingGlass, faMoneyBill, + faServer, faSignIn, faUserCog, - faUsers -} from "@fortawesome/free-solid-svg-icons"; + faUsers} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2"; -import { useOrganization, useSubscription } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { useCreateRole, useUpdateRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; @@ -40,6 +39,12 @@ const SIMPLE_PERMISSION_OPTIONS = [ icon: faUsers, formName: "member" }, + { + title: "Machine identity management", + subtitle: "Create, view, update and remove (machine) identities from the organization", + icon: faServer, + formName: "identity" + }, { title: "Billing & usage", subtitle: "Modify organization subscription plan", @@ -79,10 +84,7 @@ const SIMPLE_PERMISSION_OPTIONS = [ ] as const; export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { - const { subscription } = useSubscription(); - const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["upgradePlan"] as const); - - const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || ""); + const isNonEditable = ["owner", "admin", "member", "no-access"].includes(role?.slug || ""); const isNewRole = !role?.slug; const { createNotification } = useNotificationContext(); @@ -121,11 +123,6 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { }; const handleFormSubmit = async (el: TFormSchema) => { - if (subscription && !subscription?.rbac) { - handlePopUpOpen("upgradePlan"); - return; - } - if (!isNewRole) { await handleRoleUpdate(el); return; @@ -229,17 +226,6 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { - {subscription && ( - handlePopUpToggle("upgradePlan", isOpen)} - text={ - subscription.slug === null - ? "You can use RBAC under an Enterprise license" - : "You can use RBAC if you switch to Infisical's Team Plan." - } - /> - )} ); }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts index 92353d653..18ffa174b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -31,7 +31,8 @@ export const formSchema = z.object({ "incident-contact": generalPermissionSchema, "secret-scanning": generalPermissionSchema, sso: generalPermissionSchema, - billing: generalPermissionSchema + billing: generalPermissionSchema, + "identity": generalPermissionSchema }) .optional() }); 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..3a4bc3dfd 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,9 +92,9 @@ 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); + const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug); return ( diff --git a/frontend/src/views/Org/MembersPage/components/index.tsx b/frontend/src/views/Org/MembersPage/components/index.tsx new file mode 100644 index 000000000..9e13e717a --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/index.tsx @@ -0,0 +1,3 @@ +export { OrgIdentityTab } from "./OrgIdentityTab"; +export { OrgMembersTab } from "./OrgMembersTab"; +export { OrgRoleTabSection } from "./OrgRoleTabSection"; \ No newline at end of file diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index b48cce4e3..59fb9af36 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -50,11 +50,11 @@ export const LogsFilter = ({ control, reset }: Props) => { {actor.metadata.name} ); - case ActorType.SERVICE_V3: + case ActorType.IDENTITY: return ( {actor.metadata.name} diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx index 273347ae2..eccd843ce 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx @@ -31,6 +31,7 @@ const AUDIT_LOG_LIMIT = 15; export const LogsTable = ({ eventType, userAgentType, actor, startDate, endDate }: Props) => { const { currentWorkspace } = useWorkspace(); + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs( currentWorkspace?._id ?? "", { diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 52f35393a..153ebf33a 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -29,11 +29,11 @@ export const LogsTableRow = ({

Service token

); - case ActorType.SERVICE_V3: + case ActorType.IDENTITY: return (

{`${actor.metadata.name}`}

-

Service token V3

+

Machine Identity

); default: @@ -167,22 +167,24 @@ export const LogsTableRow = ({

{`Name: ${event.metadata.name}`}

); - case EventType.CREATE_SERVICE_TOKEN_V3: + case EventType.CREATE_IDENTITY: return ( +

{`ID: ${event.metadata.identityId}`}

{`Name: ${event.metadata.name}`}

); - case EventType.UPDATE_SERVICE_TOKEN_V3: + case EventType.UPDATE_IDENTITY: return ( +

{`ID: ${event.metadata.identityId}`}

{`Name: ${event.metadata.name}`}

); - case EventType.DELETE_SERVICE_TOKEN_V3: + case EventType.DELETE_IDENTITY: return ( -

{`Name: ${event.metadata.name}`}

+

{`ID: ${event.metadata.identityId}`}

); case EventType.CREATE_ENVIRONMENT: diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index a9b10b607..f4e88de4e 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -5,13 +5,17 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { MemberListTab } from "./components/MemberListTab"; -import { ProjectRoleListTab } from "./components/ProjectRoleListTab"; -import { ServiceTokenTab } from "./components/ServiceTokenTab"; +import { + IdentityTab, + MemberListTab, + ProjectRoleListTab, + ServiceTokenTab +} from "./components"; enum TabSections { Member = "members", Roles = "roles", + Identities = "identities", ServiceTokens = "service-tokens" } @@ -21,13 +25,21 @@ export const MembersPage = withProjectPermission(

- Access Control + Project Access Control

- Members + People + +
+

Machine Identities

+
+ New +
+
+
Service Tokens - Roles + Project Roles
+ + + diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx new file mode 100644 index 000000000..1f928daae --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -0,0 +1,19 @@ +import { motion } from "framer-motion"; + +import { + IdentitySection, +} from "./components"; + +export const IdentityTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx new file mode 100644 index 000000000..6cc8d9def --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx @@ -0,0 +1,203 @@ +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 { + useAddIdentityToWorkspace, + useGetIdentityMembershipOrgs, + useGetRoles, + useGetWorkspaceIdentityMemberships +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + identityId: yup.string().required("Identity id is required"), + role: yup.string() +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["identity"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; +}; + +export const IdentityModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const orgId = currentOrg?._id || ""; + const workspaceId = currentWorkspace?._id || ""; + + const { data: identityMembershipOrgs } = useGetIdentityMembershipOrgs(orgId); + const { data: identityMemberships } = useGetWorkspaceIdentityMemberships(workspaceId); + + const { data: roles } = useGetRoles({ + orgId, + workspaceId + }); + + const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); + + const filteredIdentityMembershipOrgs = useMemo(() => { + const wsIdentityIds = new Map(); + + identityMemberships?.forEach((identityMembership) => { + wsIdentityIds.set(identityMembership.identity._id, true); + }); + + return (identityMembershipOrgs || []).filter( + ({ identity: i }) => !wsIdentityIds.has(i._id) + ); + }, [identityMembershipOrgs, identityMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const onFormSubmit = async ({ + identityId, + role + }: FormData) => { + try { + + await addIdentityToWorkspaceMutateAsync({ + workspaceId, + identityId, + role: role || undefined + }); + + createNotification({ + text: "Successfully added identity to project", + type: "success" + }); + + reset(); + handlePopUpToggle("identity", false); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message + ?? "Failed to add identity to project"; + + createNotification({ + text, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("identity", isOpen); + reset(); + }} + > + + {filteredIdentityMembershipOrgs.length ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
All identities in your organization are already added.
+ + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx new file mode 100644 index 000000000..6212d371a --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.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 { useDeleteIdentityFromWorkspace } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { IdentityModal } from "./IdentityModal"; +import { IdentityTable } from "./IdentityTable"; + +export const IdentitySection = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?._id ?? ""; + + const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "identity", + "deleteIdentity", + "upgradePlan" + ] as const); + + const onRemoveIdentitySubmit = async (identityId: string) => { + try { + + await deleteMutateAsync({ + identityId, + workspaceId + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteIdentity"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove identity from project" + + createNotification({ + text, + type: "error" + }); + } + } + + return ( +
+
+

+ Identities +

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } +); \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx new file mode 100644 index 000000000..5ce979e65 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx @@ -0,0 +1,191 @@ +import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +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 { + EmptyState, + IconButton, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace, +} from "@app/context"; +import { + useGetRoles, + useGetWorkspaceIdentityMemberships, + useUpdateIdentityWorkspaceRole} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, + data?: { + identityId?: string; + name?: string; + } + ) => void; +}; + +export const IdentityTable = ({ + handlePopUpOpen +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + const orgId = currentOrg?._id || ""; + const workspaceId = currentWorkspace?._id || ""; + const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?._id || ""); + + const { data: roles } = useGetRoles({ + orgId, + workspaceId + }); + + const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole(); + + const handleChangeRole = async ({ + identityId, + role + }: { + identityId: string; + role: string; + }) => { + try { + + await updateMutateAsync({ + identityId, + workspaceId, + role + }); + + createNotification({ + text: "Successfully updated identity role", + type: "success" + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to update identity role" + + createNotification({ + text, + type: "error" + }); + } + } + + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + identity: { + _id, + name + }, + role, + customRole, + createdAt + }) => { + return ( + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameRoleAdded on +
{name} + + {(isAllowed) => { + return ( + + ); + }} + + {format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity", { + identityId: _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/IdentityTab/components/IdentitySection/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx new file mode 100644 index 000000000..3aa8cee58 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx @@ -0,0 +1 @@ +export { IdentitySection } from "./IdentitySection"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx new file mode 100644 index 000000000..3aa8cee58 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx @@ -0,0 +1 @@ +export { IdentitySection } from "./IdentitySection"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/index.tsx new file mode 100644 index 000000000..7b6d6dbc5 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/index.tsx @@ -0,0 +1 @@ +export { IdentityTab } from "./IdentityTab"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 758970e2d..f264744e8 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons"; +import { faMagnifyingGlass, faPlus, faUsers,faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -38,9 +38,9 @@ import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, + useSubscription, useUser, - useWorkspace -} from "@app/context"; + useWorkspace} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useAddUserToWs, @@ -60,6 +60,7 @@ type TAddMemberForm = z.infer; export const MemberListTab = () => { const { createNotification } = useNotificationContext(); + const { subscription } = useSubscription(); const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -78,7 +79,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([ @@ -170,6 +171,15 @@ export const MemberListTab = () => { if (!currentOrg?._id) return; try { + const isCustomRole = !["admin", "member", "viewer"].includes(role); + + if (isCustomRole && subscription && !subscription?.rbac) { + handlePopUpOpen("upgradePlan", { + description: "You can assign custom roles to members if you upgrade your Infisical plan." + }); + return; + } + await updateUserWorkspaceRole({ membershipId, role }); createNotification({ text: "Successfully updated user role", @@ -205,7 +215,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; @@ -243,29 +253,35 @@ export const MemberListTab = () => { const isLoading = isMembersLoading || isRolesLoading; return ( -
-
-
- setSearchMemberFilter(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> -
- - {(isAllowed) => ( - - )} +
+
+

+ Members +

+ + {(isAllowed) => ( + + )}
-
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
@@ -273,7 +289,7 @@ export const MemberListTab = () => { - @@ -296,7 +312,7 @@ export const MemberListTab = () => { {(isAllowed) => ( <> diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index dbfd6ec03..a550d4e33 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -9,6 +9,7 @@ import { faLock, faNetworkWired, faPuzzlePiece, + faServer, faShield, faTags, faUser, @@ -18,9 +19,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2"; -import { ProjectPermissionSub, useOrganization, useSubscription, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { ProjectPermissionSub, useOrganization, useWorkspace } from "@app/context"; import { useCreateRole, useUpdateRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; @@ -60,6 +60,12 @@ const SINGLE_PERMISSION_LIST = [ icon: faUser, formName: "member" }, + { + title: "Machine identity management", + subtitle: "Add, view, update and remove (machine) identities from the project", + icon: faServer, + formName: "identity" + }, { title: "Webhooks", subtitle: "Webhook management control", @@ -110,16 +116,13 @@ type Props = { }; export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { - const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["upgradePlan"] as const); - - const isNonEditable = ["admin", "member", "viewer"].includes(role?.slug || ""); + const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(role?.slug || ""); const isNewRole = !role?.slug; const { createNotification } = useNotificationContext(); const { currentOrg } = useOrganization(); const orgId = currentOrg?._id || ""; const { currentWorkspace } = useWorkspace(); - const { subscription } = useSubscription(); const workspaceId = currentWorkspace?._id || ""; const { @@ -137,7 +140,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { const handleRoleUpdate = async (el: TFormSchema) => { if (!role?._id) return; - + try { await updateRole({ orgId, @@ -155,11 +158,6 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { }; const handleFormSubmit = async (el: TFormSchema) => { - if (subscription && !subscription?.rbac) { - handlePopUpOpen("upgradePlan"); - return; - } - if (!isNewRole) { await handleRoleUpdate(el); return; @@ -282,17 +280,6 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { - {subscription && ( - handlePopUpToggle("upgradePlan", isOpen)} - text={ - subscription.slug === null - ? "You can use RBAC under an Enterprise license" - : "You can use RBAC if you switch to Infisical's Team Plan." - } - /> - )} ); }; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index fec02934c..c6175bd58 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -33,6 +33,7 @@ export const formSchema = z.object({ .object({ secrets: z.record(multiEnvPermissionSchema).optional(), member: generalPermissionSchema, + "identity": generalPermissionSchema, role: generalPermissionSchema, integrations: generalPermissionSchema, webhooks: generalPermissionSchema, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx index 54a58576f..84a4c1c9a 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -23,6 +23,7 @@ type Props = { | "tags" | "audit-logs" | "ip-allowlist" + | "identity" | ProjectPermissionSub.SecretApproval; isNonEditable?: boolean; setValue: UseFormSetValue; diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx index 86ae8bfe5..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/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx deleted file mode 100644 index cfe5937d7..000000000 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx +++ /dev/null @@ -1,539 +0,0 @@ -import { useEffect, useState } from "react"; -import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import { motion } from "framer-motion"; -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, - IconButton, - Input, - Modal, - ModalContent, - Select, - SelectItem, - Switch, - Tab, - TabList, - TabPanel, - Tabs, - UpgradePlanModal} from "@app/components/v2"; -import { - useOrganization, - useSubscription, - useWorkspace -} from "@app/context"; -import { useToggle } from "@app/hooks"; -import { - useCreateServiceTokenV3, - useGetRoles, - useGetUserWsKey, - useUpdateServiceTokenV3} from "@app/hooks/api"; -import { ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -enum TabSections { - General = "general", - Advanced = "advanced" -} - -const expirations = [ - { label: "Never", value: "" }, - { label: "1 day", value: "86400" }, - { label: "7 days", value: "604800" }, - { label: "1 month", value: "2592000" }, - { label: "6 months", value: "15552000" }, - { label: "12 months", value: "31104000" } -]; - -const schema = yup.object({ - name: yup.string().required("ST V3 name is required"), - expiresIn: yup.string(), - accessTokenTTL: yup - .string() - .test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => { - if (typeof value === "undefined") { - return false; - } - - const num = parseInt(value, 10); - return !Number.isNaN(num) && num > 0 && String(num) === value; - }) - .required("Access Token TTL is required"), - role: yup.string().required("ST V3 role is required"), - trustedIps: yup - .array( - yup.object({ - ipAddress: yup.string().max(50).required().label("IP Address") - }) - ) - .min(1) - .required() - .label("Trusted IP"), - isRefreshTokenRotationEnabled: yup.boolean().default(false) -}).required(); - -export type FormData = yup.InferType; - -type Props = { - popUp: UsePopUpState<["serviceTokenV3", "upgradePlan"]>; - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["serviceTokenV3", "upgradePlan"]>, state?: boolean) => void; -}; - -export const AddServiceTokenV3Modal = ({ - popUp, - handlePopUpOpen, - handlePopUpToggle -}: Props) => { - const [newServiceTokenJSON, setNewServiceTokenJSON] = useState(""); - const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false); - - const { subscription } = useSubscription(); - const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); - - const orgId = currentOrg?._id || ""; - const workspaceId = currentWorkspace?._id || ""; - - const { data: roles } = useGetRoles({ - orgId, - workspaceId - }); - - const { data: latestFileKey } = useGetUserWsKey(workspaceId); - const { mutateAsync: createMutateAsync } = useCreateServiceTokenV3(); - const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); - const { createNotification } = useNotificationContext(); - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: yupResolver(schema), - defaultValues: { - name: "", - accessTokenTTL: "7200", - trustedIps: [{ - ipAddress: "0.0.0.0/0" - }] - } - }); - - useEffect(() => { - let timer: NodeJS.Timeout; - - if (isServiceTokenJSONCopied) { - timer = setTimeout(() => setIsServiceTokenJSONCopied.off(), 2000); - } - - return () => clearTimeout(timer); - }, [setIsServiceTokenJSONCopied]); - - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(newServiceTokenJSON); - setIsServiceTokenJSONCopied.on(); - }; - - useEffect(() => { - const serviceTokenData = popUp?.serviceTokenV3?.data as { - serviceTokenDataId: string; - name: string; - role: string; - customRole: { - name: string; - slug: string; - }; - trustedIps: ServiceTokenV3TrustedIp[]; - accessTokenTTL: number; - isRefreshTokenRotationEnabled: boolean; - }; - - if (!roles?.length) return; - - if (serviceTokenData) { - reset({ - name: serviceTokenData.name, - role: serviceTokenData?.customRole?.slug ?? serviceTokenData.role, - trustedIps: serviceTokenData.trustedIps.map(({ - ipAddress, - prefix - }: ServiceTokenV3TrustedIp) => { - return ({ - ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` - }); - }), - accessTokenTTL: String(serviceTokenData.accessTokenTTL), - isRefreshTokenRotationEnabled: serviceTokenData.isRefreshTokenRotationEnabled - }); - } else { - reset({ - name: "", - accessTokenTTL: "7200", - role: roles[0].slug, - trustedIps: [{ - ipAddress: "0.0.0.0/0" - }] - }); - } - }, [popUp?.serviceTokenV3?.data, roles]); - - const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" }); - - const onFormSubmit = async ({ - name, - expiresIn, - accessTokenTTL, - role, - trustedIps, - isRefreshTokenRotationEnabled - }: FormData) => { - try { - const serviceTokenData = popUp?.serviceTokenV3?.data as { - serviceTokenDataId: string; - name: string; - role: string; - }; - - if (serviceTokenData) { - // update - - await updateMutateAsync({ - serviceTokenDataId: serviceTokenData.serviceTokenDataId, - name, - role, - trustedIps, - expiresIn: expiresIn === "" ? undefined : Number(expiresIn), - accessTokenTTL: Number(accessTokenTTL), - isRefreshTokenRotationEnabled - }); - - 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, - 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); - } - - createNotification({ - text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, - type: "success" - }); - - reset(); - } catch (err) { - console.error(err); - createNotification({ - text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, - type: "error" - }); - } - } - - const hasServiceTokenJSON = Boolean(newServiceTokenJSON); - - return ( - { - handlePopUpToggle("serviceTokenV3", isOpen); - reset(); - setNewServiceTokenJSON(""); - }} - > - - {!hasServiceTokenJSON ? ( -
- - -
- General - Advanced -
-
- - - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - - - -
- {tokenTrustedIps.map(({ id }, index) => ( -
- { - return ( - - { - if (subscription?.ipAllowlisting) { - field.onChange(e); - return; - } - - handlePopUpOpen("upgradePlan"); - }} - placeholder="123.456.789.0" - /> - - ); - }} - /> - { - if (subscription?.ipAllowlisting) { - removeTrustedIp(index); - return; - } - - handlePopUpOpen("upgradePlan"); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="p-3" - > - - -
- ))} -
- -
- ( - - - - )} - /> -
- ( - onChange(isChecked)} - isChecked={value} - > - Refresh Token Rotation - - )} - /> -

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

-
-
-
-
-
- - -
- - ) : ( -
-

{newServiceTokenJSON}

- - - - Click to copy - - -
- )} - handlePopUpToggle("upgradePlan", isOpen)} - text="You can use IP allowlisting if you switch to Infisical's Pro plan." - /> -
-
- ); -} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx deleted file mode 100644 index 3b00202f9..000000000 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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 } from "@app/context"; -import { withProjectPermission } from "@app/hoc"; -import { - useDeleteServiceTokenV3 -} 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 { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3(); - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "serviceTokenV3", - "deleteServiceTokenV3", - "upgradePlan" - ] as const); - - const onDeleteServiceTokenDataSubmit = async (serviceTokenDataId: string) => { - try { - await deleteMutateAsync({ - serviceTokenDataId - }); - createNotification({ - text: "Successfully deleted service token v3", - type: "success" - }); - - handlePopUpClose("deleteServiceTokenV3"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete service token v3", - type: "error" - }); - } - } - - return ( -
-
-

- Service Tokens V3 (Beta) -

- - {(isAllowed) => ( - - )} - -
- - - handlePopUpToggle("deleteServiceTokenV3", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onDeleteServiceTokenDataSubmit( - (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/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx deleted file mode 100644 index 2bfa23255..000000000 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { faKey, faPencil,faXmark } from "@fortawesome/free-solid-svg-icons"; -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 { - EmptyState, - IconButton, - Switch, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context"; -import { - useGetWorkspaceServiceTokenDataV3, - useUpdateServiceTokenV3 -} 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 { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetWorkspaceServiceTokenDataV3(currentWorkspace?._id || ""); - const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); - - const handleToggleServiceTokenDataStatus = async ({ - serviceTokenDataId, - isActive - }: { - serviceTokenDataId: string; - isActive: boolean; - }) => { - try { - await updateMutateAsync({ - serviceTokenDataId, - isActive - }); - - createNotification({ - text: `Successfully ${isActive ? "enabled" : "disabled"} service token v3`, - type: "success" - }); - } catch (err) { - console.log(err); - createNotification({ - text: `Failed to ${isActive ? "enable" : "disable"} service token v3`, - type: "error" - }); - } - } - - return ( - -
Name Email Role +
- - - - - - - - - - - - - {isLoading && } - {!isLoading && - data && - data.length > 0 && - data.map(({ - _id, - name, - isActive, - role, - customRole, - trustedIps, - createdAt, - expiresAt, - accessTokenTTL, - isRefreshTokenRotationEnabled - }) => { - return ( - - - - - - - - - - - ); - })} - {!isLoading && data && data?.length === 0 && ( - - - - )} - -
NameStatusRoleTrusted IPsAccess Token TTLCreated AtValid Until -
{name} - - {(isAllowed) => ( - handleToggleServiceTokenDataStatus({ - serviceTokenDataId: _id, - isActive: value - })} - isChecked={isActive} - isDisabled={!isAllowed} - > -

{isActive ? "Active" : "Inactive"}

-
- )} -
-
{customRole?.slug ?? role} - {trustedIps.map(({ - _id: trustedIpId, - ipAddress, - prefix - }) => { - return ( -

- {`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`} -

- ); - })} -
{accessTokenTTL}{format(new Date(createdAt), "yyyy-MM-dd")}{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} - - {(isAllowed) => ( - { - handlePopUpOpen("serviceTokenV3", { - serviceTokenDataId: _id, - name, - role, - customRole, - trustedIps, - accessTokenTTL, - isRefreshTokenRotationEnabled - }); - }} - size="lg" - colorSchema="primary" - variant="plain" - ariaLabel="update" - isDisabled={!isAllowed} - > - - - )} - - - {(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/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx deleted file mode 100644 index b6abc117c..000000000 --- a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ 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 diff --git a/frontend/src/views/Project/MembersPage/components/index.tsx b/frontend/src/views/Project/MembersPage/components/index.tsx new file mode 100644 index 000000000..a39af27f7 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/index.tsx @@ -0,0 +1,4 @@ +export { IdentityTab } from "./IdentityTab"; +export { MemberListTab } from "./MemberListTab"; +export { ProjectRoleListTab } from "./ProjectRoleListTab"; +export { ServiceTokenTab } from "./ServiceTokenTab"; \ No newline at end of file