mirror of
https://github.com/Infisical/infisical.git
synced 2025-09-07 10:22:29 +00:00
Compare commits
30 Commits
daniel/cop
...
misc/addre
Author | SHA1 | Date | |
---|---|---|---|
|
d873f2e50f | ||
|
16ea757928 | ||
|
8713643bc1 | ||
|
c35657ed49 | ||
|
5b4487fae8 | ||
|
474731d8ef | ||
|
e9f254f81b | ||
|
25191cff38 | ||
|
a6898717f4 | ||
|
cc77175188 | ||
|
fcb944d964 | ||
|
a8ad8707ac | ||
|
a7b25f3bd8 | ||
|
7896b4e85e | ||
|
ba5e6fe28a | ||
|
8d79fa3529 | ||
|
1a55909b73 | ||
|
b2efb2845a | ||
|
c680030f01 | ||
|
cf1070c65e | ||
|
9d9f6ec268 | ||
|
56aab172d3 | ||
|
c8ee06341a | ||
|
e32716c258 | ||
|
7f0d27e3dc | ||
|
5d9b99bee7 | ||
|
8fdc438940 | ||
|
3c954ea257 | ||
|
84c26581a6 | ||
|
7d5aba258a |
@@ -66,7 +66,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus
|
||||
|
||||
### Key Management (KMS):
|
||||
|
||||
- **[Cryptograhic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API.
|
||||
- **[Cryptographic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API.
|
||||
- **[Encrypt and Decrypt Data](https://infisical.com/docs/documentation/platform/kms#guide-to-encrypting-data)**: Use symmetric keys to encrypt and decrypt data.
|
||||
|
||||
### General Platform:
|
||||
|
29
backend/package-lock.json
generated
29
backend/package-lock.json
generated
@@ -49,7 +49,6 @@
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@slack/oauth": "^3.0.1",
|
||||
"@slack/web-api": "^7.3.4",
|
||||
"@team-plain/typescript-sdk": "^4.6.1",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"ajv": "^8.12.0",
|
||||
"argon2": "^0.31.2",
|
||||
@@ -5678,14 +5677,6 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@graphql-typed-document-node/core": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
|
||||
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
|
||||
"peerDependencies": {
|
||||
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.2.tgz",
|
||||
@@ -9970,18 +9961,6 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@team-plain/typescript-sdk": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-4.6.1.tgz",
|
||||
"integrity": "sha512-Uy9QJXu9U7bJb6WXL9sArGk7FXPpzdqBd6q8tAF1vexTm8fbTJRqcikTKxGtZmNADt+C2SapH3cApM4oHpO4lQ==",
|
||||
"dependencies": {
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"graphql": "^16.6.0",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@techteamer/ocsp": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz",
|
||||
@@ -15180,14 +15159,6 @@
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
"version": "16.9.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz",
|
||||
"integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
|
@@ -157,7 +157,6 @@
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@slack/oauth": "^3.0.1",
|
||||
"@slack/web-api": "^7.3.4",
|
||||
"@team-plain/typescript-sdk": "^4.6.1",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"ajv": "^8.12.0",
|
||||
"argon2": "^0.31.2",
|
||||
|
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@@ -52,6 +52,7 @@ import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-acces
|
||||
import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service";
|
||||
import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service";
|
||||
import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service";
|
||||
import { TIdentityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service";
|
||||
import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service";
|
||||
import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service";
|
||||
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
|
||||
@@ -162,6 +163,7 @@ declare module "fastify" {
|
||||
identityAwsAuth: TIdentityAwsAuthServiceFactory;
|
||||
identityAzureAuth: TIdentityAzureAuthServiceFactory;
|
||||
identityOidcAuth: TIdentityOidcAuthServiceFactory;
|
||||
identityJwtAuth: TIdentityJwtAuthServiceFactory;
|
||||
accessApprovalPolicy: TAccessApprovalPolicyServiceFactory;
|
||||
accessApprovalRequest: TAccessApprovalRequestServiceFactory;
|
||||
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
|
||||
|
8
backend/src/@types/knex.d.ts
vendored
8
backend/src/@types/knex.d.ts
vendored
@@ -98,6 +98,9 @@ import {
|
||||
TIdentityGcpAuths,
|
||||
TIdentityGcpAuthsInsert,
|
||||
TIdentityGcpAuthsUpdate,
|
||||
TIdentityJwtAuths,
|
||||
TIdentityJwtAuthsInsert,
|
||||
TIdentityJwtAuthsUpdate,
|
||||
TIdentityKubernetesAuths,
|
||||
TIdentityKubernetesAuthsInsert,
|
||||
TIdentityKubernetesAuthsUpdate,
|
||||
@@ -590,6 +593,11 @@ declare module "knex/types/tables" {
|
||||
TIdentityOidcAuthsInsert,
|
||||
TIdentityOidcAuthsUpdate
|
||||
>;
|
||||
[TableName.IdentityJwtAuth]: KnexOriginal.CompositeTableType<
|
||||
TIdentityJwtAuths,
|
||||
TIdentityJwtAuthsInsert,
|
||||
TIdentityJwtAuthsUpdate
|
||||
>;
|
||||
[TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType<
|
||||
TIdentityUaClientSecrets,
|
||||
TIdentityUaClientSecretsInsert,
|
||||
|
@@ -0,0 +1,34 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.IdentityJwtAuth))) {
|
||||
await knex.schema.createTable(TableName.IdentityJwtAuth, (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.uuid("identityId").notNullable().unique();
|
||||
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
|
||||
t.string("configurationType").notNullable();
|
||||
t.string("jwksUrl").notNullable();
|
||||
t.binary("encryptedJwksCaCert").notNullable();
|
||||
t.binary("encryptedPublicKeys").notNullable();
|
||||
t.string("boundIssuer").notNullable();
|
||||
t.string("boundAudiences").notNullable();
|
||||
t.jsonb("boundClaims").notNullable();
|
||||
t.string("boundSubject").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.IdentityJwtAuth);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.IdentityJwtAuth);
|
||||
await dropOnUpdateTrigger(knex, TableName.IdentityJwtAuth);
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.SecretVersionV2, "folderId")) {
|
||||
await knex.schema.alterTable(TableName.SecretVersionV2, (t) => {
|
||||
t.index("folderId");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.SecretVersionV2, "folderId")) {
|
||||
await knex.schema.alterTable(TableName.SecretVersionV2, (t) => {
|
||||
t.dropIndex("folderId");
|
||||
});
|
||||
}
|
||||
}
|
33
backend/src/db/schemas/identity-jwt-auths.ts
Normal file
33
backend/src/db/schemas/identity-jwt-auths.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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 { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const IdentityJwtAuthsSchema = 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(),
|
||||
identityId: z.string().uuid(),
|
||||
configurationType: z.string(),
|
||||
jwksUrl: z.string(),
|
||||
encryptedJwksCaCert: zodBuffer,
|
||||
encryptedPublicKeys: zodBuffer,
|
||||
boundIssuer: z.string(),
|
||||
boundAudiences: z.string(),
|
||||
boundClaims: z.unknown(),
|
||||
boundSubject: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TIdentityJwtAuths = z.infer<typeof IdentityJwtAuthsSchema>;
|
||||
export type TIdentityJwtAuthsInsert = Omit<z.input<typeof IdentityJwtAuthsSchema>, TImmutableDBKeys>;
|
||||
export type TIdentityJwtAuthsUpdate = Partial<Omit<z.input<typeof IdentityJwtAuthsSchema>, TImmutableDBKeys>>;
|
@@ -30,6 +30,7 @@ export * from "./identity-access-tokens";
|
||||
export * from "./identity-aws-auths";
|
||||
export * from "./identity-azure-auths";
|
||||
export * from "./identity-gcp-auths";
|
||||
export * from "./identity-jwt-auths";
|
||||
export * from "./identity-kubernetes-auths";
|
||||
export * from "./identity-metadata";
|
||||
export * from "./identity-oidc-auths";
|
||||
|
@@ -68,6 +68,7 @@ export enum TableName {
|
||||
IdentityUaClientSecret = "identity_ua_client_secrets",
|
||||
IdentityAwsAuth = "identity_aws_auths",
|
||||
IdentityOidcAuth = "identity_oidc_auths",
|
||||
IdentityJwtAuth = "identity_jwt_auths",
|
||||
IdentityOrgMembership = "identity_org_memberships",
|
||||
IdentityProjectMembership = "identity_project_memberships",
|
||||
IdentityProjectMembershipRole = "identity_project_membership_role",
|
||||
@@ -196,5 +197,6 @@ export enum IdentityAuthMethod {
|
||||
GCP_AUTH = "gcp-auth",
|
||||
AWS_AUTH = "aws-auth",
|
||||
AZURE_AUTH = "azure-auth",
|
||||
OIDC_AUTH = "oidc-auth"
|
||||
OIDC_AUTH = "oidc-auth",
|
||||
JWT_AUTH = "jwt-auth"
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas";
|
||||
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
|
||||
import { GROUPS } from "@app/lib/api-docs";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
@@ -151,7 +152,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
|
||||
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search)
|
||||
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
|
||||
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -164,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
isPartOfGroup: z.boolean()
|
||||
isPartOfGroup: z.boolean(),
|
||||
joinedGroupAt: z.date().nullable()
|
||||
})
|
||||
)
|
||||
.array(),
|
||||
|
@@ -95,6 +95,11 @@ export enum EventType {
|
||||
UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth",
|
||||
GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth",
|
||||
REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth",
|
||||
LOGIN_IDENTITY_JWT_AUTH = "login-identity-jwt-auth",
|
||||
ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth",
|
||||
UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth",
|
||||
GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth",
|
||||
REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-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",
|
||||
@@ -903,6 +908,67 @@ interface GetIdentityOidcAuthEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginIdentityJwtAuthEvent {
|
||||
type: EventType.LOGIN_IDENTITY_JWT_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
identityJwtAuthId: string;
|
||||
identityAccessTokenId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddIdentityJwtAuthEvent {
|
||||
type: EventType.ADD_IDENTITY_JWT_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
configurationType: string;
|
||||
jwksUrl?: string;
|
||||
jwksCaCert: string;
|
||||
publicKeys: string[];
|
||||
boundIssuer: string;
|
||||
boundAudiences: string;
|
||||
boundClaims: Record<string, string>;
|
||||
boundSubject: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: Array<TIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateIdentityJwtAuthEvent {
|
||||
type: EventType.UPDATE_IDENTITY_JWT_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
configurationType?: string;
|
||||
jwksUrl?: string;
|
||||
jwksCaCert?: string;
|
||||
publicKeys?: string[];
|
||||
boundIssuer?: string;
|
||||
boundAudiences?: string;
|
||||
boundClaims?: Record<string, string>;
|
||||
boundSubject?: string;
|
||||
accessTokenTTL?: number;
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteIdentityJwtAuthEvent {
|
||||
type: EventType.REVOKE_IDENTITY_JWT_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetIdentityJwtAuthEvent {
|
||||
type: EventType.GET_IDENTITY_JWT_AUTH;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateEnvironmentEvent {
|
||||
type: EventType.CREATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
@@ -1742,6 +1808,11 @@ export type Event =
|
||||
| DeleteIdentityOidcAuthEvent
|
||||
| UpdateIdentityOidcAuthEvent
|
||||
| GetIdentityOidcAuthEvent
|
||||
| LoginIdentityJwtAuthEvent
|
||||
| AddIdentityJwtAuthEvent
|
||||
| UpdateIdentityJwtAuthEvent
|
||||
| GetIdentityJwtAuthEvent
|
||||
| DeleteIdentityJwtAuthEvent
|
||||
| CreateEnvironmentEvent
|
||||
| GetEnvironmentEvent
|
||||
| UpdateEnvironmentEvent
|
||||
|
@@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
|
||||
|
||||
import { EFilterReturnedUsers } from "./group-types";
|
||||
|
||||
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
|
||||
|
||||
export const groupDALFactory = (db: TDbClient) => {
|
||||
@@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
offset = 0,
|
||||
limit,
|
||||
username, // depreciated in favor of search
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}: {
|
||||
orgId: string;
|
||||
groupId: string;
|
||||
@@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
limit?: number;
|
||||
username?: string;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
}) => {
|
||||
try {
|
||||
const query = db
|
||||
@@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.OrgMembership),
|
||||
db.ref("groupId").withSchema(TableName.UserGroupMembership),
|
||||
db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"),
|
||||
db.ref("email").withSchema(TableName.Users),
|
||||
db.ref("username").withSchema(TableName.Users),
|
||||
db.ref("firstName").withSchema(TableName.Users),
|
||||
@@ -111,17 +116,37 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`);
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case EFilterReturnedUsers.EXISTING_MEMBERS:
|
||||
void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null);
|
||||
break;
|
||||
case EFilterReturnedUsers.NON_MEMBERS:
|
||||
void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const members = await query;
|
||||
|
||||
return {
|
||||
members: members.map(
|
||||
({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({
|
||||
({
|
||||
email,
|
||||
username: memberUsername,
|
||||
firstName,
|
||||
lastName,
|
||||
userId,
|
||||
groupId: memberGroupId,
|
||||
joinedGroupAt
|
||||
}) => ({
|
||||
id: userId,
|
||||
email,
|
||||
username: memberUsername,
|
||||
firstName,
|
||||
lastName,
|
||||
isPartOfGroup: !!memberGroupId
|
||||
isPartOfGroup: !!memberGroupId,
|
||||
joinedGroupAt
|
||||
})
|
||||
),
|
||||
// @ts-expect-error col select is raw and not strongly typed
|
||||
|
@@ -222,7 +222,8 @@ export const groupServiceFactory = ({
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}: TListGroupUsersDTO) => {
|
||||
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
|
||||
|
||||
@@ -251,7 +252,8 @@ export const groupServiceFactory = ({
|
||||
offset,
|
||||
limit,
|
||||
username,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
});
|
||||
|
||||
return { users: members, totalCount };
|
||||
@@ -283,8 +285,8 @@ export const groupServiceFactory = ({
|
||||
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
|
||||
|
||||
// check if user has broader or equal to privileges than group
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission);
|
||||
if (!hasRequiredPrivileges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" });
|
||||
|
||||
const user = await userDAL.findOne({ username });
|
||||
@@ -338,8 +340,8 @@ export const groupServiceFactory = ({
|
||||
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
|
||||
|
||||
// check if user has broader or equal to privileges than group
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission);
|
||||
if (!hasRequiredPrivileges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" });
|
||||
|
||||
const user = await userDAL.findOne({ username });
|
||||
|
@@ -39,6 +39,7 @@ export type TListGroupUsersDTO = {
|
||||
limit: number;
|
||||
username?: string;
|
||||
search?: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TAddUserToGroupDTO = {
|
||||
@@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = {
|
||||
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
export enum EFilterReturnedUsers {
|
||||
EXISTING_MEMBERS = "existingMembers",
|
||||
NON_MEMBERS = "nonMembers"
|
||||
}
|
||||
|
@@ -19,7 +19,9 @@ export const GROUPS = {
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th user.",
|
||||
limit: "The number of users to return.",
|
||||
username: "The username to search for.",
|
||||
search: "The text string that user email or name will be filtered by."
|
||||
search: "The text string that user email or name will be filtered by.",
|
||||
filterUsers:
|
||||
"Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization."
|
||||
},
|
||||
ADD_USER: {
|
||||
id: "The ID of the group to add the user to.",
|
||||
@@ -349,6 +351,52 @@ export const OIDC_AUTH = {
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const JWT_AUTH = {
|
||||
LOGIN: {
|
||||
identityId: "The ID of the identity to login."
|
||||
},
|
||||
ATTACH: {
|
||||
identityId: "The ID of the identity to attach the configuration onto.",
|
||||
configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'",
|
||||
jwksUrl:
|
||||
"The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.",
|
||||
jwksCaCert: "The PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.",
|
||||
publicKeys:
|
||||
"A list of PEM-encoded public keys used to verify JWT signatures. Required if configurationType is 'static'. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.",
|
||||
boundIssuer: "The unique identifier of the JWT provider.",
|
||||
boundAudiences: "The list of intended recipients.",
|
||||
boundClaims: "The attributes that should be present in the JWT for it to be valid.",
|
||||
boundSubject: "The expected principal that is the subject of the JWT.",
|
||||
accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.",
|
||||
accessTokenTTL: "The lifetime for an access token in seconds.",
|
||||
accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.",
|
||||
accessTokenNumUsesLimit: "The maximum number of times that an access token can be used."
|
||||
},
|
||||
UPDATE: {
|
||||
identityId: "The ID of the identity to update the auth method for.",
|
||||
configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'",
|
||||
jwksUrl:
|
||||
"The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.",
|
||||
jwksCaCert: "The new PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.",
|
||||
publicKeys:
|
||||
"A new list of PEM-encoded public keys used to verify JWT signatures. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.",
|
||||
boundIssuer: "The new unique identifier of the JWT provider.",
|
||||
boundAudiences: "The new list of intended recipients.",
|
||||
boundClaims: "The new attributes that should be present in the JWT for it to be valid.",
|
||||
boundSubject: "The new expected principal that is the subject of the JWT.",
|
||||
accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.",
|
||||
accessTokenTTL: "The new lifetime for an access token in seconds.",
|
||||
accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
|
||||
accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used."
|
||||
},
|
||||
RETRIEVE: {
|
||||
identityId: "The ID of the identity to retrieve the auth method for."
|
||||
},
|
||||
REVOKE: {
|
||||
identityId: "The ID of the identity to revoke the auth method for."
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ORGANIZATIONS = {
|
||||
LIST_USER_MEMBERSHIPS: {
|
||||
organizationId: "The ID of the organization to get memberships from."
|
||||
|
@@ -166,8 +166,7 @@ const envSchema = z
|
||||
OTEL_COLLECTOR_BASIC_AUTH_PASSWORD: zpStr(z.string().optional()),
|
||||
OTEL_EXPORT_TYPE: z.enum(["prometheus", "otlp"]).optional(),
|
||||
|
||||
PLAIN_API_KEY: zpStr(z.string().optional()),
|
||||
PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()),
|
||||
PYLON_API_KEY: zpStr(z.string().optional()),
|
||||
DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"),
|
||||
SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"),
|
||||
WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()),
|
||||
|
@@ -121,6 +121,8 @@ import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/i
|
||||
import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service";
|
||||
import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal";
|
||||
import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service";
|
||||
import { identityJwtAuthDALFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-dal";
|
||||
import { identityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service";
|
||||
import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal";
|
||||
import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service";
|
||||
import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal";
|
||||
@@ -298,6 +300,7 @@ export const registerRoutes = async (
|
||||
const identityAwsAuthDAL = identityAwsAuthDALFactory(db);
|
||||
const identityGcpAuthDAL = identityGcpAuthDALFactory(db);
|
||||
const identityOidcAuthDAL = identityOidcAuthDALFactory(db);
|
||||
const identityJwtAuthDAL = identityJwtAuthDALFactory(db);
|
||||
const identityAzureAuthDAL = identityAzureAuthDALFactory(db);
|
||||
|
||||
const auditLogDAL = auditLogDALFactory(auditLogDb ?? db);
|
||||
@@ -1184,6 +1187,15 @@ export const registerRoutes = async (
|
||||
orgBotDAL
|
||||
});
|
||||
|
||||
const identityJwtAuthService = identityJwtAuthServiceFactory({
|
||||
identityJwtAuthDAL,
|
||||
permissionService,
|
||||
identityAccessTokenDAL,
|
||||
identityOrgMembershipDAL,
|
||||
licenseService,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const dynamicSecretProviders = buildDynamicSecretProviders();
|
||||
const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({
|
||||
queueService,
|
||||
@@ -1242,7 +1254,8 @@ export const registerRoutes = async (
|
||||
});
|
||||
|
||||
const userEngagementService = userEngagementServiceFactory({
|
||||
userDAL
|
||||
userDAL,
|
||||
orgDAL
|
||||
});
|
||||
|
||||
const slackService = slackServiceFactory({
|
||||
@@ -1346,6 +1359,7 @@ export const registerRoutes = async (
|
||||
identityAwsAuth: identityAwsAuthService,
|
||||
identityAzureAuth: identityAzureAuthService,
|
||||
identityOidcAuth: identityOidcAuthService,
|
||||
identityJwtAuth: identityJwtAuthService,
|
||||
accessApprovalPolicy: accessApprovalPolicyService,
|
||||
accessApprovalRequest: accessApprovalRequestService,
|
||||
secretApprovalPolicy: secretApprovalPolicyService,
|
||||
|
386
backend/src/server/routes/v1/identity-jwt-auth-router.ts
Normal file
386
backend/src/server/routes/v1/identity-jwt-auth-router.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { IdentityJwtAuthsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { JWT_AUTH } from "@app/lib/api-docs";
|
||||
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";
|
||||
import { JwtConfigurationType } from "@app/services/identity-jwt-auth/identity-jwt-auth-types";
|
||||
import {
|
||||
validateJwtAuthAudiencesField,
|
||||
validateJwtBoundClaimsField
|
||||
} from "@app/services/identity-jwt-auth/identity-jwt-auth-validators";
|
||||
|
||||
const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({
|
||||
encryptedJwksCaCert: true,
|
||||
encryptedPublicKeys: true
|
||||
}).extend({
|
||||
jwksCaCert: z.string(),
|
||||
publicKeys: z.string().array()
|
||||
});
|
||||
|
||||
const CreateBaseSchema = z.object({
|
||||
boundIssuer: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundIssuer),
|
||||
boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences),
|
||||
boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims),
|
||||
boundSubject: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundSubject),
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
|
||||
.describe(JWT_AUTH.ATTACH.accessTokenTrustedIps),
|
||||
accessTokenTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(315360000)
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenTTL must have a non zero number"
|
||||
})
|
||||
.default(2592000)
|
||||
.describe(JWT_AUTH.ATTACH.accessTokenTTL),
|
||||
accessTokenMaxTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.max(315360000)
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenMaxTTL must have a non zero number"
|
||||
})
|
||||
.default(2592000)
|
||||
.describe(JWT_AUTH.ATTACH.accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit)
|
||||
});
|
||||
|
||||
const UpdateBaseSchema = z
|
||||
.object({
|
||||
boundIssuer: z.string().trim().default("").describe(JWT_AUTH.UPDATE.boundIssuer),
|
||||
boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.UPDATE.boundAudiences),
|
||||
boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.UPDATE.boundClaims),
|
||||
boundSubject: z.string().trim().default("").describe(JWT_AUTH.UPDATE.boundSubject),
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
|
||||
.describe(JWT_AUTH.UPDATE.accessTokenTrustedIps),
|
||||
accessTokenTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(315360000)
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenTTL must have a non zero number"
|
||||
})
|
||||
.default(2592000)
|
||||
.describe(JWT_AUTH.UPDATE.accessTokenTTL),
|
||||
accessTokenMaxTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.max(315360000)
|
||||
.refine((value) => value !== 0, {
|
||||
message: "accessTokenMaxTTL must have a non zero number"
|
||||
})
|
||||
.default(2592000)
|
||||
.describe(JWT_AUTH.UPDATE.accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.UPDATE.accessTokenNumUsesLimit)
|
||||
})
|
||||
.partial();
|
||||
|
||||
const JwksConfigurationSchema = z.object({
|
||||
configurationType: z.literal(JwtConfigurationType.JWKS).describe(JWT_AUTH.ATTACH.configurationType),
|
||||
jwksUrl: z.string().trim().url().describe(JWT_AUTH.ATTACH.jwksUrl),
|
||||
jwksCaCert: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksCaCert),
|
||||
publicKeys: z.string().array().optional().default([]).describe(JWT_AUTH.ATTACH.publicKeys)
|
||||
});
|
||||
|
||||
const StaticConfigurationSchema = z.object({
|
||||
configurationType: z.literal(JwtConfigurationType.STATIC).describe(JWT_AUTH.ATTACH.configurationType),
|
||||
jwksUrl: z.string().trim().optional().default("").describe(JWT_AUTH.ATTACH.jwksUrl),
|
||||
jwksCaCert: z.string().trim().optional().default("").describe(JWT_AUTH.ATTACH.jwksCaCert),
|
||||
publicKeys: z.string().min(1).array().min(1).describe(JWT_AUTH.ATTACH.publicKeys)
|
||||
});
|
||||
|
||||
export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/jwt-auth/login",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Login with JWT Auth",
|
||||
body: z.object({
|
||||
identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId),
|
||||
jwt: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
accessToken: z.string(),
|
||||
expiresIn: z.coerce.number(),
|
||||
accessTokenMaxTTL: z.coerce.number(),
|
||||
tokenType: z.literal("Bearer")
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { identityJwtAuth, accessToken, identityAccessToken, identityMembershipOrg } =
|
||||
await server.services.identityJwtAuth.login({
|
||||
identityId: req.body.identityId,
|
||||
jwt: req.body.jwt
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: identityMembershipOrg?.orgId,
|
||||
event: {
|
||||
type: EventType.LOGIN_IDENTITY_JWT_AUTH,
|
||||
metadata: {
|
||||
identityId: identityJwtAuth.identityId,
|
||||
identityAccessTokenId: identityAccessToken.id,
|
||||
identityJwtAuthId: identityJwtAuth.id
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
accessToken,
|
||||
tokenType: "Bearer" as const,
|
||||
expiresIn: identityJwtAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/jwt-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Attach JWT Auth configuration onto identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId)
|
||||
}),
|
||||
body: z.discriminatedUnion("configurationType", [
|
||||
JwksConfigurationSchema.merge(CreateBaseSchema),
|
||||
StaticConfigurationSchema.merge(CreateBaseSchema)
|
||||
]),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityJwtAuth: IdentityJwtAuthResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityJwtAuth = await server.services.identityJwtAuth.attachJwtAuth({
|
||||
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: identityJwtAuth.orgId,
|
||||
event: {
|
||||
type: EventType.ADD_IDENTITY_JWT_AUTH,
|
||||
metadata: {
|
||||
identityId: identityJwtAuth.identityId,
|
||||
configurationType: identityJwtAuth.configurationType,
|
||||
jwksUrl: identityJwtAuth.jwksUrl,
|
||||
jwksCaCert: identityJwtAuth.jwksCaCert,
|
||||
publicKeys: identityJwtAuth.publicKeys,
|
||||
boundIssuer: identityJwtAuth.boundIssuer,
|
||||
boundAudiences: identityJwtAuth.boundAudiences,
|
||||
boundClaims: identityJwtAuth.boundClaims as Record<string, string>,
|
||||
boundSubject: identityJwtAuth.boundSubject,
|
||||
accessTokenTTL: identityJwtAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL,
|
||||
accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
|
||||
accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
identityJwtAuth
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/jwt-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Update JWT Auth configuration on identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().trim().describe(JWT_AUTH.UPDATE.identityId)
|
||||
}),
|
||||
body: z.discriminatedUnion("configurationType", [
|
||||
JwksConfigurationSchema.merge(UpdateBaseSchema),
|
||||
StaticConfigurationSchema.merge(UpdateBaseSchema)
|
||||
]),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityJwtAuth: IdentityJwtAuthResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityJwtAuth = await server.services.identityJwtAuth.updateJwtAuth({
|
||||
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: identityJwtAuth.orgId,
|
||||
event: {
|
||||
type: EventType.UPDATE_IDENTITY_JWT_AUTH,
|
||||
metadata: {
|
||||
identityId: identityJwtAuth.identityId,
|
||||
configurationType: identityJwtAuth.configurationType,
|
||||
jwksUrl: identityJwtAuth.jwksUrl,
|
||||
jwksCaCert: identityJwtAuth.jwksCaCert,
|
||||
publicKeys: identityJwtAuth.publicKeys,
|
||||
boundIssuer: identityJwtAuth.boundIssuer,
|
||||
boundAudiences: identityJwtAuth.boundAudiences,
|
||||
boundClaims: identityJwtAuth.boundClaims as Record<string, string>,
|
||||
boundSubject: identityJwtAuth.boundSubject,
|
||||
accessTokenTTL: identityJwtAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL,
|
||||
accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
|
||||
accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { identityJwtAuth };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/jwt-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Retrieve JWT Auth configuration on identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().describe(JWT_AUTH.RETRIEVE.identityId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityJwtAuth: IdentityJwtAuthResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityJwtAuth = await server.services.identityJwtAuth.getJwtAuth({
|
||||
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: identityJwtAuth.orgId,
|
||||
event: {
|
||||
type: EventType.GET_IDENTITY_JWT_AUTH,
|
||||
metadata: {
|
||||
identityId: identityJwtAuth.identityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { identityJwtAuth };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/jwt-auth/identities/:identityId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Delete JWT Auth configuration on identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().describe(JWT_AUTH.REVOKE.identityId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityJwtAuth: IdentityJwtAuthResponseSchema.omit({
|
||||
publicKeys: true,
|
||||
jwksCaCert: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityJwtAuth = await server.services.identityJwtAuth.revokeJwtAuth({
|
||||
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: identityJwtAuth.orgId,
|
||||
event: {
|
||||
type: EventType.REVOKE_IDENTITY_JWT_AUTH,
|
||||
metadata: {
|
||||
identityId: identityJwtAuth.identityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { identityJwtAuth };
|
||||
}
|
||||
});
|
||||
};
|
@@ -12,6 +12,7 @@ import { registerIdentityAccessTokenRouter } from "./identity-access-token-route
|
||||
import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router";
|
||||
import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router";
|
||||
import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router";
|
||||
import { registerIdentityJwtAuthRouter } from "./identity-jwt-auth-router";
|
||||
import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router";
|
||||
import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router";
|
||||
import { registerIdentityRouter } from "./identity-router";
|
||||
@@ -54,6 +55,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await authRouter.register(registerIdentityAwsAuthRouter);
|
||||
await authRouter.register(registerIdentityAzureAuthRouter);
|
||||
await authRouter.register(registerIdentityOidcAuthRouter);
|
||||
await authRouter.register(registerIdentityJwtAuthRouter);
|
||||
},
|
||||
{ prefix: "/auth" }
|
||||
);
|
||||
|
@@ -97,7 +97,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
querystring: z.object({
|
||||
params: z.object({
|
||||
integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId)
|
||||
}),
|
||||
body: z.object({
|
||||
@@ -126,7 +126,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
integrationAuthId: req.query.integrationAuthId,
|
||||
integrationAuthId: req.params.integrationAuthId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
|
@@ -21,7 +21,7 @@ export const registerUserEngagementRouter = async (server: FastifyZodProvider) =
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
return server.services.userEngagement.createUserWish(req.permission.id, req.body.text);
|
||||
return server.services.userEngagement.createUserWish(req.permission.id, req.permission.orgId, req.body.text);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -37,7 +37,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
)
|
||||
.leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`)
|
||||
.leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`)
|
||||
|
||||
.leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`)
|
||||
.select(selectAllTableCols(TableName.IdentityAccessToken))
|
||||
.select(
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"),
|
||||
@@ -47,6 +47,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"),
|
||||
db.ref("name").withSchema(TableName.Identity)
|
||||
)
|
||||
.first();
|
||||
@@ -61,7 +62,8 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure,
|
||||
trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s,
|
||||
trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc,
|
||||
trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken
|
||||
trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken,
|
||||
trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "IdAccessTokenFindOne" });
|
||||
|
@@ -171,7 +171,8 @@ export const identityAccessTokenServiceFactory = ({
|
||||
[IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth,
|
||||
[IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth,
|
||||
[IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth,
|
||||
[IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth
|
||||
[IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth,
|
||||
[IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth
|
||||
};
|
||||
|
||||
const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod];
|
||||
|
@@ -0,0 +1,11 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityJwtAuthDALFactory = ReturnType<typeof identityJwtAuthDALFactory>;
|
||||
|
||||
export const identityJwtAuthDALFactory = (db: TDbClient) => {
|
||||
const jwtAuthOrm = ormify(db, TableName.IdentityJwtAuth);
|
||||
|
||||
return jwtAuthOrm;
|
||||
};
|
@@ -0,0 +1,13 @@
|
||||
import picomatch from "picomatch";
|
||||
|
||||
export const doesFieldValueMatchJwtPolicy = (fieldValue: string | boolean | number, policyValue: string) => {
|
||||
if (typeof fieldValue === "boolean") {
|
||||
return fieldValue === (policyValue === "true");
|
||||
}
|
||||
|
||||
if (typeof fieldValue === "number") {
|
||||
return fieldValue === parseInt(policyValue, 10);
|
||||
}
|
||||
|
||||
return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
|
||||
};
|
@@ -0,0 +1,534 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import https from "https";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { JwksClient } from "jwks-rsa";
|
||||
|
||||
import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } 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, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
|
||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||
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 { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal";
|
||||
import { doesFieldValueMatchJwtPolicy } from "./identity-jwt-auth-fns";
|
||||
import {
|
||||
JwtConfigurationType,
|
||||
TAttachJwtAuthDTO,
|
||||
TGetJwtAuthDTO,
|
||||
TLoginJwtAuthDTO,
|
||||
TRevokeJwtAuthDTO,
|
||||
TUpdateJwtAuthDTO
|
||||
} from "./identity-jwt-auth-types";
|
||||
|
||||
type TIdentityJwtAuthServiceFactoryDep = {
|
||||
identityJwtAuthDAL: TIdentityJwtAuthDALFactory;
|
||||
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
|
||||
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "create" | "delete">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
export type TIdentityJwtAuthServiceFactory = ReturnType<typeof identityJwtAuthServiceFactory>;
|
||||
|
||||
export const identityJwtAuthServiceFactory = ({
|
||||
identityJwtAuthDAL,
|
||||
identityOrgMembershipDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
identityAccessTokenDAL,
|
||||
kmsService
|
||||
}: TIdentityJwtAuthServiceFactoryDep) => {
|
||||
const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => {
|
||||
const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId });
|
||||
if (!identityJwtAuth) {
|
||||
throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" });
|
||||
}
|
||||
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({
|
||||
identityId: identityJwtAuth.identityId
|
||||
});
|
||||
if (!identityMembershipOrg) {
|
||||
throw new NotFoundError({
|
||||
message: `Identity organization membership for identity with ID '${identityJwtAuth.identityId}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: identityMembershipOrg.orgId
|
||||
});
|
||||
|
||||
const decodedToken = jwt.decode(jwtValue, { complete: true });
|
||||
if (!decodedToken) {
|
||||
throw new UnauthorizedError({
|
||||
message: "Invalid JWT"
|
||||
});
|
||||
}
|
||||
|
||||
let tokenData: Record<string, string | boolean | number> = {};
|
||||
|
||||
if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) {
|
||||
const decryptedJwksCaCert = orgDataKeyDecryptor({
|
||||
cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
|
||||
}).toString();
|
||||
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
|
||||
const client = new JwksClient({
|
||||
jwksUri: identityJwtAuth.jwksUrl,
|
||||
requestAgent
|
||||
});
|
||||
|
||||
const { kid } = decodedToken.header;
|
||||
const jwtSigningKey = await client.getSigningKey(kid);
|
||||
|
||||
try {
|
||||
tokenData = jwt.verify(jwtValue, jwtSigningKey.getPublicKey()) as Record<string, string>;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: ${error.message}`
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys })
|
||||
.toString()
|
||||
.split(",");
|
||||
|
||||
const errors: string[] = [];
|
||||
let isMatchAnyKey = false;
|
||||
for (const publicKey of decryptedPublicKeys) {
|
||||
try {
|
||||
tokenData = jwt.verify(jwtValue, publicKey) as Record<string, string>;
|
||||
isMatchAnyKey = true;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
errors.push(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMatchAnyKey) {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (identityJwtAuth.boundIssuer) {
|
||||
if (tokenData.iss !== identityJwtAuth.boundIssuer) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: issuer mismatch"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (identityJwtAuth.boundSubject) {
|
||||
if (!tokenData.sub) {
|
||||
throw new UnauthorizedError({
|
||||
message: "Access denied: token has no subject field"
|
||||
});
|
||||
}
|
||||
|
||||
if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: subject not allowed"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (identityJwtAuth.boundAudiences) {
|
||||
if (!tokenData.aud) {
|
||||
throw new UnauthorizedError({
|
||||
message: "Access denied: token has no audience field"
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!identityJwtAuth.boundAudiences
|
||||
.split(", ")
|
||||
.some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue))
|
||||
) {
|
||||
throw new UnauthorizedError({
|
||||
message: "Access denied: token audience not allowed"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (identityJwtAuth.boundClaims) {
|
||||
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
|
||||
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
|
||||
|
||||
if (!tokenData[claimKey]) {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: token has no ${claimKey} field`
|
||||
});
|
||||
}
|
||||
|
||||
// handle both single and multi-valued claims
|
||||
if (
|
||||
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry))
|
||||
) {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: claim mismatch for field ${claimKey}`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => {
|
||||
const newToken = await identityAccessTokenDAL.create(
|
||||
{
|
||||
identityId: identityJwtAuth.identityId,
|
||||
isAccessTokenRevoked: false,
|
||||
accessTokenTTL: identityJwtAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL,
|
||||
accessTokenNumUses: 0,
|
||||
accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit,
|
||||
authMethod: IdentityAuthMethod.JWT_AUTH
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return newToken;
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
identityId: identityJwtAuth.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, identityJwtAuth, identityAccessToken, identityMembershipOrg };
|
||||
};
|
||||
|
||||
const attachJwtAuth = async ({
|
||||
identityId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TAttachJwtAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) {
|
||||
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
|
||||
}
|
||||
if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to add JWT 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 { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({
|
||||
plainText: Buffer.from(jwksCaCert)
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({
|
||||
plainText: Buffer.from(publicKeys.join(","))
|
||||
});
|
||||
|
||||
const identityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => {
|
||||
const doc = await identityJwtAuthDAL.create(
|
||||
{
|
||||
identityId: identityMembershipOrg.identityId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
encryptedJwksCaCert,
|
||||
encryptedPublicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return doc;
|
||||
});
|
||||
return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys };
|
||||
};
|
||||
|
||||
const updateJwtAuth = async ({
|
||||
identityId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateJwtAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
|
||||
|
||||
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to update JWT Auth"
|
||||
});
|
||||
}
|
||||
|
||||
const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId });
|
||||
|
||||
if (
|
||||
(accessTokenMaxTTL || identityJwtAuth.accessTokenMaxTTL) > 0 &&
|
||||
(accessTokenTTL || identityJwtAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityJwtAuth.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 updateQuery: TIdentityJwtAuthsUpdate = {
|
||||
boundIssuer,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
|
||||
? JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
: undefined
|
||||
};
|
||||
|
||||
const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } =
|
||||
await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
|
||||
if (jwksCaCert !== undefined) {
|
||||
const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({
|
||||
plainText: Buffer.from(jwksCaCert)
|
||||
});
|
||||
|
||||
updateQuery.encryptedJwksCaCert = encryptedJwksCaCert;
|
||||
}
|
||||
|
||||
if (publicKeys) {
|
||||
const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({
|
||||
plainText: Buffer.from(publicKeys.join(","))
|
||||
});
|
||||
|
||||
updateQuery.encryptedPublicKeys = encryptedPublicKeys;
|
||||
}
|
||||
|
||||
const updatedJwtAuth = await identityJwtAuthDAL.updateById(identityJwtAuth.id, updateQuery);
|
||||
const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedJwksCaCert }).toString();
|
||||
const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedPublicKeys })
|
||||
.toString()
|
||||
.split(",");
|
||||
|
||||
return {
|
||||
...updatedJwtAuth,
|
||||
orgId: identityMembershipOrg.orgId,
|
||||
jwksCaCert: decryptedJwksCaCert,
|
||||
publicKeys: decryptedPublicKeys
|
||||
};
|
||||
};
|
||||
|
||||
const getJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetJwtAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
|
||||
|
||||
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) {
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have JWT Auth attached"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId });
|
||||
|
||||
const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
|
||||
const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedJwksCaCert }).toString();
|
||||
const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys })
|
||||
.toString()
|
||||
.split(",");
|
||||
|
||||
return {
|
||||
...identityJwtAuth,
|
||||
orgId: identityMembershipOrg.orgId,
|
||||
jwksCaCert: decryptedJwksCaCert,
|
||||
publicKeys: decryptedPublicKeys
|
||||
};
|
||||
};
|
||||
|
||||
const revokeJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TRevokeJwtAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) {
|
||||
throw new NotFoundError({ message: "Failed to find identity" });
|
||||
}
|
||||
|
||||
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) {
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have JWT 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
|
||||
);
|
||||
|
||||
if (!isAtLeastAsPrivileged(permission, rolePermission)) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to revoke JWT auth of identity with more privileged role"
|
||||
});
|
||||
}
|
||||
|
||||
const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => {
|
||||
const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx);
|
||||
await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx);
|
||||
|
||||
return { ...deletedJwtAuth?.[0], orgId: identityMembershipOrg.orgId };
|
||||
});
|
||||
|
||||
return revokedIdentityJwtAuth;
|
||||
};
|
||||
|
||||
return {
|
||||
login,
|
||||
attachJwtAuth,
|
||||
updateJwtAuth,
|
||||
getJwtAuth,
|
||||
revokeJwtAuth
|
||||
};
|
||||
};
|
@@ -0,0 +1,51 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum JwtConfigurationType {
|
||||
JWKS = "jwks",
|
||||
STATIC = "static"
|
||||
}
|
||||
|
||||
export type TAttachJwtAuthDTO = {
|
||||
identityId: string;
|
||||
configurationType: JwtConfigurationType;
|
||||
jwksUrl: string;
|
||||
jwksCaCert: string;
|
||||
publicKeys: string[];
|
||||
boundIssuer: string;
|
||||
boundAudiences: string;
|
||||
boundClaims: Record<string, string>;
|
||||
boundSubject: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: { ipAddress: string }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateJwtAuthDTO = {
|
||||
identityId: string;
|
||||
configurationType?: JwtConfigurationType;
|
||||
jwksUrl?: string;
|
||||
jwksCaCert?: string;
|
||||
publicKeys?: string[];
|
||||
boundIssuer?: string;
|
||||
boundAudiences?: string;
|
||||
boundClaims?: Record<string, string>;
|
||||
boundSubject?: string;
|
||||
accessTokenTTL?: number;
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
accessTokenTrustedIps?: { ipAddress: string }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetJwtAuthDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TRevokeJwtAuthDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TLoginJwtAuthDTO = {
|
||||
identityId: string;
|
||||
jwt: string;
|
||||
};
|
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const validateJwtAuthAudiencesField = z
|
||||
.string()
|
||||
.trim()
|
||||
.default("")
|
||||
.transform((data) => {
|
||||
if (data === "") return "";
|
||||
return data
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.join(", ");
|
||||
});
|
||||
|
||||
export const validateJwtBoundClaimsField = z.record(z.string()).transform((data) => {
|
||||
const formattedClaims: Record<string, string> = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
formattedClaims[key] = data[key]
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.join(", ");
|
||||
});
|
||||
|
||||
return formattedClaims;
|
||||
});
|
@@ -7,7 +7,8 @@ export const buildAuthMethods = ({
|
||||
kubernetesId,
|
||||
oidcId,
|
||||
azureId,
|
||||
tokenId
|
||||
tokenId,
|
||||
jwtId
|
||||
}: {
|
||||
uaId?: string;
|
||||
gcpId?: string;
|
||||
@@ -16,6 +17,7 @@ export const buildAuthMethods = ({
|
||||
oidcId?: string;
|
||||
azureId?: string;
|
||||
tokenId?: string;
|
||||
jwtId?: string;
|
||||
}) => {
|
||||
return [
|
||||
...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null],
|
||||
@@ -24,6 +26,7 @@ export const buildAuthMethods = ({
|
||||
...[kubernetesId ? IdentityAuthMethod.KUBERNETES_AUTH : null],
|
||||
...[oidcId ? IdentityAuthMethod.OIDC_AUTH : null],
|
||||
...[azureId ? IdentityAuthMethod.AZURE_AUTH : null],
|
||||
...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null]
|
||||
...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null],
|
||||
...[jwtId ? IdentityAuthMethod.JWT_AUTH : null]
|
||||
].filter((authMethod) => authMethod) as IdentityAuthMethod[];
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ import {
|
||||
TIdentityAwsAuths,
|
||||
TIdentityAzureAuths,
|
||||
TIdentityGcpAuths,
|
||||
TIdentityJwtAuths,
|
||||
TIdentityKubernetesAuths,
|
||||
TIdentityOidcAuths,
|
||||
TIdentityOrgMemberships,
|
||||
@@ -70,6 +71,11 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
`${TableName.IdentityOrgMembership}.identityId`,
|
||||
`${TableName.IdentityTokenAuth}.identityId`
|
||||
)
|
||||
.leftJoin<TIdentityJwtAuths>(
|
||||
TableName.IdentityJwtAuth,
|
||||
`${TableName.IdentityOrgMembership}.identityId`,
|
||||
`${TableName.IdentityJwtAuth}.identityId`
|
||||
)
|
||||
|
||||
.select(
|
||||
selectAllTableCols(TableName.IdentityOrgMembership),
|
||||
@@ -81,6 +87,7 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth),
|
||||
db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth),
|
||||
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
|
||||
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth),
|
||||
|
||||
db.ref("name").withSchema(TableName.Identity)
|
||||
);
|
||||
@@ -183,6 +190,11 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
"paginatedIdentity.identityId",
|
||||
`${TableName.IdentityTokenAuth}.identityId`
|
||||
)
|
||||
.leftJoin<TIdentityJwtAuths>(
|
||||
TableName.IdentityJwtAuth,
|
||||
"paginatedIdentity.identityId",
|
||||
`${TableName.IdentityJwtAuth}.identityId`
|
||||
)
|
||||
|
||||
.select(
|
||||
db.ref("id").withSchema("paginatedIdentity"),
|
||||
@@ -200,7 +212,8 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth),
|
||||
db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth),
|
||||
db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth),
|
||||
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth)
|
||||
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
|
||||
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth)
|
||||
)
|
||||
// cr stands for custom role
|
||||
.select(db.ref("id").as("crId").withSchema(TableName.OrgRoles))
|
||||
@@ -237,6 +250,7 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
uaId,
|
||||
awsId,
|
||||
gcpId,
|
||||
jwtId,
|
||||
kubernetesId,
|
||||
oidcId,
|
||||
azureId,
|
||||
@@ -271,7 +285,8 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
kubernetesId,
|
||||
oidcId,
|
||||
azureId,
|
||||
tokenId
|
||||
tokenId,
|
||||
jwtId
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
@@ -20,7 +20,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
.join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretVersionV2}.secretId`)
|
||||
.join<TSecretVersionsV2, TSecretVersionsV2 & { secretId: string; max: number }>(
|
||||
(tx || db)(TableName.SecretVersionV2)
|
||||
.groupBy("folderId", "secretId")
|
||||
.where(`${TableName.SecretVersionV2}.folderId`, folderId)
|
||||
.groupBy("secretId")
|
||||
.max("version")
|
||||
.select("secretId")
|
||||
.as("latestVersion"),
|
||||
|
@@ -53,6 +53,13 @@ export const smtpServiceFactory = (cfg: TSmtpConfig) => {
|
||||
const smtp = createTransport(cfg);
|
||||
const isSmtpOn = Boolean(cfg.host);
|
||||
|
||||
handlebars.registerHelper("emailFooter", () => {
|
||||
const { SITE_URL } = getConfig();
|
||||
return new handlebars.SafeString(
|
||||
`<p style="font-size: 12px;">Email sent via Infisical at <a href="${SITE_URL}">${SITE_URL}</a></p>`
|
||||
);
|
||||
});
|
||||
|
||||
const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => {
|
||||
const appCfg = getConfig();
|
||||
const html = await fs.readFile(path.resolve(__dirname, "./templates/", template), "utf8");
|
||||
|
@@ -45,6 +45,8 @@
|
||||
View the request and approve or deny it
|
||||
<a href="{{approvalUrl}}">here</a>.
|
||||
</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -11,8 +11,11 @@
|
||||
<p>A secret approval request has been bypassed in the project "{{projectName}}".</p>
|
||||
|
||||
<p>
|
||||
{{requesterFullName}} ({{requesterEmail}}) has merged
|
||||
a secret to environment {{environment}} at secret path {{secretPath}}
|
||||
{{requesterFullName}}
|
||||
({{requesterEmail}}) has merged a secret to environment
|
||||
{{environment}}
|
||||
at secret path
|
||||
{{secretPath}}
|
||||
without obtaining the required approvals.
|
||||
</p>
|
||||
<p>
|
||||
@@ -24,5 +27,7 @@
|
||||
To review this action, please visit the request panel
|
||||
<a href="{{approvalUrl}}">here</a>.
|
||||
</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
</html>
|
@@ -1,4 +1,3 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
@@ -14,6 +13,8 @@
|
||||
<h2>{{code}}</h2>
|
||||
<p>The MFA code will be valid for 2 minutes.</p>
|
||||
<p>Not you? Contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} immediately.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -10,6 +10,8 @@
|
||||
<h2>Confirm your email address</h2>
|
||||
<p>Your confirmation code is below — enter it in the browser window where you've started confirming your email.</p>
|
||||
<h1>{{code}}</h1>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -16,6 +16,7 @@
|
||||
|
||||
<p>Error: {{error}}</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -12,6 +12,8 @@
|
||||
{{provider}}
|
||||
to Infisical is in progress. The import process may take up to 30 minutes, and you will receive once the import
|
||||
has finished or if it fails.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -9,6 +9,8 @@
|
||||
<body>
|
||||
<h2>An import from {{provider}} to Infisical was successful</h2>
|
||||
<p>An import from {{provider}} was successful. Your data is now available in Infisical.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -1,21 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Incident alert: secrets potentially leaked</title>
|
||||
</head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Incident alert: secrets potentially leaked</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo</h3>
|
||||
<p><a href="{{siteUrl}}/secret-scanning"><strong>View leaked secrets</strong></a></p>
|
||||
<body>
|
||||
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo</h3>
|
||||
<p><a href="{{siteUrl}}/secret-scanning"><strong>View leaked secrets</strong></a></p>
|
||||
|
||||
<p>If these are production secrets, please rotate them immediately.</p>
|
||||
<p>If these are production secrets, please rotate them immediately.</p>
|
||||
|
||||
<p>Once you have taken action, be sure to update the status of the risk in your <a
|
||||
href="{{siteUrl}}">Infisical
|
||||
dashboard</a>.</p>
|
||||
</body>
|
||||
<p>Once you have taken action, be sure to update the status of the risk in your
|
||||
<a href="{{siteUrl}}">Infisical dashboard</a>.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -26,6 +26,8 @@
|
||||
{{#if syncMessage}}
|
||||
<p><b>Reason: </b>{{syncMessage}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -1,4 +1,3 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
@@ -13,7 +12,11 @@
|
||||
<p><strong>Timestamp</strong>: {{timestamp}}</p>
|
||||
<p><strong>IP address</strong>: {{ip}}</p>
|
||||
<p><strong>User agent</strong>: {{userAgent}}</p>
|
||||
<p>If you believe that this login is suspicious, please contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} or reset your password immediately.</p>
|
||||
<p>If you believe that this login is suspicious, please contact
|
||||
{{#if isCloud}}Infisical{{else}}your administrator{{/if}}
|
||||
or reset your password immediately.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -12,5 +12,7 @@
|
||||
<a href="{{callback_url}}?token={{token}}{{#if metadata}}&metadata={{metadata}}{{/if}}&to={{email}}&organization_id={{organizationId}}">Click to join</a>
|
||||
<h3>What is Infisical?</h3>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -1,14 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Account Recovery</title>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Reset your password</h2>
|
||||
<p>Someone requested a password reset.</p>
|
||||
<a href="{{callback_url}}?token={{token}}&to={{email}}">Reset password</a>
|
||||
<p>If you didn't initiate this request, please contact {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}</p>
|
||||
</body>
|
||||
<p>If you didn't initiate this request, please contact
|
||||
{{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
</html>
|
@@ -27,5 +27,7 @@
|
||||
<p>Please take necessary actions to renew these items before they expire.</p>
|
||||
|
||||
<p>For more details, please log in to your Infisical account and check your PKI management section.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
</html>
|
@@ -1,16 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Organization Invitation</title>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Join your organization on Infisical</h2>
|
||||
<p>You've been invited to join the Infisical organization — {{organizationName}}</p>
|
||||
<a href="{{callback_url}}">Join now</a>
|
||||
<h3>What is Infisical?</h3>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||
</body>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets
|
||||
and configs.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
</html>
|
@@ -17,6 +17,8 @@
|
||||
View the request and approve or deny it
|
||||
<a href="{{approvalUrl}}">here</a>.
|
||||
</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -1,25 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Incident alert: secret leaked</title>
|
||||
</head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Incident alert: secret leaked</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push</h3>
|
||||
<p><a href="{{siteUrl}}/secret-scanning"><strong>View leaked secrets</strong></a></p>
|
||||
<p>You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed
|
||||
by {{pusher_name}} ({{pusher_email}}). If
|
||||
these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as comment
|
||||
in the given programming. This will prevent future notifications from being sent out for those secret(s).</p>
|
||||
<body>
|
||||
<h3>Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push</h3>
|
||||
<p><a href="{{siteUrl}}/secret-scanning"><strong>View leaked secrets</strong></a></p>
|
||||
<p>You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed
|
||||
by
|
||||
{{pusher_name}}
|
||||
({{pusher_email}}). If these are test secrets, please add `infisical-scan:ignore` at the end of the line
|
||||
containing the secret as comment in the given programming. This will prevent future notifications from being sent
|
||||
out for those secret(s).</p>
|
||||
|
||||
<p>If these are production secrets, please rotate them immediately.</p>
|
||||
<p>If these are production secrets, please rotate them immediately.</p>
|
||||
|
||||
<p>Once you have taken action, be sure to update the status of the risk in your <a
|
||||
href="{{siteUrl}}">Infisical
|
||||
dashboard</a>.</p>
|
||||
</body>
|
||||
<p>Once you have taken action, be sure to update the status of the risk in your
|
||||
<a href="{{siteUrl}}">Infisical dashboard</a>.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -13,6 +13,8 @@
|
||||
{{#if reminderNote}}
|
||||
<p>Here's the note included with the reminder: {{reminderNote}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -1,17 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Code</title>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
<h2>Confirm your email address</h2>
|
||||
<p>Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.</p>
|
||||
<h1>{{code}}</h1>
|
||||
<p>Questions about setting up Infisical? {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.</p>
|
||||
</body>
|
||||
<p>Questions about setting up Infisical?
|
||||
{{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -11,6 +11,8 @@
|
||||
<p>Your account has been temporarily locked due to multiple failed login attempts. </h2>
|
||||
<a href="{{callback_url}}?token={{token}}">To unlock your account, follow the link here</a>
|
||||
<p>If these attempts were not made by you, reset your password immediately.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -11,5 +11,7 @@
|
||||
<h3>What is Infisical?</h3>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets
|
||||
and configs.</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -1,87 +1,44 @@
|
||||
import { PlainClient } from "@team-plain/typescript-sdk";
|
||||
import axios from "axios";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { InternalServerError } from "@app/lib/errors";
|
||||
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
|
||||
type TUserEngagementServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findById">;
|
||||
};
|
||||
|
||||
export type TUserEngagementServiceFactory = ReturnType<typeof userEngagementServiceFactory>;
|
||||
|
||||
export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => {
|
||||
const createUserWish = async (userId: string, text: string) => {
|
||||
export const userEngagementServiceFactory = ({ userDAL, orgDAL }: TUserEngagementServiceFactoryDep) => {
|
||||
const createUserWish = async (userId: string, orgId: string, text: string) => {
|
||||
const user = await userDAL.findById(userId);
|
||||
const org = await orgDAL.findById(orgId);
|
||||
const appCfg = getConfig();
|
||||
|
||||
if (!appCfg.PLAIN_API_KEY) {
|
||||
if (!appCfg.PYLON_API_KEY) {
|
||||
throw new InternalServerError({
|
||||
message: "Plain is not configured."
|
||||
message: "Pylon is not configured."
|
||||
});
|
||||
}
|
||||
|
||||
const client = new PlainClient({
|
||||
apiKey: appCfg.PLAIN_API_KEY
|
||||
});
|
||||
|
||||
const customerUpsertRes = await client.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: user.email
|
||||
},
|
||||
onCreate: {
|
||||
fullName: `${user.firstName} ${user.lastName}`,
|
||||
shortName: user.firstName,
|
||||
email: {
|
||||
email: user.email as string,
|
||||
isVerified: user.isEmailVerified as boolean
|
||||
},
|
||||
|
||||
externalId: user.id
|
||||
},
|
||||
|
||||
onUpdate: {
|
||||
fullName: {
|
||||
value: `${user.firstName} ${user.lastName}`
|
||||
},
|
||||
shortName: {
|
||||
value: user.firstName
|
||||
},
|
||||
email: {
|
||||
email: user.email as string,
|
||||
isVerified: user.isEmailVerified as boolean
|
||||
},
|
||||
externalId: {
|
||||
value: user.id
|
||||
}
|
||||
const request = axios.create({
|
||||
baseURL: "https://api.usepylon.com",
|
||||
headers: {
|
||||
Authorization: `Bearer ${appCfg.PYLON_API_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
if (customerUpsertRes.error) {
|
||||
throw new InternalServerError({ message: customerUpsertRes.error.message });
|
||||
}
|
||||
|
||||
const createThreadRes = await client.createThread({
|
||||
title: "Wish",
|
||||
customerIdentifier: {
|
||||
externalId: customerUpsertRes.data.customer.externalId
|
||||
},
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text
|
||||
}
|
||||
}
|
||||
],
|
||||
labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",")
|
||||
await request.post("/issues", {
|
||||
title: `New Wish From: ${user.firstName} ${user.lastName} (${org.name})`,
|
||||
body_html: text,
|
||||
requester_email: user.email,
|
||||
requester_name: `${user.firstName} ${user.lastName} (${org.name})`,
|
||||
tags: ["wish"]
|
||||
});
|
||||
|
||||
if (createThreadRes.error) {
|
||||
throw new InternalServerError({
|
||||
message: createThreadRes.error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
createUserWish
|
||||
|
4
docs/api-reference/endpoints/jwt-auth/attach.mdx
Normal file
4
docs/api-reference/endpoints/jwt-auth/attach.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Attach"
|
||||
openapi: "POST /api/v1/auth/jwt-auth/identities/{identityId}"
|
||||
---
|
4
docs/api-reference/endpoints/jwt-auth/login.mdx
Normal file
4
docs/api-reference/endpoints/jwt-auth/login.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Login"
|
||||
openapi: "POST /api/v1/auth/jwt-auth/login"
|
||||
---
|
4
docs/api-reference/endpoints/jwt-auth/retrieve.mdx
Normal file
4
docs/api-reference/endpoints/jwt-auth/retrieve.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Retrieve"
|
||||
openapi: "GET /api/v1/auth/jwt-auth/identities/{identityId}"
|
||||
---
|
4
docs/api-reference/endpoints/jwt-auth/revoke.mdx
Normal file
4
docs/api-reference/endpoints/jwt-auth/revoke.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Revoke"
|
||||
openapi: "DELETE /api/v1/auth/jwt-auth/identities/{identityId}"
|
||||
---
|
4
docs/api-reference/endpoints/jwt-auth/update.mdx
Normal file
4
docs/api-reference/endpoints/jwt-auth/update.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/auth/jwt-auth/identities/{identityId}"
|
||||
---
|
169
docs/documentation/platform/identities/jwt-auth.mdx
Normal file
169
docs/documentation/platform/identities/jwt-auth.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: JWT Auth
|
||||
description: "Learn how to authenticate with Infisical using JWT-based authentication."
|
||||
---
|
||||
|
||||
**JWT Auth** is a platform-agnostic authentication method that validates JSON Web Tokens (JWTs) issued by your JWT issuer or authentication system, allowing secure authentication from any platform or environment that can obtain valid JWTs.
|
||||
|
||||
## Diagram
|
||||
|
||||
The following sequence diagram illustrates the JWT Auth workflow for authenticating with Infisical.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client Application
|
||||
participant Issuer as JWT Issuer
|
||||
participant Infis as Infisical
|
||||
|
||||
Client->>Issuer: Step 1: Request JWT token
|
||||
Issuer-->>Client: Return signed JWT with claims
|
||||
|
||||
Note over Client,Infis: Step 2: Login Operation
|
||||
Client->>Infis: Send signed JWT to /api/v1/auth/jwt-auth/login
|
||||
|
||||
Note over Infis: Step 3: JWT Validation
|
||||
Infis->>Infis: Validate JWT signature using configured public keys or JWKS
|
||||
Infis->>Infis: Verify required claims (aud, sub, iss)
|
||||
|
||||
Note over Infis: Step 4: Token Generation
|
||||
Infis->>Client: Return short-lived access token
|
||||
|
||||
Note over Client,Infis: Step 5: Access Infisical API with Token
|
||||
Client->>Infis: Make authenticated requests using the short-lived access token
|
||||
```
|
||||
|
||||
## Concept
|
||||
|
||||
At a high-level, Infisical authenticates a client by verifying the JWT and checking that it meets specific requirements (e.g. it is signed by a trusted key) at the `/api/v1/auth/jwt-auth/login` endpoint. If successful, then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API.
|
||||
|
||||
To be more specific:
|
||||
|
||||
1. The client requests a JWT from their JWT issuer.
|
||||
2. The fetched JWT is sent to Infisical at the `/api/v1/auth/jwt-auth/login` endpoint.
|
||||
3. Infisical validates the JWT signature using either:
|
||||
- Pre-configured public keys (Static configuration)
|
||||
- Public keys fetched from a JWKS endpoint (JWKS configuration)
|
||||
4. Infisical verifies that the configured claims match in the token. This includes standard claims like subject, audience, and issuer, as well as any additional custom claims specified in the configuration.
|
||||
5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API.
|
||||
|
||||
<Note>
|
||||
For JWKS configuration, Infisical needs network-level access to the configured
|
||||
JWKS endpoint.
|
||||
</Note>
|
||||
|
||||
## Guide
|
||||
|
||||
In the following steps, we explore how to create and use identities to access the Infisical API using the JWT authentication method.
|
||||
|
||||
<Steps>
|
||||
<Step title="Creating an identity">
|
||||
To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**.
|
||||
|
||||

|
||||
|
||||
When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles.
|
||||
|
||||

|
||||
|
||||
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 for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to.
|
||||
|
||||
Once you've created an identity, you'll be redirected to a page where you can manage the identity.
|
||||
|
||||

|
||||
|
||||
Since the identity has been configured with Universal Auth by default, you should re-configure it to use JWT Auth instead. To do this, press to edit the **Authentication** section,
|
||||
remove the existing Universal Auth configuration, and add a new JWT Auth configuration onto the identity.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<Warning>Restrict access by properly configuring the JWT validation settings.</Warning>
|
||||
|
||||
Here's some more guidance for each field:
|
||||
|
||||
**Static configuration**:
|
||||
- Public Keys: One or more PEM-encoded public keys (RSA or ECDSA) used to verify JWT signatures. Each key must include the proper BEGIN/END markers.
|
||||
|
||||
**JWKS configuration**:
|
||||
- JWKS URL: The endpoint URL that serves your JSON Web Key Sets (JWKS). This endpoint must provide the public keys used for JWT signature verification.
|
||||
- JWKS CA Certificate: Optional PEM-encoded CA certificate used for validating the TLS connection to the JWKS endpoint.
|
||||
|
||||
**Common fields for both configurations**:
|
||||
- Issuer: The unique identifier of the JWT provider. This value is used to verify the iss (issuer) claim in the JWT.
|
||||
- Audiences: A list of intended recipients. This value is checked against the aud (audience) claim in the token.
|
||||
- Subject: The expected principal that is the subject of the JWT. This value is checked against the sub (subject) claim in the token.
|
||||
- Claims: Additional claims that must be present in the JWT for it to be valid. You can specify required claim names and their expected values.
|
||||
- Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an access token in seconds. This value will be referenced at renewal time.
|
||||
- Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.
|
||||
- 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.
|
||||
- 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`, allowing usage from any network address.
|
||||
|
||||
<Info>The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible.</Info>
|
||||
</Step>
|
||||
|
||||
<Step title="Adding an identity to a project">
|
||||
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 project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to.
|
||||
|
||||

|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Accessing the Infisical API with the identity">
|
||||
To access the Infisical API as the identity, you will need to obtain a JWT from your JWT issuer that meets the validation requirements configured in step 2.
|
||||
|
||||
Once you have obtained a valid JWT, you can use it to authenticate with Infisical at the `/api/v1/auth/jwt-auth/login` endpoint.
|
||||
|
||||
We provide a code example below of how you might use the JWT to authenticate with Infisical to gain access to the [Infisical API](/api-reference/overview/introduction).
|
||||
|
||||
<Accordion
|
||||
title="Sample code for inside an application"
|
||||
>
|
||||
The shown example uses Node.js but you can use any other language to authenticate with Infisical using your JWT.
|
||||
|
||||
```javascript
|
||||
try {
|
||||
// Obtain JWT from your issuer
|
||||
const jwt = "<your-jwt-token>";
|
||||
|
||||
const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL
|
||||
const identityId = "<your-identity-id>";
|
||||
|
||||
const { data } = await axios.post(
|
||||
`{infisicalUrl}/api/v1/auth/jwt-auth/login`,
|
||||
{
|
||||
identityId,
|
||||
jwt,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("result data: ", data); // access token here
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Tip>
|
||||
We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using JWT Auth as they handle the authentication process for you.
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation;
|
||||
the default TTL is `2592000` seconds (30 days) which can be adjusted in the configuration.
|
||||
|
||||
If an identity access token exceeds its max TTL or maximum number of uses, it can no longer authenticate with the Infisical API. In this case,
|
||||
a new access token should be obtained by performing another login operation with a valid JWT.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
</Steps>
|
Binary file not shown.
After Width: | Height: | Size: 492 KiB |
Binary file not shown.
After Width: | Height: | Size: 495 KiB |
@@ -21,7 +21,7 @@ You'll need to create a new personal access token (PAT) in order to authenticate
|
||||

|
||||
|
||||
<Note>
|
||||
Please make sure that the token has access to the following scopes: Variable Groups _(read/write)_, Release _(read/write)_, Project and Team _(read)_, Service Connections _(read & query)_
|
||||
Please make sure that the token has access to the following scopes: Variable Groups _(read, create, & manage)_, Release _(read/write)_, Project and Team _(read)_, Service Connections _(read & query)_
|
||||
</Note>
|
||||
</Step>
|
||||
<Step title="Copy the new access token">
|
||||
|
@@ -32,10 +32,7 @@
|
||||
"thumbsRating": true
|
||||
},
|
||||
"api": {
|
||||
"baseUrl": [
|
||||
"https://app.infisical.com",
|
||||
"http://localhost:8080"
|
||||
]
|
||||
"baseUrl": ["https://app.infisical.com", "http://localhost:8080"]
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
@@ -76,9 +73,7 @@
|
||||
"documentation/getting-started/introduction",
|
||||
{
|
||||
"group": "Quickstart",
|
||||
"pages": [
|
||||
"documentation/guides/local-development"
|
||||
]
|
||||
"pages": ["documentation/guides/local-development"]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
@@ -229,6 +224,7 @@
|
||||
"documentation/platform/identities/gcp-auth",
|
||||
"documentation/platform/identities/azure-auth",
|
||||
"documentation/platform/identities/aws-auth",
|
||||
"documentation/platform/identities/jwt-auth",
|
||||
{
|
||||
"group": "OIDC Auth",
|
||||
"pages": [
|
||||
@@ -467,15 +463,11 @@
|
||||
},
|
||||
{
|
||||
"group": "Build Tool Integrations",
|
||||
"pages": [
|
||||
"integrations/build-tools/gradle"
|
||||
]
|
||||
"pages": ["integrations/build-tools/gradle"]
|
||||
},
|
||||
{
|
||||
"group": "",
|
||||
"pages": [
|
||||
"sdks/overview"
|
||||
]
|
||||
"pages": ["sdks/overview"]
|
||||
},
|
||||
{
|
||||
"group": "SDK's",
|
||||
@@ -495,9 +487,7 @@
|
||||
"api-reference/overview/authentication",
|
||||
{
|
||||
"group": "Examples",
|
||||
"pages": [
|
||||
"api-reference/overview/examples/integration"
|
||||
]
|
||||
"pages": ["api-reference/overview/examples/integration"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -593,6 +583,16 @@
|
||||
"api-reference/endpoints/oidc-auth/revoke"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "JWT Auth",
|
||||
"pages": [
|
||||
"api-reference/endpoints/jwt-auth/login",
|
||||
"api-reference/endpoints/jwt-auth/attach",
|
||||
"api-reference/endpoints/jwt-auth/retrieve",
|
||||
"api-reference/endpoints/jwt-auth/update",
|
||||
"api-reference/endpoints/jwt-auth/revoke"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Groups",
|
||||
"pages": [
|
||||
@@ -772,15 +772,11 @@
|
||||
},
|
||||
{
|
||||
"group": "Service Tokens",
|
||||
"pages": [
|
||||
"api-reference/endpoints/service-tokens/get"
|
||||
]
|
||||
"pages": ["api-reference/endpoints/service-tokens/get"]
|
||||
},
|
||||
{
|
||||
"group": "Audit Logs",
|
||||
"pages": [
|
||||
"api-reference/endpoints/audit-logs/export-audit-log"
|
||||
]
|
||||
"pages": ["api-reference/endpoints/audit-logs/export-audit-log"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -879,9 +875,7 @@
|
||||
},
|
||||
{
|
||||
"group": "",
|
||||
"pages": [
|
||||
"changelog/overview"
|
||||
]
|
||||
"pages": ["changelog/overview"]
|
||||
},
|
||||
{
|
||||
"group": "Contributing",
|
||||
@@ -905,9 +899,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Contributing to SDK",
|
||||
"pages": [
|
||||
"contributing/sdk/developing"
|
||||
]
|
||||
"pages": ["contributing/sdk/developing"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1090,4 +1082,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -36,7 +36,8 @@ import {
|
||||
fetchOrgUsers,
|
||||
useAddUserToWsNonE2EE,
|
||||
useCreateWorkspace,
|
||||
useGetExternalKmsList
|
||||
useGetExternalKmsList,
|
||||
useGetUserWorkspaces
|
||||
} from "@app/hooks/api";
|
||||
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
||||
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||
@@ -68,6 +69,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
|
||||
const { permission } = useOrgPermission();
|
||||
const { user } = useUser();
|
||||
const createWs = useCreateWorkspace();
|
||||
const { refetch: refetchWorkspaces } = useGetUserWorkspaces();
|
||||
const addUsersToProject = useAddUserToWsNonE2EE();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
@@ -137,8 +139,8 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
|
||||
orgId: currentOrg.id
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
|
||||
await refetchWorkspaces();
|
||||
|
||||
createNotification({ text: "Project created", type: "success" });
|
||||
reset();
|
||||
|
@@ -1,9 +1,8 @@
|
||||
export {
|
||||
useAddUserToGroup,
|
||||
useCreateGroup,
|
||||
useDeleteGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useUpdateGroup} from "./mutations";
|
||||
export {
|
||||
useListGroupUsers
|
||||
} from "./queries";
|
||||
useAddUserToGroup,
|
||||
useCreateGroup,
|
||||
useDeleteGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useUpdateGroup
|
||||
} from "./mutations";
|
||||
export { useGetGroupById, useListGroupUsers } from "./queries";
|
||||
|
@@ -56,8 +56,9 @@ export const useUpdateGroup = () => {
|
||||
|
||||
return group;
|
||||
},
|
||||
onSuccess: ({ orgId }) => {
|
||||
onSuccess: ({ orgId, id: groupId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
|
||||
queryClient.invalidateQueries(groupKeys.getGroupById(groupId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -70,8 +71,9 @@ export const useDeleteGroup = () => {
|
||||
|
||||
return group;
|
||||
},
|
||||
onSuccess: ({ orgId }) => {
|
||||
onSuccess: ({ orgId, id: groupId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
|
||||
queryClient.invalidateQueries(groupKeys.getGroupById(groupId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types";
|
||||
|
||||
export const groupKeys = {
|
||||
getGroupById: (groupId: string) => [{ groupId }, "group"] as const,
|
||||
allGroupUserMemberships: () => ["group-user-memberships"] as const,
|
||||
forGroupUserMemberships: (slug: string) =>
|
||||
[...groupKeys.allGroupUserMemberships(), slug] as const,
|
||||
@@ -10,22 +13,27 @@ export const groupKeys = {
|
||||
slug,
|
||||
offset,
|
||||
limit,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}: {
|
||||
slug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const
|
||||
filter?: EFilterReturnedUsers;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const
|
||||
};
|
||||
|
||||
type TUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isPartOfGroup: boolean;
|
||||
export const useGetGroupById = (groupId: string) => {
|
||||
return useQuery({
|
||||
enabled: Boolean(groupId),
|
||||
queryKey: groupKeys.getGroupById(groupId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TGroup>(`/api/v1/groups/${groupId}`);
|
||||
|
||||
return { group: data };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useListGroupUsers = ({
|
||||
@@ -33,20 +41,23 @@ export const useListGroupUsers = ({
|
||||
groupSlug,
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}: {
|
||||
id: string;
|
||||
groupSlug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificGroupUserMemberships({
|
||||
slug: groupSlug,
|
||||
offset,
|
||||
limit,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}),
|
||||
enabled: Boolean(groupSlug),
|
||||
keepPreviousData: true,
|
||||
@@ -54,10 +65,11 @@ export const useListGroupUsers = ({
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
search
|
||||
search,
|
||||
...(filter && { filter })
|
||||
});
|
||||
|
||||
const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>(
|
||||
const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>(
|
||||
`/api/v1/groups/${id}/users`,
|
||||
{
|
||||
params
|
||||
|
@@ -11,7 +11,7 @@ export type TGroup = {
|
||||
name: string;
|
||||
slug: string;
|
||||
orgId: string;
|
||||
createAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
role: string;
|
||||
};
|
||||
@@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = {
|
||||
slug: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TGroupUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isPartOfGroup: boolean;
|
||||
joinedGroupAt: Date;
|
||||
};
|
||||
|
||||
export enum EFilterReturnedUsers {
|
||||
EXISTING_MEMBERS = "existingMembers",
|
||||
NON_MEMBERS = "nonMembers"
|
||||
}
|
||||
|
@@ -7,5 +7,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = {
|
||||
[IdentityAuthMethod.GCP_AUTH]: "GCP Auth",
|
||||
[IdentityAuthMethod.AWS_AUTH]: "AWS Auth",
|
||||
[IdentityAuthMethod.AZURE_AUTH]: "Azure Auth",
|
||||
[IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth"
|
||||
[IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth",
|
||||
[IdentityAuthMethod.JWT_AUTH]: "JWT Auth"
|
||||
};
|
||||
|
@@ -5,5 +5,11 @@ export enum IdentityAuthMethod {
|
||||
GCP_AUTH = "gcp-auth",
|
||||
AWS_AUTH = "aws-auth",
|
||||
AZURE_AUTH = "azure-auth",
|
||||
OIDC_AUTH = "oidc-auth"
|
||||
OIDC_AUTH = "oidc-auth",
|
||||
JWT_AUTH = "jwt-auth"
|
||||
}
|
||||
|
||||
export enum IdentityJwtConfigurationType {
|
||||
JWKS = "jwks",
|
||||
STATIC = "static"
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ export {
|
||||
useAddIdentityAwsAuth,
|
||||
useAddIdentityAzureAuth,
|
||||
useAddIdentityGcpAuth,
|
||||
useAddIdentityJwtAuth,
|
||||
useAddIdentityKubernetesAuth,
|
||||
useAddIdentityOidcAuth,
|
||||
useAddIdentityTokenAuth,
|
||||
@@ -15,6 +16,7 @@ export {
|
||||
useDeleteIdentityAwsAuth,
|
||||
useDeleteIdentityAzureAuth,
|
||||
useDeleteIdentityGcpAuth,
|
||||
useDeleteIdentityJwtAuth,
|
||||
useDeleteIdentityKubernetesAuth,
|
||||
useDeleteIdentityOidcAuth,
|
||||
useDeleteIdentityTokenAuth,
|
||||
@@ -25,20 +27,24 @@ export {
|
||||
useUpdateIdentityAwsAuth,
|
||||
useUpdateIdentityAzureAuth,
|
||||
useUpdateIdentityGcpAuth,
|
||||
useUpdateIdentityJwtAuth,
|
||||
useUpdateIdentityKubernetesAuth,
|
||||
useUpdateIdentityOidcAuth,
|
||||
useUpdateIdentityTokenAuth,
|
||||
useUpdateIdentityTokenAuthToken,
|
||||
useUpdateIdentityUniversalAuth} from "./mutations";
|
||||
useUpdateIdentityUniversalAuth
|
||||
} from "./mutations";
|
||||
export {
|
||||
useGetIdentityAwsAuth,
|
||||
useGetIdentityAzureAuth,
|
||||
useGetIdentityById,
|
||||
useGetIdentityGcpAuth,
|
||||
useGetIdentityJwtAuth,
|
||||
useGetIdentityKubernetesAuth,
|
||||
useGetIdentityOidcAuth,
|
||||
useGetIdentityProjectMemberships,
|
||||
useGetIdentityTokenAuth,
|
||||
useGetIdentityTokensTokenAuth,
|
||||
useGetIdentityUniversalAuth,
|
||||
useGetIdentityUniversalAuthClientSecrets} from "./queries";
|
||||
useGetIdentityUniversalAuthClientSecrets
|
||||
} from "./queries";
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
AddIdentityAwsAuthDTO,
|
||||
AddIdentityAzureAuthDTO,
|
||||
AddIdentityGcpAuthDTO,
|
||||
AddIdentityJwtAuthDTO,
|
||||
AddIdentityKubernetesAuthDTO,
|
||||
AddIdentityOidcAuthDTO,
|
||||
AddIdentityTokenAuthDTO,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
DeleteIdentityAzureAuthDTO,
|
||||
DeleteIdentityDTO,
|
||||
DeleteIdentityGcpAuthDTO,
|
||||
DeleteIdentityJwtAuthDTO,
|
||||
DeleteIdentityKubernetesAuthDTO,
|
||||
DeleteIdentityOidcAuthDTO,
|
||||
DeleteIdentityTokenAuthDTO,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
IdentityAwsAuth,
|
||||
IdentityAzureAuth,
|
||||
IdentityGcpAuth,
|
||||
IdentityJwtAuth,
|
||||
IdentityKubernetesAuth,
|
||||
IdentityOidcAuth,
|
||||
IdentityTokenAuth,
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
UpdateIdentityAzureAuthDTO,
|
||||
UpdateIdentityDTO,
|
||||
UpdateIdentityGcpAuthDTO,
|
||||
UpdateIdentityJwtAuthDTO,
|
||||
UpdateIdentityKubernetesAuthDTO,
|
||||
UpdateIdentityOidcAuthDTO,
|
||||
UpdateIdentityTokenAuthDTO,
|
||||
@@ -518,6 +522,118 @@ export const useDeleteIdentityOidcAuth = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
export const useUpdateIdentityJwtAuth = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<IdentityJwtAuth, {}, UpdateIdentityJwtAuthDTO>({
|
||||
mutationFn: async ({
|
||||
identityId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject
|
||||
}) => {
|
||||
const {
|
||||
data: { identityJwtAuth }
|
||||
} = await apiRequest.patch<{ identityJwtAuth: IdentityJwtAuth }>(
|
||||
`/api/v1/auth/jwt-auth/identities/${identityId}`,
|
||||
{
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps
|
||||
}
|
||||
);
|
||||
|
||||
return identityJwtAuth;
|
||||
},
|
||||
onSuccess: (_, { identityId, organizationId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddIdentityJwtAuth = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<IdentityJwtAuth, {}, AddIdentityJwtAuthDTO>({
|
||||
mutationFn: async ({
|
||||
identityId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps
|
||||
}) => {
|
||||
const {
|
||||
data: { identityJwtAuth }
|
||||
} = await apiRequest.post<{ identityJwtAuth: IdentityJwtAuth }>(
|
||||
`/api/v1/auth/jwt-auth/identities/${identityId}`,
|
||||
{
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps
|
||||
}
|
||||
);
|
||||
|
||||
return identityJwtAuth;
|
||||
},
|
||||
onSuccess: (_, { identityId, organizationId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteIdentityJwtAuth = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<IdentityTokenAuth, {}, DeleteIdentityJwtAuthDTO>({
|
||||
mutationFn: async ({ identityId }) => {
|
||||
const {
|
||||
data: { identityJwtAuth }
|
||||
} = await apiRequest.delete(`/api/v1/auth/jwt-auth/identities/${identityId}`);
|
||||
return identityJwtAuth;
|
||||
},
|
||||
onSuccess: (_, { organizationId, identityId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddIdentityAzureAuth = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
IdentityAwsAuth,
|
||||
IdentityAzureAuth,
|
||||
IdentityGcpAuth,
|
||||
IdentityJwtAuth,
|
||||
IdentityKubernetesAuth,
|
||||
IdentityMembership,
|
||||
IdentityMembershipOrg,
|
||||
@@ -29,6 +30,7 @@ export const identitiesKeys = {
|
||||
getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const,
|
||||
getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const,
|
||||
getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const,
|
||||
getIdentityJwtAuth: (identityId: string) => [{ identityId }, "identity-jwt-auth"] as const,
|
||||
getIdentityTokensTokenAuth: (identityId: string) =>
|
||||
[{ identityId }, "identity-tokens-token-auth"] as const,
|
||||
getIdentityProjectMemberships: (identityId: string) =>
|
||||
@@ -276,3 +278,30 @@ export const useGetIdentityOidcAuth = (
|
||||
enabled: Boolean(identityId) && (options?.enabled ?? true)
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetIdentityJwtAuth = (
|
||||
identityId: string,
|
||||
options?: UseQueryOptions<
|
||||
IdentityJwtAuth,
|
||||
unknown,
|
||||
IdentityJwtAuth,
|
||||
ReturnType<typeof identitiesKeys.getIdentityJwtAuth>
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: identitiesKeys.getIdentityJwtAuth(identityId),
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { identityJwtAuth }
|
||||
} = await apiRequest.get<{ identityJwtAuth: IdentityJwtAuth }>(
|
||||
`/api/v1/auth/jwt-auth/identities/${identityId}`
|
||||
);
|
||||
|
||||
return identityJwtAuth;
|
||||
},
|
||||
staleTime: 0,
|
||||
cacheTime: 0,
|
||||
...options,
|
||||
enabled: Boolean(identityId) && (options?.enabled ?? true)
|
||||
});
|
||||
};
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { TOrgRole } from "../roles/types";
|
||||
import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types";
|
||||
import { IdentityAuthMethod } from "./enums";
|
||||
import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums";
|
||||
|
||||
export type IdentityTrustedIp = {
|
||||
id: string;
|
||||
@@ -446,6 +446,65 @@ export type DeleteIdentityTokenAuthDTO = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export type IdentityJwtAuth = {
|
||||
identityId: string;
|
||||
configurationType: IdentityJwtConfigurationType;
|
||||
jwksUrl: string;
|
||||
jwksCaCert: string;
|
||||
publicKeys: string[];
|
||||
boundIssuer: string;
|
||||
boundAudiences: string;
|
||||
boundClaims: Record<string, string>;
|
||||
boundSubject: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: IdentityTrustedIp[];
|
||||
};
|
||||
|
||||
export type AddIdentityJwtAuthDTO = {
|
||||
organizationId: string;
|
||||
identityId: string;
|
||||
configurationType: string;
|
||||
jwksUrl?: string;
|
||||
jwksCaCert: string;
|
||||
publicKeys?: string[];
|
||||
boundIssuer: string;
|
||||
boundAudiences: string;
|
||||
boundClaims: Record<string, string>;
|
||||
boundSubject: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: {
|
||||
ipAddress: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type UpdateIdentityJwtAuthDTO = {
|
||||
organizationId: string;
|
||||
identityId: string;
|
||||
configurationType?: string;
|
||||
jwksUrl?: string;
|
||||
jwksCaCert?: string;
|
||||
publicKeys?: string[];
|
||||
boundIssuer?: string;
|
||||
boundAudiences?: string;
|
||||
boundClaims?: Record<string, string>;
|
||||
boundSubject?: string;
|
||||
accessTokenTTL?: number;
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
accessTokenTrustedIps?: {
|
||||
ipAddress: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DeleteIdentityJwtAuthDTO = {
|
||||
organizationId: string;
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export type CreateTokenIdentityTokenAuthDTO = {
|
||||
identityId: string;
|
||||
name: string;
|
||||
|
@@ -65,7 +65,7 @@ export const WishForm = () => {
|
||||
<PopoverTrigger asChild>
|
||||
<div className="text-md mb-3 w-full pl-5 duration-200 hover:text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faRocketchat} className="mr-2" />
|
||||
Make a wish
|
||||
Request a feature
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
19
frontend/src/pages/org/[id]/groups/[groupId]/index.tsx
Normal file
19
frontend/src/pages/org/[id]/groups/[groupId]/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { GroupPage } from "@app/views/Org/GroupPage";
|
||||
|
||||
export default function Group() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<GroupPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Group.requireAuth = true;
|
175
frontend/src/views/Org/GroupPage/GroupPage.tsx
Normal file
175
frontend/src/views/Org/GroupPage/GroupPage.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { useGetGroupById } from "@app/hooks/api/groups/queries";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { TabSections } from "@app/views/Org/Types";
|
||||
|
||||
import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal";
|
||||
import { GroupMembersSection } from "./components/GroupMembersSection";
|
||||
import { GroupDetailsSection } from "./components";
|
||||
|
||||
export const GroupPage = withPermission(
|
||||
() => {
|
||||
const router = useRouter();
|
||||
const groupId = router.query.groupId as string;
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data, isLoading } = useGetGroupById(groupId);
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"groupCreateUpdate",
|
||||
"deleteGroup",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
id
|
||||
});
|
||||
createNotification({
|
||||
text: `Successfully deleted the ${name} group`,
|
||||
type: "success"
|
||||
});
|
||||
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to delete the ${name} group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("deleteGroup");
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner size="sm" className="mt-2 ml-2" />;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
{data && (
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`);
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-3xl font-semibold text-white">{data.group.name}</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("groupCreateUpdate", {
|
||||
groupId,
|
||||
name: data.group.name,
|
||||
slug: data.group.slug,
|
||||
role: data.group.role
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Edit Group
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
id: groupId,
|
||||
name: data.group.name
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Group
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-4 w-96">
|
||||
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<GroupCreateUpdateModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to delete the group named ${
|
||||
(popUp?.deleteGroup?.data as { name: string })?.name || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string })
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups }
|
||||
);
|
@@ -22,21 +22,22 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useDebounce, useResetPageHelper } from "@app/hooks";
|
||||
import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
|
||||
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["groupMembers"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void;
|
||||
popUp: UsePopUpState<["addGroupMembers"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchMemberFilter);
|
||||
|
||||
const popUpData = popUp?.groupMembers?.data as {
|
||||
const popUpData = popUp?.addGroupMembers?.data as {
|
||||
groupId: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
groupSlug: popUpData?.slug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search: debouncedSearch
|
||||
search: debouncedSearch,
|
||||
filter: EFilterReturnedUsers.NON_MEMBERS
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
@@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
|
||||
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
|
||||
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
|
||||
|
||||
const handleAssignment = async (username: string, assign: boolean) => {
|
||||
const handleAddMember = async (username: string) => {
|
||||
try {
|
||||
if (!popUpData?.slug) return;
|
||||
|
||||
if (assign) {
|
||||
await assignMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
} else {
|
||||
await unassignMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
if (!popUpData?.slug) {
|
||||
createNotification({
|
||||
text: "Some data is missing, please refresh the page and try again",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addUserToGroupMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${assign ? "assigned" : "removed"} user ${
|
||||
assign ? "to" : "from"
|
||||
} group`,
|
||||
text: "Successfully assigned user to the group",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`,
|
||||
text: "Failed to assign user to the group",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
@@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.groupMembers?.isOpen}
|
||||
isOpen={popUp?.addGroupMembers?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("groupMembers", isOpen);
|
||||
handlePopUpToggle("addGroupMembers", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Manage Group Members">
|
||||
<ModalContent title="Add Group Members">
|
||||
<Input
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
@@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
|
||||
{!isLoading &&
|
||||
data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => {
|
||||
data?.users?.map(({ id, firstName, lastName, username }) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
@@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handleAssignment(username, !isPartOfGroup)}
|
||||
onClick={() => handleAddMember(username)}
|
||||
>
|
||||
{isPartOfGroup ? "Unassign" : "Assign"}
|
||||
Assign
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
@@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
)}
|
||||
{!isLoading && !data?.users?.length && (
|
||||
<EmptyState
|
||||
title={debouncedSearch ? "No users match search" : "No users found"}
|
||||
title={
|
||||
debouncedSearch ? "No users match search" : "All users are already in the group"
|
||||
}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
@@ -0,0 +1,192 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const GroupFormSchema = z.object({
|
||||
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
|
||||
slug: z
|
||||
.string()
|
||||
.min(5, "Slug must be at least 5 characters long")
|
||||
.max(36, "Slug must be 36 characters or fewer"),
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["groupCreateUpdate"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["groupCreateUpdate"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: roles } = useGetOrgRoles(currentOrg?.id || "");
|
||||
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup();
|
||||
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<TGroupFormData>({
|
||||
resolver: zodResolver(GroupFormSchema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const group = popUp?.groupCreateUpdate?.data as {
|
||||
groupId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
role: string;
|
||||
customRole: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!roles?.length) return;
|
||||
|
||||
if (group) {
|
||||
reset({
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.groupCreateUpdate?.data, roles]);
|
||||
|
||||
const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const group = popUp?.groupCreateUpdate?.data as {
|
||||
groupId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
if (group) {
|
||||
await updateMutateAsync({
|
||||
id: group.groupId,
|
||||
name,
|
||||
slug,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
name,
|
||||
slug,
|
||||
organizationId: currentOrg.id,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("groupCreateUpdate", false);
|
||||
reset();
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.groupCreateUpdate?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("groupCreateUpdate", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.groupCreateUpdate?.data ? "Update" : "Create"} Group`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Name" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input {...field} placeholder="Engineering" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Slug" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input {...field} placeholder="engineering" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={createIsLoading || updateIsLoading}
|
||||
>
|
||||
{!popUp?.groupCreateUpdate?.data ? "Create" : "Update"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("groupCreateUpdate")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@@ -0,0 +1,88 @@
|
||||
import { faPencil } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useGetGroupById } from "@app/hooks/api/";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => {
|
||||
const { data, isLoading } = useGetGroupById(groupId);
|
||||
|
||||
if (isLoading) return <Spinner size="sm" className="mt-2 ml-2" />;
|
||||
|
||||
return data ? (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Group Details</h3>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Edit Group">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="edit group button"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("groupCreateUpdate", {
|
||||
groupId,
|
||||
name: data.group.name,
|
||||
slug: data.group.slug,
|
||||
role: data.group.role
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Group ID</p>
|
||||
<div className="group flex items-center gap-2">
|
||||
<p className="text-sm text-mineshaft-300">{data.group.id}</p>
|
||||
<CopyButton value={data.group.id} name="Group ID" size="xs" variant="plain" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.group.name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
|
||||
<div className="group flex items-center gap-2">
|
||||
<p className="text-sm text-mineshaft-300">{data.group.slug}</p>
|
||||
<CopyButton value={data.group.slug} name="Slug" size="xs" variant="plain" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.group.role}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Created At</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{new Date(data.group.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<p className="text-mineshaft-300">Group data not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,90 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, IconButton } from "@app/components/v2";
|
||||
import { useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddGroupMembersModal } from "../AddGroupMemberModal";
|
||||
import { GroupMembersTable } from "./GroupMembersTable";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
};
|
||||
|
||||
export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"addGroupMembers",
|
||||
"removeMemberFromGroup"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup();
|
||||
const handleRemoveUserFromGroup = async (username: string) => {
|
||||
try {
|
||||
await removeUserFromGroupMutateAsync({
|
||||
groupId,
|
||||
username,
|
||||
slug: groupSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully removed user ${username} from the group`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("removeMemberFromGroup", false);
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to remove user ${username} from the group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Group Members</h3>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addGroupMembers", {
|
||||
groupId,
|
||||
slug: groupSlug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<GroupMembersTable
|
||||
groupId={groupId}
|
||||
groupSlug={groupSlug}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
</div>
|
||||
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMemberFromGroup.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
|
||||
} from the group?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMemberFromGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => {
|
||||
const userData = popUp?.removeMemberFromGroup?.data as {
|
||||
username: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
return handleRemoveUserFromGroup(userData.username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListGroupUsers } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GroupMembershipRow } from "./GroupMembershipRow";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>,
|
||||
data?: {}
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => {
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const { data: groupMemberships, isLoading } = useListGroupUsers({
|
||||
id: groupId,
|
||||
groupSlug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: EFilterReturnedUsers.EXISTING_MEMBERS
|
||||
});
|
||||
|
||||
const filteredGroupMemberships = useMemo(() => {
|
||||
return groupMemberships && groupMemberships?.users
|
||||
? groupMemberships?.users
|
||||
?.filter((membership) => {
|
||||
const userSearchString = `${membership.firstName && membership.firstName} ${
|
||||
membership.lastName && membership.lastName
|
||||
} ${membership.email && membership.email} ${
|
||||
membership.username && membership.username
|
||||
}`;
|
||||
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
const membershipOneComparisonString = membershipOne.firstName
|
||||
? membershipOne.firstName
|
||||
: membershipOne.email;
|
||||
|
||||
const membershipTwoComparisonString = membershipTwo.firstName
|
||||
? membershipTwo.firstName
|
||||
: membershipTwo.email;
|
||||
|
||||
const comparison = membershipOneComparisonString
|
||||
.toLowerCase()
|
||||
.localeCompare(membershipTwoComparisonString.toLowerCase());
|
||||
|
||||
return comparison;
|
||||
})
|
||||
: [];
|
||||
}, [groupMemberships, orderDirection, search]);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships?.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
|
||||
{!isLoading &&
|
||||
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
|
||||
return (
|
||||
<GroupMembershipRow
|
||||
key={`user-group-membership-${userGroupMembership.id}`}
|
||||
user={userGroupMembership}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships?.users.length
|
||||
? "No users match this search..."
|
||||
: "This group does not have any members yet"
|
||||
}
|
||||
icon={groupMemberships?.users.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
{!groupMemberships?.users.length && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addGroupMembers", {
|
||||
groupId,
|
||||
slug: groupSlug
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add members
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,53 @@
|
||||
import { faUserMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { TGroupUser } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
user: TGroupUser;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipRow = ({
|
||||
user: { firstName, lastName, username, joinedGroupAt, email, id },
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<p>{email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td className="justify-end">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Remove user from group">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="Remove user from group"
|
||||
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserMinus} className="cursor-pointer" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
@@ -0,0 +1 @@
|
||||
export { GroupMembersSection } from "./GroupMembersSection";
|
1
frontend/src/views/Org/GroupPage/components/index.tsx
Normal file
1
frontend/src/views/Org/GroupPage/components/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { GroupDetailsSection } from "./GroupDetailsSection";
|
1
frontend/src/views/Org/GroupPage/index.tsx
Normal file
1
frontend/src/views/Org/GroupPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { GroupPage } from "./GroupPage";
|
@@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { OrgGroupMembersModal } from "./OrgGroupMembersModal";
|
||||
import { OrgGroupModal } from "./OrgGroupModal";
|
||||
import { OrgGroupsTable } from "./OrgGroupsTable";
|
||||
|
||||
@@ -78,7 +77,6 @@ export const OrgGroupsSection = () => {
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<OrgGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to delete the group named ${
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
@@ -61,6 +62,7 @@ enum GroupsOrderBy {
|
||||
}
|
||||
|
||||
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const router = useRouter();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
|
||||
@@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
.slice(offset, perPage * page)
|
||||
.map(({ id, name, slug, role, customRole }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`org-group-${id}`}>
|
||||
<Tr
|
||||
onClick={() => router.push(`/org/${orgId}/groups/${id}`)}
|
||||
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
key={`org-group-${id}`}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
@@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("groupMembers", {
|
||||
groupId: id,
|
||||
slug
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Manage Users
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
@@ -324,6 +307,23 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={() => router.push(`/org/${orgId}/groups/${id}`)}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Manage Members
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
|
@@ -23,12 +23,17 @@ import {
|
||||
useDeleteIdentityTokenAuth,
|
||||
useDeleteIdentityUniversalAuth
|
||||
} from "@app/hooks/api";
|
||||
import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities";
|
||||
import {
|
||||
IdentityAuthMethod,
|
||||
identityAuthToNameMap,
|
||||
useDeleteIdentityJwtAuth
|
||||
} from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm";
|
||||
import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm";
|
||||
import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm";
|
||||
import { IdentityJwtAuthForm } from "./IdentityJwtAuthForm";
|
||||
import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm";
|
||||
import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm";
|
||||
import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm";
|
||||
@@ -68,7 +73,11 @@ const identityAuthMethods = [
|
||||
{ label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH },
|
||||
{ label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH },
|
||||
{ label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH },
|
||||
{ label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH }
|
||||
{ label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH },
|
||||
{
|
||||
label: "JWT Auth",
|
||||
value: IdentityAuthMethod.JWT_AUTH
|
||||
}
|
||||
];
|
||||
|
||||
const schema = yup
|
||||
@@ -100,6 +109,7 @@ export const IdentityAuthMethodModalContent = ({
|
||||
const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth();
|
||||
const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth();
|
||||
const { mutateAsync: revokeOidcAuth } = useDeleteIdentityOidcAuth();
|
||||
const { mutateAsync: revokeJwtAuth } = useDeleteIdentityJwtAuth();
|
||||
|
||||
const { control, watch } = useForm<FormData>({
|
||||
resolver: yupResolver(schema),
|
||||
@@ -216,6 +226,17 @@ export const IdentityAuthMethodModalContent = ({
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
[IdentityAuthMethod.JWT_AUTH]: {
|
||||
revokeMethod: revokeJwtAuth,
|
||||
render: () => (
|
||||
<IdentityJwtAuthForm
|
||||
identityAuthMethodData={identityAuthMethodData}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -0,0 +1,688 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { useAddIdentityJwtAuth, useUpdateIdentityJwtAuth } from "@app/hooks/api";
|
||||
import { IdentityAuthMethod } from "@app/hooks/api/identities";
|
||||
import { IdentityJwtConfigurationType } from "@app/hooks/api/identities/enums";
|
||||
import { useGetIdentityJwtAuth } from "@app/hooks/api/identities/queries";
|
||||
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const commonSchema = z.object({
|
||||
accessTokenTrustedIps: z
|
||||
.array(
|
||||
z.object({
|
||||
ipAddress: z.string().max(50)
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
|
||||
message: "Access Token TTL cannot be greater than 315360000"
|
||||
}),
|
||||
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
|
||||
message: "Access Token Max TTL cannot be greater than 315360000"
|
||||
}),
|
||||
accessTokenNumUsesLimit: z.string(),
|
||||
boundIssuer: z.string().trim().default(""),
|
||||
boundAudiences: z.string().optional().default(""),
|
||||
boundClaims: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string()
|
||||
})
|
||||
),
|
||||
boundSubject: z.string().optional().default("")
|
||||
});
|
||||
|
||||
const schema = z.discriminatedUnion("configurationType", [
|
||||
z
|
||||
.object({
|
||||
configurationType: z.literal(IdentityJwtConfigurationType.JWKS),
|
||||
jwksUrl: z.string().trim().url(),
|
||||
jwksCaCert: z.string().trim().default(""),
|
||||
publicKeys: z
|
||||
.object({
|
||||
value: z.string()
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
})
|
||||
.merge(commonSchema),
|
||||
z
|
||||
.object({
|
||||
configurationType: z.literal(IdentityJwtConfigurationType.STATIC),
|
||||
jwksUrl: z.string().trim().optional(),
|
||||
jwksCaCert: z.string().trim().optional().default(""),
|
||||
publicKeys: z
|
||||
.object({
|
||||
value: z.string().min(1)
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
})
|
||||
.merge(commonSchema)
|
||||
]);
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
identityAuthMethodData: {
|
||||
identityId: string;
|
||||
name: string;
|
||||
configuredAuthMethods?: IdentityAuthMethod[];
|
||||
authMethod?: IdentityAuthMethod;
|
||||
};
|
||||
};
|
||||
|
||||
export const IdentityJwtAuthForm = ({
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle,
|
||||
identityAuthMethodData
|
||||
}: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { mutateAsync: addMutateAsync } = useAddIdentityJwtAuth();
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateIdentityJwtAuth();
|
||||
|
||||
const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes(
|
||||
identityAuthMethodData.authMethod! || ""
|
||||
);
|
||||
const { data } = useGetIdentityJwtAuth(identityAuthMethodData?.identityId ?? "", {
|
||||
enabled: isUpdate
|
||||
});
|
||||
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
accessTokenTTL: "2592000",
|
||||
accessTokenMaxTTL: "2592000",
|
||||
accessTokenNumUsesLimit: "0",
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
configurationType: IdentityJwtConfigurationType.JWKS
|
||||
}
|
||||
});
|
||||
|
||||
const selectedConfigurationType = watch("configurationType") as IdentityJwtConfigurationType;
|
||||
|
||||
const {
|
||||
fields: publicKeyFields,
|
||||
append: appendPublicKeyFields,
|
||||
remove: removePublicKeyFields
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "publicKeys"
|
||||
});
|
||||
|
||||
const {
|
||||
fields: boundClaimsFields,
|
||||
append: appendBoundClaimField,
|
||||
remove: removeBoundClaimField
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: "boundClaims"
|
||||
});
|
||||
|
||||
const {
|
||||
fields: accessTokenTrustedIpsFields,
|
||||
append: appendAccessTokenTrustedIp,
|
||||
remove: removeAccessTokenTrustedIp
|
||||
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({
|
||||
configurationType: data.configurationType,
|
||||
jwksUrl: data.jwksUrl,
|
||||
jwksCaCert: data.jwksCaCert,
|
||||
publicKeys: data.publicKeys.map((pk) => ({
|
||||
value: pk
|
||||
})),
|
||||
boundIssuer: data.boundIssuer,
|
||||
boundAudiences: data.boundAudiences,
|
||||
boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({
|
||||
key,
|
||||
value
|
||||
})),
|
||||
boundSubject: data.boundSubject,
|
||||
accessTokenTTL: String(data.accessTokenTTL),
|
||||
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
|
||||
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
|
||||
({ ipAddress, prefix }: IdentityTrustedIp) => {
|
||||
return {
|
||||
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
|
||||
};
|
||||
}
|
||||
)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
configurationType: IdentityJwtConfigurationType.JWKS,
|
||||
jwksUrl: "",
|
||||
jwksCaCert: "",
|
||||
boundIssuer: "",
|
||||
boundAudiences: "",
|
||||
boundClaims: [],
|
||||
boundSubject: "",
|
||||
accessTokenTTL: "2592000",
|
||||
accessTokenMaxTTL: "2592000",
|
||||
accessTokenNumUsesLimit: "0",
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const onFormSubmit = async ({
|
||||
accessTokenTrustedIps,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys,
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims,
|
||||
boundSubject
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!identityAuthMethodData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
await updateMutateAsync({
|
||||
identityId: identityAuthMethodData.identityId,
|
||||
organizationId: orgId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys: publicKeys?.map((field) => field.value).filter(Boolean),
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])),
|
||||
boundSubject,
|
||||
accessTokenTTL: Number(accessTokenTTL),
|
||||
accessTokenMaxTTL: Number(accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
|
||||
accessTokenTrustedIps
|
||||
});
|
||||
} else {
|
||||
await addMutateAsync({
|
||||
identityId: identityAuthMethodData.identityId,
|
||||
configurationType,
|
||||
jwksUrl,
|
||||
jwksCaCert,
|
||||
publicKeys: publicKeys?.map((field) => field.value).filter(Boolean),
|
||||
boundIssuer,
|
||||
boundAudiences,
|
||||
boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])),
|
||||
boundSubject,
|
||||
organizationId: orgId,
|
||||
accessTokenTTL: Number(accessTokenTTL),
|
||||
accessTokenMaxTTL: Number(accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
|
||||
accessTokenTrustedIps
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpToggle("identityAuthMethod", false);
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="configurationType"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Configuration Type"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
if (e === IdentityJwtConfigurationType.JWKS) {
|
||||
setValue("publicKeys", []);
|
||||
} else {
|
||||
setValue("publicKeys", [
|
||||
{
|
||||
value: ""
|
||||
}
|
||||
]);
|
||||
setValue("jwksUrl", "");
|
||||
setValue("jwksCaCert", "");
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={IdentityJwtConfigurationType.JWKS} key="jwks">
|
||||
JWKS
|
||||
</SelectItem>
|
||||
<SelectItem value={IdentityJwtConfigurationType.STATIC} key="static">
|
||||
Static
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{selectedConfigurationType === IdentityJwtConfigurationType.JWKS && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="jwksUrl"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
label="JWKS URL"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="text" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="jwksCaCert"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="JWKS CA Certificate"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedConfigurationType === IdentityJwtConfigurationType.STATIC && (
|
||||
<>
|
||||
{publicKeyFields.map(({ id }, index) => (
|
||||
<div key={id} className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`publicKeys.${index}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-grow"
|
||||
label={`Public Key ${index + 1}`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
icon={
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field only accepts PEM-formatted public keys</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<TextArea {...field} placeholder="-----BEGIN PUBLIC KEY----- ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (publicKeyFields.length === 1) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "A public key is required for static configurations"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
removePublicKeyFields(index);
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-4 ml-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() =>
|
||||
appendPublicKeyFields({
|
||||
value: ""
|
||||
})
|
||||
}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
>
|
||||
Add Public Key
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="boundIssuer"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Issuer" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} type="text" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="boundSubject"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Subject"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
icon={
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field supports glob patterns</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input {...field} type="text" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="boundAudiences"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Audiences"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
icon={
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field supports glob patterns</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input {...field} type="text" placeholder="service1, service2" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{boundClaimsFields.map(({ id }, index) => (
|
||||
<div className="mb-3 flex items-end space-x-2" key={id}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`boundClaims.${index}.key`}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="mb-0 flex-grow"
|
||||
label={index === 0 ? "Claims" : undefined}
|
||||
icon={
|
||||
index === 0 ? (
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content={<span>This field supports glob patterns</span>}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e)}
|
||||
placeholder="property"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`boundClaims.${index}.value`}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="mb-0 flex-grow"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e)}
|
||||
placeholder="value1, value2"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
onClick={() => removeBoundClaimField(index)}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-4 ml-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() =>
|
||||
appendBoundClaimField({
|
||||
key: "",
|
||||
value: ""
|
||||
})
|
||||
}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
>
|
||||
Add Claims
|
||||
</Button>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue="2592000"
|
||||
name="accessTokenTTL"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Access Token TTL (seconds)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue="2592000"
|
||||
name="accessTokenMaxTTL"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Access Token Max TTL (seconds)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="2592000" type="number" min="1" step="1" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue="0"
|
||||
name="accessTokenNumUsesLimit"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Access Token Max Number of Uses"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="0" type="number" min="0" step="1" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{accessTokenTrustedIpsFields.map(({ id }, index) => (
|
||||
<div className="mb-3 flex items-end space-x-2" key={id}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`accessTokenTrustedIps.${index}.ipAddress`}
|
||||
defaultValue="0.0.0.0/0"
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="mb-0 flex-grow"
|
||||
label={index === 0 ? "Access Token Trusted IPs" : undefined}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
field.onChange(e);
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
placeholder="123.456.789.0"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
removeAccessTokenTrustedIp(index);
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-4 ml-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
appendAccessTokenTrustedIp({
|
||||
ipAddress: "0.0.0.0/0"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
>
|
||||
Add IP Address
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{isUpdate ? "Update" : "Create"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{isUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
onClick={() => handlePopUpToggle("revokeAuthMethod", true)}
|
||||
>
|
||||
Remove Auth Method
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@@ -20,7 +20,7 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
>
|
||||
<ModalContent
|
||||
title="Share a Secret"
|
||||
subTitle="Once you share a secret, the share link is only accessible once."
|
||||
subTitle="Securely share one off secrets with your team."
|
||||
>
|
||||
<ShareSecretForm
|
||||
isPublic={false}
|
||||
|
Reference in New Issue
Block a user