mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-04 07:26:11 +00:00
Merge branch 'main' of https://github.com/Infisical/infisical into feat/adds-GETtokenAuthTokenById-api-endpoint
This commit is contained in:
Generated
+72
-3
@@ -128,6 +128,7 @@
|
|||||||
"sjcl": "^1.0.8",
|
"sjcl": "^1.0.8",
|
||||||
"smee-client": "^2.0.0",
|
"smee-client": "^2.0.0",
|
||||||
"snowflake-sdk": "^1.14.0",
|
"snowflake-sdk": "^1.14.0",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
"tedious": "^18.2.1",
|
"tedious": "^18.2.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"tweetnacl-util": "^0.15.1",
|
"tweetnacl-util": "^0.15.1",
|
||||||
@@ -164,6 +165,7 @@
|
|||||||
"@types/resolve": "^1.20.6",
|
"@types/resolve": "^1.20.6",
|
||||||
"@types/safe-regex": "^1.1.6",
|
"@types/safe-regex": "^1.1.6",
|
||||||
"@types/sjcl": "^1.0.34",
|
"@types/sjcl": "^1.0.34",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||||
"@typescript-eslint/parser": "^6.20.0",
|
"@typescript-eslint/parser": "^6.20.0",
|
||||||
@@ -15634,6 +15636,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ssh2": {
|
||||||
|
"version": "1.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||||
|
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^18.11.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||||
|
"version": "18.19.130",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||||
|
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ssh2/node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/sshpk": {
|
"node_modules/@types/sshpk": {
|
||||||
"version": "1.10.3",
|
"version": "1.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz",
|
||||||
@@ -18061,6 +18090,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bullmq": {
|
"node_modules/bullmq": {
|
||||||
"version": "5.4.2",
|
"version": "5.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz",
|
||||||
@@ -18901,6 +18939,20 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-hash": {
|
"node_modules/create-hash": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
||||||
@@ -24996,9 +25048,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nan": {
|
"node_modules/nan": {
|
||||||
"version": "2.22.2",
|
"version": "2.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
|
||||||
"integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==",
|
"integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -31492,6 +31544,23 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.10",
|
||||||
|
"nan": "^2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sshpk": {
|
"node_modules/sshpk": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"@types/resolve": "^1.20.6",
|
"@types/resolve": "^1.20.6",
|
||||||
"@types/safe-regex": "^1.1.6",
|
"@types/safe-regex": "^1.1.6",
|
||||||
"@types/sjcl": "^1.0.34",
|
"@types/sjcl": "^1.0.34",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||||
"@typescript-eslint/parser": "^6.20.0",
|
"@typescript-eslint/parser": "^6.20.0",
|
||||||
@@ -257,6 +258,7 @@
|
|||||||
"sjcl": "^1.0.8",
|
"sjcl": "^1.0.8",
|
||||||
"smee-client": "^2.0.0",
|
"smee-client": "^2.0.0",
|
||||||
"snowflake-sdk": "^1.14.0",
|
"snowflake-sdk": "^1.14.0",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
"tedious": "^18.2.1",
|
"tedious": "^18.2.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"tweetnacl-util": "^0.15.1",
|
"tweetnacl-util": "^0.15.1",
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
SanitizedPostgresAccountWithResourceSchema,
|
SanitizedPostgresAccountWithResourceSchema,
|
||||||
UpdatePostgresAccountSchema
|
UpdatePostgresAccountSchema
|
||||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||||
|
import {
|
||||||
|
CreateSSHAccountSchema,
|
||||||
|
SanitizedSSHAccountWithResourceSchema,
|
||||||
|
UpdateSSHAccountSchema
|
||||||
|
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||||
|
|
||||||
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
|
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
|
||||||
|
|
||||||
@@ -30,5 +35,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fasti
|
|||||||
createAccountSchema: CreateMySQLAccountSchema,
|
createAccountSchema: CreateMySQLAccountSchema,
|
||||||
updateAccountSchema: UpdateMySQLAccountSchema
|
updateAccountSchema: UpdateMySQLAccountSchema
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
[PamResource.SSH]: async (server: FastifyZodProvider) => {
|
||||||
|
registerPamResourceEndpoints({
|
||||||
|
server,
|
||||||
|
resourceType: PamResource.SSH,
|
||||||
|
accountResponseSchema: SanitizedSSHAccountWithResourceSchema,
|
||||||
|
createAccountSchema: CreateSSHAccountSchema,
|
||||||
|
updateAccountSchema: UpdateSSHAccountSchema
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { PamFoldersSchema } from "@app/db/schemas";
|
import { PamFoldersSchema } from "@app/db/schemas";
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums";
|
||||||
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||||
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||||
|
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
import { ms } from "@app/lib/ms";
|
import { ms } from "@app/lib/ms";
|
||||||
|
import { OrderByDirection } from "@app/lib/types";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
const SanitizedAccountSchema = z.union([
|
const SanitizedAccountSchema = z.union([
|
||||||
|
SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS
|
||||||
SanitizedPostgresAccountWithResourceSchema,
|
SanitizedPostgresAccountWithResourceSchema,
|
||||||
SanitizedMySQLAccountWithResourceSchema
|
SanitizedMySQLAccountWithResourceSchema
|
||||||
]);
|
]);
|
||||||
@@ -26,33 +31,69 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
|||||||
schema: {
|
schema: {
|
||||||
description: "List PAM accounts",
|
description: "List PAM accounts",
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
projectId: z.string().uuid()
|
projectId: z.string().uuid(),
|
||||||
|
accountPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||||
|
accountView: z.nativeEnum(PamAccountView).default(PamAccountView.Flat),
|
||||||
|
offset: z.coerce.number().min(0).default(0),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(100),
|
||||||
|
orderBy: z.nativeEnum(PamAccountOrderBy).default(PamAccountOrderBy.Name),
|
||||||
|
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC),
|
||||||
|
search: z.string().trim().optional(),
|
||||||
|
filterResourceIds: z
|
||||||
|
.string()
|
||||||
|
.transform((val) =>
|
||||||
|
val
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
accounts: SanitizedAccountSchema.array(),
|
accounts: SanitizedAccountSchema.array(),
|
||||||
folders: PamFoldersSchema.array()
|
folders: PamFoldersSchema.array(),
|
||||||
|
totalCount: z.number().default(0),
|
||||||
|
folderId: z.string().optional(),
|
||||||
|
folderPaths: z.record(z.string(), z.string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const response = await server.services.pamAccount.list(req.query.projectId, req.permission);
|
const { projectId, accountPath, accountView, limit, offset, search, orderBy, orderDirection, filterResourceIds } =
|
||||||
|
req.query;
|
||||||
|
|
||||||
|
const { accounts, folders, totalCount, folderId, folderPaths } = await server.services.pamAccount.list({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectId,
|
||||||
|
accountPath,
|
||||||
|
accountView,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
search,
|
||||||
|
orderBy,
|
||||||
|
orderDirection,
|
||||||
|
filterResourceIds
|
||||||
|
});
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
await server.services.auditLog.createAuditLog({
|
||||||
...req.auditLogInfo,
|
...req.auditLogInfo,
|
||||||
orgId: req.permission.orgId,
|
orgId: req.permission.orgId,
|
||||||
projectId: req.query.projectId,
|
projectId,
|
||||||
event: {
|
event: {
|
||||||
type: EventType.PAM_ACCOUNT_LIST,
|
type: EventType.PAM_ACCOUNT_LIST,
|
||||||
metadata: {
|
metadata: {
|
||||||
accountCount: response.accounts.length,
|
accountCount: accounts.length,
|
||||||
folderCount: response.folders.length
|
folderCount: folders.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return { accounts, folders, totalCount, folderId, folderPaths };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +134,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
|||||||
gatewayClientPrivateKey: z.string(),
|
gatewayClientPrivateKey: z.string(),
|
||||||
gatewayServerCertificateChain: z.string(),
|
gatewayServerCertificateChain: z.string(),
|
||||||
relayHost: z.string(),
|
relayHost: z.string(),
|
||||||
metadata: z.record(z.string(), z.string()).optional()
|
metadata: z.record(z.string(), z.string().optional()).optional()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
SanitizedPostgresResourceSchema,
|
SanitizedPostgresResourceSchema,
|
||||||
UpdatePostgresResourceSchema
|
UpdatePostgresResourceSchema
|
||||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||||
|
import {
|
||||||
|
CreateSSHResourceSchema,
|
||||||
|
SanitizedSSHResourceSchema,
|
||||||
|
UpdateSSHResourceSchema
|
||||||
|
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||||
|
|
||||||
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
|
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
|
||||||
|
|
||||||
@@ -30,5 +35,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
|
|||||||
createResourceSchema: CreateMySQLResourceSchema,
|
createResourceSchema: CreateMySQLResourceSchema,
|
||||||
updateResourceSchema: UpdateMySQLResourceSchema
|
updateResourceSchema: UpdateMySQLResourceSchema
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
[PamResource.SSH]: async (server: FastifyZodProvider) => {
|
||||||
|
registerPamResourceEndpoints({
|
||||||
|
server,
|
||||||
|
resourceType: PamResource.SSH,
|
||||||
|
resourceResponseSchema: SanitizedSSHResourceSchema,
|
||||||
|
createResourceSchema: CreateSSHResourceSchema,
|
||||||
|
updateResourceSchema: UpdateSSHResourceSchema
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,19 +5,30 @@ import {
|
|||||||
MySQLResourceListItemSchema,
|
MySQLResourceListItemSchema,
|
||||||
SanitizedMySQLResourceSchema
|
SanitizedMySQLResourceSchema
|
||||||
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||||
|
import { PamResourceOrderBy } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||||
import {
|
import {
|
||||||
PostgresResourceListItemSchema,
|
PostgresResourceListItemSchema,
|
||||||
SanitizedPostgresResourceSchema
|
SanitizedPostgresResourceSchema
|
||||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||||
|
import {
|
||||||
|
SanitizedSSHResourceSchema,
|
||||||
|
SSHResourceListItemSchema
|
||||||
|
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||||
|
import { OrderByDirection } from "@app/lib/types";
|
||||||
import { readLimit } from "@app/server/config/rateLimiter";
|
import { readLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]);
|
const SanitizedResourceSchema = z.union([
|
||||||
|
SanitizedPostgresResourceSchema,
|
||||||
|
SanitizedMySQLResourceSchema,
|
||||||
|
SanitizedSSHResourceSchema
|
||||||
|
]);
|
||||||
|
|
||||||
const ResourceOptionsSchema = z.discriminatedUnion("resource", [
|
const ResourceOptionsSchema = z.discriminatedUnion("resource", [
|
||||||
PostgresResourceListItemSchema,
|
PostgresResourceListItemSchema,
|
||||||
MySQLResourceListItemSchema
|
MySQLResourceListItemSchema,
|
||||||
|
SSHResourceListItemSchema
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||||
@@ -52,17 +63,46 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
|||||||
schema: {
|
schema: {
|
||||||
description: "List PAM resources",
|
description: "List PAM resources",
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
projectId: z.string().uuid()
|
projectId: z.string().uuid(),
|
||||||
|
offset: z.coerce.number().min(0).default(0),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(100),
|
||||||
|
orderBy: z.nativeEnum(PamResourceOrderBy).default(PamResourceOrderBy.Name),
|
||||||
|
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC),
|
||||||
|
search: z.string().trim().optional(),
|
||||||
|
filterResourceTypes: z
|
||||||
|
.string()
|
||||||
|
.transform((val) =>
|
||||||
|
val
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
resources: SanitizedResourceSchema.array()
|
resources: SanitizedResourceSchema.array(),
|
||||||
|
totalCount: z.number().default(0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const response = await server.services.pamResource.list(req.query.projectId, req.permission);
|
const { projectId, limit, offset, search, orderBy, orderDirection, filterResourceTypes } = req.query;
|
||||||
|
|
||||||
|
const { resources, totalCount } = await server.services.pamResource.list({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectId,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
search,
|
||||||
|
orderBy,
|
||||||
|
orderDirection,
|
||||||
|
filterResourceTypes
|
||||||
|
});
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
await server.services.auditLog.createAuditLog({
|
||||||
...req.auditLogInfo,
|
...req.auditLogInfo,
|
||||||
@@ -71,12 +111,12 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
|||||||
event: {
|
event: {
|
||||||
type: EventType.PAM_RESOURCE_LIST,
|
type: EventType.PAM_RESOURCE_LIST,
|
||||||
metadata: {
|
metadata: {
|
||||||
count: response.resources.length
|
count: resources.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return { resources, totalCount };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ import { PamSessionsSchema } from "@app/db/schemas";
|
|||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||||
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
|
import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||||
|
import {
|
||||||
|
PamSessionCommandLogSchema,
|
||||||
|
SanitizedSessionSchema,
|
||||||
|
TerminalEventSchema
|
||||||
|
} from "@app/ee/services/pam-session/pam-session-schemas";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]);
|
const SessionCredentialsSchema = z.union([
|
||||||
|
SSHSessionCredentialsSchema,
|
||||||
|
PostgresSessionCredentialsSchema,
|
||||||
|
MySQLSessionCredentialsSchema
|
||||||
|
]);
|
||||||
|
|
||||||
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||||
// Meant to be hit solely by gateway identities
|
// Meant to be hit solely by gateway identities
|
||||||
@@ -50,7 +59,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { credentials };
|
return { credentials: credentials as z.infer<typeof SessionCredentialsSchema> };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +76,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
|||||||
sessionId: z.string().uuid()
|
sessionId: z.string().uuid()
|
||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
logs: PamSessionCommandLogSchema.array()
|
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -1,46 +1,109 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName, TPamAccounts } from "@app/db/schemas";
|
import { TableName } from "@app/db/schemas";
|
||||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
|
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||||
|
import { OrderByDirection } from "@app/lib/types";
|
||||||
|
|
||||||
|
import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
|
||||||
|
|
||||||
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
|
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
|
||||||
|
|
||||||
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
|
|
||||||
|
|
||||||
export const pamAccountDALFactory = (db: TDbClient) => {
|
export const pamAccountDALFactory = (db: TDbClient) => {
|
||||||
const orm = ormify(db, TableName.PamAccount);
|
const orm = ormify(db, TableName.PamAccount);
|
||||||
|
|
||||||
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
|
const findByProjectIdWithResourceDetails = async (
|
||||||
const query = (tx || db.replicaNode())(TableName.PamAccount)
|
{
|
||||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
projectId,
|
||||||
.select(selectAllTableCols(TableName.PamAccount))
|
folderId,
|
||||||
.select(
|
accountView = PamAccountView.Nested,
|
||||||
|
search,
|
||||||
|
limit,
|
||||||
|
offset = 0,
|
||||||
|
orderBy = PamAccountOrderBy.Name,
|
||||||
|
orderDirection = OrderByDirection.ASC,
|
||||||
|
filterResourceIds
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
folderId?: string | null;
|
||||||
|
accountView?: PamAccountView;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
orderBy?: PamAccountOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
filterResourceIds?: string[];
|
||||||
|
},
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const dbInstance = tx || db.replicaNode();
|
||||||
|
const query = dbInstance(TableName.PamAccount)
|
||||||
|
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||||
|
.where(`${TableName.PamAccount}.projectId`, projectId);
|
||||||
|
|
||||||
|
if (accountView === PamAccountView.Nested) {
|
||||||
|
if (folderId) {
|
||||||
|
void query.where(`${TableName.PamAccount}.folderId`, folderId);
|
||||||
|
} else {
|
||||||
|
void query.whereNull(`${TableName.PamAccount}.folderId`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// escape special characters (`%`, `_`) and the escape character itself (`\`)
|
||||||
|
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||||
|
const pattern = `%${escapedSearch}%`;
|
||||||
|
void query.where((q) => {
|
||||||
|
void q
|
||||||
|
.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "name", pattern])
|
||||||
|
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern])
|
||||||
|
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "description", pattern]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterResourceIds && filterResourceIds.length) {
|
||||||
|
void query.whereIn(`${TableName.PamAccount}.resourceId`, filterResourceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countQuery = query.clone().count("*", { as: "count" }).first();
|
||||||
|
|
||||||
|
void query.select(selectAllTableCols(TableName.PamAccount)).select(
|
||||||
// resource
|
// resource
|
||||||
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
|
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
|
||||||
db.ref("resourceType").withSchema(TableName.PamResource),
|
db.ref("resourceType").withSchema(TableName.PamResource),
|
||||||
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
|
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (filter) {
|
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
|
||||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
|
void query.orderByRaw(`${TableName.PamAccount}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
|
||||||
|
|
||||||
|
if (typeof limit === "number") {
|
||||||
|
void query.limit(limit).offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [results, countResult] = await Promise.all([query, countQuery]);
|
||||||
|
const totalCount = Number(countResult?.count || 0);
|
||||||
|
|
||||||
|
const accounts = results.map(
|
||||||
|
// @ts-expect-error resourceName, resourceType, encryptedRotationAccountCredentials are from joined table
|
||||||
|
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
|
||||||
|
...account,
|
||||||
|
resourceId,
|
||||||
|
resource: {
|
||||||
|
id: resourceId,
|
||||||
|
name: resourceName as string,
|
||||||
|
resourceType,
|
||||||
|
encryptedRotationAccountCredentials
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { accounts, totalCount };
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find PAM accounts with resource details" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await query;
|
|
||||||
|
|
||||||
return accounts.map(
|
|
||||||
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
|
|
||||||
...account,
|
|
||||||
resourceId,
|
|
||||||
resource: {
|
|
||||||
id: resourceId,
|
|
||||||
name: resourceName,
|
|
||||||
resourceType,
|
|
||||||
encryptedRotationAccountCredentials
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAccountsDueForRotation = async (tx?: Knex) => {
|
const findAccountsDueForRotation = async (tx?: Knex) => {
|
||||||
@@ -59,5 +122,9 @@ export const pamAccountDALFactory = (db: TDbClient) => {
|
|||||||
return accounts;
|
return accounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...orm, findWithResourceDetails, findAccountsDueForRotation };
|
return {
|
||||||
|
...orm,
|
||||||
|
findByProjectIdWithResourceDetails,
|
||||||
|
findAccountsDueForRotation
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum PamAccountOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PamAccountView {
|
||||||
|
Flat = "flat",
|
||||||
|
Nested = "nested"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ForbiddenError, subject } from "@casl/ability";
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
|
||||||
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamResources } from "@app/db/schemas";
|
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas";
|
||||||
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
|
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
|
||||||
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
|
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||||
@@ -27,12 +27,14 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
|
|||||||
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
|
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
|
||||||
import { PamResource } from "../pam-resource/pam-resource-enums";
|
import { PamResource } from "../pam-resource/pam-resource-enums";
|
||||||
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
||||||
|
import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types";
|
||||||
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
||||||
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
||||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||||
import { TPamAccountDALFactory } from "./pam-account-dal";
|
import { TPamAccountDALFactory } from "./pam-account-dal";
|
||||||
|
import { PamAccountView } from "./pam-account-enums";
|
||||||
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
|
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
|
||||||
import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types";
|
import { TAccessAccountDTO, TCreateAccountDTO, TListAccountsDTO, TUpdateAccountDTO } from "./pam-account-types";
|
||||||
|
|
||||||
type TPamAccountServiceFactoryDep = {
|
type TPamAccountServiceFactoryDep = {
|
||||||
pamResourceDAL: TPamResourceDALFactory;
|
pamResourceDAL: TPamResourceDALFactory;
|
||||||
@@ -251,17 +253,17 @@ export const pamAccountServiceFactory = ({
|
|||||||
gatewayV2Service
|
gatewayV2Service
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logic to prevent overwriting unedited censored values
|
const decryptedCredentials = await decryptAccountCredentials({
|
||||||
const finalCredentials = { ...credentials };
|
encryptedCredentials: account.encryptedCredentials,
|
||||||
if (credentials.password === "__INFISICAL_UNCHANGED__") {
|
projectId: account.projectId,
|
||||||
const decryptedCredentials = await decryptAccountCredentials({
|
kmsService
|
||||||
encryptedCredentials: account.encryptedCredentials,
|
});
|
||||||
projectId: account.projectId,
|
|
||||||
kmsService
|
|
||||||
});
|
|
||||||
|
|
||||||
finalCredentials.password = decryptedCredentials.password;
|
// Logic to prevent overwriting unedited censored values
|
||||||
}
|
const finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
|
||||||
|
credentials,
|
||||||
|
decryptedCredentials
|
||||||
|
);
|
||||||
|
|
||||||
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
|
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
|
||||||
const encryptedCredentials = await encryptAccountCredentials({
|
const encryptedCredentials = await encryptAccountCredentials({
|
||||||
@@ -334,21 +336,96 @@ export const pamAccountServiceFactory = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
const list = async ({
|
||||||
|
projectId,
|
||||||
|
accountPath,
|
||||||
|
accountView,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
...params
|
||||||
|
}: TListAccountsDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor: actor.type,
|
actor,
|
||||||
actorAuthMethod: actor.authMethod,
|
actorId,
|
||||||
actorId: actor.id,
|
|
||||||
actorOrgId: actor.orgId,
|
|
||||||
projectId,
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
actionProjectType: ActionProjectType.PAM
|
actionProjectType: ActionProjectType.PAM
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId });
|
const limit = params.limit || 20;
|
||||||
|
const offset = params.offset || 0;
|
||||||
|
|
||||||
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
|
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
|
||||||
|
|
||||||
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : [];
|
const folder = accountPath === "/" ? null : await pamFolderDAL.findByPath(projectId, accountPath);
|
||||||
|
if (accountPath !== "/" && !folder) {
|
||||||
|
return { accounts: [], folders: [], totalCount: 0, folderPaths: {} };
|
||||||
|
}
|
||||||
|
const folderId = folder?.id;
|
||||||
|
|
||||||
|
let totalFolderCount = 0;
|
||||||
|
if (canReadFolders && accountView === PamAccountView.Nested) {
|
||||||
|
const { totalCount } = await pamFolderDAL.findByProjectId({
|
||||||
|
projectId,
|
||||||
|
parentId: folderId,
|
||||||
|
search: params.search
|
||||||
|
});
|
||||||
|
totalFolderCount = totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
let folders: TPamFolders[] = [];
|
||||||
|
if (canReadFolders && accountView === PamAccountView.Nested && offset < totalFolderCount) {
|
||||||
|
const folderLimit = Math.min(limit, totalFolderCount - offset);
|
||||||
|
const { folders: foldersResp } = await pamFolderDAL.findByProjectId({
|
||||||
|
projectId,
|
||||||
|
parentId: folderId,
|
||||||
|
limit: folderLimit,
|
||||||
|
offset,
|
||||||
|
search: params.search,
|
||||||
|
orderBy: params.orderBy,
|
||||||
|
orderDirection: params.orderDirection
|
||||||
|
});
|
||||||
|
|
||||||
|
folders = foldersResp;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accountsWithResourceDetails: Awaited<
|
||||||
|
ReturnType<typeof pamAccountDAL.findByProjectIdWithResourceDetails>
|
||||||
|
>["accounts"] = [];
|
||||||
|
let totalAccountCount = 0;
|
||||||
|
|
||||||
|
const accountsToFetch = limit - folders.length;
|
||||||
|
if (accountsToFetch > 0) {
|
||||||
|
const accountOffset = Math.max(0, offset - totalFolderCount);
|
||||||
|
const { accounts, totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
|
||||||
|
projectId,
|
||||||
|
folderId,
|
||||||
|
accountView,
|
||||||
|
offset: accountOffset,
|
||||||
|
limit: accountsToFetch,
|
||||||
|
search: params.search,
|
||||||
|
orderBy: params.orderBy,
|
||||||
|
orderDirection: params.orderDirection,
|
||||||
|
filterResourceIds: params.filterResourceIds
|
||||||
|
});
|
||||||
|
accountsWithResourceDetails = accounts;
|
||||||
|
totalAccountCount = totalCount;
|
||||||
|
} else {
|
||||||
|
// if no accounts are to be fetched for the current page, we still need the total count for pagination
|
||||||
|
const { totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
|
||||||
|
projectId,
|
||||||
|
folderId,
|
||||||
|
accountView,
|
||||||
|
search: params.search,
|
||||||
|
filterResourceIds: params.filterResourceIds
|
||||||
|
});
|
||||||
|
totalAccountCount = totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = totalFolderCount + totalAccountCount;
|
||||||
|
|
||||||
const decryptedAndPermittedAccounts: Array<
|
const decryptedAndPermittedAccounts: Array<
|
||||||
TPamAccounts & {
|
TPamAccounts & {
|
||||||
@@ -359,12 +436,6 @@ export const pamAccountServiceFactory = ({
|
|||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
for await (const account of accountsWithResourceDetails) {
|
for await (const account of accountsWithResourceDetails) {
|
||||||
const accountPath = await getFullPamFolderPath({
|
|
||||||
pamFolderDAL,
|
|
||||||
folderId: account.folderId,
|
|
||||||
projectId: account.projectId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check permission for each individual account
|
// Check permission for each individual account
|
||||||
if (
|
if (
|
||||||
permission.can(
|
permission.can(
|
||||||
@@ -391,9 +462,27 @@ export const pamAccountServiceFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const folderPaths: Record<string, string> = {};
|
||||||
|
const accountFolderIds = [
|
||||||
|
...new Set(decryptedAndPermittedAccounts.flatMap((a) => (a.folderId ? [a.folderId] : [])))
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
accountFolderIds.map(async (fId) => {
|
||||||
|
folderPaths[fId] = await getFullPamFolderPath({
|
||||||
|
pamFolderDAL,
|
||||||
|
folderId: fId,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: decryptedAndPermittedAccounts,
|
accounts: decryptedAndPermittedAccounts,
|
||||||
folders
|
folders,
|
||||||
|
totalCount,
|
||||||
|
folderId,
|
||||||
|
folderPaths
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -486,11 +575,11 @@ export const pamAccountServiceFactory = ({
|
|||||||
case PamResource.Postgres:
|
case PamResource.Postgres:
|
||||||
case PamResource.MySQL:
|
case PamResource.MySQL:
|
||||||
{
|
{
|
||||||
const connectionCredentials = await decryptResourceConnectionDetails({
|
const connectionCredentials = (await decryptResourceConnectionDetails({
|
||||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||||
kmsService,
|
kmsService,
|
||||||
projectId: account.projectId
|
projectId: account.projectId
|
||||||
});
|
})) as TSqlResourceConnectionDetails;
|
||||||
|
|
||||||
const credentials = await decryptAccountCredentials({
|
const credentials = await decryptAccountCredentials({
|
||||||
encryptedCredentials: account.encryptedCredentials,
|
encryptedCredentials: account.encryptedCredentials,
|
||||||
@@ -506,6 +595,19 @@ export const pamAccountServiceFactory = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case PamResource.SSH:
|
||||||
|
{
|
||||||
|
const credentials = await decryptAccountCredentials({
|
||||||
|
encryptedCredentials: account.encryptedCredentials,
|
||||||
|
kmsService,
|
||||||
|
projectId: account.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
username: credentials.username
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { TPamAccount } from "../pam-resource/pam-resource-types";
|
import { TPamAccount } from "../pam-resource/pam-resource-types";
|
||||||
|
import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
export type TCreateAccountDTO = Pick<
|
export type TCreateAccountDTO = Pick<
|
||||||
@@ -18,3 +21,14 @@ export type TAccessAccountDTO = {
|
|||||||
actorUserAgent: string;
|
actorUserAgent: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TListAccountsDTO = {
|
||||||
|
accountPath: string;
|
||||||
|
accountView: PamAccountView;
|
||||||
|
search?: string;
|
||||||
|
orderBy?: PamAccountOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
filterResourceIds?: string[];
|
||||||
|
} & TProjectPermission;
|
||||||
|
|||||||
@@ -1,9 +1,106 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName } from "@app/db/schemas";
|
import { TableName } from "@app/db/schemas";
|
||||||
import { ormify } from "@app/lib/knex";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
|
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||||
|
import { OrderByDirection } from "@app/lib/types";
|
||||||
|
|
||||||
|
import { PamAccountOrderBy } from "../pam-account/pam-account-enums";
|
||||||
|
|
||||||
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
|
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
|
||||||
export const pamFolderDALFactory = (db: TDbClient) => {
|
export const pamFolderDALFactory = (db: TDbClient) => {
|
||||||
const orm = ormify(db, TableName.PamFolder);
|
const orm = ormify(db, TableName.PamFolder);
|
||||||
return { ...orm };
|
|
||||||
|
const findByProjectId = async (
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
parentId,
|
||||||
|
search,
|
||||||
|
limit,
|
||||||
|
offset = 0,
|
||||||
|
orderBy = PamAccountOrderBy.Name,
|
||||||
|
orderDirection = OrderByDirection.ASC
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
orderBy?: PamAccountOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
},
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const dbInstance = tx || db.replicaNode();
|
||||||
|
const query = dbInstance(TableName.PamFolder).where(`${TableName.PamFolder}.projectId`, projectId);
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
void query.where(`${TableName.PamFolder}.parentId`, parentId);
|
||||||
|
} else {
|
||||||
|
void query.whereNull(`${TableName.PamFolder}.parentId`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// escape special characters (`%`, `_`) and the escape character itself (`\`)
|
||||||
|
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||||
|
void query.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamFolder, "name", `%${escapedSearch}%`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countQuery = query.clone().count("*", { as: "count" }).first();
|
||||||
|
|
||||||
|
void query.select(selectAllTableCols(TableName.PamFolder));
|
||||||
|
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||||
|
|
||||||
|
void query.orderByRaw(`${TableName.PamFolder}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
|
||||||
|
|
||||||
|
if (typeof limit === "number") {
|
||||||
|
void query.limit(limit).offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [folders, countResult] = await Promise.all([query, countQuery]);
|
||||||
|
const totalCount = Number(countResult?.count || 0);
|
||||||
|
|
||||||
|
return { folders, totalCount };
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find PAM folders" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findByPath = async (projectId: string, path: string, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const dbInstance = tx || db.replicaNode();
|
||||||
|
const pathSegments = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
let parentId: string | null = null;
|
||||||
|
let currentFolder: Awaited<ReturnType<typeof orm.findOne>> | undefined;
|
||||||
|
|
||||||
|
for await (const segment of pathSegments) {
|
||||||
|
const query = dbInstance(TableName.PamFolder)
|
||||||
|
.where(`${TableName.PamFolder}.projectId`, projectId)
|
||||||
|
.where(`${TableName.PamFolder}.name`, segment);
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
void query.where(`${TableName.PamFolder}.parentId`, parentId);
|
||||||
|
} else {
|
||||||
|
void query.whereNull(`${TableName.PamFolder}.parentId`);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFolder = await query.first();
|
||||||
|
|
||||||
|
if (!currentFolder) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
parentId = currentFolder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentFolder;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find PAM folder by path" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...orm, findByProjectId, findByPath };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { Knex } from "knex";
|
|||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName } from "@app/db/schemas";
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||||
|
import { OrderByDirection } from "@app/lib/types";
|
||||||
|
|
||||||
|
import { PamResourceOrderBy } from "./pam-resource-enums";
|
||||||
|
|
||||||
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
|
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
|
||||||
export const pamResourceDALFactory = (db: TDbClient) => {
|
export const pamResourceDALFactory = (db: TDbClient) => {
|
||||||
@@ -20,5 +24,65 @@ export const pamResourceDALFactory = (db: TDbClient) => {
|
|||||||
return doc;
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...orm, findById };
|
const findByProjectId = async (
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
search,
|
||||||
|
limit,
|
||||||
|
offset = 0,
|
||||||
|
orderBy = PamResourceOrderBy.Name,
|
||||||
|
orderDirection = OrderByDirection.ASC,
|
||||||
|
filterResourceTypes
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
orderBy?: PamResourceOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
filterResourceTypes?: string[];
|
||||||
|
},
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const dbInstance = tx || db.replicaNode();
|
||||||
|
const query = dbInstance(TableName.PamResource).where(`${TableName.PamResource}.projectId`, projectId);
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// escape special characters (`%`, `_`) and the escape character itself (`\`)
|
||||||
|
const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||||
|
const pattern = `%${escapedSearch}%`;
|
||||||
|
void query.where((q) => {
|
||||||
|
void q
|
||||||
|
.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern])
|
||||||
|
.orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "resourceType", pattern]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterResourceTypes && filterResourceTypes.length) {
|
||||||
|
void query.whereIn(`${TableName.PamResource}.resourceType`, filterResourceTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countQuery = query.clone().count("*", { as: "count" }).first();
|
||||||
|
|
||||||
|
void query.select(selectAllTableCols(TableName.PamResource));
|
||||||
|
|
||||||
|
const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
|
||||||
|
|
||||||
|
void query.orderByRaw(`${TableName.PamResource}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]);
|
||||||
|
|
||||||
|
if (typeof limit === "number") {
|
||||||
|
void query.limit(limit).offset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [resources, countResult] = await Promise.all([query, countQuery]);
|
||||||
|
const totalCount = Number(countResult?.count || 0);
|
||||||
|
|
||||||
|
return { resources, totalCount };
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find PAM resources" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...orm, findById, findByProjectId };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export enum PamResource {
|
export enum PamResource {
|
||||||
Postgres = "postgres",
|
Postgres = "postgres",
|
||||||
MySQL = "mysql"
|
MySQL = "mysql",
|
||||||
|
SSH = "ssh"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PamResourceOrderBy {
|
||||||
|
Name = "name"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PamResource } from "./pam-resource-enums";
|
import { PamResource } from "./pam-resource-enums";
|
||||||
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
|
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
|
||||||
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
|
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
|
||||||
|
import { sshResourceFactory } from "./ssh/ssh-resource-factory";
|
||||||
|
|
||||||
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
|
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
|
||||||
|
|
||||||
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
||||||
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
||||||
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation
|
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation,
|
||||||
|
[PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
encryptResourceConnectionDetails,
|
encryptResourceConnectionDetails,
|
||||||
listResourceOptions
|
listResourceOptions
|
||||||
} from "./pam-resource-fns";
|
} from "./pam-resource-fns";
|
||||||
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
import { TCreateResourceDTO, TListResourcesDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
||||||
|
|
||||||
type TPamResourceServiceFactoryDep = {
|
type TPamResourceServiceFactoryDep = {
|
||||||
pamResourceDAL: TPamResourceDALFactory;
|
pamResourceDAL: TPamResourceDALFactory;
|
||||||
@@ -192,19 +192,18 @@ export const pamResourceServiceFactory = ({
|
|||||||
gatewayV2Service
|
gatewayV2Service
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logic to prevent overwriting unedited censored values
|
let finalCredentials = { ...rotationAccountCredentials };
|
||||||
const finalCredentials = { ...rotationAccountCredentials };
|
if (resource.encryptedRotationAccountCredentials) {
|
||||||
if (
|
|
||||||
resource.encryptedRotationAccountCredentials &&
|
|
||||||
rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__"
|
|
||||||
) {
|
|
||||||
const decryptedCredentials = await decryptAccountCredentials({
|
const decryptedCredentials = await decryptAccountCredentials({
|
||||||
encryptedCredentials: resource.encryptedRotationAccountCredentials,
|
encryptedCredentials: resource.encryptedRotationAccountCredentials,
|
||||||
projectId: resource.projectId,
|
projectId: resource.projectId,
|
||||||
kmsService
|
kmsService
|
||||||
});
|
});
|
||||||
|
|
||||||
finalCredentials.password = decryptedCredentials.password;
|
finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
|
||||||
|
rotationAccountCredentials,
|
||||||
|
decryptedCredentials
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -268,22 +267,23 @@ export const pamResourceServiceFactory = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
const list = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId, ...params }: TListResourcesDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor: actor.type,
|
actor,
|
||||||
actorAuthMethod: actor.authMethod,
|
actorId,
|
||||||
actorId: actor.id,
|
actorAuthMethod,
|
||||||
actorOrgId: actor.orgId,
|
actorOrgId,
|
||||||
projectId,
|
projectId,
|
||||||
actionProjectType: ActionProjectType.PAM
|
actionProjectType: ActionProjectType.PAM
|
||||||
});
|
});
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||||
|
|
||||||
const resources = await pamResourceDAL.find({ projectId });
|
const { resources, totalCount } = await pamResourceDAL.findByProjectId({ projectId, ...params });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService)))
|
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))),
|
||||||
|
totalCount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||||
import {
|
import {
|
||||||
TMySQLAccount,
|
TMySQLAccount,
|
||||||
@@ -5,22 +7,31 @@ import {
|
|||||||
TMySQLResource,
|
TMySQLResource,
|
||||||
TMySQLResourceConnectionDetails
|
TMySQLResourceConnectionDetails
|
||||||
} from "./mysql/mysql-resource-types";
|
} from "./mysql/mysql-resource-types";
|
||||||
import { PamResource } from "./pam-resource-enums";
|
import { PamResource, PamResourceOrderBy } from "./pam-resource-enums";
|
||||||
import {
|
import {
|
||||||
TPostgresAccount,
|
TPostgresAccount,
|
||||||
TPostgresAccountCredentials,
|
TPostgresAccountCredentials,
|
||||||
TPostgresResource,
|
TPostgresResource,
|
||||||
TPostgresResourceConnectionDetails
|
TPostgresResourceConnectionDetails
|
||||||
} from "./postgres/postgres-resource-types";
|
} from "./postgres/postgres-resource-types";
|
||||||
|
import {
|
||||||
|
TSSHAccount,
|
||||||
|
TSSHAccountCredentials,
|
||||||
|
TSSHResource,
|
||||||
|
TSSHResourceConnectionDetails
|
||||||
|
} from "./ssh/ssh-resource-types";
|
||||||
|
|
||||||
// Resource types
|
// Resource types
|
||||||
export type TPamResource = TPostgresResource | TMySQLResource;
|
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
|
||||||
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails;
|
export type TPamResourceConnectionDetails =
|
||||||
|
| TPostgresResourceConnectionDetails
|
||||||
|
| TMySQLResourceConnectionDetails
|
||||||
|
| TSSHResourceConnectionDetails;
|
||||||
|
|
||||||
// Account types
|
// Account types
|
||||||
export type TPamAccount = TPostgresAccount | TMySQLAccount;
|
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
|
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
|
||||||
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials;
|
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials;
|
||||||
|
|
||||||
// Resource DTOs
|
// Resource DTOs
|
||||||
export type TCreateResourceDTO = Pick<
|
export type TCreateResourceDTO = Pick<
|
||||||
@@ -32,6 +43,15 @@ export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType"
|
|||||||
resourceId: string;
|
resourceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TListResourcesDTO = {
|
||||||
|
search?: string;
|
||||||
|
orderBy?: PamResourceOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
filterResourceTypes?: string[];
|
||||||
|
} & TProjectPermission;
|
||||||
|
|
||||||
// Resource factory
|
// Resource factory
|
||||||
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
|
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
|
||||||
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||||
@@ -51,4 +71,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
|
|||||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||||
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
|
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
|
||||||
|
handleOverwritePreventionForCensoredValues: (updatedAccountCredentials: C, currentCredentials: C) => Promise<C>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -337,9 +337,24 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOverwritePreventionForCensoredValues = async (
|
||||||
|
updatedAccountCredentials: TSqlAccountCredentials,
|
||||||
|
currentCredentials: TSqlAccountCredentials
|
||||||
|
) => {
|
||||||
|
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
|
||||||
|
return {
|
||||||
|
...updatedAccountCredentials,
|
||||||
|
password: currentCredentials.password
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedAccountCredentials;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validateConnection,
|
validateConnection,
|
||||||
validateAccountCredentials,
|
validateAccountCredentials,
|
||||||
rotateAccountCredentials
|
rotateAccountCredentials,
|
||||||
|
handleOverwritePreventionForCensoredValues
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum SSHAuthMethod {
|
||||||
|
Password = "password",
|
||||||
|
PublicKey = "public-key",
|
||||||
|
Certificate = "certificate"
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { Client } from "ssh2";
|
||||||
|
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { GatewayProxyProtocol } from "@app/lib/gateway";
|
||||||
|
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
|
||||||
|
import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns";
|
||||||
|
import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
|
||||||
|
import { PamResource } from "../pam-resource-enums";
|
||||||
|
import {
|
||||||
|
TPamResourceFactory,
|
||||||
|
TPamResourceFactoryRotateAccountCredentials,
|
||||||
|
TPamResourceFactoryValidateAccountCredentials
|
||||||
|
} from "../pam-resource-types";
|
||||||
|
import { SSHAuthMethod } from "./ssh-resource-enums";
|
||||||
|
import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types";
|
||||||
|
|
||||||
|
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||||
|
|
||||||
|
export const executeWithGateway = async <T>(
|
||||||
|
config: {
|
||||||
|
connectionDetails: TSSHResourceConnectionDetails;
|
||||||
|
resourceType: PamResource;
|
||||||
|
gatewayId: string;
|
||||||
|
},
|
||||||
|
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
|
||||||
|
operation: (proxyPort: number) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
const { connectionDetails, gatewayId } = config;
|
||||||
|
const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true);
|
||||||
|
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
|
||||||
|
gatewayId,
|
||||||
|
targetHost,
|
||||||
|
targetPort: connectionDetails.port
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!platformConnectionDetails) {
|
||||||
|
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return withGatewayV2Proxy(
|
||||||
|
async (proxyPort) => {
|
||||||
|
return operation(proxyPort);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: GatewayProxyProtocol.Tcp,
|
||||||
|
relayHost: platformConnectionDetails.relayHost,
|
||||||
|
gateway: platformConnectionDetails.gateway,
|
||||||
|
relay: platformConnectionDetails.relay
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetails, TSSHAccountCredentials> = (
|
||||||
|
resourceType,
|
||||||
|
connectionDetails,
|
||||||
|
gatewayId,
|
||||||
|
gatewayV2Service
|
||||||
|
) => {
|
||||||
|
const validateConnection = async () => {
|
||||||
|
try {
|
||||||
|
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const client = new Client();
|
||||||
|
let handshakeComplete = false;
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
logger.info(
|
||||||
|
{ error: err.message, handshakeComplete },
|
||||||
|
"[SSH Resource Factory] SSH client error event received"
|
||||||
|
);
|
||||||
|
// If we got an authentication error, it means we successfully reached the SSH server
|
||||||
|
// and completed the SSH handshake - that's good enough for connection validation
|
||||||
|
if (handshakeComplete || err.message.includes("authentication") || err.message.includes("publickey")) {
|
||||||
|
logger.info(
|
||||||
|
{ handshakeComplete, errorMessage: err.message },
|
||||||
|
"[SSH Resource Factory] SSH connection validation succeeded (auth error after handshake)"
|
||||||
|
);
|
||||||
|
client.end();
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
{ error: err.message, handshakeComplete },
|
||||||
|
"[SSH Resource Factory] SSH connection validation failed"
|
||||||
|
);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("handshake", () => {
|
||||||
|
// SSH handshake completed - the server is reachable and responding
|
||||||
|
logger.info("[SSH Resource Factory] SSH handshake event received - setting handshakeComplete to true");
|
||||||
|
handshakeComplete = true;
|
||||||
|
client.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("timeout", () => {
|
||||||
|
logger.error("[SSH Resource Factory] SSH connection timeout");
|
||||||
|
reject(new Error("Connection timeout"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempt connection with a dummy username (we don't care about auth success)
|
||||||
|
// The goal is just to verify SSH server is reachable and responding
|
||||||
|
client.connect({
|
||||||
|
host: "localhost",
|
||||||
|
port: proxyPort,
|
||||||
|
username: "infisical-connection-test",
|
||||||
|
password: "infisical-connection-test-password",
|
||||||
|
readyTimeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||||
|
tryKeyboard: false,
|
||||||
|
// We want to fail fast on auth, we're just testing reachability
|
||||||
|
authHandler: () => {
|
||||||
|
// If authHandler is called, SSH handshake succeeded
|
||||||
|
handshakeComplete = true;
|
||||||
|
return false; // Don't continue with auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return connectionDetails;
|
||||||
|
} catch (error) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSSHAccountCredentials> = async (
|
||||||
|
credentials
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const client = new Client();
|
||||||
|
|
||||||
|
client.on("ready", () => {
|
||||||
|
logger.info(
|
||||||
|
{ username: credentials.username, authMethod: credentials.authMethod },
|
||||||
|
"[SSH Resource Factory] SSH authentication successful"
|
||||||
|
);
|
||||||
|
client.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
logger.error(
|
||||||
|
{ error: err.message, username: credentials.username, authMethod: credentials.authMethod },
|
||||||
|
"[SSH Resource Factory] SSH authentication failed"
|
||||||
|
);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("timeout", () => {
|
||||||
|
logger.error(
|
||||||
|
{ username: credentials.username, authMethod: credentials.authMethod },
|
||||||
|
"[SSH Resource Factory] SSH authentication timeout"
|
||||||
|
);
|
||||||
|
reject(new Error("Connection timeout"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build connection config based on auth method
|
||||||
|
const baseConfig = {
|
||||||
|
host: "localhost",
|
||||||
|
port: proxyPort,
|
||||||
|
username: credentials.username,
|
||||||
|
readyTimeout: EXTERNAL_REQUEST_TIMEOUT
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (credentials.authMethod) {
|
||||||
|
case SSHAuthMethod.Password:
|
||||||
|
client.connect({
|
||||||
|
...baseConfig,
|
||||||
|
password: credentials.password,
|
||||||
|
tryKeyboard: false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case SSHAuthMethod.PublicKey:
|
||||||
|
client.connect({
|
||||||
|
...baseConfig,
|
||||||
|
privateKey: credentials.privateKey,
|
||||||
|
tryKeyboard: false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reject(new Error(`Unsupported SSH auth method: ${(credentials as TSSHAccountCredentials).authMethod}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return credentials;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Check for common authentication failure messages
|
||||||
|
if (
|
||||||
|
error.message.includes("authentication") ||
|
||||||
|
error.message.includes("All configured authentication methods failed") ||
|
||||||
|
error.message.includes("publickey")
|
||||||
|
) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Account credentials invalid."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "Connection timeout") {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Connection timeout. Verify that the SSH server is reachable"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TSSHAccountCredentials> = async (
|
||||||
|
rotationAccountCredentials
|
||||||
|
) => {
|
||||||
|
return rotationAccountCredentials;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverwritePreventionForCensoredValues = async (
|
||||||
|
updatedAccountCredentials: TSSHAccountCredentials,
|
||||||
|
currentCredentials: TSSHAccountCredentials
|
||||||
|
) => {
|
||||||
|
if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) {
|
||||||
|
return updatedAccountCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatedAccountCredentials.authMethod === SSHAuthMethod.Password &&
|
||||||
|
currentCredentials.authMethod === SSHAuthMethod.Password
|
||||||
|
) {
|
||||||
|
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
|
||||||
|
return {
|
||||||
|
...updatedAccountCredentials,
|
||||||
|
password: currentCredentials.password
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatedAccountCredentials.authMethod === SSHAuthMethod.PublicKey &&
|
||||||
|
currentCredentials.authMethod === SSHAuthMethod.PublicKey
|
||||||
|
) {
|
||||||
|
if (updatedAccountCredentials.privateKey === "__INFISICAL_UNCHANGED__") {
|
||||||
|
return {
|
||||||
|
...updatedAccountCredentials,
|
||||||
|
privateKey: currentCredentials.privateKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedAccountCredentials;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateConnection,
|
||||||
|
validateAccountCredentials,
|
||||||
|
rotateAccountCredentials,
|
||||||
|
handleOverwritePreventionForCensoredValues
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { PamResource } from "../pam-resource-enums";
|
||||||
|
import {
|
||||||
|
BaseCreatePamAccountSchema,
|
||||||
|
BaseCreatePamResourceSchema,
|
||||||
|
BasePamAccountSchema,
|
||||||
|
BasePamAccountSchemaWithResource,
|
||||||
|
BasePamResourceSchema,
|
||||||
|
BaseUpdatePamAccountSchema,
|
||||||
|
BaseUpdatePamResourceSchema
|
||||||
|
} from "../pam-resource-schemas";
|
||||||
|
import { SSHAuthMethod } from "./ssh-resource-enums";
|
||||||
|
|
||||||
|
export const BaseSSHResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.SSH) });
|
||||||
|
|
||||||
|
export const SSHResourceListItemSchema = z.object({
|
||||||
|
name: z.literal("SSH"),
|
||||||
|
resource: z.literal(PamResource.SSH)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SSHResourceConnectionDetailsSchema = z.object({
|
||||||
|
host: z.string().trim().max(255),
|
||||||
|
port: z.number()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SSHPasswordCredentialsSchema = z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Password),
|
||||||
|
username: z.string().trim().max(255),
|
||||||
|
password: z.string().trim().max(255)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SSHPublicKeyCredentialsSchema = z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||||
|
username: z.string().trim().max(255),
|
||||||
|
privateKey: z.string().trim().max(5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SSHCertificateCredentialsSchema = z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||||
|
username: z.string().trim().max(255)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SSHAccountCredentialsSchema = z.discriminatedUnion("authMethod", [
|
||||||
|
SSHPasswordCredentialsSchema,
|
||||||
|
SSHPublicKeyCredentialsSchema,
|
||||||
|
SSHCertificateCredentialsSchema
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SSHResourceSchema = BaseSSHResourceSchema.extend({
|
||||||
|
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||||
|
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({
|
||||||
|
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||||
|
rotationAccountCredentials: z
|
||||||
|
.discriminatedUnion("authMethod", [
|
||||||
|
z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Password),
|
||||||
|
username: z.string()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||||
|
username: z.string()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||||
|
username: z.string()
|
||||||
|
})
|
||||||
|
])
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||||
|
connectionDetails: SSHResourceConnectionDetailsSchema,
|
||||||
|
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||||
|
connectionDetails: SSHResourceConnectionDetailsSchema.optional(),
|
||||||
|
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
export const SSHAccountSchema = BasePamAccountSchema.extend({
|
||||||
|
credentials: SSHAccountCredentialsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateSSHAccountSchema = BaseCreatePamAccountSchema.extend({
|
||||||
|
credentials: SSHAccountCredentialsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateSSHAccountSchema = BaseUpdatePamAccountSchema.extend({
|
||||||
|
credentials: SSHAccountCredentialsSchema.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SanitizedSSHAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
|
||||||
|
credentials: z.discriminatedUnion("authMethod", [
|
||||||
|
z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Password),
|
||||||
|
username: z.string()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||||
|
username: z.string()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||||
|
username: z.string()
|
||||||
|
})
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
export const SSHSessionCredentialsSchema = SSHResourceConnectionDetailsSchema.and(SSHAccountCredentialsSchema);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SSHAccountCredentialsSchema,
|
||||||
|
SSHAccountSchema,
|
||||||
|
SSHResourceConnectionDetailsSchema,
|
||||||
|
SSHResourceSchema
|
||||||
|
} from "./ssh-resource-schemas";
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
export type TSSHResource = z.infer<typeof SSHResourceSchema>;
|
||||||
|
export type TSSHResourceConnectionDetails = z.infer<typeof SSHResourceConnectionDetailsSchema>;
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
export type TSSHAccount = z.infer<typeof SSHAccountSchema>;
|
||||||
|
export type TSSHAccountCredentials = z.infer<typeof SSHAccountCredentialsSchema>;
|
||||||
@@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas";
|
|||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||||
|
|
||||||
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
|
import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session-types";
|
||||||
|
|
||||||
export const decryptSessionCommandLogs = async ({
|
export const decryptSessionCommandLogs = async ({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({
|
|||||||
cipherTextBlob: encryptedLogs
|
cipherTextBlob: encryptedLogs
|
||||||
});
|
});
|
||||||
|
|
||||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
|
return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptSession = async (
|
export const decryptSession = async (
|
||||||
@@ -32,7 +32,7 @@ export const decryptSession = async (
|
|||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
commandLogs: session.encryptedLogsBlob
|
logs: session.encryptedLogsBlob
|
||||||
? await decryptSessionCommandLogs({
|
? await decryptSessionCommandLogs({
|
||||||
projectId,
|
projectId,
|
||||||
encryptedLogs: session.encryptedLogsBlob,
|
encryptedLogs: session.encryptedLogsBlob,
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({
|
|||||||
timestamp: z.coerce.date()
|
timestamp: z.coerce.date()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SSH Terminal Event schemas
|
||||||
|
export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]);
|
||||||
|
|
||||||
|
export const TerminalEventSchema = z.object({
|
||||||
|
timestamp: z.coerce.date(),
|
||||||
|
eventType: TerminalEventTypeSchema,
|
||||||
|
data: z.string(), // Base64 encoded binary data
|
||||||
|
elapsedTime: z.number() // Seconds since session start (for replay)
|
||||||
|
});
|
||||||
|
|
||||||
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
||||||
encryptedLogsBlob: true
|
encryptedLogsBlob: true
|
||||||
}).extend({
|
}).extend({
|
||||||
commandLogs: PamSessionCommandLogSchema.array()
|
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
|
|||||||
import { TLicenseServiceFactory } from "../license/license-service";
|
import { TLicenseServiceFactory } from "../license/license-service";
|
||||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||||
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
|
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||||
import { TUpdateSessionLogsDTO } from "./pam-session.types";
|
|
||||||
import { TPamSessionDALFactory } from "./pam-session-dal";
|
import { TPamSessionDALFactory } from "./pam-session-dal";
|
||||||
import { PamSessionStatus } from "./pam-session-enums";
|
import { PamSessionStatus } from "./pam-session-enums";
|
||||||
import { decryptSession } from "./pam-session-fns";
|
import { decryptSession } from "./pam-session-fns";
|
||||||
|
import { TUpdateSessionLogsDTO } from "./pam-session-types";
|
||||||
|
|
||||||
type TPamSessionServiceFactoryDep = {
|
type TPamSessionServiceFactoryDep = {
|
||||||
pamSessionDAL: TPamSessionDALFactory;
|
pamSessionDAL: TPamSessionDALFactory;
|
||||||
|
|||||||
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
|
import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas";
|
||||||
|
|
||||||
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
||||||
|
export type TTerminalEvent = z.infer<typeof TerminalEventSchema>;
|
||||||
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
export type TUpdateSessionLogsDTO = {
|
export type TUpdateSessionLogsDTO = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
logs: TPamSessionCommandLog[];
|
logs: (TPamSessionCommandLog | TTerminalEvent)[];
|
||||||
};
|
};
|
||||||
@@ -213,7 +213,7 @@ export const certificateProfileServiceFactory = ({
|
|||||||
throw new NotFoundError({ message: "Project not found" });
|
throw new NotFoundError({ message: "Project not found" });
|
||||||
}
|
}
|
||||||
const plan = await licenseService.getPlan(project.orgId);
|
const plan = await licenseService.getPlan(project.orgId);
|
||||||
if (!plan.pkiAcme) {
|
if (!plan.pkiAcme && data.enrollmentType === EnrollmentType.ACME) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue"
|
message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue"
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
|
|||||||
|
|
||||||
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
|
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
|
||||||
[SecretSync.AWSParameterStore]: [],
|
[SecretSync.AWSParameterStore]: [],
|
||||||
[SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"],
|
[SecretSync.AWSSecretsManager]: ["mappingBehavior"],
|
||||||
[SecretSync.GitHub]: [],
|
[SecretSync.GitHub]: [],
|
||||||
[SecretSync.GCPSecretManager]: [],
|
[SecretSync.GCPSecretManager]: [],
|
||||||
[SecretSync.AzureKeyVault]: [],
|
[SecretSync.AzureKeyVault]: [],
|
||||||
|
|||||||
+547
-527
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,18 @@ export const useDeleteCert = () => {
|
|||||||
);
|
);
|
||||||
return certificate;
|
return certificate;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { projectSlug }) => {
|
onSuccess: (_, { projectId }) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: projectKeys.forProjectCertificates(projectSlug)
|
queryKey: ["certificate-profiles", "list"]
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: projectKeys.allProjectCertificates()
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: projectKeys.forProjectCertificates(projectId)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -49,16 +58,18 @@ export const useRevokeCert = () => {
|
|||||||
);
|
);
|
||||||
return certificate;
|
return certificate;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { projectSlug }) => {
|
onSuccess: (_, { projectId }) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: projectKeys.forProjectCertificates(projectSlug)
|
queryKey: ["certificate-profiles", "list"]
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
|
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["certificate-profiles", "list"]
|
queryKey: projectKeys.allProjectCertificates()
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: projectKeys.forProjectCertificates(projectId)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ export type TCertificate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TDeleteCertDTO = {
|
export type TDeleteCertDTO = {
|
||||||
projectSlug: string;
|
projectId: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TRevokeCertDTO = {
|
export type TRevokeCertDTO = {
|
||||||
projectSlug: string;
|
projectId: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
revocationReason: string;
|
revocationReason: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Resources
|
||||||
export enum PamResourceType {
|
export enum PamResourceType {
|
||||||
Postgres = "postgres",
|
Postgres = "postgres",
|
||||||
MySQL = "mysql",
|
MySQL = "mysql",
|
||||||
@@ -18,9 +19,24 @@ export enum PamResourceType {
|
|||||||
DynamoDB = "dynamodb"
|
DynamoDB = "dynamodb"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PamResourceOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions
|
||||||
export enum PamSessionStatus {
|
export enum PamSessionStatus {
|
||||||
Starting = "starting",
|
Starting = "starting",
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Ended = "ended",
|
Ended = "ended",
|
||||||
Terminated = "terminated"
|
Terminated = "terminated"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
export enum PamAccountOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PamAccountView {
|
||||||
|
Flat = "flat",
|
||||||
|
Nested = "nested"
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const useCreatePamResource = () => {
|
|||||||
return data.resource;
|
return data.resource;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -48,7 +48,7 @@ export const useUpdatePamResource = () => {
|
|||||||
return data.resource;
|
return data.resource;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,7 @@ export const useDeletePamResource = () => {
|
|||||||
return data.resource;
|
return data.resource;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -82,7 +82,7 @@ export const useCreatePamAccount = () => {
|
|||||||
return data.account;
|
return data.account;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -99,7 +99,7 @@ export const useUpdatePamAccount = () => {
|
|||||||
return data.account;
|
return data.account;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -115,7 +115,7 @@ export const useDeletePamAccount = () => {
|
|||||||
return data.account;
|
return data.account;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -130,7 +130,7 @@ export const useCreatePamFolder = () => {
|
|||||||
return data.folder;
|
return data.folder;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -147,7 +147,7 @@ export const useUpdatePamFolder = () => {
|
|||||||
return data.folder;
|
return data.folder;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -163,7 +163,7 @@ export const useDeletePamFolder = () => {
|
|||||||
return data.folder;
|
return data.folder;
|
||||||
},
|
},
|
||||||
onSuccess: ({ projectId }) => {
|
onSuccess: ({ projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts({ projectId }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { apiRequest } from "@app/config/request";
|
|||||||
|
|
||||||
import { TPamResourceOption } from "./types/resource-options";
|
import { TPamResourceOption } from "./types/resource-options";
|
||||||
import { PamResourceType } from "./enums";
|
import { PamResourceType } from "./enums";
|
||||||
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
|
import {
|
||||||
|
TListPamAccountsDTO,
|
||||||
|
TListPamResourcesDTO,
|
||||||
|
TPamAccount,
|
||||||
|
TPamFolder,
|
||||||
|
TPamResource,
|
||||||
|
TPamSession
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const pamKeys = {
|
export const pamKeys = {
|
||||||
all: ["pam"] as const,
|
all: ["pam"] as const,
|
||||||
@@ -12,14 +19,24 @@ export const pamKeys = {
|
|||||||
account: () => [...pamKeys.all, "account"] as const,
|
account: () => [...pamKeys.all, "account"] as const,
|
||||||
session: () => [...pamKeys.all, "session"] as const,
|
session: () => [...pamKeys.all, "session"] as const,
|
||||||
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
|
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
|
||||||
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
|
listResources: ({ projectId, ...params }: TListPamResourcesDTO) => [
|
||||||
|
...pamKeys.resource(),
|
||||||
|
"list",
|
||||||
|
projectId,
|
||||||
|
params
|
||||||
|
],
|
||||||
getResource: (resourceType: string, resourceId: string) => [
|
getResource: (resourceType: string, resourceId: string) => [
|
||||||
...pamKeys.resource(),
|
...pamKeys.resource(),
|
||||||
"get",
|
"get",
|
||||||
resourceType,
|
resourceType,
|
||||||
resourceId
|
resourceId
|
||||||
],
|
],
|
||||||
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
|
listAccounts: ({ projectId, ...params }: TListPamAccountsDTO) => [
|
||||||
|
...pamKeys.account(),
|
||||||
|
"list",
|
||||||
|
projectId,
|
||||||
|
params
|
||||||
|
],
|
||||||
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
|
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
|
||||||
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
|
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
|
||||||
};
|
};
|
||||||
@@ -49,28 +66,33 @@ export const useListPamResourceOptions = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TListPamResourcesResponse = {
|
||||||
|
resources: TPamResource[];
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const useListPamResources = (
|
export const useListPamResources = (
|
||||||
projectId: string,
|
params: TListPamResourcesDTO,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<
|
UseQueryOptions<
|
||||||
TPamResource[],
|
TListPamResourcesResponse,
|
||||||
unknown,
|
unknown,
|
||||||
TPamResource[],
|
TListPamResourcesResponse,
|
||||||
ReturnType<typeof pamKeys.listResources>
|
ReturnType<typeof pamKeys.listResources>
|
||||||
>,
|
>,
|
||||||
"queryKey" | "queryFn"
|
"queryKey" | "queryFn"
|
||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: pamKeys.listResources(projectId),
|
queryKey: pamKeys.listResources(params),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiRequest.get<{ resources: TPamResource[] }>(
|
const { data } = await apiRequest.get<TListPamResourcesResponse>("/api/v1/pam/resources", {
|
||||||
"/api/v1/pam/resources",
|
params
|
||||||
{ params: { projectId } }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return data.resources;
|
return data;
|
||||||
},
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -98,28 +120,36 @@ export const useGetPamResourceById = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Accounts
|
// Accounts
|
||||||
|
type TListPamAccountsResponse = {
|
||||||
|
accounts: TPamAccount[];
|
||||||
|
folders: TPamFolder[];
|
||||||
|
totalCount: number;
|
||||||
|
folderId?: string;
|
||||||
|
folderPaths: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export const useListPamAccounts = (
|
export const useListPamAccounts = (
|
||||||
projectId: string,
|
params: TListPamAccountsDTO,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<
|
UseQueryOptions<
|
||||||
{ accounts: TPamAccount[]; folders: TPamFolder[] },
|
TListPamAccountsResponse,
|
||||||
unknown,
|
unknown,
|
||||||
{ accounts: TPamAccount[]; folders: TPamFolder[] },
|
TListPamAccountsResponse,
|
||||||
ReturnType<typeof pamKeys.listAccounts>
|
ReturnType<typeof pamKeys.listAccounts>
|
||||||
>,
|
>,
|
||||||
"queryKey" | "queryFn"
|
"queryKey" | "queryFn"
|
||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: pamKeys.listAccounts(projectId),
|
queryKey: pamKeys.listAccounts(params),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>(
|
const { data } = await apiRequest.get<TListPamAccountsResponse>("/api/v1/pam/accounts", {
|
||||||
"/api/v1/pam/accounts",
|
params
|
||||||
{ params: { projectId } }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { PamResourceType, PamSessionStatus } from "../enums";
|
import { OrderByDirection } from "../../generic/types";
|
||||||
|
import {
|
||||||
|
PamAccountOrderBy,
|
||||||
|
PamAccountView,
|
||||||
|
PamResourceOrderBy,
|
||||||
|
PamResourceType,
|
||||||
|
PamSessionStatus
|
||||||
|
} from "../enums";
|
||||||
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
|
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
|
||||||
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
||||||
|
import { TSSHAccount, TSSHResource } from "./ssh-resource";
|
||||||
|
|
||||||
export * from "./mysql-resource";
|
export * from "./mysql-resource";
|
||||||
export * from "./postgres-resource";
|
export * from "./postgres-resource";
|
||||||
|
export * from "./ssh-resource";
|
||||||
|
|
||||||
export type TPamResource = TPostgresResource | TMySQLResource;
|
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
|
||||||
|
|
||||||
export type TPamAccount = TPostgresAccount | TMySQLAccount;
|
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
|
||||||
|
|
||||||
export type TPamFolder = {
|
export type TPamFolder = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +28,22 @@ export type TPamFolder = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Session log types
|
||||||
|
export type TPamCommandLog = {
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTerminalEvent = {
|
||||||
|
timestamp: string;
|
||||||
|
eventType: "input" | "output" | "resize" | "error";
|
||||||
|
data: string; // Base64 encoded binary data
|
||||||
|
elapsedTime: number; // Seconds since session start (for replay)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TPamSessionLog = TPamCommandLog | TTerminalEvent;
|
||||||
|
|
||||||
export type TPamSession = {
|
export type TPamSession = {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -37,14 +62,20 @@ export type TPamSession = {
|
|||||||
endedAt?: string | null;
|
endedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
commandLogs: {
|
logs: TPamSessionLog[];
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
timestamp: string;
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resource DTOs
|
// Resource DTOs
|
||||||
|
export type TListPamResourcesDTO = {
|
||||||
|
projectId: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: PamResourceOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
search?: string;
|
||||||
|
filterResourceTypes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TCreatePamResourceDTO = Pick<
|
export type TCreatePamResourceDTO = Pick<
|
||||||
TPamResource,
|
TPamResource,
|
||||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
||||||
@@ -63,6 +94,18 @@ export type TDeletePamResourceDTO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Account DTOs
|
// Account DTOs
|
||||||
|
export type TListPamAccountsDTO = {
|
||||||
|
projectId: string;
|
||||||
|
accountPath?: string | null;
|
||||||
|
accountView?: PamAccountView;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: PamAccountOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
search?: string;
|
||||||
|
filterResourceIds?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TCreatePamAccountDTO = Pick<
|
export type TCreatePamAccountDTO = Pick<
|
||||||
TPamAccount,
|
TPamAccount,
|
||||||
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
|
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { PamResourceType } from "../enums";
|
||||||
|
import { TBasePamAccount } from "./base-account";
|
||||||
|
import { TBasePamResource } from "./base-resource";
|
||||||
|
|
||||||
|
export enum SSHAuthMethod {
|
||||||
|
Password = "password",
|
||||||
|
PublicKey = "public-key",
|
||||||
|
Certificate = "certificate"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TSSHConnectionDetails = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSSHPasswordCredentials = {
|
||||||
|
authMethod: SSHAuthMethod.Password;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSSHPublicKeyCredentials = {
|
||||||
|
authMethod: SSHAuthMethod.PublicKey;
|
||||||
|
username: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSSHCertificateCredentials = {
|
||||||
|
authMethod: SSHAuthMethod.Certificate;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSSHCredentials =
|
||||||
|
| TSSHPasswordCredentials
|
||||||
|
| TSSHPublicKeyCredentials
|
||||||
|
| TSSHCertificateCredentials;
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
export type TSSHResource = TBasePamResource & { resourceType: PamResourceType.SSH } & {
|
||||||
|
connectionDetails: TSSHConnectionDetails;
|
||||||
|
rotationAccountCredentials?: TSSHCredentials | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
export type TSSHAccount = TBasePamAccount & {
|
||||||
|
credentials: TSSHCredentials;
|
||||||
|
};
|
||||||
@@ -16,15 +16,19 @@ import {
|
|||||||
faSignOut,
|
faSignOut,
|
||||||
faToolbox,
|
faToolbox,
|
||||||
faUser,
|
faUser,
|
||||||
|
faUserCog,
|
||||||
|
faUserPlus,
|
||||||
faUsers
|
faUsers
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { UserPlusIcon } from "lucide-react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { Mfa } from "@app/components/auth/Mfa";
|
import { Mfa } from "@app/components/auth/Mfa";
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||||
import {
|
import {
|
||||||
BreadcrumbContainer,
|
BreadcrumbContainer,
|
||||||
@@ -44,7 +48,13 @@ import {
|
|||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { Badge, InstanceIcon, OrgIcon, SubOrgIcon } from "@app/components/v3";
|
import { Badge, InstanceIcon, OrgIcon, SubOrgIcon } from "@app/components/v3";
|
||||||
import { envConfig } from "@app/config/env";
|
import { envConfig } from "@app/config/env";
|
||||||
import { useOrganization, useSubscription, useUser } from "@app/context";
|
import {
|
||||||
|
OrgPermissionActions,
|
||||||
|
OrgPermissionSubjects,
|
||||||
|
useOrganization,
|
||||||
|
useSubscription,
|
||||||
|
useUser
|
||||||
|
} from "@app/context";
|
||||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||||
import { useToggle } from "@app/hooks";
|
import { useToggle } from "@app/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -567,15 +577,36 @@ export const Navbar = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{user.superAdmin && !location.pathname.startsWith("/admin") && (
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
<Link
|
{!location.pathname.startsWith("/admin") ? (
|
||||||
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
|
user.superAdmin ? (
|
||||||
to="/admin"
|
<Link
|
||||||
>
|
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
|
||||||
<InstanceIcon className="inline-block size-3.5" />
|
to="/admin"
|
||||||
<span className="ml-2 hidden md:inline-block">Server Console</span>
|
>
|
||||||
</Link>
|
<InstanceIcon className="inline-block size-3.5" />
|
||||||
)}
|
<span className="ml-2 hidden md:inline-block">Server Console</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
|
||||||
|
{(isAllowed) =>
|
||||||
|
isAllowed ? (
|
||||||
|
<Link
|
||||||
|
className="mr-2 flex h-[34px] items-center rounded-md border border-mineshaft-500 px-2.5 py-1.5 text-sm whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600"
|
||||||
|
to="/organization/access-management"
|
||||||
|
search={{
|
||||||
|
selectedTab: "members",
|
||||||
|
action: "invite-members"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlusIcon className="inline-block size-3.5" />
|
||||||
|
<span className="ml-2 hidden md:inline-block">Invite Members</span>
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<div className="rounded-l-md border border-r-0 border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
|
<div className="rounded-l-md border border-r-0 border-mineshaft-500 px-2.5 py-1 hover:bg-mineshaft-600">
|
||||||
@@ -657,8 +688,27 @@ export const Navbar = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/personal-settings">
|
<Link to="/personal-settings">
|
||||||
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
|
<DropdownMenuItem icon={<FontAwesomeIcon icon={faUserCog} />}>
|
||||||
|
Personal Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
|
||||||
|
{(isAllowed) =>
|
||||||
|
isAllowed ? (
|
||||||
|
<Link
|
||||||
|
to="/organization/access-management"
|
||||||
|
search={{
|
||||||
|
selectedTab: "members",
|
||||||
|
action: "invite-members"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem icon={<FontAwesomeIcon icon={faUserPlus} />}>
|
||||||
|
Invite Members
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</OrgPermissionCan>
|
||||||
<a
|
<a
|
||||||
href="https://infisical.com/docs/documentation/getting-started/introduction"
|
href="https://infisical.com/docs/documentation/getting-started/introduction"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props)
|
|||||||
const { serialNumber } = popUp.revokeCertificate.data as { serialNumber: string };
|
const { serialNumber } = popUp.revokeCertificate.data as { serialNumber: string };
|
||||||
|
|
||||||
await revokeCertificate({
|
await revokeCertificate({
|
||||||
projectSlug: currentProject.slug,
|
projectId: currentProject.id,
|
||||||
serialNumber,
|
serialNumber,
|
||||||
revocationReason
|
revocationReason
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export const CertificatesSection = () => {
|
|||||||
const onRemoveCertificateSubmit = async (serialNumber: string) => {
|
const onRemoveCertificateSubmit = async (serialNumber: string) => {
|
||||||
if (!currentProject?.slug) return;
|
if (!currentProject?.slug) return;
|
||||||
|
|
||||||
await deleteCert({ serialNumber, projectSlug: currentProject.slug });
|
await deleteCert({
|
||||||
|
serialNumber,
|
||||||
|
projectId: currentProject.id
|
||||||
|
});
|
||||||
|
|
||||||
createNotification({
|
createNotification({
|
||||||
text: "Successfully deleted certificate",
|
text: "Successfully deleted certificate",
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ export const OrgGroupsSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Groups</p>
|
<p className="text-xl font-medium text-mineshaft-100">Groups</p>
|
||||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/groups" />
|
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/groups" />
|
||||||
|
|||||||
+2
-2
@@ -97,7 +97,7 @@ export const IdentitySection = withPermission(
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex w-full items-center gap-4">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex flex-1 items-center gap-x-2">
|
<div className="flex flex-1 items-center gap-x-2">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
|
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
|
||||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/machine-identities" />
|
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/machine-identities" />
|
||||||
@@ -139,7 +139,7 @@ export const IdentitySection = withPermission(
|
|||||||
</div>
|
</div>
|
||||||
{/* Identity Auth Templates Section */}
|
{/* Identity Auth Templates Section */}
|
||||||
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Identity Auth Templates</p>
|
<p className="text-xl font-medium text-mineshaft-100">Identity Auth Templates</p>
|
||||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/auth-templates" />
|
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/auth-templates" />
|
||||||
|
|||||||
+25
-6
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { BanIcon } from "lucide-react";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
import { BanIcon, UserPlusIcon } from "lucide-react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { Badge, DocumentationLinkBadge } from "@app/components/v3";
|
import { Badge, DocumentationLinkBadge } from "@app/components/v3";
|
||||||
|
import { ROUTE_PATHS } from "@app/const/routes";
|
||||||
import {
|
import {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
OrgPermissionSubjects,
|
OrgPermissionSubjects,
|
||||||
@@ -35,6 +37,7 @@ import { OrgMembersTable } from "./OrgMembersTable";
|
|||||||
export const OrgMembersSection = () => {
|
export const OrgMembersSection = () => {
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const { currentOrg, isSubOrganization } = useOrganization();
|
const { currentOrg, isSubOrganization } = useOrganization();
|
||||||
|
const navigate = useNavigate();
|
||||||
const orgId = currentOrg?.id ?? "";
|
const orgId = currentOrg?.id ?? "";
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const userId = user?.id || "";
|
const userId = user?.id || "";
|
||||||
@@ -55,6 +58,22 @@ export const OrgMembersSection = () => {
|
|||||||
|
|
||||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
|
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const urlAction = useSearch({
|
||||||
|
from: ROUTE_PATHS.Organization.AccessControlPage.id,
|
||||||
|
select: (el) => el.action,
|
||||||
|
structuralSharing: true
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlAction === "invite-members") {
|
||||||
|
handlePopUpOpen("addMember");
|
||||||
|
navigate({
|
||||||
|
to: ".",
|
||||||
|
search: ({ action, ...search }) => search
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [urlAction]);
|
||||||
|
|
||||||
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
|
const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership();
|
||||||
const { mutateAsync: deleteBatchMutateAsync } = useDeleteOrgMembershipBatch();
|
const { mutateAsync: deleteBatchMutateAsync } = useDeleteOrgMembershipBatch();
|
||||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||||
@@ -184,7 +203,7 @@ export const OrgMembersSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Users</p>
|
<p className="text-xl font-medium text-mineshaft-100">Users</p>
|
||||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/user-identities" />
|
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/identities/user-identities" />
|
||||||
@@ -194,13 +213,13 @@ export const OrgMembersSection = () => {
|
|||||||
<Button
|
<Button
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
type="submit"
|
type="submit"
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<UserPlusIcon size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal()
|
isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal()
|
||||||
}
|
}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
Add Member
|
{isSubOrganization ? "Add Members" : "Invite Members"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</OrgPermissionCan>
|
</OrgPermissionCan>
|
||||||
|
|||||||
+2
-2
@@ -392,7 +392,7 @@ export const OrgMembersTable = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Th>
|
</Th>
|
||||||
<Th className="w-1/3">
|
<Th className="min-w-40 md:w-1/3 md:min-w-0">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Name
|
Name
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -505,7 +505,7 @@ export const OrgMembersTable = ({
|
|||||||
<Td
|
<Td
|
||||||
className={twMerge("group max-w-0", isActive ? "" : "text-mineshaft-400")}
|
className={twMerge("group max-w-0", isActive ? "" : "text-mineshaft-400")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex w-full items-center gap-x-2">
|
||||||
<p className="truncate">
|
<p className="truncate">
|
||||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+1
-1
@@ -189,7 +189,7 @@ export const OrgRoleTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">
|
<p className="text-xl font-medium text-mineshaft-100">
|
||||||
{isSubOrganization ? "Sub-" : ""}Organization Roles
|
{isSubOrganization ? "Sub-" : ""}Organization Roles
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ const Page = () => {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="flex">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<div className="mr-4 w-96">
|
<div className="w-full md:w-96">
|
||||||
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
|
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
|
||||||
</div>
|
</div>
|
||||||
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
|
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ const Page = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="flex">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<div className="mr-4 w-96">
|
<div className="w-full md:w-96">
|
||||||
<IdentityDetailsSection
|
<IdentityDetailsSection
|
||||||
isOrgIdentity={data.identity.orgId === currentOrg.id}
|
isOrgIdentity={data.identity.orgId === currentOrg.id}
|
||||||
identityId={identityId}
|
identityId={identityId}
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ export const ExternalMigrationsTab = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-base font-medium text-mineshaft-100">
|
<p className="text-base font-medium text-mineshaft-100">
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ export const VaultConnectionSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src="/images/integrations/Vault.png"
|
src="/images/integrations/Vault.png"
|
||||||
|
|||||||
+6
-4
@@ -34,9 +34,11 @@ export const ProjectTemplatesSection = () => {
|
|||||||
project setup
|
project setup
|
||||||
</p>
|
</p>
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex items-center gap-x-2">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
|
<div className="flex items-center gap-x-2">
|
||||||
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/project-templates" />
|
<p className="text-xl font-medium text-mineshaft-100">Project Templates</p>
|
||||||
|
<DocumentationLinkBadge href="https://infisical.com/docs/documentation/platform/project-templates" />
|
||||||
|
</div>
|
||||||
<OrgPermissionCan
|
<OrgPermissionCan
|
||||||
I={OrgPermissionActions.Create}
|
I={OrgPermissionActions.Create}
|
||||||
a={OrgPermissionSubjects.ProjectTemplates}
|
a={OrgPermissionSubjects.ProjectTemplates}
|
||||||
@@ -57,7 +59,7 @@ export const ProjectTemplatesSection = () => {
|
|||||||
handlePopUpOpen("addTemplate");
|
handlePopUpOpen("addTemplate");
|
||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
className="ml-auto"
|
// className="ml-auto"
|
||||||
>
|
>
|
||||||
Add Template
|
Add Template
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -242,8 +242,8 @@ const Page = withPermission(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="flex">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<div className="mr-4 w-96">
|
<div className="w-full md:w-96">
|
||||||
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
|
<UserDetailsSection membershipId={membershipId} handlePopUpOpen={handlePopUpOpen} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Button } from "@app/components/v2";
|
import { Button } from "@app/components/v2";
|
||||||
|
import { PamAccountView } from "@app/hooks/api/pam";
|
||||||
export enum AccountView {
|
|
||||||
Flat = "flat",
|
|
||||||
Nested = "nested"
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: AccountView;
|
value: PamAccountView;
|
||||||
onChange: (value: AccountView) => void;
|
onChange: (value: PamAccountView) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AccountViewToggle = ({ value, onChange }: Props) => {
|
export const AccountViewToggle = ({ value, onChange }: Props) => {
|
||||||
@@ -16,11 +12,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(AccountView.Flat);
|
onChange(PamAccountView.Flat);
|
||||||
}}
|
}}
|
||||||
size="xs"
|
size="xs"
|
||||||
className={`${
|
className={`${
|
||||||
value === AccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
|
value === PamAccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
|
||||||
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
||||||
>
|
>
|
||||||
Hide Folders
|
Hide Folders
|
||||||
@@ -28,11 +24,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(AccountView.Nested);
|
onChange(PamAccountView.Nested);
|
||||||
}}
|
}}
|
||||||
size="xs"
|
size="xs"
|
||||||
className={`${
|
className={`${
|
||||||
value === AccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
|
value === PamAccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
|
||||||
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
||||||
>
|
>
|
||||||
Show Folders
|
Show Folders
|
||||||
|
|||||||
@@ -58,15 +58,19 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
|
|||||||
return duration;
|
return duration;
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
const command = useMemo(
|
const command = useMemo(() => {
|
||||||
() =>
|
if (!account) return "";
|
||||||
account &&
|
|
||||||
(account.resource.resourceType === PamResourceType.Postgres ||
|
switch (account.resource.resourceType) {
|
||||||
account.resource.resourceType === PamResourceType.MySQL)
|
case PamResourceType.Postgres:
|
||||||
? `infisical pam db access-account ${account.id} --duration ${cliDuration}`
|
case PamResourceType.MySQL:
|
||||||
: "",
|
return `infisical pam db access-account ${account.id} --duration ${cliDuration}`;
|
||||||
[account, cliDuration]
|
case PamResourceType.SSH:
|
||||||
);
|
return `infisical pam ssh access-account ${account.id} --duration ${cliDuration}`;
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, [account, cliDuration]);
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types";
|
|||||||
import { PamAccountHeader } from "../PamAccountHeader";
|
import { PamAccountHeader } from "../PamAccountHeader";
|
||||||
import { MySQLAccountForm } from "./MySQLAccountForm";
|
import { MySQLAccountForm } from "./MySQLAccountForm";
|
||||||
import { PostgresAccountForm } from "./PostgresAccountForm";
|
import { PostgresAccountForm } from "./PostgresAccountForm";
|
||||||
|
import { SshAccountForm } from "./SshAccountForm";
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
onComplete: (account: TPamAccount) => void;
|
onComplete: (account: TPamAccount) => void;
|
||||||
@@ -65,6 +66,10 @@ const CreateForm = ({
|
|||||||
return (
|
return (
|
||||||
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||||
);
|
);
|
||||||
|
case PamResourceType.SSH:
|
||||||
|
return (
|
||||||
|
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||||
}
|
}
|
||||||
@@ -90,9 +95,11 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
|||||||
|
|
||||||
switch (account.resource.resourceType) {
|
switch (account.resource.resourceType) {
|
||||||
case PamResourceType.Postgres:
|
case PamResourceType.Postgres:
|
||||||
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
|
return <PostgresAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||||
case PamResourceType.MySQL:
|
case PamResourceType.MySQL:
|
||||||
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
|
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||||
|
case PamResourceType.SSH:
|
||||||
|
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
ModalClose,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
TextArea
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { PamResourceType, TSSHAccount } from "@app/hooks/api/pam";
|
||||||
|
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||||
|
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
|
||||||
|
|
||||||
|
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
account?: TSSHAccount;
|
||||||
|
resourceId?: string;
|
||||||
|
resourceType?: PamResourceType;
|
||||||
|
onSubmit: (formData: FormData) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SSHPasswordCredentialsSchema = z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Password),
|
||||||
|
username: z.string().trim().min(1, "Username is required"),
|
||||||
|
password: z.string().trim().min(1, "Password is required")
|
||||||
|
});
|
||||||
|
|
||||||
|
const SSHPublicKeyCredentialsSchema = z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.PublicKey),
|
||||||
|
username: z.string().trim().min(1, "Username is required"),
|
||||||
|
privateKey: z.string().trim().min(1, "Private key is required")
|
||||||
|
});
|
||||||
|
|
||||||
|
const SSHCertificateCredentialsSchema = z.object({
|
||||||
|
authMethod: z.literal(SSHAuthMethod.Certificate),
|
||||||
|
username: z.string().trim().min(1, "Username is required")
|
||||||
|
});
|
||||||
|
|
||||||
|
const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [
|
||||||
|
SSHPasswordCredentialsSchema,
|
||||||
|
SSHPublicKeyCredentialsSchema,
|
||||||
|
SSHCertificateCredentialsSchema
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formSchema = genericAccountFieldsSchema.extend({
|
||||||
|
credentials: BaseSshAccountSchema,
|
||||||
|
// We don't support rotation for now, just feed a false value to
|
||||||
|
// make the schema happy
|
||||||
|
rotationEnabled: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const SshAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||||
|
const { control, setValue } = useFormContext();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const authMethod =
|
||||||
|
useWatch({ control, name: "credentials.authMethod" }) || SSHAuthMethod.Password;
|
||||||
|
const password = useWatch({ control, name: "credentials.password" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (password === UNCHANGED_PASSWORD_SENTINEL) {
|
||||||
|
setShowPassword(false);
|
||||||
|
}
|
||||||
|
}, [password]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||||
|
<Controller
|
||||||
|
name="credentials.authMethod"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
className="mb-3"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
label="Authentication Method"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={value || SSHAuthMethod.Password}
|
||||||
|
onValueChange={(newAuthMethod) => {
|
||||||
|
onChange(newAuthMethod);
|
||||||
|
// Clear out credentials from other auth methods
|
||||||
|
setValue("credentials.password", undefined, { shouldDirty: true });
|
||||||
|
setValue("credentials.privateKey", undefined, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
<SelectItem value={SSHAuthMethod.Password}>Password</SelectItem>
|
||||||
|
<SelectItem value={SSHAuthMethod.PublicKey}>SSH Key</SelectItem>
|
||||||
|
<SelectItem value={SSHAuthMethod.Certificate}>Certificate</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="credentials.username"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
className="mb-3"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Username"
|
||||||
|
>
|
||||||
|
<Input {...field} autoComplete="off" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{authMethod === SSHAuthMethod.Password && (
|
||||||
|
<Controller
|
||||||
|
name="credentials.password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
className="mb-0"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Password"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onFocus={() => {
|
||||||
|
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||||
|
field.onChange("");
|
||||||
|
}
|
||||||
|
setShowPassword(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (isUpdate && field.value === "") {
|
||||||
|
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||||
|
}
|
||||||
|
setShowPassword(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authMethod === SSHAuthMethod.PublicKey && (
|
||||||
|
<Controller
|
||||||
|
name="credentials.privateKey"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
className="mb-0"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Private Key"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
value={field.value === UNCHANGED_PASSWORD_SENTINEL ? "" : field.value}
|
||||||
|
className="min-h-32 resize-y font-mono text-xs"
|
||||||
|
placeholder={
|
||||||
|
isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL
|
||||||
|
? "Private key unchanged - click to update"
|
||||||
|
: "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
|
||||||
|
}
|
||||||
|
onFocus={() => {
|
||||||
|
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
|
||||||
|
field.onChange("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (isUpdate && field.value === "") {
|
||||||
|
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authMethod === SSHAuthMethod.Certificate && (
|
||||||
|
<p className="mb-0 text-xs text-mineshaft-400">
|
||||||
|
Certificate-based authentication will use the certificate configured on the SSH resource.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SshAccountForm = ({ account, onSubmit }: Props) => {
|
||||||
|
const isUpdate = Boolean(account);
|
||||||
|
|
||||||
|
const getDefaultCredentials = () => {
|
||||||
|
if (!account) return undefined;
|
||||||
|
|
||||||
|
if (account.credentials.authMethod === SSHAuthMethod.Password) {
|
||||||
|
return {
|
||||||
|
...account.credentials,
|
||||||
|
password: UNCHANGED_PASSWORD_SENTINEL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.credentials.authMethod === SSHAuthMethod.PublicKey) {
|
||||||
|
return {
|
||||||
|
...account.credentials,
|
||||||
|
privateKey: UNCHANGED_PASSWORD_SENTINEL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return account.credentials;
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: account
|
||||||
|
? {
|
||||||
|
...account,
|
||||||
|
credentials: getDefaultCredentials()
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
credentials: {
|
||||||
|
authMethod: SSHAuthMethod.Password,
|
||||||
|
username: "",
|
||||||
|
password: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting, isDirty }
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
handleSubmit(onSubmit)(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GenericAccountFields />
|
||||||
|
<SshAccountFields isUpdate={isUpdate} />
|
||||||
|
<div className="mt-6 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
colorSchema="secondary"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting || !isDirty}
|
||||||
|
>
|
||||||
|
{isUpdate ? "Update Account" : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button colorSchema="secondary" variant="plain">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,9 @@
|
|||||||
import { ContentLoader } from "@app/components/v2";
|
|
||||||
import { useProject } from "@app/context";
|
import { useProject } from "@app/context";
|
||||||
import { useListPamAccounts } from "@app/hooks/api/pam";
|
|
||||||
|
|
||||||
import { PamAccountsTable } from "./PamAccountsTable";
|
import { PamAccountsTable } from "./PamAccountsTable";
|
||||||
|
|
||||||
export const PamAccountsSection = () => {
|
export const PamAccountsSection = () => {
|
||||||
const { currentProject } = useProject();
|
const { currentProject } = useProject();
|
||||||
|
|
||||||
const { data, isPending } = useListPamAccounts(currentProject.id, {
|
return <PamAccountsTable projectId={currentProject.id} />;
|
||||||
refetchInterval: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending) return <ContentLoader />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PamAccountsTable
|
|
||||||
projectId={currentProject.id}
|
|
||||||
accounts={data?.accounts || []}
|
|
||||||
folders={data?.folders || []}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
TBody,
|
TBody,
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
@@ -40,11 +41,22 @@ import {
|
|||||||
ProjectPermissionPamAccountActions,
|
ProjectPermissionPamAccountActions,
|
||||||
ProjectPermissionSub
|
ProjectPermissionSub
|
||||||
} from "@app/context/ProjectPermissionContext/types";
|
} from "@app/context/ProjectPermissionContext/types";
|
||||||
|
import {
|
||||||
|
getUserTablePreference,
|
||||||
|
PreferenceKey,
|
||||||
|
setUserTablePreference
|
||||||
|
} from "@app/helpers/userTablePreferences";
|
||||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { PAM_RESOURCE_TYPE_MAP, TPamAccount, TPamFolder } from "@app/hooks/api/pam";
|
import {
|
||||||
|
PAM_RESOURCE_TYPE_MAP,
|
||||||
|
PamAccountOrderBy,
|
||||||
|
PamAccountView,
|
||||||
|
TPamFolder
|
||||||
|
} from "@app/hooks/api/pam";
|
||||||
|
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
|
||||||
|
|
||||||
import { AccountView, AccountViewToggle } from "./AccountViewToggle";
|
import { AccountViewToggle } from "./AccountViewToggle";
|
||||||
import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
|
import { FolderBreadCrumbs } from "./FolderBreadCrumbs";
|
||||||
import { PamAccessAccountModal } from "./PamAccessAccountModal";
|
import { PamAccessAccountModal } from "./PamAccessAccountModal";
|
||||||
import { PamAccountRow } from "./PamAccountRow";
|
import { PamAccountRow } from "./PamAccountRow";
|
||||||
@@ -56,21 +68,15 @@ import { PamFolderRow } from "./PamFolderRow";
|
|||||||
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
|
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
|
||||||
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
|
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
|
||||||
|
|
||||||
enum OrderBy {
|
type PamAccountFilter = {
|
||||||
Name = "name"
|
resourceIds: string[];
|
||||||
}
|
|
||||||
|
|
||||||
type Filters = {
|
|
||||||
resource: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
accounts: TPamAccount[];
|
|
||||||
folders: TPamFolder[];
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
export const PamAccountsTable = ({ projectId }: Props) => {
|
||||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
|
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
@@ -92,18 +98,21 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
from: ROUTE_PATHS.Pam.AccountsPage.id
|
from: ROUTE_PATHS.Pam.AccountsPage.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const [accountView, setAccountView] = useState<AccountView>(initAccountView ?? AccountView.Flat);
|
const [accountView, setAccountView] = useState<PamAccountView>(
|
||||||
|
initAccountView ?? PamAccountView.Flat
|
||||||
|
);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<Filters>({
|
const [filter, setFilter] = useState<PamAccountFilter>({
|
||||||
resource: []
|
resourceIds: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
|
debouncedSearch,
|
||||||
setSearch,
|
setSearch,
|
||||||
setPage,
|
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
|
setPage,
|
||||||
setPerPage,
|
setPerPage,
|
||||||
offset,
|
offset,
|
||||||
orderDirection,
|
orderDirection,
|
||||||
@@ -111,114 +120,74 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
orderBy,
|
orderBy,
|
||||||
setOrderDirection,
|
setOrderDirection,
|
||||||
setOrderBy
|
setOrderBy
|
||||||
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
|
} = usePagination<PamAccountOrderBy>(PamAccountOrderBy.Name, {
|
||||||
|
initPerPage: getUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, 20),
|
||||||
|
initSearch
|
||||||
|
});
|
||||||
|
|
||||||
const { foldersByParentId, pathMap, folderPaths } = useMemo(() => {
|
const handlePerPageChange = (newPerPage: number) => {
|
||||||
const foldersById: Record<string, TPamFolder> = {};
|
setPerPage(newPerPage);
|
||||||
const tempFoldersByParentId: Record<string, TPamFolder[]> = { null: [] };
|
setUserTablePreference("pamAccountsTable", PreferenceKey.PerPage, newPerPage);
|
||||||
const tempPathMap: Record<string, string> = { "/": "null" };
|
};
|
||||||
const tempFolderPaths: Record<string, string> = {};
|
|
||||||
|
|
||||||
folders.forEach((folder) => {
|
const { data, isLoading } = useListPamAccounts({
|
||||||
foldersById[folder.id] = folder;
|
projectId,
|
||||||
if (!tempFoldersByParentId[folder.parentId || "null"]) {
|
accountPath,
|
||||||
tempFoldersByParentId[folder.parentId || "null"] = [];
|
accountView,
|
||||||
}
|
offset,
|
||||||
tempFoldersByParentId[folder.parentId || "null"].push(folder);
|
limit: perPage,
|
||||||
});
|
search: debouncedSearch,
|
||||||
|
orderBy,
|
||||||
|
orderDirection,
|
||||||
|
filterResourceIds: filter.resourceIds.length ? filter.resourceIds.join(",") : undefined
|
||||||
|
});
|
||||||
|
|
||||||
const buildPaths = (parentId: string | null, currentPath: string) => {
|
const accounts = data?.accounts || [];
|
||||||
(tempFoldersByParentId[parentId || "null"] || []).forEach((folder) => {
|
const folders = data?.folders || [];
|
||||||
const newPath = `${currentPath}${folder.name}/`;
|
const totalCount = data?.totalCount || 0;
|
||||||
tempPathMap[newPath] = folder.id;
|
const folderPaths = data?.folderPaths || {};
|
||||||
tempFolderPaths[folder.id] = newPath;
|
const currentFolderId = data?.folderId ?? null;
|
||||||
buildPaths(folder.id, newPath);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPaths(null, "/");
|
|
||||||
|
|
||||||
return {
|
|
||||||
foldersByParentId: tempFoldersByParentId,
|
|
||||||
pathMap: tempPathMap,
|
|
||||||
folderPaths: tempFolderPaths
|
|
||||||
};
|
|
||||||
}, [folders]);
|
|
||||||
|
|
||||||
const effectiveFolderIdForFiltering = useMemo(() => {
|
|
||||||
if (accountView === AccountView.Flat) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const folderId = pathMap[accountPath];
|
|
||||||
return folderId === "null" ? null : folderId || null;
|
|
||||||
}, [accountView, accountPath, pathMap]);
|
|
||||||
|
|
||||||
const foldersToRender = useMemo(() => {
|
|
||||||
if (accountView === AccountView.Flat) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return (foldersByParentId[effectiveFolderIdForFiltering || "null"] || []).filter((folder) =>
|
|
||||||
folder.name.toLowerCase().includes(search.trim().toLowerCase())
|
|
||||||
);
|
|
||||||
}, [accountView, effectiveFolderIdForFiltering, foldersByParentId, search]);
|
|
||||||
|
|
||||||
const accountsToProcess = useMemo(() => {
|
|
||||||
if (accountView === AccountView.Flat) {
|
|
||||||
return accounts;
|
|
||||||
}
|
|
||||||
return accounts.filter(
|
|
||||||
(acc) => (acc.folderId || "null") === (effectiveFolderIdForFiltering || "null")
|
|
||||||
);
|
|
||||||
}, [accountView, accounts, effectiveFolderIdForFiltering]);
|
|
||||||
|
|
||||||
const filteredAccounts = useMemo(
|
|
||||||
() =>
|
|
||||||
accountsToProcess
|
|
||||||
.filter((account) => {
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
resource: { name: resourceName, id: resourceId }
|
|
||||||
} = account;
|
|
||||||
|
|
||||||
if (filters.resource.length && !filters.resource.includes(resourceId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchValue = search.trim().toLowerCase();
|
|
||||||
const path = (account.folderId && folderPaths[account.folderId]) || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
name.toLowerCase().includes(searchValue) ||
|
|
||||||
resourceName.toLowerCase().includes(searchValue) ||
|
|
||||||
(description || "").toLowerCase().includes(searchValue) ||
|
|
||||||
path.toLowerCase().includes(searchValue)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
const [accOne, accTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
|
||||||
|
|
||||||
switch (orderBy) {
|
|
||||||
case OrderBy.Name:
|
|
||||||
default:
|
|
||||||
return accOne.name.toLowerCase().localeCompare(accTwo.name.toLowerCase());
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[accountsToProcess, orderDirection, search, orderBy, filters, folderPaths]
|
|
||||||
);
|
|
||||||
|
|
||||||
useResetPageHelper({
|
useResetPageHelper({
|
||||||
totalCount: filteredAccounts.length,
|
totalCount,
|
||||||
offset,
|
offset,
|
||||||
setPage
|
setPage
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPageData = useMemo(
|
const foldersToRender = useMemo(() => {
|
||||||
() => filteredAccounts.slice(offset, perPage * page),
|
if (accountView === PamAccountView.Flat) {
|
||||||
[filteredAccounts, offset, perPage, page]
|
return [];
|
||||||
|
}
|
||||||
|
return folders.filter((folder) =>
|
||||||
|
folder.name.toLowerCase().includes(search.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
}, [accountView, folders, search]);
|
||||||
|
|
||||||
|
const filteredAccounts = useMemo(
|
||||||
|
() =>
|
||||||
|
accounts.filter((account) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
resource: { name: resourceName, id: resourceId }
|
||||||
|
} = account;
|
||||||
|
|
||||||
|
if (filter.resourceIds.length && !filter.resourceIds.includes(resourceId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
name.toLowerCase().includes(searchValue) ||
|
||||||
|
resourceName.toLowerCase().includes(searchValue) ||
|
||||||
|
(description || "").toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[accounts, search, filter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSort = (column: OrderBy) => {
|
const handleSort = (column: PamAccountOrderBy) => {
|
||||||
if (column === orderBy) {
|
if (column === orderBy) {
|
||||||
toggleOrderDirection();
|
toggleOrderDirection();
|
||||||
return;
|
return;
|
||||||
@@ -228,15 +197,16 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
setOrderDirection(OrderByDirection.ASC);
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
const getClassName = (col: PamAccountOrderBy) =>
|
||||||
|
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||||
|
|
||||||
const getColSortIcon = (col: OrderBy) =>
|
const getColSortIcon = (col: PamAccountOrderBy) =>
|
||||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||||
|
|
||||||
const isTableFiltered = Boolean(filters.resource.length);
|
const isTableFiltered = Boolean(filter.resourceIds.length);
|
||||||
|
|
||||||
const handleFolderClick = (folder: TPamFolder) => {
|
const handleFolderClick = (folder: TPamFolder) => {
|
||||||
if (accountView === AccountView.Flat) {
|
if (accountView === PamAccountView.Flat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newPath = `${accountPath}${folder.name}/`;
|
const newPath = `${accountPath}${folder.name}/`;
|
||||||
@@ -246,17 +216,17 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
|
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
|
||||||
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
||||||
|
|
||||||
const uniqueResources = useMemo(() => {
|
const { data: resourcesData } = useListPamResources({
|
||||||
const resourceMap = new Map<string, TPamAccount["resource"]>();
|
projectId,
|
||||||
accounts.forEach((account) => {
|
// temporarily returning a large number until we rework table filtering
|
||||||
resourceMap.set(account.resource.id, account.resource);
|
limit: 100
|
||||||
});
|
});
|
||||||
return Array.from(resourceMap.values());
|
|
||||||
}, [accounts]);
|
const resources = resourcesData?.resources || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{accountView === AccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
|
{accountView === PamAccountView.Nested && <FolderBreadCrumbs path={accountPath} />}
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
|
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
|
||||||
{(isAllowed) =>
|
{(isAllowed) =>
|
||||||
@@ -264,12 +234,14 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
<AccountViewToggle
|
<AccountViewToggle
|
||||||
value={accountView}
|
value={accountView}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setFilter({ resourceIds: [] });
|
||||||
setAccountView(e);
|
setAccountView(e);
|
||||||
navigate({
|
navigate({
|
||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
accountView: e === AccountView.Flat ? undefined : e,
|
accountView: e === PamAccountView.Flat ? undefined : e,
|
||||||
accountPath: e === AccountView.Flat ? "/" : prev.accountPath
|
accountPath: e === PamAccountView.Flat ? "/" : prev.accountPath
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -307,25 +279,25 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
|
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
|
||||||
<DropdownMenuLabel>Resource</DropdownMenuLabel>
|
<DropdownMenuLabel>Resource</DropdownMenuLabel>
|
||||||
{uniqueResources.length ? (
|
{resources.length ? (
|
||||||
uniqueResources.map((resource) => {
|
resources.map((resource) => {
|
||||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
|
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newResources = filters.resource.includes(resource.id)
|
const newResources = filter.resourceIds.includes(resource.id)
|
||||||
? filters.resource.filter((a) => a !== resource.id)
|
? filter.resourceIds.filter((a) => a !== resource.id)
|
||||||
: [...filters.resource, resource.id];
|
: [...filter.resourceIds, resource.id];
|
||||||
setFilters((prev) => ({
|
setFilter((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
resource: newResources
|
resourceIds: newResources
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
key={resource.id}
|
key={resource.id}
|
||||||
icon={
|
icon={
|
||||||
filters.resource.includes(resource.id) && (
|
filter.resourceIds.includes(resource.id) && (
|
||||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,12 +330,12 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
onClick={() => handlePopUpOpen("addAccount")}
|
onClick={() => handlePopUpOpen("addAccount")}
|
||||||
isDisabled={!isAllowedToCreateAccounts}
|
isDisabled={!isAllowedToCreateAccounts}
|
||||||
className={`h-10 transition-colors ${accountView === AccountView.Flat ? "" : "rounded-r-none"}`}
|
className={`h-10 transition-colors ${accountView === PamAccountView.Flat ? "" : "rounded-r-none"}`}
|
||||||
>
|
>
|
||||||
Add Account
|
Add Account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{accountView !== AccountView.Flat && (
|
{accountView !== PamAccountView.Flat && (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={popUp.misc.isOpen}
|
open={popUp.misc.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||||
@@ -414,11 +386,11 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
Accounts
|
Accounts
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="plain"
|
variant="plain"
|
||||||
className={getClassName(OrderBy.Name)}
|
className={getClassName(PamAccountOrderBy.Name)}
|
||||||
ariaLabel="sort"
|
ariaLabel="sort"
|
||||||
onClick={() => handleSort(OrderBy.Name)}
|
onClick={() => handleSort(PamAccountOrderBy.Name)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
|
<FontAwesomeIcon icon={getColSortIcon(PamAccountOrderBy.Name)} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Th>
|
</Th>
|
||||||
@@ -426,45 +398,48 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{accountView !== AccountView.Flat &&
|
{isLoading && <TableSkeleton columns={2} innerKey="pam-accounts" />}
|
||||||
foldersToRender.map((folder) => (
|
{!isLoading && (
|
||||||
<PamFolderRow
|
<>
|
||||||
key={folder.id}
|
{accountView !== PamAccountView.Flat &&
|
||||||
folder={folder}
|
foldersToRender.map((folder) => (
|
||||||
search={search}
|
<PamFolderRow
|
||||||
onClick={() => handleFolderClick(folder)}
|
key={folder.id}
|
||||||
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
|
folder={folder}
|
||||||
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
|
search={search}
|
||||||
/>
|
onClick={() => handleFolderClick(folder)}
|
||||||
))}
|
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
|
||||||
{currentPageData.map((account) => (
|
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
|
||||||
<PamAccountRow
|
/>
|
||||||
key={account.id}
|
))}
|
||||||
account={account}
|
{filteredAccounts.map((account) => (
|
||||||
search={search}
|
<PamAccountRow
|
||||||
isFlatView={accountView === AccountView.Flat}
|
key={account.id}
|
||||||
accountPath={
|
account={account}
|
||||||
account.folderId ? folderPaths[account.folderId]?.slice(0, -1) : undefined
|
search={search}
|
||||||
}
|
isFlatView={accountView === PamAccountView.Flat}
|
||||||
onAccess={(e) => {
|
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
|
||||||
handlePopUpOpen("accessAccount", e);
|
onAccess={(e) => {
|
||||||
}}
|
handlePopUpOpen("accessAccount", e);
|
||||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
}}
|
||||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||||
/>
|
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{Boolean(filteredAccounts.length) && (
|
{Boolean(totalCount) && !isLoading && (
|
||||||
<Pagination
|
<Pagination
|
||||||
count={filteredAccounts.length}
|
count={totalCount}
|
||||||
page={page}
|
page={page}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
onChangePage={setPage}
|
onChangePage={(newPage) => setPage(newPage)}
|
||||||
onChangePerPage={setPerPage}
|
onChangePerPage={handlePerPageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isContentEmpty && (
|
{!isLoading && isContentEmpty && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
|
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
|
||||||
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
||||||
@@ -485,7 +460,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
isOpen={popUp.addFolder.isOpen}
|
isOpen={popUp.addFolder.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
currentFolderId={effectiveFolderIdForFiltering}
|
currentFolderId={currentFolderId}
|
||||||
/>
|
/>
|
||||||
<PamAccessAccountModal
|
<PamAccessAccountModal
|
||||||
isOpen={popUp.accessAccount.isOpen}
|
isOpen={popUp.accessAccount.isOpen}
|
||||||
@@ -506,7 +481,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
|||||||
isOpen={popUp.addAccount.isOpen}
|
isOpen={popUp.addAccount.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
currentFolderId={effectiveFolderIdForFiltering}
|
currentFolderId={currentFolderId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ type Props = {
|
|||||||
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
|
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
|
||||||
const createPamFolder = useCreatePamFolder();
|
const createPamFolder = useCreatePamFolder();
|
||||||
|
|
||||||
console.log({ currentFolderId });
|
|
||||||
|
|
||||||
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
|
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
|
||||||
await createPamFolder.mutateAsync({
|
await createPamFolder.mutateAsync({
|
||||||
...formData,
|
...formData,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import { SingleValue } from "react-select";
|
import { SingleValue } from "react-select";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2";
|
import { Button, FilterableSelect, FormControl, ModalClose } from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { useDebounce, usePopUp } from "@app/hooks";
|
||||||
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
|
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
|
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
|
||||||
@@ -28,7 +29,17 @@ type FormData = z.infer<typeof formSchema>;
|
|||||||
|
|
||||||
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const { isPending, data: resources } = useListPamResources(projectId);
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebounce(search, 350);
|
||||||
|
|
||||||
|
const { isPending, data } = useListPamResources({
|
||||||
|
projectId,
|
||||||
|
limit: 100,
|
||||||
|
search: debouncedSearch
|
||||||
|
});
|
||||||
|
|
||||||
|
const resources = data?.resources || [];
|
||||||
|
|
||||||
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
|
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
|
||||||
|
|
||||||
@@ -43,15 +54,6 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
|||||||
ProjectPermissionSub.PamResources
|
ProjectPermissionSub.PamResources
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center py-2.5">
|
|
||||||
<Spinner size="lg" className="text-mineshaft-500" />
|
|
||||||
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
@@ -65,6 +67,12 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
|||||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
|
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
|
||||||
<FilterableSelect
|
<FilterableSelect
|
||||||
value={value}
|
value={value}
|
||||||
|
inputValue={search}
|
||||||
|
onInputChange={(val, actionMeta) => {
|
||||||
|
if (actionMeta.action === "input-change") {
|
||||||
|
setSearch(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
|
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
|
||||||
handlePopUpOpen("addResource");
|
handlePopUpOpen("addResource");
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react
|
|||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { AccountView } from "./components/AccountViewToggle";
|
import { PamAccountView } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
import { PamAccountsPage } from "./PamAccountsPage";
|
import { PamAccountsPage } from "./PamAccountsPage";
|
||||||
|
|
||||||
const PamAccountsPageQueryParamsSchema = z.object({
|
const PamAccountsPageQueryParamsSchema = z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
accountView: z.nativeEnum(AccountView).optional(),
|
accountView: z.nativeEnum(PamAccountView).optional(),
|
||||||
accountPath: z.string().catch("/")
|
accountPath: z.string().catch("/")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { DiscriminativePick } from "@app/types";
|
|||||||
import { PamResourceHeader } from "../PamResourceHeader";
|
import { PamResourceHeader } from "../PamResourceHeader";
|
||||||
import { MySQLResourceForm } from "./MySQLResourceForm";
|
import { MySQLResourceForm } from "./MySQLResourceForm";
|
||||||
import { PostgresResourceForm } from "./PostgresResourceForm";
|
import { PostgresResourceForm } from "./PostgresResourceForm";
|
||||||
|
import { SSHResourceForm } from "./SSHResourceForm";
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
onComplete: (resource: TPamResource) => void;
|
onComplete: (resource: TPamResource) => void;
|
||||||
@@ -51,6 +52,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
|
|||||||
return <PostgresResourceForm onSubmit={onSubmit} />;
|
return <PostgresResourceForm onSubmit={onSubmit} />;
|
||||||
case PamResourceType.MySQL:
|
case PamResourceType.MySQL:
|
||||||
return <MySQLResourceForm onSubmit={onSubmit} />;
|
return <MySQLResourceForm onSubmit={onSubmit} />;
|
||||||
|
case PamResourceType.SSH:
|
||||||
|
return <SSHResourceForm onSubmit={onSubmit} />;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
|
|||||||
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
|
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||||
case PamResourceType.MySQL:
|
case PamResourceType.MySQL:
|
||||||
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
|
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||||
|
case PamResourceType.SSH:
|
||||||
|
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
|
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { Button, ModalClose } from "@app/components/v2";
|
||||||
|
import { PamResourceType, TSSHResource } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { SshResourceFields } from "./shared/SshResourceFields";
|
||||||
|
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: TSSHResource;
|
||||||
|
onSubmit: (formData: FormData) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BaseSshConnectionDetailsSchema = z.object({
|
||||||
|
host: z.string().trim().min(1, "Host is required"),
|
||||||
|
port: z.number().int().min(1).max(65535)
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = genericResourceFieldsSchema.extend({
|
||||||
|
resourceType: z.literal(PamResourceType.SSH),
|
||||||
|
connectionDetails: BaseSshConnectionDetailsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const SSHResourceForm = ({ resource, onSubmit }: Props) => {
|
||||||
|
const isUpdate = Boolean(resource);
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: resource ?? {
|
||||||
|
resourceType: PamResourceType.SSH,
|
||||||
|
connectionDetails: {
|
||||||
|
host: "",
|
||||||
|
port: 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting, isDirty }
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<GenericResourceFields />
|
||||||
|
<SshResourceFields />
|
||||||
|
<div className="mt-6 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
colorSchema="secondary"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting || !isDirty}
|
||||||
|
>
|
||||||
|
{isUpdate ? "Update Details" : "Create Resource"}
|
||||||
|
</Button>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button colorSchema="secondary" variant="plain">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { FormControl, Input } from "@app/components/v2";
|
||||||
|
|
||||||
|
export const SshResourceFields = () => {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||||
|
<div className="mt-[0.675rem] flex items-start gap-2">
|
||||||
|
<Controller
|
||||||
|
name="connectionDetails.host"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
className="flex-1"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Host"
|
||||||
|
>
|
||||||
|
<Input placeholder="example.com or 192.168.1.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="connectionDetails.port"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
className="w-28"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Port"
|
||||||
|
>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import { ContentLoader } from "@app/components/v2";
|
|
||||||
import { useProject } from "@app/context";
|
import { useProject } from "@app/context";
|
||||||
import { useListPamResources } from "@app/hooks/api/pam";
|
|
||||||
|
|
||||||
import { PamResourcesTable } from "./PamResourcesTable";
|
import { PamResourcesTable } from "./PamResourcesTable";
|
||||||
|
|
||||||
export const PamResourcesSection = () => {
|
export const PamResourcesSection = () => {
|
||||||
const { currentProject } = useProject();
|
const { currentProject } = useProject();
|
||||||
|
|
||||||
const { data: resources = [], isPending } = useListPamResources(currentProject.id, {
|
return <PamResourcesTable projectId={currentProject.id} />;
|
||||||
refetchInterval: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending) return <ContentLoader />;
|
|
||||||
|
|
||||||
return <PamResourcesTable resources={resources} projectId={currentProject.id} />;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
TBody,
|
TBody,
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
@@ -39,29 +40,34 @@ import {
|
|||||||
OrgGatewayPermissionActions,
|
OrgGatewayPermissionActions,
|
||||||
OrgPermissionSubjects
|
OrgPermissionSubjects
|
||||||
} from "@app/context/OrgPermissionContext/types";
|
} from "@app/context/OrgPermissionContext/types";
|
||||||
|
import {
|
||||||
|
getUserTablePreference,
|
||||||
|
PreferenceKey,
|
||||||
|
setUserTablePreference
|
||||||
|
} from "@app/helpers/userTablePreferences";
|
||||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { PAM_RESOURCE_TYPE_MAP, PamResourceType, TPamResource } from "@app/hooks/api/pam";
|
import {
|
||||||
|
PAM_RESOURCE_TYPE_MAP,
|
||||||
|
PamResourceOrderBy,
|
||||||
|
PamResourceType,
|
||||||
|
useListPamResources
|
||||||
|
} from "@app/hooks/api/pam";
|
||||||
|
|
||||||
import { PamAddResourceModal } from "./PamAddResourceModal";
|
import { PamAddResourceModal } from "./PamAddResourceModal";
|
||||||
import { PamDeleteResourceModal } from "./PamDeleteResourceModal";
|
import { PamDeleteResourceModal } from "./PamDeleteResourceModal";
|
||||||
import { PamResourceRow } from "./PamResourceRow";
|
import { PamResourceRow } from "./PamResourceRow";
|
||||||
import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
|
import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
|
||||||
|
|
||||||
enum OrderBy {
|
type PamResourceFilter = {
|
||||||
Name = "name"
|
resourceTypes: PamResourceType[];
|
||||||
}
|
|
||||||
|
|
||||||
type Filters = {
|
|
||||||
resourceType: PamResourceType[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
resources: TPamResource[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
export const PamResourcesTable = ({ projectId }: Props) => {
|
||||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path });
|
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path });
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
@@ -74,12 +80,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
|||||||
from: ROUTE_PATHS.Pam.ResourcesPage.id
|
from: ROUTE_PATHS.Pam.ResourcesPage.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const [filters, setFilters] = useState<Filters>({
|
const [filter, setFilter] = useState<PamResourceFilter>({
|
||||||
resourceType: []
|
resourceTypes: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
|
debouncedSearch,
|
||||||
setSearch,
|
setSearch,
|
||||||
setPage,
|
setPage,
|
||||||
page,
|
page,
|
||||||
@@ -91,51 +98,55 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
|||||||
orderBy,
|
orderBy,
|
||||||
setOrderDirection,
|
setOrderDirection,
|
||||||
setOrderBy
|
setOrderBy
|
||||||
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
|
} = usePagination<PamResourceOrderBy>(PamResourceOrderBy.Name, {
|
||||||
|
initPerPage: getUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, 20),
|
||||||
|
initSearch
|
||||||
|
});
|
||||||
|
|
||||||
const filteredResources = useMemo(
|
const handlePerPageChange = (newPerPage: number) => {
|
||||||
() =>
|
setPerPage(newPerPage);
|
||||||
resources
|
setUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, newPerPage);
|
||||||
.filter((resource) => {
|
};
|
||||||
const { name, resourceType } = resource;
|
|
||||||
|
|
||||||
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) {
|
const { data, isLoading } = useListPamResources({
|
||||||
return false;
|
projectId,
|
||||||
}
|
offset,
|
||||||
|
limit: perPage,
|
||||||
|
search: debouncedSearch,
|
||||||
|
orderBy,
|
||||||
|
orderDirection,
|
||||||
|
filterResourceTypes: filter.resourceTypes.length ? filter.resourceTypes.join(",") : undefined
|
||||||
|
});
|
||||||
|
|
||||||
const searchValue = search.trim().toLowerCase();
|
const resources = data?.resources || [];
|
||||||
|
const totalCount = data?.totalCount || 0;
|
||||||
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
|
||||||
|
|
||||||
return (
|
|
||||||
name.toLowerCase().includes(searchValue) ||
|
|
||||||
resourceTypeName.toLowerCase().includes(searchValue)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
|
||||||
|
|
||||||
switch (orderBy) {
|
|
||||||
case OrderBy.Name:
|
|
||||||
default:
|
|
||||||
return one.name.toLowerCase().localeCompare(two.name.toLowerCase());
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[resources, orderDirection, search, orderBy, filters]
|
|
||||||
);
|
|
||||||
|
|
||||||
useResetPageHelper({
|
useResetPageHelper({
|
||||||
totalCount: filteredResources.length,
|
totalCount,
|
||||||
offset,
|
offset,
|
||||||
setPage
|
setPage
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPageData = useMemo(
|
const filteredResources = useMemo(
|
||||||
() => filteredResources.slice(offset, perPage * page),
|
() =>
|
||||||
[filteredResources, offset, perPage, page]
|
resources.filter((resource) => {
|
||||||
|
const { name, resourceType } = resource;
|
||||||
|
|
||||||
|
if (filter.resourceTypes.length && !filter.resourceTypes.includes(resourceType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
name.toLowerCase().includes(searchValue) ||
|
||||||
|
resourceType.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[resources, search, filter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSort = (column: OrderBy) => {
|
const handleSort = (column: PamResourceOrderBy) => {
|
||||||
if (column === orderBy) {
|
if (column === orderBy) {
|
||||||
toggleOrderDirection();
|
toggleOrderDirection();
|
||||||
return;
|
return;
|
||||||
@@ -145,12 +156,13 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
|||||||
setOrderDirection(OrderByDirection.ASC);
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
const getClassName = (col: PamResourceOrderBy) =>
|
||||||
|
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||||
|
|
||||||
const getColSortIcon = (col: OrderBy) =>
|
const getColSortIcon = (col: PamResourceOrderBy) =>
|
||||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||||
|
|
||||||
const isTableFiltered = Boolean(filters.resourceType.length);
|
const isTableFiltered = Boolean(filter.resourceTypes.length);
|
||||||
const isContentEmpty = !filteredResources.length;
|
const isContentEmpty = !filteredResources.length;
|
||||||
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
||||||
|
|
||||||
@@ -187,43 +199,38 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
|
<DropdownMenuContent className="max-h-[70vh] thin-scrollbar overflow-y-auto" align="end">
|
||||||
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
|
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
|
||||||
{resources.length ? (
|
{Object.entries(PAM_RESOURCE_TYPE_MAP).map(([type, { name, image }]) => {
|
||||||
[...new Set(resources.map(({ resourceType }) => resourceType))].map((type) => {
|
const resourceType = type as PamResourceType;
|
||||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
return (
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.preventDefault();
|
||||||
onClick={(e) => {
|
setFilter((prev) => ({
|
||||||
e.preventDefault();
|
...prev,
|
||||||
setFilters((prev) => ({
|
resourceTypes: prev.resourceTypes.includes(resourceType)
|
||||||
...prev,
|
? prev.resourceTypes.filter((a) => a !== resourceType)
|
||||||
resourceType: prev.resourceType.includes(type)
|
: [...prev.resourceTypes, resourceType]
|
||||||
? prev.resourceType.filter((a) => a !== type)
|
}));
|
||||||
: [...prev.resourceType, type]
|
}}
|
||||||
}));
|
key={resourceType}
|
||||||
}}
|
icon={
|
||||||
key={type}
|
filter.resourceTypes.includes(resourceType) && (
|
||||||
icon={
|
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||||
filters.resourceType.includes(type) && (
|
)
|
||||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
}
|
||||||
)
|
iconPos="right"
|
||||||
}
|
>
|
||||||
iconPos="right"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<img
|
||||||
<div className="flex items-center gap-2">
|
alt={`${name} resource type`}
|
||||||
<img
|
src={`/images/integrations/${image}`}
|
||||||
alt={`${name} resource type`}
|
className="h-4 w-4"
|
||||||
src={`/images/integrations/${image}`}
|
/>
|
||||||
className="h-4 w-4"
|
<span>{name}</span>
|
||||||
/>
|
</div>
|
||||||
<span>{name}</span>
|
</DropdownMenuItem>
|
||||||
</div>
|
);
|
||||||
</DropdownMenuItem>
|
})}
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem isDisabled>No Resources</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<OrgPermissionCan
|
<OrgPermissionCan
|
||||||
@@ -263,11 +270,11 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
|||||||
Resource
|
Resource
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="plain"
|
variant="plain"
|
||||||
className={getClassName(OrderBy.Name)}
|
className={getClassName(PamResourceOrderBy.Name)}
|
||||||
ariaLabel="sort"
|
ariaLabel="sort"
|
||||||
onClick={() => handleSort(OrderBy.Name)}
|
onClick={() => handleSort(PamResourceOrderBy.Name)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
|
<FontAwesomeIcon icon={getColSortIcon(PamResourceOrderBy.Name)} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Th>
|
</Th>
|
||||||
@@ -275,27 +282,29 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{currentPageData.map((resource) => (
|
{isLoading && <TableSkeleton columns={2} innerKey="pam-resources" />}
|
||||||
<PamResourceRow
|
{!isLoading &&
|
||||||
key={resource.id}
|
filteredResources.map((resource) => (
|
||||||
resource={resource}
|
<PamResourceRow
|
||||||
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
|
key={resource.id}
|
||||||
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
|
resource={resource}
|
||||||
search={search.trim().toLowerCase()}
|
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
|
||||||
/>
|
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
|
||||||
))}
|
search={search.trim().toLowerCase()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{Boolean(filteredResources.length) && (
|
{Boolean(totalCount) && !isLoading && (
|
||||||
<Pagination
|
<Pagination
|
||||||
count={filteredResources.length}
|
count={totalCount}
|
||||||
page={page}
|
page={page}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
onChangePage={setPage}
|
onChangePage={(newPage) => setPage(newPage)}
|
||||||
onChangePerPage={setPerPage}
|
onChangePerPage={handlePerPageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isContentEmpty && (
|
{!isLoading && isContentEmpty && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={isSearchEmpty ? "No resources match search" : "No resources"}
|
title={isSearchEmpty ? "No resources match search" : "No resources"}
|
||||||
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
|
|||||||
// We temporarily show a special license modal for these because we will have to write some code to complete the integration
|
// We temporarily show a special license modal for these because we will have to write some code to complete the integration
|
||||||
if (
|
if (
|
||||||
resource === PamResourceType.RDP ||
|
resource === PamResourceType.RDP ||
|
||||||
resource === PamResourceType.SSH ||
|
|
||||||
resource === PamResourceType.Kubernetes ||
|
resource === PamResourceType.Kubernetes ||
|
||||||
resource === PamResourceType.MCP ||
|
resource === PamResourceType.MCP ||
|
||||||
resource === PamResourceType.Redis ||
|
resource === PamResourceType.Redis ||
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { Input } from "@app/components/v2";
|
||||||
|
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||||
|
import { TPamCommandLog } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
logs: TPamCommandLog[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandLogView = ({ logs }: Props) => {
|
||||||
|
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const toggleExpand = (timestamp: string) => {
|
||||||
|
setExpandedLogTimestamps((prev) => {
|
||||||
|
if (prev.has(timestamp)) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set([timestamp]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(
|
||||||
|
() =>
|
||||||
|
logs.filter((log) => {
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
log.input.toLowerCase().includes(searchValue) ||
|
||||||
|
log.output.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[logs, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search logs..."
|
||||||
|
className="flex-1 bg-mineshaft-800"
|
||||||
|
containerClassName="bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||||
|
{filteredLogs.length > 0 ? (
|
||||||
|
filteredLogs.map((log, index) => {
|
||||||
|
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
|
||||||
|
const formattedInput = formatLogContent(log.input);
|
||||||
|
const logKey = `${log.timestamp}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={logKey}
|
||||||
|
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
|
||||||
|
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleExpand(log.timestamp)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-bunker-400">
|
||||||
|
<div className="flex items-center gap-2 select-none">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faChevronRight}
|
||||||
|
className={twMerge(
|
||||||
|
"size-3 transition-transform duration-100 ease-in-out",
|
||||||
|
isExpanded && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`mt-2 font-mono ${
|
||||||
|
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HighlightText text={formattedInput} highlight={search} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"grid transition-all duration-100 ease-in-out",
|
||||||
|
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{log.output && (
|
||||||
|
<div className="pt-2 text-bunker-300">
|
||||||
|
<HighlightText text={log.output} highlight={search} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
|
{search.length ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">No logs match search criteria</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">Session logs are not yet available</div>
|
||||||
|
<div className="text-xs text-bunker-400">
|
||||||
|
Logs will be uploaded after the session duration has elapsed.
|
||||||
|
<br />
|
||||||
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,44 +1,19 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { Input } from "@app/components/v2";
|
import { CommandLogView } from "./CommandLogView";
|
||||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
import { TerminalEventView } from "./TerminalEventView";
|
||||||
import { TPamSession } from "@app/hooks/api/pam";
|
|
||||||
|
|
||||||
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: TPamSession;
|
session: TPamSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PamSessionLogsSection = ({ session }: Props) => {
|
export const PamSessionLogsSection = ({ session }: Props) => {
|
||||||
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
// Determine log type based on resource type
|
||||||
const [search, setSearch] = useState("");
|
const isSSHSession = session.resourceType === PamResourceType.SSH;
|
||||||
|
const isDatabaseSession =
|
||||||
const toggleExpand = (timestamp: string) => {
|
session.resourceType === PamResourceType.Postgres ||
|
||||||
setExpandedLogTimestamps((prev) => {
|
session.resourceType === PamResourceType.MySQL;
|
||||||
if (prev.has(timestamp)) {
|
const hasLogs = session.logs.length > 0;
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
return new Set([timestamp]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
|
||||||
() =>
|
|
||||||
session.commandLogs.filter((log) => {
|
|
||||||
const { input, output } = log;
|
|
||||||
|
|
||||||
const searchValue = search.trim().toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[session.commandLogs, search]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
@@ -46,91 +21,20 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
|||||||
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
|
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
|
||||||
<Input
|
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
|
||||||
value={search}
|
{!hasLogs && (
|
||||||
onChange={(e) => {
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
const newSearch = e.target.value;
|
<div className="text-center">
|
||||||
setSearch(newSearch);
|
<div className="mb-2">Session logs are not yet available</div>
|
||||||
}}
|
<div className="text-xs text-bunker-400">
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
Logs will be uploaded after the session duration has elapsed.
|
||||||
placeholder="Search logs..."
|
<br />
|
||||||
className="flex-1 bg-mineshaft-800"
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
containerClassName="bg-transparent"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
|
||||||
{filteredLogs.length > 0 ? (
|
|
||||||
filteredLogs.map((log) => {
|
|
||||||
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
|
|
||||||
const formattedInput = formatLogContent(log.input);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={log.timestamp}
|
|
||||||
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
|
|
||||||
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleExpand(log.timestamp)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between text-bunker-400">
|
|
||||||
<div className="flex items-center gap-2 select-none">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faChevronRight}
|
|
||||||
className={twMerge(
|
|
||||||
"size-3 transition-transform duration-100 ease-in-out",
|
|
||||||
isExpanded && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`mt-2 font-mono ${
|
|
||||||
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<HighlightText text={formattedInput} highlight={search} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"grid transition-all duration-100 ease-in-out",
|
|
||||||
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
{log.output && (
|
|
||||||
<div className="pt-2 text-bunker-300">
|
|
||||||
<HighlightText text={log.output} highlight={search} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="flex grow items-center justify-center text-bunker-300">
|
|
||||||
{search.length ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-2">No logs match search criteria</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-2">Session logs are not yet available</div>
|
|
||||||
<div className="text-xs text-bunker-400">
|
|
||||||
Logs will be uploaded after the session duration has elapsed.
|
|
||||||
<br />
|
|
||||||
If logs do not appear after some time, please contact your Gateway administrators.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Input } from "@app/components/v2";
|
||||||
|
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||||
|
import { TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { aggregateTerminalEvents } from "./terminal-utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
events: TTerminalEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TerminalEventView = ({ events }: Props) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const aggregatedEvents = useMemo(() => aggregateTerminalEvents(events), [events]);
|
||||||
|
|
||||||
|
const filteredEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
aggregatedEvents.filter((event) => {
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
if (!searchValue) return true;
|
||||||
|
return event.data.toLowerCase().includes(searchValue);
|
||||||
|
}),
|
||||||
|
[aggregatedEvents, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search terminal output..."
|
||||||
|
className="flex-1 bg-mineshaft-800"
|
||||||
|
containerClassName="bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||||
|
{filteredEvents.length > 0 ? (
|
||||||
|
filteredEvents.map((event, index) => {
|
||||||
|
const eventKey = `${event.timestamp}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={eventKey}
|
||||||
|
className="flex w-full flex-col rounded-md border border-mineshaft-700 bg-mineshaft-800 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-bunker-400">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span>{new Date(event.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 font-mono whitespace-pre-wrap text-bunker-100">
|
||||||
|
<HighlightText text={event.data} highlight={search} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
|
{search.length ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">No terminal output matches search criteria</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">Terminal session logs are not yet available</div>
|
||||||
|
<div className="text-xs text-bunker-400">
|
||||||
|
Logs will be uploaded after the session duration has elapsed.
|
||||||
|
<br />
|
||||||
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
// Strip ANSI escape codes from terminal output
|
||||||
|
export const stripAnsiCodes = (text: string): string => {
|
||||||
|
// Remove ANSI escape sequences
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AggregatedTerminalEvent = {
|
||||||
|
timestamp: string;
|
||||||
|
eventType: string;
|
||||||
|
data: string;
|
||||||
|
elapsedTime: number;
|
||||||
|
eventCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate consecutive output events to avoid character-by-character display
|
||||||
|
export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => {
|
||||||
|
// Filter to only show output events (input is echoed, so redundant)
|
||||||
|
const outputEvents = events.filter((e) => e.eventType === "output");
|
||||||
|
|
||||||
|
if (outputEvents.length === 0) return [];
|
||||||
|
|
||||||
|
// First, combine all events into one string to process
|
||||||
|
const allText = outputEvents
|
||||||
|
.map((e) => {
|
||||||
|
try {
|
||||||
|
return stripAnsiCodes(atob(e.data));
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Split on lines that contain shell prompts
|
||||||
|
// Pattern matches: user@hostname:path# or user@hostname:path$
|
||||||
|
const promptPattern = /^[\w-]+@[\w-]+[^\s]*[:#$]\s+/;
|
||||||
|
|
||||||
|
const lines = allText.split("\n");
|
||||||
|
const segments: string[] = [];
|
||||||
|
let currentSegment: string[] = [];
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const hasPrompt = promptPattern.test(line);
|
||||||
|
|
||||||
|
if (hasPrompt && currentSegment.length > 0) {
|
||||||
|
// Found a new prompt, save current segment and start new one
|
||||||
|
segments.push(currentSegment.join("\n"));
|
||||||
|
currentSegment = [line];
|
||||||
|
} else {
|
||||||
|
// Add line to current segment
|
||||||
|
currentSegment.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the last segment
|
||||||
|
if (currentSegment.length > 0) {
|
||||||
|
segments.push(currentSegment.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty segments and convert to aggregated events
|
||||||
|
const validSegments = segments.filter((seg) => seg.trim().length > 0);
|
||||||
|
|
||||||
|
return validSegments.map((segment) => ({
|
||||||
|
timestamp: outputEvents[0].timestamp,
|
||||||
|
eventType: "output",
|
||||||
|
data: segment,
|
||||||
|
elapsedTime: outputEvents[0].elapsedTime,
|
||||||
|
eventCount: Math.ceil(outputEvents.length / validSegments.length)
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
faBoxOpen,
|
faBoxOpen,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
@@ -25,18 +25,19 @@ import {
|
|||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
|
import { PAM_RESOURCE_TYPE_MAP, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
|
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
|
||||||
|
import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";
|
||||||
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: TPamSession;
|
session: TPamSession;
|
||||||
search: string;
|
search: string;
|
||||||
filteredCommandLogs: TPamSession["commandLogs"];
|
filteredLogs: TPamSession["logs"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
|
export const PamSessionRow = ({ session, search, filteredLogs }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showAllLogs, setShowAllLogs] = useState(false);
|
const [showAllLogs, setShowAllLogs] = useState(false);
|
||||||
|
|
||||||
@@ -55,8 +56,24 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
|
|
||||||
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||||
|
|
||||||
|
// Check if logs are terminal events and aggregate them
|
||||||
|
const processedLogs = useMemo(() => {
|
||||||
|
if (filteredLogs.length === 0) return [];
|
||||||
|
|
||||||
|
// Check if first log is a terminal event
|
||||||
|
const isTerminalEvents = "data" in filteredLogs[0];
|
||||||
|
|
||||||
|
if (isTerminalEvents) {
|
||||||
|
// Aggregate terminal events for better display
|
||||||
|
return aggregateTerminalEvents(filteredLogs as TTerminalEvent[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return command logs as-is
|
||||||
|
return filteredLogs;
|
||||||
|
}, [filteredLogs]);
|
||||||
|
|
||||||
const LOGS_TO_SHOW = 5;
|
const LOGS_TO_SHOW = 5;
|
||||||
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
|
const logsToShow = showAllLogs ? processedLogs : processedLogs.slice(0, LOGS_TO_SHOW);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -135,8 +152,8 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent sideOffset={2} align="end">
|
<DropdownMenuContent sideOffset={2} align="end">
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Edit}
|
I={ProjectPermissionActions.Read}
|
||||||
a={ProjectPermissionSub.PamResources}
|
a={ProjectPermissionSub.PamSessions}
|
||||||
>
|
>
|
||||||
{(isAllowed: boolean) => (
|
{(isAllowed: boolean) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -157,32 +174,56 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
|
|
||||||
{filteredCommandLogs.length > 0 && (
|
{filteredLogs.length > 0 && (
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td colSpan={5} className="py-3 text-xs">
|
<Td colSpan={5} className="py-3 text-xs">
|
||||||
{logsToShow.map((log) => {
|
{logsToShow.map((log) => {
|
||||||
const formattedInput = formatLogContent(log.input);
|
// Handle command logs (database sessions)
|
||||||
|
if ("input" in log && "output" in log) {
|
||||||
|
const formattedInput = formatLogContent(log.input);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${id}-log-${log.timestamp}`}
|
key={`${id}-log-${log.timestamp}`}
|
||||||
className="mb-4 flex flex-col gap-1 last:mb-0"
|
className="mb-4 flex flex-col gap-1 last:mb-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 text-bunker-400">
|
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||||
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="font-mono break-all whitespace-pre-wrap">
|
<div className="font-mono break-all whitespace-pre-wrap">
|
||||||
<HighlightText text={formattedInput} highlight={search} />
|
<HighlightText text={formattedInput} highlight={search} />
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-bunker-300">
|
||||||
|
<HighlightText text={log.output.trim()} highlight={search} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-bunker-300">
|
);
|
||||||
<HighlightText text={log.output.trim()} highlight={search} />
|
}
|
||||||
|
|
||||||
|
// Handle aggregated terminal events (SSH sessions)
|
||||||
|
if ("data" in log && typeof log.data === "string") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${id}-log-${log.timestamp}`}
|
||||||
|
className="mb-4 flex flex-col gap-1 last:mb-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||||
|
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||||
|
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono break-all whitespace-pre-wrap text-bunker-300">
|
||||||
|
<HighlightText text={log.data.trim()} highlight={search} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
})}
|
})}
|
||||||
{filteredCommandLogs.length > LOGS_TO_SHOW && (
|
{filteredLogs.length > LOGS_TO_SHOW && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -193,7 +234,7 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
>
|
>
|
||||||
{showAllLogs
|
{showAllLogs
|
||||||
? "Show less"
|
? "Show less"
|
||||||
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
: `Show ${filteredLogs.length - LOGS_TO_SHOW} more log${filteredLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
|||||||
id,
|
id,
|
||||||
resourceName,
|
resourceName,
|
||||||
userId,
|
userId,
|
||||||
commandLogs
|
logs
|
||||||
} = session;
|
} = session;
|
||||||
|
|
||||||
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||||
@@ -131,11 +131,25 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
|||||||
|
|
||||||
const filteredLogs =
|
const filteredLogs =
|
||||||
searchValue.length >= 2
|
searchValue.length >= 2
|
||||||
? commandLogs.filter(
|
? logs.filter((log) => {
|
||||||
(log) =>
|
// Handle command logs (database sessions)
|
||||||
log.input.toLowerCase().includes(searchValue) ||
|
if ("input" in log && "output" in log) {
|
||||||
log.output.toLowerCase().includes(searchValue)
|
return (
|
||||||
)
|
log.input.toLowerCase().includes(searchValue) ||
|
||||||
|
log.output.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Handle terminal events (SSH sessions)
|
||||||
|
if ("data" in log) {
|
||||||
|
try {
|
||||||
|
const decodedData = atob(log.data);
|
||||||
|
return decodedData.toLowerCase().includes(searchValue);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -385,7 +399,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
search={search.trim().toLowerCase()}
|
search={search.trim().toLowerCase()}
|
||||||
filteredCommandLogs={filteredLogs}
|
filteredLogs={filteredLogs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TBody>
|
</TBody>
|
||||||
|
|||||||
Reference in New Issue
Block a user