diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c7e58fb099..33ee885cc4 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -42,6 +42,7 @@ import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-a import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; +import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; @@ -127,6 +128,7 @@ declare module "fastify" { identity: TIdentityServiceFactory; identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; + identityTokenAuth: TIdentityTokenAuthServiceFactory; identityUa: TIdentityUaServiceFactory; identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; identityGcpAuth: TIdentityGcpAuthServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 82ca897421..9d54335bbc 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -104,6 +104,9 @@ import { TIdentityProjectMemberships, TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate, + TIdentityTokenAuths, + TIdentityTokenAuthsInsert, + TIdentityTokenAuthsUpdate, TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, TIdentityUaClientSecretsUpdate, @@ -450,6 +453,11 @@ declare module "knex/types/tables" { TIntegrationAuthsUpdate >; [TableName.Identity]: KnexOriginal.CompositeTableType; + [TableName.IdentityTokenAuth]: KnexOriginal.CompositeTableType< + TIdentityTokenAuths, + TIdentityTokenAuthsInsert, + TIdentityTokenAuthsUpdate + >; [TableName.IdentityUniversalAuth]: KnexOriginal.CompositeTableType< TIdentityUniversalAuths, TIdentityUniversalAuthsInsert, diff --git a/backend/src/db/migrations/20240702175124_identity-token-auth.ts b/backend/src/db/migrations/20240702175124_identity-token-auth.ts new file mode 100644 index 0000000000..66ca55b491 --- /dev/null +++ b/backend/src/db/migrations/20240702175124_identity-token-auth.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable(TableName.IdentityTokenAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + }); + + await createOnUpdateTrigger(knex, TableName.IdentityTokenAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityTokenAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityTokenAuth); +} diff --git a/backend/src/db/schemas/identity-token-auths.ts b/backend/src/db/schemas/identity-token-auths.ts new file mode 100644 index 0000000000..0f3c8c9ff6 --- /dev/null +++ b/backend/src/db/schemas/identity-token-auths.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityTokenAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid() +}); + +export type TIdentityTokenAuths = z.infer; +export type TIdentityTokenAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityTokenAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index af8c2070a1..bce99dfea7 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -32,6 +32,7 @@ export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; +export * from "./identity-token-auths"; export * from "./identity-ua-client-secrets"; export * from "./identity-universal-auths"; export * from "./incident-contacts"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index bdc574bcb8..1dba712094 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -53,6 +53,7 @@ export enum TableName { Webhook = "webhooks", Identity = "identities", IdentityAccessToken = "identity_access_tokens", + IdentityTokenAuth = "identity_token_auths", IdentityUniversalAuth = "identity_universal_auths", IdentityKubernetesAuth = "identity_kubernetes_auths", IdentityGcpAuth = "identity_gcp_auths", @@ -161,6 +162,7 @@ export enum ProjectUpgradeStatus { } export enum IdentityAuthMethod { + TOKEN_AUTH = "token-auth", Univeral = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 0c6ff51c8e..802fc628df 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -66,6 +66,11 @@ export enum EventType { UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", REVOKE_IDENTITY_UNIVERSAL_AUTH = "revoke-identity-universal-auth", + CREATE_TOKEN_IDENTITY_TOKEN_AUTH = "create-token-identity-token-auth", + ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", + UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", + GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", + REVOKE_IDENTITY_TOKEN_AUTH = "revoke-identity-token-auth", LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", @@ -447,6 +452,50 @@ interface DeleteIdentityUniversalAuthEvent { }; } +interface CreateTokenIdentityTokenAuthEvent { + type: EventType.CREATE_TOKEN_IDENTITY_TOKEN_AUTH; + metadata: { + identityId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityTokenAuthEvent { + type: EventType.ADD_IDENTITY_TOKEN_AUTH; + metadata: { + identityId: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityTokenAuthEvent { + type: EventType.UPDATE_IDENTITY_TOKEN_AUTH; + metadata: { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityTokenAuthEvent { + type: EventType.GET_IDENTITY_TOKEN_AUTH; + metadata: { + identityId: string; + }; +} + +interface DeleteIdentityTokenAuthEvent { + type: EventType.REVOKE_IDENTITY_TOKEN_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityKubernetesAuthEvent { type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH; metadata: { @@ -1054,6 +1103,11 @@ export type Event = | UpdateIdentityUniversalAuthEvent | DeleteIdentityUniversalAuthEvent | GetIdentityUniversalAuthEvent + | CreateTokenIdentityTokenAuthEvent + | AddIdentityTokenAuthEvent + | UpdateIdentityTokenAuthEvent + | GetIdentityTokenAuthEvent + | DeleteIdentityTokenAuthEvent | LoginIdentityKubernetesAuthEvent | DeleteIdentityKubernetesAuthEvent | AddIdentityKubernetesAuthEvent diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 54c5e51d66..540ff84ce9 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -105,6 +105,8 @@ import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kub import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; +import { identityTokenAuthDALFactory } from "@app/services/identity-token-auth/identity-token-auth-dal"; +import { identityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; import { identityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; @@ -233,6 +235,7 @@ export const registerRoutes = async ( const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); + const identityTokenAuthDAL = identityTokenAuthDALFactory(db); const identityUaDAL = identityUaDALFactory(db); const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); @@ -829,6 +832,14 @@ export const registerRoutes = async ( permissionService, identityProjectDAL }); + const identityTokenAuthService = identityTokenAuthServiceFactory({ + identityTokenAuthDAL, + identityDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + permissionService, + licenseService + }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, permissionService, @@ -967,6 +978,7 @@ export const registerRoutes = async ( identity: identityService, identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, + identityTokenAuth: identityTokenAuthService, identityUa: identityUaService, identityKubernetesAuth: identityKubernetesAuthService, identityGcpAuth: identityGcpAuthService, diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts new file mode 100644 index 0000000000..e373198033 --- /dev/null +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -0,0 +1,312 @@ +import { z } from "zod"; + +import { IdentityTokenAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; + +export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/token-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Token Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityTokenAuth: IdentityTokenAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTokenAuth = await server.services.identityTokenAuth.attachTokenAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityTokenAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: identityTokenAuth.identityId, + accessTokenTTL: identityTokenAuth.accessTokenTTL, + accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityTokenAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityTokenAuth.accessTokenNumUsesLimit + } + } + }); + + return { + identityTokenAuth + }; + } + }); + + server.route({ + method: "PATCH", + url: "/token-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Token Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + 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() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityTokenAuth: IdentityTokenAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTokenAuth = await server.services.identityTokenAuth.updateTokenAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityTokenAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: identityTokenAuth.identityId, + accessTokenTTL: identityTokenAuth.accessTokenTTL, + accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityTokenAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityTokenAuth.accessTokenNumUsesLimit + } + } + }); + + return { + identityTokenAuth + }; + } + }); + + server.route({ + method: "GET", + url: "/token-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Token Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityTokenAuth: IdentityTokenAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTokenAuth = await server.services.identityTokenAuth.getTokenAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityTokenAuth.orgId, + event: { + type: EventType.GET_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: identityTokenAuth.identityId + } + } + }); + + return { identityTokenAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/token-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete Token Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityTokenAuth: IdentityTokenAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTokenAuth = await server.services.identityTokenAuth.revokeIdentityTokenAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityTokenAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: identityTokenAuth.identityId + } + } + }); + + return { identityTokenAuth }; + } + }); + + server.route({ + method: "POST", + url: "/token-auth/identities/:identityId/token", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create token for identity with Token Auth configured", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityTokenAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityTokenAuth.createTokenTokenAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.orgId, + event: { + type: EventType.CREATE_TOKEN_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: identityTokenAuth.identityId, + identityAccessTokenId: identityAccessToken.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityTokenAuth.accessTokenTTL, + accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index c2969b382e..6c6ff3b513 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -9,6 +9,7 @@ import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; +import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; @@ -33,6 +34,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register( async (authRouter) => { await authRouter.register(registerAuthRoutes); + await authRouter.register(registerIdentityTokenAuthRouter); await authRouter.register(registerIdentityUaRouter); await authRouter.register(registerIdentityKubernetesRouter); await authRouter.register(registerIdentityGcpAuthRouter); diff --git a/backend/src/services/identity-token-auth/identity-token-auth-dal.ts b/backend/src/services/identity-token-auth/identity-token-auth-dal.ts new file mode 100644 index 0000000000..64f5c9e7a2 --- /dev/null +++ b/backend/src/services/identity-token-auth/identity-token-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityTokenAuthDALFactory = ReturnType; + +export const identityTokenAuthDALFactory = (db: TDbClient) => { + const tokenAuthOrm = ormify(db, TableName.IdentityTokenAuth); + return tokenAuthOrm; +}; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts new file mode 100644 index 0000000000..4b94447193 --- /dev/null +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -0,0 +1,331 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityTokenAuthDALFactory } from "./identity-token-auth-dal"; +import { + TAttachTokenAuthDTO, + TCreateTokenTokenAuthDTO, + TGetTokenAuthDTO, + TRevokeTokenAuthDTO, + TUpdateTokenAuthDTO +} from "./identity-token-auth-types"; + +type TIdentityTokenAuthServiceFactoryDep = { + identityTokenAuthDAL: TIdentityTokenAuthDALFactory; + identityDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityTokenAuthServiceFactory = ReturnType; + +export const identityTokenAuthServiceFactory = ({ + identityTokenAuthDAL, + identityDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + permissionService, + licenseService +}: TIdentityTokenAuthServiceFactoryDep) => { + const attachTokenAuth = async ({ + identityId, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachTokenAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add Token Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { + const doc = await identityTokenAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.TOKEN_AUTH + }, + tx + ); + return doc; + }); + return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateTokenAuth = async ({ + identityId, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateTokenAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH) + throw new BadRequestError({ + message: "Failed to update Token Auth" + }); + + const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityTokenAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityTokenAuth.accessTokenMaxTTL) > + (accessTokenMaxTTL || identityTokenAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedTokenAuth = await identityTokenAuthDAL.updateById(identityTokenAuth.id, { + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedTokenAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getTokenAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetTokenAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH) + throw new BadRequestError({ + message: "The identity does not have Token Auth attached" + }); + + const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId }; + }; + + const revokeIdentityTokenAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeTokenAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH) + throw new BadRequestError({ + message: "The identity does not have Token Auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke Token Auth of identity with more privileged role" + }); + + const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { + const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx); + await identityDAL.updateById(identityId, { authMethod: null }, tx); + return { ...deletedTokenAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityTokenAuth; + }; + + const createTokenTokenAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TCreateTokenTokenAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH) + throw new BadRequestError({ + message: "The identity does not have Token Auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to revoke Token Auth of identity with more privileged role" + }); + + const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); + + const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityTokenAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityTokenAuth.accessTokenTTL, + accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityTokenAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityTokenAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityTokenAuth, identityAccessToken, identityMembershipOrg }; + }; + + return { + attachTokenAuth, + updateTokenAuth, + getTokenAuth, + revokeIdentityTokenAuth, + createTokenTokenAuth + }; +}; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-types.ts b/backend/src/services/identity-token-auth/identity-token-auth-types.ts new file mode 100644 index 0000000000..3115b0237a --- /dev/null +++ b/backend/src/services/identity-token-auth/identity-token-auth-types.ts @@ -0,0 +1,29 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TAttachTokenAuthDTO = { + identityId: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateTokenAuthDTO = { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetTokenAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeTokenAuthDTO = { + identityId: string; +} & Omit; + +export type TCreateTokenTokenAuthDTO = { + identityId: string; +} & Omit; diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 51495d4f24..bfa5147451 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -1,6 +1,7 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { + [IdentityAuthMethod.TOKEN_AUTH]: "Token Auth", [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth", [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 66af910939..4ad5337f16 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,4 +1,5 @@ export enum IdentityAuthMethod { + TOKEN_AUTH = "token-auth", UNIVERSAL_AUTH = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 41b03669b9..e615ae561b 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -5,9 +5,11 @@ export { useAddIdentityAzureAuth, useAddIdentityGcpAuth, useAddIdentityKubernetesAuth, + useAddIdentityTokenAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, + useCreateTokenIdentityTokenAuth, useDeleteIdentity, useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, @@ -15,13 +17,13 @@ export { useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, useUpdateIdentityKubernetesAuth, - useUpdateIdentityUniversalAuth -} from "./mutations"; + useUpdateIdentityTokenAuth, + useUpdateIdentityUniversalAuth} from "./mutations"; export { useGetIdentityAwsAuth, useGetIdentityAzureAuth, useGetIdentityGcpAuth, useGetIdentityKubernetesAuth, + useGetIdentityTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets -} from "./queries"; + useGetIdentityUniversalAuthClientSecrets} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index cb1fe4c170..0d59ec9991 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -9,11 +9,14 @@ import { AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, AddIdentityKubernetesAuthDTO, + AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, CreateIdentityUniversalAuthClientSecretDTO, CreateIdentityUniversalAuthClientSecretRes, + CreateTokenIdentityTokenAuthDTO, + CreateTokenIdentityTokenAuthRes, DeleteIdentityDTO, DeleteIdentityUniversalAuthClientSecretDTO, Identity, @@ -21,14 +24,15 @@ import { IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, + IdentityTokenAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, UpdateIdentityKubernetesAuthDTO, - UpdateIdentityUniversalAuthDTO -} from "./types"; + UpdateIdentityTokenAuthDTO, + UpdateIdentityUniversalAuthDTO} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -485,3 +489,79 @@ export const useUpdateIdentityKubernetesAuth = () => { } }); }; + +export const useAddIdentityTokenAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityTokenAuth } + } = await apiRequest.post<{ identityTokenAuth: IdentityTokenAuth }>( + `/api/v1/auth/token-auth/identities/${identityId}`, + { + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityTokenAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityTokenAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityTokenAuth } + } = await apiRequest.patch<{ identityTokenAuth: IdentityTokenAuth }>( + `/api/v1/auth/token-auth/identities/${identityId}`, + { + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityTokenAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useCreateTokenIdentityTokenAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { data } = await apiRequest.post( + `/api/v1/auth/token-auth/identities/${identityId}/token` + ); + + return data; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index eb04227eb6..4b74f086ce 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -8,6 +8,7 @@ import { IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, + IdentityTokenAuth, IdentityUniversalAuth} from "./types"; export const identitiesKeys = { @@ -19,7 +20,8 @@ export const identitiesKeys = { [{ identityId }, "identity-kubernetes-auth"] as const, getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, - getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const + getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, + getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { @@ -111,3 +113,18 @@ export const useGetIdentityKubernetesAuth = (identityId: string) => { } }); }; + +export const useGetIdentityTokenAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityTokenAuth(identityId), + queryFn: async () => { + const { + data: { identityTokenAuth } + } = await apiRequest.get<{ identityTokenAuth: IdentityTokenAuth }>( + `/api/v1/auth/token-auth/identities/${identityId}` + ); + return identityTokenAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 80d066c720..117fcbb88a 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -311,3 +311,45 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = { identityId: string; clientSecretId: string; }; + +export type IdentityTokenAuth = { + identityId: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityTokenAuthDTO = { + organizationId: string; + identityId: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityTokenAuthDTO = { + organizationId: string; + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type CreateTokenIdentityTokenAuthDTO = { + identityId: string; + organizationId: string; +}; + +export type CreateTokenIdentityTokenAuthRes = { + accessToken: string; + tokenType: string; + expiresIn: number; + accessTokenMaxTTL: number; +}; 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 index a1dc5d678a..bee7b65a6b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -18,6 +18,7 @@ import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; +import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -30,6 +31,7 @@ type Props = { }; const identityAuthMethods = [ + { label: "Token Auth", value: IdentityAuthMethod.TOKEN_AUTH }, { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, { label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH }, { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, @@ -117,6 +119,16 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog /> ); } + case IdentityAuthMethod.TOKEN_AUTH: { + return ( + + ); + } + default: { return
; } diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index 142b25dc30..abb541cfb5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -17,8 +17,6 @@ import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -// TODO: Add CA cert and token reviewer JWT fields - const schema = z .object({ kubernetesHost: z.string(), @@ -77,11 +75,11 @@ export const IdentityKubernetesAuthForm = ({ } = useForm({ resolver: zodResolver(schema), defaultValues: { - kubernetesHost: "", // TODO + kubernetesHost: "", tokenReviewerJwt: "", - allowedNames: "", // TODO - allowedNamespaces: "", // TODO - allowedAudience: "", // TODO + allowedNames: "", + allowedNamespaces: "", + allowedAudience: "", caCert: "", accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", @@ -118,7 +116,7 @@ export const IdentityKubernetesAuthForm = ({ }); } else { reset({ - kubernetesHost: "", // TODO + kubernetesHost: "", tokenReviewerJwt: "", allowedNames: "", allowedNamespaces: "", 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 index b8b1f1708c..c2acc8b4b3 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -18,6 +18,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { IdentityAuthMethodModal } from "./IdentityAuthMethodModal"; import { IdentityModal } from "./IdentityModal"; import { IdentityTable } from "./IdentityTable"; +import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal"; import { IdentityUniversalAuthClientSecretModal } from "./IdentityUniversalAuthClientSecretModal"; export const IdentitySection = withPermission( @@ -33,7 +34,8 @@ export const IdentitySection = withPermission( "deleteIdentity", "universalAuthClientSecret", "deleteUniversalAuthClientSecret", - "upgradePlan" + "upgradePlan", + "tokenAuthToken" ] as const); const isMoreIdentitiesAllowed = subscription?.identityLimit @@ -122,6 +124,11 @@ export const IdentitySection = withPermission( handlePopUpOpen={handlePopUpOpen} handlePopUpToggle={handlePopUpToggle} /> + , data?: { identityId?: string; @@ -50,6 +60,7 @@ type Props = { name: string; slug: string; }; + accessToken?: string; } ) => void; }; @@ -59,6 +70,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { const orgId = currentOrg?.id || ""; const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); + const { mutateAsync: createToken } = useCreateTokenIdentityTokenAuth(); const { data, isLoading } = useGetIdentityMembershipOrgs(orgId); const { data: roles } = useGetOrgRoles(orgId); @@ -139,6 +151,28 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { {authMethod ? identityAuthToNameMap[authMethod] : "Not configured"}
+ {authMethod === IdentityAuthMethod.TOKEN_AUTH && ( + + { + const newTokenData = await createToken({ + identityId: id, + organizationId: orgId + }); + + handlePopUpOpen("tokenAuthToken", { + accessToken: newTokenData.accessToken + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + + + )} {authMethod === IdentityAuthMethod.UNIVERSAL_AUTH && ( ; + +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 IdentityTokenAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityTokenAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityTokenAuth(); + + const { data } = useGetIdentityTokenAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + const onFormSubmit = async ({ + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + 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) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {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" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthTokenModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthTokenModal.tsx new file mode 100644 index 0000000000..ee421c6a95 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthTokenModal.tsx @@ -0,0 +1,53 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Modal, ModalContent, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["tokenAuthToken"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["tokenAuthToken"]>, state?: boolean) => void; +}; + +export const IdentityTokenAuthTokenModal = ({ popUp, handlePopUpToggle }: Props) => { + const [copyTextAccessToken, isCopyingAccessToken, setCopyTextAccessToken] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + + const popUpData = popUp?.tokenAuthToken?.data as { + accessToken: string; + }; + + return ( + { + handlePopUpToggle("tokenAuthToken", isOpen); + }} + > + + {popUpData?.accessToken && ( +
+

{popUpData.accessToken}

+ + { + navigator.clipboard.writeText(popUpData.accessToken); + setCopyTextAccessToken("Copied"); + }} + > + + + +
+ )} +
+
+ ); +};