Compare commits

...

42 Commits

Author SHA1 Message Date
Sheen Capadngan
d873f2e50f misc: address cpu usage issue of secret version query 2024-12-13 20:31:34 +08:00
Sheen
16ea757928 Merge pull request #2857 from Infisical/feat/jwt-auth
feat: jwt auth
2024-12-13 14:15:43 +08:00
Sheen Capadngan
8713643bc1 misc: add support for number field values 2024-12-13 14:02:32 +08:00
Sheen Capadngan
c35657ed49 misc: addressed review comments 2024-12-13 13:39:23 +08:00
Maidul Islam
5b4487fae8 add period to secret share text 2024-12-12 16:04:51 -05:00
Maidul Islam
474731d8ef update share secret text 2024-12-12 16:02:30 -05:00
Maidul Islam
e9f254f81b Update azure-devops.mdx 2024-12-12 15:36:38 -05:00
Maidul Islam
25191cff38 Merge pull request #2872 from Infisical/maidul-update-make-wish
Update make wish text
2024-12-12 10:05:12 -05:00
Maidul Islam
a6898717f4 update make wish text 2024-12-12 10:01:13 -05:00
Maidul Islam
cc77175188 Merge pull request #2861 from Infisical/daniel/plain-to-pylon
feat: remove plain and move to pylon
2024-12-11 19:56:56 -05:00
Maidul Islam
fcb944d964 Merge pull request #2856 from Infisical/omar/eng-1806-add-instance-url-to-email-verification-for-infisical
improvement: Add email footer with instance URL
2024-12-11 19:48:27 -05:00
Daniel Hougaard
a8ad8707ac Merge pull request #2859 from Infisical/daniel/copy-paste
fix(dashboard): pasting secrets into create secret modal
2024-12-12 03:56:43 +04:00
Daniel Hougaard
4568370552 Update parseEnvVar.ts 2024-12-12 03:55:27 +04:00
Daniel Hougaard
c000a6f707 more requested changes 2024-12-12 03:34:08 +04:00
Daniel Hougaard
1ace8eebf8 fix(k8s): dynamic secret bugs 2024-12-12 03:27:07 +04:00
Daniel Hougaard
3b3482b280 fix: improve ref handling 2024-12-11 21:51:20 +04:00
Daniel Hougaard
422fd27b9a fix: requested changes 2024-12-11 21:44:42 +04:00
Sheen Capadngan
a7b25f3bd8 misc: addressed module issue 2024-12-12 00:24:36 +08:00
Sheen Capadngan
7896b4e85e doc: added documentation 2024-12-12 00:23:06 +08:00
Maidul Islam
ba5e6fe28a Merge pull request #2867 from muhammed-mamun/patch-1
Fix typo in README.md
2024-12-11 10:19:17 -05:00
Sheen Capadngan
8d79fa3529 misc: finalized login logic and other ui/ux changes 2024-12-11 22:49:26 +08:00
Md. Mamun Hossain
1a55909b73 Fix typo in README.md
Corrected the typo "Cryptograhic" to "Cryptographic" in the README.md file.
2024-12-11 19:59:06 +06:00
Sheen Capadngan
b2efb2845a misc: finalized api endpoint schema 2024-12-11 21:09:25 +08:00
Sheen
c680030f01 Merge pull request #2866 from Infisical/misc/moved-integration-auth-to-params
misc: moved integration auth to params
2024-12-11 19:04:39 +08:00
Sheen Capadngan
cf1070c65e misc: moved integration auth to params 2024-12-11 17:56:30 +08:00
Daniel Hougaard
3a8219db03 fix: requested changes 2024-12-11 08:32:10 +04:00
Sheen Capadngan
9d9f6ec268 misc: initial ui work 2024-12-11 03:40:21 +08:00
Sheen Capadngan
56aab172d3 feat: added logic for jwt auth login 2024-12-11 00:05:31 +08:00
Sheen Capadngan
c8ee06341a feat: finished crud endpoints 2024-12-10 23:10:44 +08:00
McPizza
e32716c258 improvement: Better group member management (#2851)
* improvement: Better org member management
2024-12-10 14:10:14 +01:00
Daniel Hougaard
7f0d27e3dc Merge pull request #2862 from Infisical/daniel/improve-project-creation-speed
fix(dashboard): improved project creation speed
2024-12-10 16:33:39 +04:00
Daniel Hougaard
5d9b99bee7 Update NewProjectModal.tsx 2024-12-10 07:47:36 +04:00
Daniel Hougaard
8fdc438940 feat: remove plain and move to pylon 2024-12-10 07:32:09 +04:00
Daniel Hougaard
d2b909b72b fix(dashboard): pasting secrets into create secret modal 2024-12-10 04:01:17 +04:00
Maidul Islam
68988a3e78 Merge pull request #2853 from Infisical/misc/add-ssl-setting-pg-bpss
misc: add ssl setting for pg boss
2024-12-09 18:11:09 -05:00
Maidul Islam
a92de1273e Merge pull request #2855 from akhilmhdh/feat/integration-auth-update-endpoint
feat: added endpoint to update integration auth
2024-12-09 14:42:10 -05:00
McPizza
97f85fa8d9 fix(Approval Workflows): Workflows keep approval history after deletion (#2834)
* improvement: Approval Workflows can be deleted while maintaining history
Co-authored-by: Daniel Hougaard <daniel@infisical.com>
2024-12-09 20:03:45 +01:00
Sheen Capadngan
84c26581a6 feat: jwt auth setup 2024-12-10 02:41:04 +08:00
=
a808b6d4a0 feat: added new audit log event in ui 2024-12-09 20:24:30 +05:30
=
826916399b feat: changed integration option to nativeEnum in zod and added audit log event 2024-12-09 20:16:34 +05:30
=
40d69d4620 feat: added endpoint to update integration auth 2024-12-09 19:15:17 +05:30
Sheen Capadngan
3f6b1fe3bd misc: add ssl setting for pg boss 2024-12-09 13:17:04 +08:00
99 changed files with 3960 additions and 299 deletions

View File

@@ -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:

View File

@@ -53,7 +53,7 @@ export default {
extension: "ts"
});
const smtp = mockSmtpServer();
const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI);
const queue = queueServiceFactory(cfg.REDIS_URL, { dbConnectionUrl: cfg.DB_CONNECTION_URI });
const keyStore = keyStoreFactory(cfg.REDIS_URL);
const hsmModule = initializeHsmModule();

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,59 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn(
TableName.AccessApprovalPolicy,
"deletedAt"
);
const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn(
TableName.SecretApprovalPolicy,
"deletedAt"
);
if (!hasAccessApprovalPolicyDeletedAtColumn) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.timestamp("deletedAt");
});
}
if (!hasSecretApprovalPolicyDeletedAtColumn) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.timestamp("deletedAt");
});
}
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
t.dropForeign(["privilegeId"]);
// Add the new foreign key constraint with ON DELETE SET NULL
t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("SET NULL");
});
}
export async function down(knex: Knex): Promise<void> {
const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn(
TableName.AccessApprovalPolicy,
"deletedAt"
);
const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn(
TableName.SecretApprovalPolicy,
"deletedAt"
);
if (hasAccessApprovalPolicyDeletedAtColumn) {
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
t.dropColumn("deletedAt");
});
}
if (hasSecretApprovalPolicyDeletedAtColumn) {
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
t.dropColumn("deletedAt");
});
}
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
t.dropForeign(["privilegeId"]);
t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE");
});
}

View File

@@ -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);
}

View File

@@ -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");
});
}
}

View File

@@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({
envId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
enforcementLevel: z.string().default("hard")
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional()
});
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;

View 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>>;

View File

@@ -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";

View File

@@ -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"
}

View File

@@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({
envId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
enforcementLevel: z.string().default("hard")
enforcementLevel: z.string().default("hard"),
deletedAt: z.date().nullable().optional()
});
export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;

View File

@@ -109,7 +109,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
approvers: z.string().array(),
secretPath: z.string().nullish(),
envId: z.string(),
enforcementLevel: z.string()
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
}),
reviewers: z
.object({

View File

@@ -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(),

View File

@@ -52,7 +52,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
})
.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string()
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
}),
committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
@@ -260,7 +261,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
approvals: z.number(),
approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string()
enforcementLevel: z.string(),
deletedAt: z.date().nullish()
}),
environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(),

View File

@@ -139,5 +139,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
}
};
return { ...accessApprovalPolicyOrm, find, findById };
const softDeleteById = async (policyId: string, tx?: Knex) => {
const softDeletedPolicy = await accessApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx);
return softDeletedPolicy;
};
return { ...accessApprovalPolicyOrm, find, findById, softDeleteById };
};

View File

@@ -8,7 +8,11 @@ import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal";
import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-request/access-approval-request-reviewer-dal";
import { ApprovalStatus } from "../access-approval-request/access-approval-request-types";
import { TGroupDALFactory } from "../group/group-dal";
import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal";
import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal";
import {
@@ -21,7 +25,7 @@ import {
TUpdateAccessApprovalPolicy
} from "./access-approval-policy-types";
type TSecretApprovalPolicyServiceFactoryDep = {
type TAccessApprovalPolicyServiceFactoryDep = {
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory;
@@ -30,6 +34,9 @@ type TSecretApprovalPolicyServiceFactoryDep = {
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
groupDAL: TGroupDALFactory;
userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find">;
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update">;
};
export type TAccessApprovalPolicyServiceFactory = ReturnType<typeof accessApprovalPolicyServiceFactory>;
@@ -41,8 +48,11 @@ export const accessApprovalPolicyServiceFactory = ({
permissionService,
projectEnvDAL,
projectDAL,
userDAL
}: TSecretApprovalPolicyServiceFactoryDep) => {
userDAL,
accessApprovalRequestDAL,
additionalPrivilegeDAL,
accessApprovalRequestReviewerDAL
}: TAccessApprovalPolicyServiceFactoryDep) => {
const createAccessApprovalPolicy = async ({
name,
actor,
@@ -189,7 +199,7 @@ export const accessApprovalPolicyServiceFactory = ({
);
// ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id });
const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id, deletedAt: null });
return accessApprovalPolicies;
};
@@ -326,7 +336,29 @@ export const accessApprovalPolicyServiceFactory = ({
ProjectPermissionSub.SecretApproval
);
await accessApprovalPolicyDAL.deleteById(policyId);
await accessApprovalPolicyDAL.transaction(async (tx) => {
await accessApprovalPolicyDAL.softDeleteById(policyId, tx);
const allAccessApprovalRequests = await accessApprovalRequestDAL.find({ policyId });
if (allAccessApprovalRequests.length) {
const accessApprovalRequestsIds = allAccessApprovalRequests.map((request) => request.id);
const privilegeIdsArray = allAccessApprovalRequests
.map((request) => request.privilegeId)
.filter((id): id is string => id != null);
if (privilegeIdsArray.length) {
await additionalPrivilegeDAL.delete({ $in: { id: privilegeIdsArray } }, tx);
}
await accessApprovalRequestReviewerDAL.update(
{ $in: { id: accessApprovalRequestsIds }, status: ApprovalStatus.PENDING },
{ status: ApprovalStatus.REJECTED },
tx
);
}
});
return policy;
};
@@ -356,7 +388,11 @@ export const accessApprovalPolicyServiceFactory = ({
const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug });
if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` });
const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id });
const policies = await accessApprovalPolicyDAL.find({
envId: environment.id,
projectId: project.id,
deletedAt: null
});
if (!policies) throw new NotFoundError({ message: `No policies found in environment with slug '${envSlug}'` });
return { count: policies.length };

View File

@@ -61,7 +61,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId")
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
)
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
@@ -118,7 +119,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: doc.policyApprovals,
secretPath: doc.policySecretPath,
enforcementLevel: doc.policyEnforcementLevel,
envId: doc.policyEnvId
envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt
},
requestedByUser: {
userId: doc.requestedByUserId,
@@ -141,7 +143,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
}
: null,
isApproved: !!doc.privilegeId
isApproved: !!doc.policyDeletedAt || !!doc.privilegeId
}),
childrenMapper: [
{
@@ -252,7 +254,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals")
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
);
const findById = async (id: string, tx?: Knex) => {
@@ -271,7 +274,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel
enforcementLevel: el.policyEnforcementLevel,
deletedAt: el.policyDeletedAt
},
requestedByUser: {
userId: el.requestedByUserId,
@@ -363,6 +367,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
)
.where(`${TableName.Environment}.projectId`, projectId)
.where(`${TableName.AccessApprovalPolicy}.deletedAt`, null)
.select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"))
.select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId"));

View File

@@ -130,6 +130,9 @@ export const accessApprovalRequestServiceFactory = ({
message: `No policy in environment with slug '${environment.slug}' and with secret path '${secretPath}' was found.`
});
}
if (policy.deletedAt) {
throw new BadRequestError({ message: "The policy linked to this request has been deleted" });
}
const approverIds: string[] = [];
const approverGroupIds: string[] = [];
@@ -309,6 +312,12 @@ export const accessApprovalRequestServiceFactory = ({
}
const { policy } = accessApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this access request has been deleted."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission(
actor,
actorId,

View File

@@ -60,6 +60,7 @@ export enum EventType {
DELETE_SECRETS = "delete-secrets",
GET_WORKSPACE_KEY = "get-workspace-key",
AUTHORIZE_INTEGRATION = "authorize-integration",
UPDATE_INTEGRATION_AUTH = "update-integration-auth",
UNAUTHORIZE_INTEGRATION = "unauthorize-integration",
CREATE_INTEGRATION = "create-integration",
DELETE_INTEGRATION = "delete-integration",
@@ -94,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",
@@ -357,6 +363,13 @@ interface AuthorizeIntegrationEvent {
};
}
interface UpdateIntegrationAuthEvent {
type: EventType.UPDATE_INTEGRATION_AUTH;
metadata: {
integration: string;
};
}
interface UnauthorizeIntegrationEvent {
type: EventType.UNAUTHORIZE_INTEGRATION;
metadata: {
@@ -895,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: {
@@ -1680,6 +1754,7 @@ export type Event =
| DeleteSecretBatchEvent
| GetWorkspaceKeyEvent
| AuthorizeIntegrationEvent
| UpdateIntegrationAuthEvent
| UnauthorizeIntegrationEvent
| CreateIntegrationEvent
| DeleteIntegrationEvent
@@ -1733,6 +1808,11 @@ export type Event =
| DeleteIdentityOidcAuthEvent
| UpdateIdentityOidcAuthEvent
| GetIdentityOidcAuthEvent
| LoginIdentityJwtAuthEvent
| AddIdentityJwtAuthEvent
| UpdateIdentityJwtAuthEvent
| GetIdentityJwtAuthEvent
| DeleteIdentityJwtAuthEvent
| CreateEnvironmentEvent
| GetEnvironmentEvent
| UpdateEnvironmentEvent

View File

@@ -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

View File

@@ -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 });

View File

@@ -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"
}

View File

@@ -177,5 +177,10 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
}
};
return { ...secretApprovalPolicyOrm, findById, find };
const softDeleteById = async (policyId: string, tx?: Knex) => {
const softDeletedPolicy = await secretApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx);
return softDeletedPolicy;
};
return { ...secretApprovalPolicyOrm, findById, find, softDeleteById };
};

View File

@@ -11,6 +11,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import { ApproverType } from "../access-approval-policy/access-approval-policy-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal";
import { RequestState } from "../secret-approval-request/secret-approval-request-types";
import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal";
import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal";
import {
@@ -34,6 +36,7 @@ type TSecretApprovalPolicyServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find">;
secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "update">;
};
export type TSecretApprovalPolicyServiceFactory = ReturnType<typeof secretApprovalPolicyServiceFactory>;
@@ -44,7 +47,8 @@ export const secretApprovalPolicyServiceFactory = ({
secretApprovalPolicyApproverDAL,
projectEnvDAL,
userDAL,
licenseService
licenseService,
secretApprovalRequestDAL
}: TSecretApprovalPolicyServiceFactoryDep) => {
const createSecretApprovalPolicy = async ({
name,
@@ -301,8 +305,16 @@ export const secretApprovalPolicyServiceFactory = ({
});
}
await secretApprovalPolicyDAL.deleteById(secretPolicyId);
return sapPolicy;
const deletedPolicy = await secretApprovalPolicyDAL.transaction(async (tx) => {
await secretApprovalRequestDAL.update(
{ policyId: secretPolicyId, status: RequestState.Open },
{ status: RequestState.Closed },
tx
);
const updatedPolicy = await secretApprovalPolicyDAL.softDeleteById(secretPolicyId, tx);
return updatedPolicy;
});
return { ...deletedPolicy, projectId: sapPolicy.projectId, environment: sapPolicy.environment };
};
const getSecretApprovalPolicyByProjectId = async ({
@@ -321,7 +333,7 @@ export const secretApprovalPolicyServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
const sapPolicies = await secretApprovalPolicyDAL.find({ projectId });
const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null });
return sapPolicies;
};
@@ -334,7 +346,7 @@ export const secretApprovalPolicyServiceFactory = ({
});
}
const policies = await secretApprovalPolicyDAL.find({ envId: env.id });
const policies = await secretApprovalPolicyDAL.find({ envId: env.id, deletedAt: null });
if (!policies.length) return;
// this will filter policies either without scoped to secret path or the one that matches with secret path
const policiesFilteredByPath = policies.filter(

View File

@@ -111,7 +111,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"),
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt")
);
const findById = async (id: string, tx?: Knex) => {
@@ -147,7 +148,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
approvals: el.policyApprovals,
secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId
envId: el.policyEnvId,
deletedAt: el.policyDeletedAt
}
}),
childrenMapper: [
@@ -222,6 +224,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalRequest}.policyId`,
`${TableName.SecretApprovalPolicyApprover}.policyId`
)
.join(
TableName.SecretApprovalPolicy,
`${TableName.SecretApprovalRequest}.policyId`,
`${TableName.SecretApprovalPolicy}.id`
)
.where({ projectId })
.andWhere(
(bd) =>
@@ -229,6 +236,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
)
.andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null))
.select("status", `${TableName.SecretApprovalRequest}.id`)
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
.count("status")

View File

@@ -232,10 +232,10 @@ export const secretApprovalRequestServiceFactory = ({
type: KmsDataKey.SecretManager,
projectId
});
const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2(
const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2(
secretApprovalRequest.id
);
secrets = encrypedSecrets.map((el) => ({
secrets = encryptedSecrets.map((el) => ({
...el,
secretKey: el.key,
id: el.id,
@@ -274,8 +274,8 @@ export const secretApprovalRequestServiceFactory = ({
}));
} else {
if (!botKey) throw new NotFoundError({ message: `Project bot key not found`, name: "BotKeyNotFound" }); // CLI depends on this error message. TODO(daniel): Make API check for name BotKeyNotFound instead of message
const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
secrets = encrypedSecrets.map((el) => ({
const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
secrets = encryptedSecrets.map((el) => ({
...el,
...decryptSecretWithBot(el, botKey),
secret: el.secret
@@ -323,6 +323,12 @@ export const secretApprovalRequestServiceFactory = ({
}
const { policy } = secretApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this secret approval request has been deleted."
});
}
const { hasRole } = await permissionService.getProjectPermission(
ActorType.USER,
actorId,
@@ -383,6 +389,12 @@ export const secretApprovalRequestServiceFactory = ({
}
const { policy } = secretApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this secret approval request has been deleted."
});
}
const { hasRole } = await permissionService.getProjectPermission(
ActorType.USER,
actorId,
@@ -433,6 +445,12 @@ export const secretApprovalRequestServiceFactory = ({
}
const { policy, folderId, projectId } = secretApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this secret approval request has been deleted."
});
}
const { hasRole } = await permissionService.getProjectPermission(
ActorType.USER,
actorId,

View File

@@ -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."
@@ -1032,6 +1080,9 @@ export const INTEGRATION_AUTH = {
DELETE_BY_ID: {
integrationAuthId: "The ID of integration authentication object to delete."
},
UPDATE_BY_ID: {
integrationAuthId: "The ID of integration authentication object to update."
},
CREATE_ACCESS_TOKEN: {
workspaceId: "The ID of the project to create the integration auth for.",
integration: "The slug of integration for the auth object.",
@@ -1088,11 +1139,13 @@ export const INTEGRATION = {
},
UPDATE: {
integrationId: "The ID of the integration object.",
region: "AWS region to sync secrets to.",
app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.",
appId:
"The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.",
isActive: "Whether the integration should be active or disabled.",
secretPath: "The path of the secrets to sync secrets from.",
path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault.",
owner: "External integration providers service entity owner. Used in Github.",
targetEnvironment:
"The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.",

View File

@@ -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()),

View File

@@ -57,7 +57,11 @@ const run = async () => {
const smtp = smtpServiceFactory(formatSmtpConfig());
const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI);
const queue = queueServiceFactory(appCfg.REDIS_URL, {
dbConnectionUrl: appCfg.DB_CONNECTION_URI,
dbRootCert: appCfg.DB_ROOT_CERT
});
await queue.initialize();
const keyStore = keyStoreFactory(appCfg.REDIS_URL);

View File

@@ -187,7 +187,10 @@ export type TQueueJobTypes = {
};
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => {
export const queueServiceFactory = (
redisUrl: string,
{ dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string }
) => {
const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
const queueContainer = {} as Record<
QueueName,
@@ -198,7 +201,13 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) =
connectionString: dbConnectionUrl,
archiveCompletedAfterSeconds: 60,
archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried
deleteAfterSeconds: 30
deleteAfterSeconds: 30,
ssl: dbRootCert
? {
rejectUnauthorized: true,
ca: Buffer.from(dbRootCert, "base64").toString("ascii")
}
: false
});
const queueContainerPg = {} as Record<QueueJobs, boolean>;

View File

@@ -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);
@@ -414,7 +417,8 @@ export const registerRoutes = async (
permissionService,
secretApprovalPolicyDAL,
licenseService,
userDAL
userDAL,
secretApprovalRequestDAL
});
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL });
@@ -994,7 +998,10 @@ export const registerRoutes = async (
projectEnvDAL,
projectMembershipDAL,
projectDAL,
userDAL
userDAL,
accessApprovalRequestDAL,
additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL,
accessApprovalRequestReviewerDAL
});
const accessApprovalRequestService = accessApprovalRequestServiceFactory({
@@ -1180,6 +1187,15 @@ export const registerRoutes = async (
orgBotDAL
});
const identityJwtAuthService = identityJwtAuthServiceFactory({
identityJwtAuthDAL,
permissionService,
identityAccessTokenDAL,
identityOrgMembershipDAL,
licenseService,
kmsService
});
const dynamicSecretProviders = buildDynamicSecretProviders();
const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({
queueService,
@@ -1238,7 +1254,8 @@ export const registerRoutes = async (
});
const userEngagementService = userEngagementServiceFactory({
userDAL
userDAL,
orgDAL
});
const slackService = slackServiceFactory({
@@ -1342,6 +1359,7 @@ export const registerRoutes = async (
identityAwsAuth: identityAwsAuthService,
identityAzureAuth: identityAzureAuthService,
identityOidcAuth: identityOidcAuthService,
identityJwtAuth: identityJwtAuthService,
accessApprovalPolicy: accessApprovalPolicyService,
accessApprovalRequest: accessApprovalRequestService,
secretApprovalPolicy: secretApprovalPolicyService,

View 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 };
}
});
};

View File

@@ -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" }
);

View File

@@ -6,6 +6,7 @@ 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 { OctopusDeployScope } from "@app/services/integration-auth/integration-auth-types";
import { Integrations } from "@app/services/integration-auth/integration-list";
import { integrationAuthPubSchema } from "../sanitizedSchemas";
@@ -82,6 +83,67 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
}
});
server.route({
method: "PATCH",
url: "/:integrationAuthId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Update the integration authentication object required for syncing secrets.",
security: [
{
bearerAuth: []
}
],
params: z.object({
integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId)
}),
body: z.object({
integration: z.nativeEnum(Integrations).optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration),
accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId),
accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken),
awsAssumeIamRoleArn: z
.string()
.url()
.trim()
.optional()
.describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.awsAssumeIamRoleArn),
url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url),
namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace),
refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken)
}),
response: {
200: z.object({
integrationAuth: integrationAuthPubSchema
})
}
},
handler: async (req) => {
const integrationAuth = await server.services.integrationAuth.updateIntegrationAuth({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
integrationAuthId: req.params.integrationAuthId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: integrationAuth.projectId,
event: {
type: EventType.UPDATE_INTEGRATION_AUTH,
metadata: {
integration: integrationAuth.integration
}
}
});
return { integrationAuth };
}
});
server.route({
method: "DELETE",
url: "/",

View File

@@ -141,7 +141,9 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
targetEnvironment: z.string().trim().optional().describe(INTEGRATION.UPDATE.targetEnvironment),
owner: z.string().trim().optional().describe(INTEGRATION.UPDATE.owner),
environment: z.string().trim().optional().describe(INTEGRATION.UPDATE.environment),
metadata: IntegrationMetadataSchema.optional()
path: z.string().trim().optional().describe(INTEGRATION.UPDATE.path),
metadata: IntegrationMetadataSchema.optional(),
region: z.string().trim().optional().describe(INTEGRATION.UPDATE.region)
}),
response: {
200: z.object({

View File

@@ -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);
}
});
};

View File

@@ -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" });

View File

@@ -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];

View File

@@ -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;
};

View File

@@ -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);
};

View File

@@ -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
};
};

View File

@@ -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;
};

View File

@@ -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;
});

View File

@@ -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[];
};

View File

@@ -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
})
}
}),

View File

@@ -55,6 +55,7 @@ import {
TOctopusDeployVariableSet,
TSaveIntegrationAccessTokenDTO,
TTeamCityBuildConfig,
TUpdateIntegrationAuthDTO,
TVercelBranches
} from "./integration-auth-types";
import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list";
@@ -368,6 +369,148 @@ export const integrationAuthServiceFactory = ({
return integrationAuthDAL.create(updateDoc);
};
const updateIntegrationAuth = async ({
integrationAuthId,
refreshToken,
actorId,
integration: newIntegration,
url,
actor,
actorOrgId,
actorAuthMethod,
accessId,
namespace,
accessToken,
awsAssumeIamRoleArn
}: TUpdateIntegrationAuthDTO) => {
const integrationAuth = await integrationAuthDAL.findById(integrationAuthId);
if (!integrationAuth) {
throw new NotFoundError({ message: `Integration auth with id ${integrationAuthId} not found.` });
}
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
integrationAuth.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations);
const { projectId } = integrationAuth;
const integration = newIntegration || integrationAuth.integration;
const updateDoc: TIntegrationAuthsInsert = {
projectId,
integration,
namespace,
url,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8,
...(integration === Integrations.GCP_SECRET_MANAGER
? {
metadata: {
authMethod: "serviceAccount"
}
}
: {})
};
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) {
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
if (refreshToken) {
const tokenDetails = await exchangeRefresh(
integration,
refreshToken,
url,
updateDoc.metadata as Record<string, string>
);
const refreshEncToken = secretManagerEncryptor({
plainText: Buffer.from(tokenDetails.refreshToken)
}).cipherTextBlob;
updateDoc.encryptedRefresh = refreshEncToken;
const accessEncToken = secretManagerEncryptor({
plainText: Buffer.from(tokenDetails.accessToken)
}).cipherTextBlob;
updateDoc.encryptedAccess = accessEncToken;
updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt;
}
if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) {
if (accessToken) {
const accessEncToken = secretManagerEncryptor({
plainText: Buffer.from(accessToken)
}).cipherTextBlob;
updateDoc.encryptedAccess = accessEncToken;
updateDoc.encryptedAwsAssumeIamRoleArn = null;
}
if (accessId) {
const accessEncToken = secretManagerEncryptor({
plainText: Buffer.from(accessId)
}).cipherTextBlob;
updateDoc.encryptedAccessId = accessEncToken;
updateDoc.encryptedAwsAssumeIamRoleArn = null;
}
if (awsAssumeIamRoleArn) {
const awsAssumeIamRoleArnEncrypted = secretManagerEncryptor({
plainText: Buffer.from(awsAssumeIamRoleArn)
}).cipherTextBlob;
updateDoc.encryptedAwsAssumeIamRoleArn = awsAssumeIamRoleArnEncrypted;
updateDoc.encryptedAccess = null;
updateDoc.encryptedAccessId = null;
}
}
} else {
if (!botKey) throw new NotFoundError({ message: `Project bot key for project with ID '${projectId}' not found` });
if (refreshToken) {
const tokenDetails = await exchangeRefresh(
integration,
refreshToken,
url,
updateDoc.metadata as Record<string, string>
);
const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, botKey);
updateDoc.refreshIV = refreshEncToken.iv;
updateDoc.refreshTag = refreshEncToken.tag;
updateDoc.refreshCiphertext = refreshEncToken.ciphertext;
const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, botKey);
updateDoc.accessIV = accessEncToken.iv;
updateDoc.accessTag = accessEncToken.tag;
updateDoc.accessCiphertext = accessEncToken.ciphertext;
updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt;
}
if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) {
if (accessToken) {
const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, botKey);
updateDoc.accessIV = accessEncToken.iv;
updateDoc.accessTag = accessEncToken.tag;
updateDoc.accessCiphertext = accessEncToken.ciphertext;
}
if (accessId) {
const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessId, botKey);
updateDoc.accessIdIV = accessEncToken.iv;
updateDoc.accessIdTag = accessEncToken.tag;
updateDoc.accessIdCiphertext = accessEncToken.ciphertext;
}
if (awsAssumeIamRoleArn) {
const awsAssumeIamRoleArnEnc = encryptSymmetric128BitHexKeyUTF8(awsAssumeIamRoleArn, botKey);
updateDoc.awsAssumeIamRoleArnCipherText = awsAssumeIamRoleArnEnc.ciphertext;
updateDoc.awsAssumeIamRoleArnIV = awsAssumeIamRoleArnEnc.iv;
updateDoc.awsAssumeIamRoleArnTag = awsAssumeIamRoleArnEnc.tag;
}
}
}
return integrationAuthDAL.updateById(integrationAuthId, updateDoc);
};
// helper function
const getIntegrationAccessToken = async (
integrationAuth: TIntegrationAuths,
@@ -1615,6 +1758,7 @@ export const integrationAuthServiceFactory = ({
getIntegrationAuth,
oauthExchange,
saveIntegrationToken,
updateIntegrationAuth,
deleteIntegrationAuthById,
deleteIntegrationAuths,
getIntegrationAuthTeams,

View File

@@ -22,6 +22,11 @@ export type TSaveIntegrationAccessTokenDTO = {
awsAssumeIamRoleArn?: string;
} & TProjectPermission;
export type TUpdateIntegrationAuthDTO = Omit<TSaveIntegrationAccessTokenDTO, "projectId" | "integration"> & {
integrationAuthId: string;
integration?: string;
};
export type TDeleteIntegrationAuthsDTO = TProjectPermission & {
integration: string;
projectId: string;

View File

@@ -151,7 +151,9 @@ export const integrationServiceFactory = ({
isActive,
environment,
secretPath,
metadata
region,
metadata,
path
}: TUpdateIntegrationDTO) => {
const integration = await integrationDAL.findById(id);
if (!integration) throw new NotFoundError({ message: `Integration with ID '${id}' not found` });
@@ -192,7 +194,9 @@ export const integrationServiceFactory = ({
appId,
targetEnvironment,
owner,
region,
secretPath,
path,
metadata: {
...(integration.metadata as object),
...metadata

View File

@@ -49,6 +49,8 @@ export type TUpdateIntegrationDTO = {
appId?: string;
isActive?: boolean;
secretPath?: string;
region?: string;
path?: string;
targetEnvironment?: string;
owner?: string;
environment?: string;

View File

@@ -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"),

View File

@@ -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

View File

@@ -0,0 +1,4 @@
---
title: "Attach"
openapi: "POST /api/v1/auth/jwt-auth/identities/{identityId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Login"
openapi: "POST /api/v1/auth/jwt-auth/login"
---

View File

@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/auth/jwt-auth/identities/{identityId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Revoke"
openapi: "DELETE /api/v1/auth/jwt-auth/identities/{identityId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/auth/jwt-auth/identities/{identityId}"
---

View 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**.
![identities organization](/images/platform/identities/identities-org.png)
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.
![identities organization create](/images/platform/identities/identities-org-create.png)
Now input a few details for your new identity. Here's some guidance for each field:
- Name (required): A friendly name for the identity.
- Role (required): A role from the **Organization Roles** tab 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.
![identities page](/images/platform/identities/identities-page.png)
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.
![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png)
![identities create jwt auth method](/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png)
![identities create jwt auth method](/images/platform/identities/identities-org-create-jwt-auth-method-static.png)
<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.
![identities project](/images/platform/identities/identities-project.png)
![identities project create](/images/platform/identities/identities-project-create.png)
</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

View File

@@ -21,7 +21,7 @@ You'll need to create a new personal access token (PAT) in order to authenticate
![integrations](../../images/integrations/azure-devops/create-new-token.png)
<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">

View File

@@ -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 @@
}
]
}
}
}

View File

@@ -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();

View File

@@ -1,14 +1,31 @@
/** Extracts the key and value from a passed in env string based on the provided delimiters. */
export const getKeyValue = (pastedContent: string, delimiters: string[]) => {
const foundDelimiter = delimiters.find((delimiter) => pastedContent.includes(delimiter));
if (!pastedContent) {
return { key: "", value: "" };
}
if (!foundDelimiter) {
let firstDelimiterIndex = -1;
let foundDelimiter = "";
delimiters.forEach((delimiter) => {
const index = pastedContent.indexOf(delimiter);
if (index !== -1 && (firstDelimiterIndex === -1 || index < firstDelimiterIndex)) {
firstDelimiterIndex = index;
foundDelimiter = delimiter;
}
});
const hasValueAfterDelimiter = pastedContent.length > firstDelimiterIndex + foundDelimiter.length;
if (firstDelimiterIndex === -1 || !hasValueAfterDelimiter) {
return { key: pastedContent.trim(), value: "" };
}
const [key, value] = pastedContent.split(foundDelimiter);
const key = pastedContent.substring(0, firstDelimiterIndex);
const value = pastedContent.substring(firstDelimiterIndex + foundDelimiter.length);
return {
key: key.trim(),
value: (value ?? "").trim()
value: value.trim()
};
};

View File

@@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = {
approvers?: Approver[];
};
export enum ApproverType{
export enum ApproverType {
User = "user",
Group = "group"
}
export type Approver ={
export type Approver = {
id: string;
type: ApproverType;
}
};
export type TAccessApprovalRequest = {
id: string;
@@ -70,6 +70,7 @@ export type TAccessApprovalRequest = {
secretPath?: string | null;
envId: string;
enforcementLevel: EnforcementLevel;
deletedAt: Date | null;
};
reviewers: {

View File

@@ -8,6 +8,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.DELETE_SECRET]: "Delete secret",
[EventType.GET_WORKSPACE_KEY]: "Read project key",
[EventType.AUTHORIZE_INTEGRATION]: "Authorize integration",
[EventType.UPDATE_INTEGRATION_AUTH]: "Update integration auth",
[EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration",
[EventType.CREATE_INTEGRATION]: "Create integration",
[EventType.DELETE_INTEGRATION]: "Delete integration",

View File

@@ -23,6 +23,7 @@ export enum EventType {
DELETE_SECRET = "delete-secret",
GET_WORKSPACE_KEY = "get-workspace-key",
AUTHORIZE_INTEGRATION = "authorize-integration",
UPDATE_INTEGRATION_AUTH = "update-integration-auth",
UNAUTHORIZE_INTEGRATION = "unauthorize-integration",
CREATE_INTEGRATION = "create-integration",
DELETE_INTEGRATION = "delete-integration",

View File

@@ -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";

View File

@@ -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));
}
});
};

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"
};

View File

@@ -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"
}

View File

@@ -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";

View File

@@ -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();

View File

@@ -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)
});
};

View File

@@ -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;

View File

@@ -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

View 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;

View 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 }
);

View File

@@ -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}
/>
)}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { GroupMembersSection } from "./GroupMembersSection";

View File

@@ -0,0 +1 @@
export { GroupDetailsSection } from "./GroupDetailsSection";

View File

@@ -0,0 +1 @@
export { GroupPage } from "./GroupPage";

View File

@@ -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 ${

View File

@@ -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}

View File

@@ -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}
/>
)
}
};

View File

@@ -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>
);
};

View File

@@ -130,12 +130,14 @@ export const AccessApprovalRequest = ({
if (statusFilter === "open")
return requests?.filter(
(request) =>
!request.policy.deletedAt &&
!request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
if (statusFilter === "close")
return requests?.filter(
(request) =>
request.policy.deletedAt ||
request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
@@ -144,8 +146,6 @@ export const AccessApprovalRequest = ({
}, [requests, statusFilter, requestedByFilter, envFilter]);
const generateRequestDetails = (request: TAccessApprovalRequest) => {
console.log(request);
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED

View File

@@ -1,4 +1,4 @@
import { ClipboardEvent } from "react";
import { ClipboardEvent, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -46,6 +46,7 @@ export const CreateSecretForm = ({
control,
reset,
setValue,
watch,
formState: { errors, isSubmitting }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const { closePopUp } = usePopUpAction();
@@ -59,6 +60,11 @@ export const CreateSecretForm = ({
canReadTags ? workspaceId : ""
);
const secretKeyInputRef = useRef<HTMLInputElement>(null);
const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key");
const secretKey = watch("key");
const slugSchema = z.string().trim().toLowerCase().min(1);
const createNewTag = async (slug: string) => {
// TODO: Replace with slugSchema generic
@@ -108,13 +114,23 @@ export const CreateSecretForm = ({
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const delimitters = [":", "="];
const pastedContent = e.clipboardData.getData("text");
const { key, value } = getKeyValue(pastedContent, delimitters);
setValue("key", key);
setValue("value", value);
const isWholeKeyHighlighted =
secretKeyInputRef.current &&
secretKeyInputRef.current.selectionStart === 0 &&
secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length;
if (!secretKey || isWholeKeyHighlighted) {
e.preventDefault();
setValue("key", key);
if (value) {
setValue("value", value);
}
}
};
return (
@@ -126,7 +142,12 @@ export const CreateSecretForm = ({
errorText={errors?.key?.message}
>
<Input
{...register("key")}
{...secretKeyRegisterRest}
ref={(e) => {
setSecretKeyHookRef(e);
// @ts-expect-error this is for multiple ref single component
secretKeyInputRef.current = e;
}}
placeholder="Type your secret name"
onPaste={handlePaste}
autoCapitalization={autoCapitalize}

View File

@@ -1,4 +1,4 @@
import { ClipboardEvent } from "react";
import { ClipboardEvent, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
@@ -46,6 +46,7 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
control,
reset,
setValue,
watch,
formState: { isSubmitting, errors }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
@@ -61,6 +62,11 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
canReadTags ? workspaceId : ""
);
const secretKeyInputRef = useRef<HTMLInputElement>(null);
const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key");
const secretKey = watch("key");
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => {
const promises = selectedEnv.map(async (env) => {
const environment = env.slug;
@@ -152,13 +158,23 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const delimitters = [":", "="];
const pastedContent = e.clipboardData.getData("text");
const { key, value } = getKeyValue(pastedContent, delimitters);
setValue("key", key);
setValue("value", value);
const isWholeKeyHighlighted =
secretKeyInputRef.current &&
secretKeyInputRef.current.selectionStart === 0 &&
secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length;
if (!secretKey || isWholeKeyHighlighted) {
e.preventDefault();
setValue("key", key);
if (value) {
setValue("value", value);
}
}
};
const createWsTag = useCreateWsTag();
@@ -189,7 +205,12 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
errorText={errors?.key?.message}
>
<Input
{...register("key")}
{...secretKeyRegisterRest}
ref={(e) => {
setSecretKeyHookRef(e);
// @ts-expect-error this is for multiple ref single component
secretKeyInputRef.current = e;
}}
placeholder="Type your secret name"
onPaste={handlePaste}
autoCapitalization={currentWorkspace?.autoCapitalization}

View File

@@ -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}