mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 15:38:03 -05:00
feat(pam): PAM Platform V1
This commit is contained in:
6
backend/src/@types/fastify.d.ts
vendored
6
backend/src/@types/fastify.d.ts
vendored
@@ -28,6 +28,9 @@ import { TKmipServiceFactory } from "@app/ee/services/kmip/kmip-service";
|
||||
import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
||||
import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
|
||||
import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
|
||||
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
|
||||
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
|
||||
@@ -314,6 +317,9 @@ declare module "fastify" {
|
||||
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
|
||||
notification: TNotificationServiceFactory;
|
||||
offlineUsageReport: TOfflineUsageReportServiceFactory;
|
||||
pamFolder: TPamFolderServiceFactory;
|
||||
pamResource: TPamResourceServiceFactory;
|
||||
pamSession: TPamSessionServiceFactory;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
// everywhere else access using service layer
|
||||
|
||||
8
backend/src/@types/knex.d.ts
vendored
8
backend/src/@types/knex.d.ts
vendored
@@ -530,6 +530,10 @@ import {
|
||||
TMicrosoftTeamsIntegrationsInsert,
|
||||
TMicrosoftTeamsIntegrationsUpdate
|
||||
} from "@app/db/schemas/microsoft-teams-integrations";
|
||||
import { TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate } from "@app/db/schemas/pam-accounts";
|
||||
import { TPamFolders, TPamFoldersInsert, TPamFoldersUpdate } from "@app/db/schemas/pam-folders";
|
||||
import { TPamResources, TPamResourcesInsert, TPamResourcesUpdate } from "@app/db/schemas/pam-resources";
|
||||
import { TPamSessions, TPamSessionsInsert, TPamSessionsUpdate } from "@app/db/schemas/pam-sessions";
|
||||
import {
|
||||
TProjectMicrosoftTeamsConfigs,
|
||||
TProjectMicrosoftTeamsConfigsInsert,
|
||||
@@ -1308,5 +1312,9 @@ declare module "knex/types/tables" {
|
||||
TKeyValueStoreInsert,
|
||||
TKeyValueStoreUpdate
|
||||
>;
|
||||
[TableName.PamFolder]: KnexOriginal.CompositeTableType<TPamFolders, TPamFoldersInsert, TPamFoldersUpdate>;
|
||||
[TableName.PamResource]: KnexOriginal.CompositeTableType<TPamResources, TPamResourcesInsert, TPamResourcesUpdate>;
|
||||
[TableName.PamAccount]: KnexOriginal.CompositeTableType<TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate>;
|
||||
[TableName.PamSession]: KnexOriginal.CompositeTableType<TPamSessions, TPamSessionsInsert, TPamSessionsUpdate>;
|
||||
}
|
||||
}
|
||||
|
||||
125
backend/src/db/migrations/20250917052037_pam.ts
Normal file
125
backend/src/db/migrations/20250917052037_pam.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// PAM Folders
|
||||
if (!(await knex.schema.hasTable(TableName.PamFolder))) {
|
||||
await knex.schema.createTable(TableName.PamFolder, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.uuid("parentId").nullable();
|
||||
t.foreign("parentId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
|
||||
t.index("parentId");
|
||||
|
||||
t.string("name").notNullable();
|
||||
t.index("name");
|
||||
t.text("description").nullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// PAM Resources
|
||||
if (!(await knex.schema.hasTable(TableName.PamResource))) {
|
||||
await knex.schema.createTable(TableName.PamResource, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.string("name").notNullable();
|
||||
t.index("name");
|
||||
t.string("gatewayId").notNullable();
|
||||
t.index("gatewayId");
|
||||
t.string("resourceType").notNullable();
|
||||
t.index("resourceType");
|
||||
t.binary("encryptedConnectionDetails").notNullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// PAM Accounts
|
||||
if (!(await knex.schema.hasTable(TableName.PamAccount))) {
|
||||
await knex.schema.createTable(TableName.PamAccount, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.uuid("folderId").nullable();
|
||||
t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
|
||||
t.index("folderId");
|
||||
|
||||
t.uuid("resourceId").notNullable();
|
||||
t.foreign("resourceId").references("id").inTable(TableName.PamResource).onDelete("CASCADE");
|
||||
t.index("resourceId");
|
||||
|
||||
t.string("name").notNullable();
|
||||
t.index("name");
|
||||
t.text("description").nullable();
|
||||
|
||||
t.binary("encryptedCredentials").notNullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// PAM Sessions
|
||||
if (!(await knex.schema.hasTable(TableName.PamSession))) {
|
||||
await knex.schema.createTable(TableName.PamSession, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.uuid("accountId").nullable();
|
||||
t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL");
|
||||
t.index("accountId");
|
||||
|
||||
// To be used in the event of an account deletion
|
||||
t.string("resourceType").notNullable();
|
||||
t.string("resourceName").notNullable();
|
||||
t.string("accountName").notNullable();
|
||||
|
||||
t.uuid("userId").nullable();
|
||||
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL");
|
||||
t.index("userId");
|
||||
|
||||
// To be used in the event of user deletion
|
||||
t.string("actorName").notNullable();
|
||||
t.string("actorEmail").notNullable();
|
||||
|
||||
t.string("actorIp").notNullable();
|
||||
t.string("actorUserAgent").notNullable();
|
||||
|
||||
t.string("status").notNullable();
|
||||
t.index("status");
|
||||
|
||||
t.binary("encryptedLogsBlob").nullable();
|
||||
|
||||
t.datetime("expiresAt").nullable(); // null means unlimited duration / no expiry
|
||||
|
||||
t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established
|
||||
t.datetime("endedAt").nullable();
|
||||
t.index(["startedAt", "endedAt"]);
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.PamSession);
|
||||
await knex.schema.dropTableIfExists(TableName.PamAccount);
|
||||
await knex.schema.dropTableIfExists(TableName.PamResource);
|
||||
await knex.schema.dropTableIfExists(TableName.PamFolder);
|
||||
}
|
||||
@@ -83,6 +83,10 @@ export * from "./org-memberships";
|
||||
export * from "./org-relay-config";
|
||||
export * from "./org-roles";
|
||||
export * from "./organizations";
|
||||
export * from "./pam-accounts";
|
||||
export * from "./pam-folders";
|
||||
export * from "./pam-resources";
|
||||
export * from "./pam-sessions";
|
||||
export * from "./pki-alerts";
|
||||
export * from "./pki-collection-items";
|
||||
export * from "./pki-collections";
|
||||
|
||||
@@ -287,7 +287,8 @@ export enum ProjectType {
|
||||
CertificateManager = "cert-manager",
|
||||
KMS = "kms",
|
||||
SSH = "ssh",
|
||||
SecretScanning = "secret-scanning"
|
||||
SecretScanning = "secret-scanning",
|
||||
PAM = "pam"
|
||||
}
|
||||
|
||||
export enum ActionProjectType {
|
||||
@@ -296,6 +297,7 @@ export enum ActionProjectType {
|
||||
KMS = ProjectType.KMS,
|
||||
SSH = ProjectType.SSH,
|
||||
SecretScanning = ProjectType.SecretScanning,
|
||||
PAM = ProjectType.PAM,
|
||||
// project operations that happen on all types
|
||||
Any = "any"
|
||||
}
|
||||
|
||||
26
backend/src/db/schemas/pam-accounts.ts
Normal file
26
backend/src/db/schemas/pam-accounts.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamAccountsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
resourceId: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
encryptedCredentials: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamAccounts = z.infer<typeof PamAccountsSchema>;
|
||||
export type TPamAccountsInsert = Omit<z.input<typeof PamAccountsSchema>, TImmutableDBKeys>;
|
||||
export type TPamAccountsUpdate = Partial<Omit<z.input<typeof PamAccountsSchema>, TImmutableDBKeys>>;
|
||||
22
backend/src/db/schemas/pam-folders.ts
Normal file
22
backend/src/db/schemas/pam-folders.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamFoldersSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamFolders = z.infer<typeof PamFoldersSchema>;
|
||||
export type TPamFoldersInsert = Omit<z.input<typeof PamFoldersSchema>, TImmutableDBKeys>;
|
||||
export type TPamFoldersUpdate = Partial<Omit<z.input<typeof PamFoldersSchema>, TImmutableDBKeys>>;
|
||||
25
backend/src/db/schemas/pam-resources.ts
Normal file
25
backend/src/db/schemas/pam-resources.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamResourcesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
gatewayId: z.string(),
|
||||
resourceType: z.string(),
|
||||
encryptedConnectionDetails: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamResources = z.infer<typeof PamResourcesSchema>;
|
||||
export type TPamResourcesInsert = Omit<z.input<typeof PamResourcesSchema>, TImmutableDBKeys>;
|
||||
export type TPamResourcesUpdate = Partial<Omit<z.input<typeof PamResourcesSchema>, TImmutableDBKeys>>;
|
||||
35
backend/src/db/schemas/pam-sessions.ts
Normal file
35
backend/src/db/schemas/pam-sessions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamSessionsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
accountId: z.string().uuid().nullable().optional(),
|
||||
resourceType: z.string(),
|
||||
resourceName: z.string(),
|
||||
accountName: z.string(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
actorName: z.string(),
|
||||
actorEmail: z.string(),
|
||||
actorIp: z.string(),
|
||||
actorUserAgent: z.string(),
|
||||
status: z.string(),
|
||||
encryptedLogsBlob: zodBuffer.nullable().optional(),
|
||||
expiresAt: z.date().nullable().optional(),
|
||||
startedAt: z.date().nullable().optional(),
|
||||
endedAt: z.date().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamSessions = z.infer<typeof PamSessionsSchema>;
|
||||
export type TPamSessionsInsert = Omit<z.input<typeof PamSessionsSchema>, TImmutableDBKeys>;
|
||||
export type TPamSessionsUpdate = Partial<Omit<z.input<typeof PamSessionsSchema>, TImmutableDBKeys>>;
|
||||
@@ -23,6 +23,11 @@ import { registerLdapRouter } from "./ldap-router";
|
||||
import { registerLicenseRouter } from "./license-router";
|
||||
import { registerOidcRouter } from "./oidc-router";
|
||||
import { registerOrgRoleRouter } from "./org-role-router";
|
||||
import { registerPamAccountRouter } from "./pam-account-router";
|
||||
import { registerPamFolderRouter } from "./pam-folder-router";
|
||||
import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
|
||||
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
|
||||
import { registerPamSessionRouter } from "./pam-session-router";
|
||||
import { registerPITRouter } from "./pit-router";
|
||||
import { registerProjectRoleRouter } from "./project-role-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
@@ -166,4 +171,22 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
{ prefix: "/kmip" }
|
||||
);
|
||||
|
||||
await server.register(registerPamFolderRouter, { prefix: "/pam/folders" });
|
||||
await server.register(registerPamAccountRouter, { prefix: "/pam/accounts" });
|
||||
await server.register(registerPamSessionRouter, { prefix: "/pam/sessions" });
|
||||
|
||||
await server.register(
|
||||
async (pamResourceRouter) => {
|
||||
await pamResourceRouter.register(registerPamResourceRouter);
|
||||
|
||||
// Provider-specific endpoints
|
||||
await Promise.all(
|
||||
Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) =>
|
||||
pamResourceRouter.register(router, { prefix: `/${provider}` })
|
||||
)
|
||||
);
|
||||
},
|
||||
{ prefix: "/pam/resources" }
|
||||
);
|
||||
};
|
||||
|
||||
131
backend/src/ee/routes/v1/pam-account-router.ts
Normal file
131
backend/src/ee/routes/v1/pam-account-router.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
// Use z.union([...]) when more resources are added
|
||||
const SanitizedAccountSchema = SanitizedPostgresAccountWithResourceSchema;
|
||||
|
||||
export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM accounts",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
accounts: SanitizedAccountSchema.array(),
|
||||
folders: PamFoldersSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamResource.listAccounts(req.query.projectId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_LIST,
|
||||
metadata: {
|
||||
accountCount: response.accounts.length,
|
||||
folderCount: response.folders.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:accountId/access",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Access PAM account",
|
||||
params: z.object({
|
||||
accountId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
duration: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (val === undefined) return undefined;
|
||||
if (!val || val === "permanent") return null;
|
||||
const parsedMs = ms(val);
|
||||
|
||||
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid duration format. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return parsedMs;
|
||||
})
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
sessionId: z.string(),
|
||||
resourceType: z.nativeEnum(PamResource),
|
||||
relayCertificate: z.string(),
|
||||
gatewayCertificate: z.string(),
|
||||
relayHost: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
if (req.auth.authMode !== AuthMode.JWT) {
|
||||
throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." });
|
||||
}
|
||||
|
||||
const response = await server.services.pamResource.accessAccount(
|
||||
{
|
||||
accountId: req.params.accountId,
|
||||
actorEmail: req.auth.user.email ?? "",
|
||||
actorIp: req.realIp,
|
||||
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
|
||||
actorUserAgent: req.auditLogInfo.userAgent ?? "",
|
||||
...req.body
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: response.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_ACCESS,
|
||||
metadata: {
|
||||
accountId: req.params.accountId,
|
||||
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
149
backend/src/ee/routes/v1/pam-folder-router.ts
Normal file
149
backend/src/ee/routes/v1/pam-folder-router.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { isValidFolderName } from "@app/lib/validator";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerPamFolderRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create PAM folder",
|
||||
body: z.object({
|
||||
projectId: z.string().uuid(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((name) => isValidFolderName(name), {
|
||||
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
|
||||
}),
|
||||
description: z.string().trim().max(512).nullable().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: PamFoldersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const folder = await server.services.pamFolder.createFolder(req.body, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_FOLDER_CREATE,
|
||||
metadata: {
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
parentId: req.body.parentId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { folder };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:folderId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM folder",
|
||||
params: z.object({
|
||||
folderId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((name) => (name ? isValidFolderName(name) : true), {
|
||||
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
|
||||
}),
|
||||
description: z.string().trim().max(512).nullable().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: PamFoldersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const folder = await server.services.pamFolder.updateFolder(
|
||||
{
|
||||
...req.body,
|
||||
id: req.params.folderId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: folder.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_FOLDER_UPDATE,
|
||||
metadata: {
|
||||
folderId: req.params.folderId,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { folder };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:folderId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete PAM folder",
|
||||
params: z.object({
|
||||
folderId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: PamFoldersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const folder = await server.services.pamFolder.deleteFolder(req.params.folderId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: folder.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_FOLDER_DELETE,
|
||||
metadata: {
|
||||
folderId: req.params.folderId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { folder };
|
||||
}
|
||||
});
|
||||
};
|
||||
26
backend/src/ee/routes/v1/pam-resource-routers/index.ts
Normal file
26
backend/src/ee/routes/v1/pam-resource-routers/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import {
|
||||
CreatePostgresAccountSchema,
|
||||
CreatePostgresResourceSchema,
|
||||
PostgresResourceSchema,
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
UpdatePostgresAccountSchema,
|
||||
UpdatePostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
|
||||
|
||||
export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.Postgres,
|
||||
resourceResponseSchema: PostgresResourceSchema,
|
||||
accountResponseSchema: SanitizedPostgresAccountWithResourceSchema,
|
||||
createResourceSchema: CreatePostgresResourceSchema,
|
||||
createAccountSchema: CreatePostgresAccountSchema,
|
||||
updateResourceSchema: UpdatePostgresResourceSchema,
|
||||
updateAccountSchema: UpdatePostgresAccountSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { TPamAccount, TPamResource } from "@app/ee/services/pam-resource/pam-resource-types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerPamResourceEndpoints = <T extends TPamResource, C extends TPamAccount>({
|
||||
server,
|
||||
resourceType,
|
||||
createResourceSchema,
|
||||
updateResourceSchema,
|
||||
createAccountSchema,
|
||||
updateAccountSchema,
|
||||
resourceResponseSchema,
|
||||
accountResponseSchema
|
||||
}: {
|
||||
server: FastifyZodProvider;
|
||||
resourceType: PamResource;
|
||||
createResourceSchema: z.ZodType<{
|
||||
projectId: T["projectId"];
|
||||
connectionDetails: T["connectionDetails"];
|
||||
gatewayId: T["gatewayId"];
|
||||
name: T["name"];
|
||||
}>;
|
||||
createAccountSchema: z.ZodType<{
|
||||
credentials: C["credentials"];
|
||||
folderId?: C["folderId"];
|
||||
name: C["name"];
|
||||
description?: C["description"];
|
||||
}>;
|
||||
updateResourceSchema: z.ZodType<{
|
||||
connectionDetails?: T["connectionDetails"];
|
||||
gatewayId?: T["gatewayId"];
|
||||
name?: T["name"];
|
||||
}>;
|
||||
updateAccountSchema: z.ZodType<{
|
||||
credentials?: C["credentials"];
|
||||
name?: C["name"];
|
||||
description?: C["description"];
|
||||
}>;
|
||||
resourceResponseSchema: z.ZodTypeAny;
|
||||
accountResponseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:resourceId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get PAM resource",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.getById(req.params.resourceId, resourceType, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: resource.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_GET,
|
||||
metadata: {
|
||||
resourceId: resource.id,
|
||||
resourceType: resource.resourceType,
|
||||
name: resource.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create PAM resource",
|
||||
body: createResourceSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.create(
|
||||
{
|
||||
...req.body,
|
||||
resourceType
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_CREATE,
|
||||
metadata: {
|
||||
resourceType,
|
||||
gatewayId: req.body.gatewayId,
|
||||
name: req.body.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:resourceId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM resource",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
body: updateResourceSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.updateById(
|
||||
{
|
||||
...req.body,
|
||||
resourceId: req.params.resourceId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: resource.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_UPDATE,
|
||||
metadata: {
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType,
|
||||
gatewayId: req.body.gatewayId,
|
||||
name: req.body.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:resourceId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete PAM resource",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.deleteById(req.params.resourceId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: resource.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_DELETE,
|
||||
metadata: {
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
// PAM Accounts
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:resourceId/accounts",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create PAM resource account",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
body: createAccountSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
account: accountResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const account = await server.services.pamResource.createAccount(
|
||||
{
|
||||
...req.body,
|
||||
resourceId: req.params.resourceId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: account.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_CREATE,
|
||||
metadata: {
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType,
|
||||
folderId: req.body.folderId,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { account };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:resourceId/accounts/:accountId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM resource account",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid(),
|
||||
accountId: z.string().uuid()
|
||||
}),
|
||||
body: updateAccountSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
account: accountResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const account = await server.services.pamResource.updateAccountById(
|
||||
{
|
||||
...req.body,
|
||||
accountId: req.params.accountId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: account.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_UPDATE,
|
||||
metadata: {
|
||||
accountId: req.params.accountId,
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { account };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:resourceId/accounts/:accountId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete PAM resource account",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid(),
|
||||
accountId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
account: accountResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const account = await server.services.pamResource.deleteAccountById(req.params.accountId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: account.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_DELETE,
|
||||
metadata: {
|
||||
accountId: req.params.accountId,
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { account };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import {
|
||||
PostgresResourceListItemSchema,
|
||||
PostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
// Use z.union([...]) when more resources are added
|
||||
const ResourceSchema = PostgresResourceSchema;
|
||||
|
||||
const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]);
|
||||
|
||||
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/options",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM resource types",
|
||||
response: {
|
||||
200: z.object({
|
||||
resourceOptions: ResourceOptionsSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: () => {
|
||||
const resourceOptions = server.services.pamResource.listResourceOptions();
|
||||
|
||||
return { resourceOptions };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM resources",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resources: ResourceSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamResource.list(req.query.projectId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_LIST,
|
||||
metadata: {
|
||||
count: response.resources.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
221
backend/src/ee/routes/v1/pam-session-router.ts
Normal file
221
backend/src/ee/routes/v1/pam-session-router.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
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 { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
// Use z.union([]) once there's multiple
|
||||
const SessionCredentialsSchema = PostgresSessionCredentialsSchema;
|
||||
|
||||
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
// Meant to be hit solely by gateway identities
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sessionId/credentials",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get PAM session credentials and start session",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
credentials: SessionCredentialsSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { credentials, projectId } = await server.services.pamResource.getSessionCredentials(
|
||||
req.params.sessionId,
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_START,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { credentials };
|
||||
}
|
||||
});
|
||||
|
||||
// Meant to be hit solely by gateway identities
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:sessionId/logs",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM session logs",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
logs: PamSessionCommandLogSchema.array()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
session: PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { session, projectId } = await server.services.pamSession.updateLogsById(
|
||||
{
|
||||
sessionId: req.params.sessionId,
|
||||
logs: req.body.logs
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_LOGS_UPDATE,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { session };
|
||||
}
|
||||
});
|
||||
|
||||
// Meant to be hit solely by gateway identities
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:sessionId/end",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "End PAM session",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
session: PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { session, projectId } = await server.services.pamSession.endSessionById(
|
||||
req.params.sessionId,
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_END,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { session };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sessionId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get PAM session",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
session: SanitizedSessionSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamSession.getById(req.params.sessionId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: response.session.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_GET,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM sessions",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
sessions: SanitizedSessionSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamSession.list(req.query.projectId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_LIST,
|
||||
metadata: {
|
||||
count: response.sessions.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -500,7 +500,26 @@ export enum EventType {
|
||||
|
||||
DASHBOARD_LIST_SECRETS = "dashboard-list-secrets",
|
||||
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value",
|
||||
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value"
|
||||
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value",
|
||||
|
||||
PAM_SESSION_START = "pam-session-start",
|
||||
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
|
||||
PAM_SESSION_END = "pam-session-end",
|
||||
PAM_SESSION_GET = "pam-session-get",
|
||||
PAM_SESSION_LIST = "pam-session-list",
|
||||
PAM_FOLDER_CREATE = "pam-folder-create",
|
||||
PAM_FOLDER_UPDATE = "pam-folder-update",
|
||||
PAM_FOLDER_DELETE = "pam-folder-delete",
|
||||
PAM_ACCOUNT_LIST = "pam-account-list",
|
||||
PAM_ACCOUNT_ACCESS = "pam-account-access",
|
||||
PAM_ACCOUNT_CREATE = "pam-account-create",
|
||||
PAM_ACCOUNT_UPDATE = "pam-account-update",
|
||||
PAM_ACCOUNT_DELETE = "pam-account-delete",
|
||||
PAM_RESOURCE_LIST = "pam-resource-list",
|
||||
PAM_RESOURCE_GET = "pam-resource-get",
|
||||
PAM_RESOURCE_CREATE = "pam-resource-create",
|
||||
PAM_RESOURCE_UPDATE = "pam-resource-update",
|
||||
PAM_RESOURCE_DELETE = "pam-resource-delete"
|
||||
}
|
||||
|
||||
export const filterableSecretEvents: EventType[] = [
|
||||
@@ -3687,6 +3706,156 @@ interface OrgRoleDeleteEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionStartEvent {
|
||||
type: EventType.PAM_SESSION_START;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionLogsUpdateEvent {
|
||||
type: EventType.PAM_SESSION_LOGS_UPDATE;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionEndEvent {
|
||||
type: EventType.PAM_SESSION_END;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionGetEvent {
|
||||
type: EventType.PAM_SESSION_GET;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionListEvent {
|
||||
type: EventType.PAM_SESSION_LIST;
|
||||
metadata: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamFolderCreateEvent {
|
||||
type: EventType.PAM_FOLDER_CREATE;
|
||||
metadata: {
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamFolderUpdateEvent {
|
||||
type: EventType.PAM_FOLDER_UPDATE;
|
||||
metadata: {
|
||||
folderId: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamFolderDeleteEvent {
|
||||
type: EventType.PAM_FOLDER_DELETE;
|
||||
metadata: {
|
||||
folderId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountListEvent {
|
||||
type: EventType.PAM_ACCOUNT_LIST;
|
||||
metadata: {
|
||||
accountCount: number;
|
||||
folderCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountAccessEvent {
|
||||
type: EventType.PAM_ACCOUNT_ACCESS;
|
||||
metadata: {
|
||||
accountId: string;
|
||||
duration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountCreateEvent {
|
||||
type: EventType.PAM_ACCOUNT_CREATE;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
folderId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountUpdateEvent {
|
||||
type: EventType.PAM_ACCOUNT_UPDATE;
|
||||
metadata: {
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountDeleteEvent {
|
||||
type: EventType.PAM_ACCOUNT_DELETE;
|
||||
metadata: {
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceListEvent {
|
||||
type: EventType.PAM_RESOURCE_LIST;
|
||||
metadata: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceGetEvent {
|
||||
type: EventType.PAM_RESOURCE_GET;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceCreateEvent {
|
||||
type: EventType.PAM_RESOURCE_CREATE;
|
||||
metadata: {
|
||||
resourceType: string;
|
||||
gatewayId: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceUpdateEvent {
|
||||
type: EventType.PAM_RESOURCE_UPDATE;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
gatewayId?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceDeleteEvent {
|
||||
type: EventType.PAM_RESOURCE_DELETE;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| GetSecretsEvent
|
||||
| GetSecretEvent
|
||||
@@ -4020,4 +4189,22 @@ export type Event =
|
||||
| ProjectRoleDeleteEvent
|
||||
| OrgRoleCreateEvent
|
||||
| OrgRoleUpdateEvent
|
||||
| OrgRoleDeleteEvent;
|
||||
| OrgRoleDeleteEvent
|
||||
| PamSessionStartEvent
|
||||
| PamSessionLogsUpdateEvent
|
||||
| PamSessionEndEvent
|
||||
| PamSessionGetEvent
|
||||
| PamSessionListEvent
|
||||
| PamFolderCreateEvent
|
||||
| PamFolderUpdateEvent
|
||||
| PamFolderDeleteEvent
|
||||
| PamAccountListEvent
|
||||
| PamAccountAccessEvent
|
||||
| PamAccountCreateEvent
|
||||
| PamAccountUpdateEvent
|
||||
| PamAccountDeleteEvent
|
||||
| PamResourceListEvent
|
||||
| PamResourceGetEvent
|
||||
| PamResourceCreateEvent
|
||||
| PamResourceUpdateEvent
|
||||
| PamResourceDeleteEvent;
|
||||
|
||||
@@ -268,11 +268,13 @@ export const gatewayV2ServiceFactory = ({
|
||||
const getPlatformConnectionDetailsByGatewayId = async ({
|
||||
gatewayId,
|
||||
targetHost,
|
||||
targetPort
|
||||
targetPort,
|
||||
actorMetadata
|
||||
}: {
|
||||
gatewayId: string;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
actorMetadata?: { sessionId?: string; resourceType?: string };
|
||||
}) => {
|
||||
const gateway = await gatewayV2DAL.findById(gatewayId);
|
||||
if (!gateway) {
|
||||
@@ -359,7 +361,9 @@ export const gatewayV2ServiceFactory = ({
|
||||
const actorExtension = new x509.Extension(
|
||||
GATEWAY_ACTOR_OID,
|
||||
false,
|
||||
Buffer.from(JSON.stringify({ type: ActorType.PLATFORM }))
|
||||
Buffer.from(
|
||||
JSON.stringify(actorMetadata ? { type: ActorType.PLATFORM, ...actorMetadata } : { type: ActorType.PLATFORM })
|
||||
)
|
||||
);
|
||||
|
||||
const clientCert = await x509.X509CertificateGenerator.create({
|
||||
|
||||
@@ -58,7 +58,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
enforceMfa: false,
|
||||
projectTemplates: false,
|
||||
kmip: false,
|
||||
gateway: false,
|
||||
gateway: true,
|
||||
sshHostGroups: false,
|
||||
secretScanning: false,
|
||||
enterpriseSecretSyncs: false,
|
||||
@@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
enterpriseAppConnections: false,
|
||||
fips: false,
|
||||
eventSubscriptions: false,
|
||||
machineIdentityAuthTemplates: false
|
||||
machineIdentityAuthTemplates: false,
|
||||
pam: true
|
||||
});
|
||||
|
||||
export const setupLicenseRequestWithStore = (
|
||||
|
||||
@@ -80,6 +80,7 @@ export type TFeatureSet = {
|
||||
machineIdentityAuthTemplates: false;
|
||||
fips: false;
|
||||
eventSubscriptions: false;
|
||||
pam: false;
|
||||
};
|
||||
|
||||
export type TOrgPlansTableDTO = {
|
||||
|
||||
9
backend/src/ee/services/pam-folder/pam-folder-dal.ts
Normal file
9
backend/src/ee/services/pam-folder/pam-folder-dal.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
|
||||
export const pamFolderDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamFolder);
|
||||
return { ...orm };
|
||||
};
|
||||
33
backend/src/ee/services/pam-folder/pam-folder-fns.ts
Normal file
33
backend/src/ee/services/pam-folder/pam-folder-fns.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { TPamFolderDALFactory } from "./pam-folder-dal";
|
||||
|
||||
type GetFullFolderPath = {
|
||||
pamFolderDAL: Pick<TPamFolderDALFactory, "find">;
|
||||
folderId?: string | null;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const getFullPamFolderPath = async ({
|
||||
pamFolderDAL,
|
||||
folderId,
|
||||
projectId
|
||||
}: GetFullFolderPath): Promise<string> => {
|
||||
if (!folderId) return "/";
|
||||
|
||||
const folders = await pamFolderDAL.find({ projectId });
|
||||
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
|
||||
if (!folderMap.has(folderId)) return "";
|
||||
|
||||
const path: string[] = [];
|
||||
let currentFolderId: string | null | undefined = folderId;
|
||||
|
||||
while (currentFolderId) {
|
||||
const folder = folderMap.get(currentFolderId);
|
||||
if (!folder) break;
|
||||
|
||||
path.unshift(folder.name);
|
||||
currentFolderId = folder.parentId;
|
||||
}
|
||||
|
||||
return `/${path.join("/")}`;
|
||||
};
|
||||
151
backend/src/ee/services/pam-folder/pam-folder-service.ts
Normal file
151
backend/src/ee/services/pam-folder/pam-folder-service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TPamFolders } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { TPamFolderDALFactory } from "./pam-folder-dal";
|
||||
import { TCreateFolderDTO, TUpdateFolderDTO } from "./pam-folder-types";
|
||||
|
||||
type TPamFolderServiceFactoryDep = {
|
||||
pamFolderDAL: TPamFolderDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TPamFolderServiceFactory = ReturnType<typeof pamFolderServiceFactory>;
|
||||
|
||||
export const pamFolderServiceFactory = ({
|
||||
pamFolderDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
}: TPamFolderServiceFactoryDep) => {
|
||||
const createFolder = async ({ name, description, parentId, projectId }: TCreateFolderDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamFolders);
|
||||
|
||||
if (parentId) {
|
||||
if (!(await pamFolderDAL.findOne({ id: parentId, projectId }))) {
|
||||
throw new NotFoundError({
|
||||
message: `Parent folder '${parentId}' not found for project '${projectId}'`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingFolder = await pamFolderDAL.findOne({
|
||||
name,
|
||||
parentId: parentId || null,
|
||||
projectId
|
||||
});
|
||||
|
||||
if (existingFolder) {
|
||||
throw new BadRequestError({
|
||||
message: `Folder with name '${name}' already exists for this parent`
|
||||
});
|
||||
}
|
||||
|
||||
const folder = await pamFolderDAL.create({
|
||||
name,
|
||||
description: description ?? null,
|
||||
parentId: parentId || null,
|
||||
projectId
|
||||
});
|
||||
|
||||
return folder;
|
||||
};
|
||||
|
||||
const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const folder = await pamFolderDAL.findById(id);
|
||||
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: folder.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const updateDoc: Partial<TPamFolders> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updateDoc.description = description;
|
||||
}
|
||||
|
||||
if (name && name !== folder.name) {
|
||||
const existingFolder = await pamFolderDAL.findOne({
|
||||
name,
|
||||
parentId: folder.parentId || null,
|
||||
projectId: folder.projectId
|
||||
});
|
||||
|
||||
if (existingFolder) {
|
||||
throw new BadRequestError({
|
||||
message: `Folder with name '${name}' already exists for this parent`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return folder;
|
||||
}
|
||||
|
||||
const updatedFolder = await pamFolderDAL.updateById(id, updateDoc);
|
||||
|
||||
return updatedFolder;
|
||||
};
|
||||
|
||||
const deleteFolder = async (id: string, actor: OrgServiceActor) => {
|
||||
const folder = await pamFolderDAL.findById(id);
|
||||
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: folder.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const deletedFolder = await pamFolderDAL.deleteById(id);
|
||||
|
||||
return deletedFolder;
|
||||
};
|
||||
|
||||
return { createFolder, updateFolder, deleteFolder };
|
||||
};
|
||||
13
backend/src/ee/services/pam-folder/pam-folder-types.ts
Normal file
13
backend/src/ee/services/pam-folder/pam-folder-types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// DTOs
|
||||
export interface TCreateFolderDTO {
|
||||
projectId: string;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface TUpdateFolderDTO {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
43
backend/src/ee/services/pam-resource/pam-account-dal.ts
Normal file
43
backend/src/ee/services/pam-resource/pam-account-dal.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPamAccounts } from "@app/db/schemas";
|
||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
|
||||
|
||||
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
|
||||
|
||||
export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamAccount);
|
||||
|
||||
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
|
||||
const query = (tx || db.replicaNode())(TableName.PamAccount)
|
||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||
.select(selectAllTableCols(TableName.PamAccount))
|
||||
.select(
|
||||
// resource
|
||||
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
|
||||
db.ref("resourceType").withSchema(TableName.PamResource)
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
|
||||
}
|
||||
|
||||
const accounts = await query;
|
||||
|
||||
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({
|
||||
...account,
|
||||
resourceId,
|
||||
resource: {
|
||||
id: resourceId,
|
||||
name: resourceName,
|
||||
resourceType
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
return { ...orm, findWithResourceDetails };
|
||||
};
|
||||
9
backend/src/ee/services/pam-resource/pam-resource-dal.ts
Normal file
9
backend/src/ee/services/pam-resource/pam-resource-dal.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
|
||||
export const pamResourceDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamResource);
|
||||
return { ...orm };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum PamResource {
|
||||
Postgres = "postgres"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
|
||||
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
|
||||
|
||||
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
|
||||
|
||||
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
||||
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation
|
||||
};
|
||||
126
backend/src/ee/services/pam-resource/pam-resource-fns.ts
Normal file
126
backend/src/ee/services/pam-resource/pam-resource-fns.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { TPamResources } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamAccountCredentials, TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
|
||||
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
|
||||
|
||||
export const listResourceOptions = () => {
|
||||
return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
// Resource
|
||||
export const encryptResourceConnectionDetails = async ({
|
||||
orgId,
|
||||
connectionDetails,
|
||||
kmsService
|
||||
}: {
|
||||
orgId: string;
|
||||
connectionDetails: TPamResourceConnectionDetails;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(connectionDetails))
|
||||
});
|
||||
|
||||
return encryptedConnectionDetailsBlob;
|
||||
};
|
||||
|
||||
export const decryptResourceConnectionDetails = async ({
|
||||
orgId,
|
||||
encryptedConnectionDetails,
|
||||
kmsService
|
||||
}: {
|
||||
orgId: string;
|
||||
encryptedConnectionDetails: Buffer;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
});
|
||||
|
||||
const decryptedPlainTextBlob = decryptor({
|
||||
cipherTextBlob: encryptedConnectionDetails
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails;
|
||||
};
|
||||
|
||||
export const decryptResource = async (
|
||||
resource: TPamResources,
|
||||
orgId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
return {
|
||||
...resource,
|
||||
connectionDetails: await decryptResourceConnectionDetails({
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
orgId,
|
||||
kmsService
|
||||
})
|
||||
} as TPamResource;
|
||||
};
|
||||
|
||||
// Account
|
||||
export const encryptAccountCredentials = async ({
|
||||
orgId,
|
||||
credentials,
|
||||
kmsService
|
||||
}: {
|
||||
orgId: string;
|
||||
credentials: TPamAccountCredentials;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(credentials))
|
||||
});
|
||||
|
||||
return encryptedCredentialsBlob;
|
||||
};
|
||||
|
||||
export const decryptAccountCredentials = async ({
|
||||
orgId,
|
||||
encryptedCredentials,
|
||||
kmsService
|
||||
}: {
|
||||
orgId: string;
|
||||
encryptedCredentials: Buffer;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
});
|
||||
|
||||
const decryptedPlainTextBlob = decryptor({
|
||||
cipherTextBlob: encryptedCredentials
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials;
|
||||
};
|
||||
|
||||
export const decryptAccount = async <T extends { encryptedCredentials: Buffer }>(
|
||||
account: T,
|
||||
orgId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<T & { credentials: TPamAccountCredentials }> => {
|
||||
return {
|
||||
...account,
|
||||
credentials: await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
orgId,
|
||||
kmsService
|
||||
})
|
||||
} as T & { credentials: TPamAccountCredentials };
|
||||
};
|
||||
45
backend/src/ee/services/pam-resource/pam-resource-schemas.ts
Normal file
45
backend/src/ee/services/pam-resource/pam-resource-schemas.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
|
||||
// Resources
|
||||
export const BasePamResoureSchema = PamResourcesSchema.omit({
|
||||
encryptedConnectionDetails: true,
|
||||
resourceType: true
|
||||
});
|
||||
|
||||
export const BaseCreatePamResourceSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
gatewayId: z.string().uuid(),
|
||||
name: slugSchema({ field: "name" })
|
||||
});
|
||||
|
||||
export const BaseUpdatePamResourceSchema = z.object({
|
||||
gatewayId: z.string().uuid().optional(),
|
||||
name: slugSchema({ field: "name" }).optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const BasePamAccountSchema = PamAccountsSchema.omit({
|
||||
encryptedCredentials: true
|
||||
});
|
||||
|
||||
export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
|
||||
resource: PamResourcesSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
resourceType: true
|
||||
})
|
||||
});
|
||||
|
||||
export const BaseCreatePamAccountSchema = z.object({
|
||||
folderId: z.string().uuid().optional(),
|
||||
name: slugSchema({ field: "name" }),
|
||||
description: z.string().max(512).optional()
|
||||
});
|
||||
|
||||
export const BaseUpdatePamAccountSchema = z.object({
|
||||
name: slugSchema({ field: "name" }).optional(),
|
||||
description: z.string().max(512).optional()
|
||||
});
|
||||
671
backend/src/ee/services/pam-resource/pam-resource-service.ts
Normal file
671
backend/src/ee/services/pam-resource/pam-resource-service.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
|
||||
import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
|
||||
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
||||
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPamAccountDALFactory } from "./pam-account-dal";
|
||||
import { TPamResourceDALFactory } from "./pam-resource-dal";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
|
||||
import {
|
||||
decryptAccount,
|
||||
decryptAccountCredentials,
|
||||
decryptResource,
|
||||
decryptResourceConnectionDetails,
|
||||
encryptAccountCredentials,
|
||||
encryptResourceConnectionDetails,
|
||||
listResourceOptions
|
||||
} from "./pam-resource-fns";
|
||||
import {
|
||||
TAccessAccountDTO,
|
||||
TCreateAccountDTO,
|
||||
TCreateResourceDTO,
|
||||
TPamAccountCredentials,
|
||||
TUpdateAccountDTO,
|
||||
TUpdateResourceDTO
|
||||
} from "./pam-resource-types";
|
||||
|
||||
type TPamResourceServiceFactoryDep = {
|
||||
pamResourceDAL: TPamResourceDALFactory;
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
pamAccountDAL: TPamAccountDALFactory;
|
||||
pamFolderDAL: TPamFolderDALFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
|
||||
};
|
||||
|
||||
export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>;
|
||||
|
||||
export const pamResourceServiceFactory = ({
|
||||
pamResourceDAL,
|
||||
pamSessionDAL,
|
||||
pamAccountDAL,
|
||||
pamFolderDAL,
|
||||
projectDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service
|
||||
}: TPamResourceServiceFactoryDep) => {
|
||||
const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => {
|
||||
const resource = await pamResourceDAL.findById(id);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||
|
||||
if (resource.resourceType !== resourceType) {
|
||||
throw new BadRequestError({
|
||||
message: `Resource with ID '${id}' is not of type '${resourceType}'`
|
||||
});
|
||||
}
|
||||
|
||||
return decryptResource(resource, actor.orgId, kmsService);
|
||||
};
|
||||
|
||||
const create = async (
|
||||
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources);
|
||||
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType](
|
||||
resourceType,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedConnectionDetails = await factory.validateConnection();
|
||||
|
||||
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
|
||||
connectionDetails: validatedConnectionDetails,
|
||||
orgId: actor.orgId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const resource = await pamResourceDAL.create({
|
||||
resourceType,
|
||||
encryptedConnectionDetails,
|
||||
gatewayId,
|
||||
name,
|
||||
projectId
|
||||
});
|
||||
|
||||
return decryptResource(resource, actor.orgId, kmsService);
|
||||
};
|
||||
|
||||
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await pamResourceDAL.findById(resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources);
|
||||
|
||||
const updateDoc: Partial<TPamResources> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (connectionDetails !== undefined) {
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedConnectionDetails = await factory.validateConnection();
|
||||
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
|
||||
connectionDetails: validatedConnectionDetails,
|
||||
orgId: actor.orgId,
|
||||
kmsService
|
||||
});
|
||||
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
|
||||
}
|
||||
|
||||
// If nothing was updated, return the fetched resource
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return decryptResource(resource, actor.orgId, kmsService);
|
||||
}
|
||||
|
||||
const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc);
|
||||
|
||||
return decryptResource(updatedResource, actor.orgId, kmsService);
|
||||
};
|
||||
|
||||
const deleteById = async (id: string, actor: OrgServiceActor) => {
|
||||
const resource = await pamResourceDAL.findById(id);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources);
|
||||
|
||||
const deletedResource = await pamResourceDAL.deleteById(id);
|
||||
|
||||
return decryptResource(deletedResource, actor.orgId, kmsService);
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||
|
||||
const resources = await pamResourceDAL.find({ projectId });
|
||||
|
||||
return {
|
||||
resources: await Promise.all(resources.map((resource) => decryptResource(resource, actor.orgId, kmsService)))
|
||||
};
|
||||
};
|
||||
|
||||
// Accounts
|
||||
const createAccount = async (
|
||||
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await pamResourceDAL.findById(resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId,
|
||||
projectId: resource.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Create,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const connectionDetails = await decryptResourceConnectionDetails({
|
||||
orgId: actor.orgId,
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedCredentials = await factory.validateAccountCredentials(credentials);
|
||||
|
||||
const encryptedCredentials = await encryptAccountCredentials({
|
||||
credentials: validatedCredentials,
|
||||
orgId: actor.orgId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const account = await pamAccountDAL.create({
|
||||
projectId: resource.projectId,
|
||||
resourceId: resource.id,
|
||||
encryptedCredentials,
|
||||
name,
|
||||
description,
|
||||
folderId
|
||||
});
|
||||
|
||||
return {
|
||||
...(await decryptAccount(account, actor.orgId, kmsService)),
|
||||
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
|
||||
};
|
||||
};
|
||||
|
||||
const updateAccountById = async (
|
||||
{ accountId, credentials, description, name }: TUpdateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Edit,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const updateDoc: Partial<TPamAccounts> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updateDoc.description = description;
|
||||
}
|
||||
|
||||
if (credentials !== undefined) {
|
||||
const connectionDetails = await decryptResourceConnectionDetails({
|
||||
orgId: actor.orgId,
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = { ...credentials };
|
||||
if (credentials.password === "******") {
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
orgId: actor.orgId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
finalCredentials.password = decryptedCredentials.password;
|
||||
}
|
||||
|
||||
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
|
||||
const encryptedCredentials = await encryptAccountCredentials({
|
||||
credentials: validatedCredentials,
|
||||
orgId: actor.orgId,
|
||||
kmsService
|
||||
});
|
||||
updateDoc.encryptedCredentials = encryptedCredentials;
|
||||
}
|
||||
|
||||
// If nothing was updated, return the fetched account
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return decryptAccount(account, actor.orgId, kmsService);
|
||||
}
|
||||
|
||||
const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc);
|
||||
|
||||
return {
|
||||
...(await decryptAccount(updatedAccount, actor.orgId, kmsService)),
|
||||
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
|
||||
};
|
||||
};
|
||||
|
||||
const deleteAccountById = async (id: string, actor: OrgServiceActor) => {
|
||||
const account = await pamAccountDAL.findById(id);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Delete,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const deletedAccount = await pamAccountDAL.deleteById(id);
|
||||
|
||||
return {
|
||||
...(await decryptAccount(deletedAccount, actor.orgId, kmsService)),
|
||||
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
|
||||
};
|
||||
};
|
||||
|
||||
const listAccounts = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId });
|
||||
|
||||
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : [];
|
||||
|
||||
const decryptedAndPermittedAccounts: Array<
|
||||
TPamAccounts & {
|
||||
resource: Pick<TPamResources, "id" | "name" | "resourceType">;
|
||||
credentials: TPamAccountCredentials;
|
||||
}
|
||||
> = [];
|
||||
|
||||
for await (const account of accountsWithResourceDetails) {
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
// Check permission for each individual account
|
||||
if (
|
||||
permission.can(
|
||||
ProjectPermissionPamAccountActions.Read,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: account.resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
)
|
||||
) {
|
||||
// Decrypt the account only if the user has permission to read it
|
||||
const decryptedAccount = await decryptAccount(account, actor.orgId, kmsService);
|
||||
decryptedAndPermittedAccounts.push({
|
||||
...decryptedAccount,
|
||||
resource: {
|
||||
id: account.resource.id,
|
||||
name: account.resource.name,
|
||||
resourceType: account.resource.resourceType
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: decryptedAndPermittedAccounts,
|
||||
folders
|
||||
};
|
||||
};
|
||||
|
||||
const accessAccount = async (
|
||||
{ accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Access,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const session = await pamSessionDAL.create({
|
||||
accountName: account.name,
|
||||
actorEmail,
|
||||
actorIp,
|
||||
actorName,
|
||||
actorUserAgent,
|
||||
projectId: account.projectId,
|
||||
resourceName: resource.name,
|
||||
resourceType: resource.resourceType,
|
||||
status: PamSessionStatus.Starting,
|
||||
accountId: account.id,
|
||||
userId: actor.id,
|
||||
expiresAt: duration ? new Date(Date.now() + duration) : null
|
||||
});
|
||||
|
||||
const { connectionDetails, gatewayId, resourceType } = await decryptResource(resource, actor.orgId, kmsService);
|
||||
|
||||
const gatewayConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
|
||||
gatewayId,
|
||||
targetHost: connectionDetails.host,
|
||||
targetPort: connectionDetails.port,
|
||||
actorMetadata: {
|
||||
sessionId: session.id,
|
||||
resourceType: resource.resourceType
|
||||
}
|
||||
});
|
||||
|
||||
if (!gatewayConnectionDetails) {
|
||||
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
resourceType,
|
||||
relayCertificate: gatewayConnectionDetails.relay.clientCertificate,
|
||||
gatewayCertificate: gatewayConnectionDetails.gateway.clientCertificate,
|
||||
relayHost: gatewayConnectionDetails.relayHost,
|
||||
projectId: account.projectId
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
// To be hit by gateways only
|
||||
if (actor.type !== ActorType.IDENTITY) {
|
||||
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
|
||||
}
|
||||
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const project = await projectDAL.findById(session.projectId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
project.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" });
|
||||
|
||||
// Verify that the session has not ended
|
||||
if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) {
|
||||
throw new BadRequestError({ message: "Session has ended or expired" });
|
||||
}
|
||||
|
||||
// Verify that the session has not already had credentials fetched
|
||||
if (session.status !== PamSessionStatus.Starting) {
|
||||
throw new BadRequestError({ message: "Session has already been started" });
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(session.accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const decryptedAccount = await decryptAccount(account, actor.orgId, kmsService);
|
||||
|
||||
const decryptedResource = await decryptResource(resource, actor.orgId, kmsService);
|
||||
|
||||
// Mark session as started
|
||||
await pamSessionDAL.updateById(sessionId, {
|
||||
status: PamSessionStatus.Active,
|
||||
startedAt: new Date()
|
||||
});
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
...decryptedResource.connectionDetails,
|
||||
...decryptedAccount.credentials
|
||||
},
|
||||
projectId: project.id
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getById,
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
list,
|
||||
listResourceOptions,
|
||||
createAccount,
|
||||
updateAccountById,
|
||||
deleteAccountById,
|
||||
listAccounts,
|
||||
accessAccount,
|
||||
getSessionCredentials
|
||||
};
|
||||
};
|
||||
58
backend/src/ee/services/pam-resource/pam-resource-types.ts
Normal file
58
backend/src/ee/services/pam-resource/pam-resource-types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import {
|
||||
TPostgresAccount,
|
||||
TPostgresAccountCredentials,
|
||||
TPostgresResource,
|
||||
TPostgresResourceConnectionDetails
|
||||
} from "./postgres/postgres-resource-types";
|
||||
|
||||
// Resource types
|
||||
export type TPamResource = TPostgresResource;
|
||||
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails;
|
||||
|
||||
// Account types
|
||||
export type TPamAccount = TPostgresAccount;
|
||||
export type TPamAccountCredentials = TPostgresAccountCredentials;
|
||||
|
||||
// Resource DTOs
|
||||
export type TCreateResourceDTO = Pick<
|
||||
TPamResource,
|
||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
||||
>;
|
||||
|
||||
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
// Account DTOs
|
||||
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">;
|
||||
|
||||
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export type TAccessAccountDTO = {
|
||||
accountId: string;
|
||||
actorEmail: string;
|
||||
actorIp: string;
|
||||
actorName: string;
|
||||
actorUserAgent: string;
|
||||
duration?: number | null;
|
||||
};
|
||||
|
||||
// Resource factory
|
||||
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
|
||||
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||
credentials: C
|
||||
) => Promise<C>;
|
||||
|
||||
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
|
||||
resourceType: PamResource,
|
||||
connectionDetails: T,
|
||||
gatewayId: string,
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
|
||||
) => {
|
||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PostgresResourceListItemSchema } from "./postgres-resource-schemas";
|
||||
|
||||
export const getPostgresResourceListItem = () => {
|
||||
return {
|
||||
name: PostgresResourceListItemSchema.shape.name.value,
|
||||
resource: PostgresResourceListItemSchema.shape.resource.value
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
BaseCreatePamAccountSchema,
|
||||
BaseCreatePamResourceSchema,
|
||||
BasePamAccountSchema,
|
||||
BasePamAccountSchemaWithResource,
|
||||
BasePamResoureSchema,
|
||||
BaseUpdatePamAccountSchema,
|
||||
BaseUpdatePamResourceSchema
|
||||
} from "../pam-resource-schemas";
|
||||
import {
|
||||
BaseSqlAccountCredentialsSchema,
|
||||
BaseSqlResourceConnectionDetailsSchema
|
||||
} from "../shared/sql/sql-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema;
|
||||
|
||||
const BasePostgresResourceSchema = BasePamResoureSchema.extend({ resourceType: z.literal(PamResource.Postgres) });
|
||||
|
||||
export const PostgresResourceSchema = BasePostgresResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema
|
||||
});
|
||||
|
||||
export const PostgresResourceListItemSchema = z.object({
|
||||
name: z.literal("PostgreSQL"),
|
||||
resource: z.literal(PamResource.Postgres)
|
||||
});
|
||||
|
||||
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema
|
||||
});
|
||||
|
||||
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema.optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
|
||||
|
||||
export const PostgresAccountSchema = BasePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const CreatePostgresAccountSchema = BaseCreatePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const UpdatePostgresAccountSchema = BaseUpdatePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema.optional()
|
||||
});
|
||||
|
||||
export const SanitizedPostgresAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
|
||||
credentials: PostgresAccountCredentialsSchema.pick({
|
||||
username: true
|
||||
})
|
||||
});
|
||||
|
||||
// Sessions
|
||||
export const PostgresSessionCredentialsSchema = PostgresResourceConnectionDetailsSchema.and(
|
||||
PostgresAccountCredentialsSchema
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PostgresAccountCredentialsSchema,
|
||||
PostgresAccountSchema,
|
||||
PostgresResourceConnectionDetailsSchema,
|
||||
PostgresResourceSchema
|
||||
} from "./postgres-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export type TPostgresResource = z.infer<typeof PostgresResourceSchema>;
|
||||
export type TPostgresResourceConnectionDetails = z.infer<typeof PostgresResourceConnectionDetailsSchema>;
|
||||
|
||||
// Accounts
|
||||
export type TPostgresAccount = z.infer<typeof PostgresAccountSchema>;
|
||||
export type TPostgresAccountCredentials = z.infer<typeof PostgresAccountCredentialsSchema>;
|
||||
@@ -0,0 +1,173 @@
|
||||
import knex, { Knex } from "knex";
|
||||
|
||||
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
|
||||
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { GatewayProxyProtocol } from "@app/lib/gateway";
|
||||
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
|
||||
|
||||
import { PamResource } from "../../pam-resource-enums";
|
||||
import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types";
|
||||
import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types";
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test";
|
||||
const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password";
|
||||
|
||||
const SQL_CONNECTION_CLIENT_MAP = {
|
||||
[PamResource.Postgres]: "pg"
|
||||
};
|
||||
|
||||
const getConnectionConfig = (
|
||||
resourceType: PamResource,
|
||||
{ host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails
|
||||
) => {
|
||||
switch (resourceType) {
|
||||
case PamResource.Postgres: {
|
||||
return {
|
||||
ssl: sslEnabled
|
||||
? {
|
||||
rejectUnauthorized: sslRejectUnauthorized,
|
||||
ca: sslCertificate,
|
||||
servername: host
|
||||
}
|
||||
: false
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: `Unhandled SQL Resource Connection Config: ${resourceType as PamResource}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const executeWithGateway = async <T>(
|
||||
config: {
|
||||
connectionDetails: TSqlResourceConnectionDetails;
|
||||
resourceType: PamResource;
|
||||
gatewayId: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
|
||||
operation: (client: Knex) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const { connectionDetails, resourceType, gatewayId, username, password } = 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) => {
|
||||
const client = knex({
|
||||
client: SQL_CONNECTION_CLIENT_MAP[resourceType],
|
||||
connection: {
|
||||
database: connectionDetails.database,
|
||||
port: proxyPort,
|
||||
host: "localhost",
|
||||
user: username ?? TEST_CONNECTION_USERNAME, // Use provided username or fallback
|
||||
password: password ?? TEST_CONNECTION_PASSWORD, // Use provided password or fallback
|
||||
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
|
||||
...getConnectionConfig(resourceType, connectionDetails)
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await operation(client);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
},
|
||||
{
|
||||
protocol: GatewayProxyProtocol.Tcp,
|
||||
relayHost: platformConnectionDetails.relayHost,
|
||||
gateway: platformConnectionDetails.gateway,
|
||||
relay: platformConnectionDetails.relay
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetails, TSqlAccountCredentials> = (
|
||||
resourceType,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
) => {
|
||||
const validateConnection = async () => {
|
||||
try {
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => {
|
||||
await client.raw("Select 1");
|
||||
});
|
||||
return connectionDetails;
|
||||
} catch (error) {
|
||||
// Hacky way to know if we successfully hit the database
|
||||
if (error instanceof BadRequestError) {
|
||||
if (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`) {
|
||||
return connectionDetails;
|
||||
}
|
||||
|
||||
if (error.message === "Connection terminated unexpectedly") {
|
||||
throw new BadRequestError({
|
||||
message: "Connection terminated unexpectedly. Verify that host and port are correct"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSqlAccountCredentials> = async (
|
||||
credentials
|
||||
) => {
|
||||
try {
|
||||
await executeWithGateway(
|
||||
{
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
resourceType,
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
},
|
||||
gatewayV2Service,
|
||||
async (client) => {
|
||||
await client.raw("Select 1");
|
||||
}
|
||||
);
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
if (error.message === `password authentication failed for user "${credentials.username}"`) {
|
||||
throw new BadRequestError({
|
||||
message: "Account credentials invalid: Username or password incorrect"
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Connection terminated unexpectedly") {
|
||||
throw new BadRequestError({
|
||||
message: "Connection terminated unexpectedly. Verify that host and port are correct"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
validateAccountCredentials
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Resources
|
||||
export const BaseSqlResourceConnectionDetailsSchema = z.object({
|
||||
host: z.string().trim().min(1).max(255),
|
||||
port: z.coerce.number(),
|
||||
database: z.string().trim().min(1).max(255),
|
||||
sslEnabled: z.boolean(),
|
||||
sslRejectUnauthorized: z.boolean(),
|
||||
sslCertificate: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value || undefined)
|
||||
.optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const BaseSqlAccountCredentialsSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
password: z.string().trim().min(1)
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
TPostgresAccountCredentials,
|
||||
TPostgresResourceConnectionDetails
|
||||
} from "../../postgres/postgres-resource-types";
|
||||
|
||||
export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails;
|
||||
export type TSqlAccountCredentials = TPostgresAccountCredentials;
|
||||
9
backend/src/ee/services/pam-session/pam-session-dal.ts
Normal file
9
backend/src/ee/services/pam-session/pam-session-dal.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>;
|
||||
export const pamSessionDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamSession);
|
||||
return { ...orm };
|
||||
};
|
||||
6
backend/src/ee/services/pam-session/pam-session-enums.ts
Normal file
6
backend/src/ee/services/pam-session/pam-session-enums.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum PamSessionStatus {
|
||||
Starting = "starting", // Starting, user connecting to resource
|
||||
Active = "active", // Active, user is connected to resource
|
||||
Ended = "ended", // Ended by user
|
||||
Terminated = "terminated" // Terminated by an admin
|
||||
}
|
||||
43
backend/src/ee/services/pam-session/pam-session-fns.ts
Normal file
43
backend/src/ee/services/pam-session/pam-session-fns.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { TPamSessions } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
|
||||
|
||||
export const decryptSessionCommandLogs = async ({
|
||||
orgId,
|
||||
encryptedLogs,
|
||||
kmsService
|
||||
}: {
|
||||
orgId: string;
|
||||
encryptedLogs: Buffer;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
});
|
||||
|
||||
const decryptedPlainTextBlob = decryptor({
|
||||
cipherTextBlob: encryptedLogs
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
|
||||
};
|
||||
|
||||
export const decryptSession = async (
|
||||
session: TPamSessions,
|
||||
orgId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
return {
|
||||
...session,
|
||||
commandLogs: session.encryptedLogsBlob
|
||||
? await decryptSessionCommandLogs({
|
||||
orgId,
|
||||
encryptedLogs: session.encryptedLogsBlob,
|
||||
kmsService
|
||||
})
|
||||
: []
|
||||
} as TPamSanitizedSession;
|
||||
};
|
||||
15
backend/src/ee/services/pam-session/pam-session-schemas.ts
Normal file
15
backend/src/ee/services/pam-session/pam-session-schemas.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionsSchema } from "@app/db/schemas";
|
||||
|
||||
export const PamSessionCommandLogSchema = z.object({
|
||||
input: z.string(),
|
||||
output: z.string(),
|
||||
timestamp: z.coerce.date()
|
||||
});
|
||||
|
||||
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
}).extend({
|
||||
commandLogs: PamSessionCommandLogSchema.array()
|
||||
});
|
||||
168
backend/src/ee/services/pam-session/pam-session-service.ts
Normal file
168
backend/src/ee/services/pam-session/pam-session-service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TUpdateSessionLogsDTO } from "./pam-session.types";
|
||||
import { TPamSessionDALFactory } from "./pam-session-dal";
|
||||
import { PamSessionStatus } from "./pam-session-enums";
|
||||
import { decryptSession } from "./pam-session-fns";
|
||||
|
||||
type TPamSessionServiceFactoryDep = {
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
export type TPamSessionServiceFactory = ReturnType<typeof pamSessionServiceFactory>;
|
||||
|
||||
export const pamSessionServiceFactory = ({
|
||||
pamSessionDAL,
|
||||
projectDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService
|
||||
}: TPamSessionServiceFactoryDep) => {
|
||||
const getById = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: session.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamSessionActions.Read,
|
||||
ProjectPermissionSub.PamSessions
|
||||
);
|
||||
|
||||
return {
|
||||
session: await decryptSession(session, actor.orgId, kmsService)
|
||||
};
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamSessionActions.Read,
|
||||
ProjectPermissionSub.PamSessions
|
||||
);
|
||||
|
||||
const sessions = await pamSessionDAL.find({ projectId });
|
||||
|
||||
return {
|
||||
sessions: await Promise.all(sessions.map((session) => decryptSession(session, actor.orgId, kmsService)))
|
||||
};
|
||||
};
|
||||
|
||||
const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
// To be hit by gateways only
|
||||
if (actor.type !== ActorType.IDENTITY) {
|
||||
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
|
||||
}
|
||||
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const project = await projectDAL.findById(session.projectId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
project.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: project.orgId
|
||||
});
|
||||
|
||||
const { cipherTextBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(logs))
|
||||
});
|
||||
|
||||
const updatedSession = await pamSessionDAL.updateById(sessionId, {
|
||||
encryptedLogsBlob: cipherTextBlob
|
||||
});
|
||||
|
||||
return { session: updatedSession, projectId: project.id };
|
||||
};
|
||||
|
||||
const endSessionById = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
// To be hit by gateways only
|
||||
if (actor.type !== ActorType.IDENTITY) {
|
||||
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
|
||||
}
|
||||
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const project = await projectDAL.findById(session.projectId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
project.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (session.status !== PamSessionStatus.Active) {
|
||||
throw new BadRequestError({ message: "Cannot end sessions that are not active" });
|
||||
}
|
||||
|
||||
const updatedSession = await pamSessionDAL.updateById(sessionId, {
|
||||
endedAt: new Date(),
|
||||
status: PamSessionStatus.Ended
|
||||
});
|
||||
|
||||
return { session: updatedSession, projectId: project.id };
|
||||
};
|
||||
|
||||
return { getById, list, updateLogsById, endSessionById };
|
||||
};
|
||||
12
backend/src/ee/services/pam-session/pam-session.types.ts
Normal file
12
backend/src/ee/services/pam-session/pam-session.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
|
||||
|
||||
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
||||
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
||||
|
||||
// DTOs
|
||||
export type TUpdateSessionLogsDTO = {
|
||||
sessionId: string;
|
||||
logs: TPamSessionCommandLog[];
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ProjectPermissionIdentityActions,
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionMemberActions,
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionPamSessionActions,
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiSyncActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
@@ -49,7 +51,9 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.SshCertificateAuthorities,
|
||||
ProjectPermissionSub.SshCertificates,
|
||||
ProjectPermissionSub.SshCertificateTemplates,
|
||||
ProjectPermissionSub.SshHostGroups
|
||||
ProjectPermissionSub.SshHostGroups,
|
||||
ProjectPermissionSub.PamFolders,
|
||||
ProjectPermissionSub.PamResources
|
||||
].forEach((el) => {
|
||||
can(
|
||||
[
|
||||
@@ -290,6 +294,19 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.AppConnections
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionPamAccountActions.Access,
|
||||
ProjectPermissionPamAccountActions.Read,
|
||||
ProjectPermissionPamAccountActions.Create,
|
||||
ProjectPermissionPamAccountActions.Edit,
|
||||
ProjectPermissionPamAccountActions.Delete
|
||||
],
|
||||
ProjectPermissionSub.PamAccounts
|
||||
);
|
||||
|
||||
can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -518,6 +535,15 @@ const buildMemberPermissionRules = () => {
|
||||
|
||||
can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
|
||||
|
||||
can(
|
||||
[ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read],
|
||||
ProjectPermissionSub.PamAccounts
|
||||
);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -579,6 +605,12 @@ const buildViewerPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretEvents
|
||||
);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
|
||||
|
||||
can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
|
||||
@@ -186,6 +186,19 @@ export enum ProjectPermissionAuditLogsActions {
|
||||
Read = "read"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPamAccountActions {
|
||||
Access = "access",
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPamSessionActions {
|
||||
Read = "read"
|
||||
// Terminate = "terminate"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSub {
|
||||
Role = "role",
|
||||
Member = "member",
|
||||
@@ -228,7 +241,11 @@ export enum ProjectPermissionSub {
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
SecretScanningConfigs = "secret-scanning-configs",
|
||||
SecretEvents = "secret-events",
|
||||
AppConnections = "app-connections"
|
||||
AppConnections = "app-connections",
|
||||
PamFolders = "pam-folders",
|
||||
PamResources = "pam-resources",
|
||||
PamAccounts = "pam-accounts",
|
||||
PamSessions = "pam-sessions"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -300,6 +317,12 @@ export type AppConnectionSubjectFields = {
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
export type PamAccountSubjectFields = {
|
||||
resourceName: string;
|
||||
accountName: string;
|
||||
accountPath: string;
|
||||
};
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionSecretActions,
|
||||
@@ -404,7 +427,14 @@ export type ProjectPermissionSet =
|
||||
| ProjectPermissionSub.AppConnections
|
||||
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
|
||||
)
|
||||
];
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PamFolders]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PamResources]
|
||||
| [
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
|
||||
]
|
||||
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
|
||||
|
||||
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
||||
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
@@ -427,6 +457,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
})
|
||||
.partial()
|
||||
]);
|
||||
const PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
||||
const PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
z.string().refine((val) => val.startsWith("/"), SECRET_PATH_MISSING_SLASH_ERR_MSG),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ].refine(
|
||||
(val) => val.startsWith("/"),
|
||||
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
|
||||
),
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ].refine(
|
||||
(val) => val.startsWith("/"),
|
||||
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
|
||||
),
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN].refine(
|
||||
(val) => val.every((el) => el.startsWith("/")),
|
||||
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
|
||||
),
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]);
|
||||
// akhilmhdh: don't modify this for v2
|
||||
// if you want to update create a new schema
|
||||
const SecretConditionV1Schema = z
|
||||
@@ -650,6 +701,34 @@ const AppConnectionConditionSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
const PamAccountConditionSchema = z
|
||||
.object({
|
||||
resourceName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
accountName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
accountPath: PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA
|
||||
})
|
||||
.partial();
|
||||
|
||||
const GeneralPermissionSchema = [
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
|
||||
@@ -840,6 +919,34 @@ const GeneralPermissionSchema = [
|
||||
conditions: AppConnectionConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamFolders).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamResources).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamAccounts).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamAccountActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
),
|
||||
conditions: PamAccountConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamSessions).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
})
|
||||
];
|
||||
|
||||
|
||||
@@ -66,6 +66,13 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal";
|
||||
import { licenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
|
||||
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
||||
import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal";
|
||||
import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
|
||||
import { pamAccountDALFactory } from "@app/ee/services/pam-resource/pam-account-dal";
|
||||
import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal";
|
||||
import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
|
||||
import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal";
|
||||
import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
|
||||
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
|
||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
|
||||
@@ -2099,6 +2106,37 @@ export const registerRoutes = async (
|
||||
appConnectionDAL
|
||||
});
|
||||
|
||||
const pamFolderDAL = pamFolderDALFactory(db);
|
||||
const pamResourceDAL = pamResourceDALFactory(db);
|
||||
const pamAccountDAL = pamAccountDALFactory(db);
|
||||
const pamSessionDAL = pamSessionDALFactory(db);
|
||||
|
||||
const pamFolderService = pamFolderServiceFactory({
|
||||
pamFolderDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
});
|
||||
|
||||
const pamResourceService = pamResourceServiceFactory({
|
||||
pamResourceDAL,
|
||||
pamSessionDAL,
|
||||
pamAccountDAL,
|
||||
pamFolderDAL,
|
||||
projectDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service
|
||||
});
|
||||
|
||||
const pamSessionService = pamSessionServiceFactory({
|
||||
pamSessionDAL,
|
||||
projectDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService
|
||||
});
|
||||
|
||||
// setup the communication with license key server
|
||||
await licenseService.init();
|
||||
|
||||
@@ -2236,7 +2274,10 @@ export const registerRoutes = async (
|
||||
reminder: reminderService,
|
||||
bus: eventBusService,
|
||||
sse: sseService,
|
||||
notification: notificationService
|
||||
notification: notificationService,
|
||||
pamFolder: pamFolderService,
|
||||
pamResource: pamResourceService,
|
||||
pamSession: pamSessionService
|
||||
});
|
||||
|
||||
const cronJobs: CronJob[] = [];
|
||||
|
||||
@@ -213,6 +213,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
|
||||
return false;
|
||||
case ProjectType.SSH:
|
||||
return false;
|
||||
case ProjectType.PAM:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ const PROJECT_TYPE_MENU_ITEMS = [
|
||||
{
|
||||
label: "Secret Scanning",
|
||||
value: ProjectType.SecretScanning
|
||||
},
|
||||
{
|
||||
label: "PAM",
|
||||
value: ProjectType.PAM
|
||||
}
|
||||
];
|
||||
|
||||
@@ -193,12 +197,12 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="mt-2 grid grid-cols-5 gap-4">
|
||||
<div className="mt-2 grid grid-cols-3 gap-3">
|
||||
{PROJECT_TYPE_MENU_ITEMS.map((el) => (
|
||||
<div
|
||||
key={el.value}
|
||||
className={twMerge(
|
||||
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
|
||||
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 px-2 py-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
|
||||
field.value === el.value && "border-primary-400 bg-mineshaft-600 opacity-100"
|
||||
)}
|
||||
onClick={() => field.onChange(el.value)}
|
||||
|
||||
@@ -8,9 +8,24 @@ export const HighlightText = ({
|
||||
highlightClassName?: string;
|
||||
}) => {
|
||||
if (!text) return null;
|
||||
|
||||
const renderTextWithNewlines = (input: string, baseKeyPrefix: string = ""): React.ReactNode[] => {
|
||||
if (!input) return [];
|
||||
const lines = input.split("\n");
|
||||
return lines.flatMap((line, index) => {
|
||||
const nodes: React.ReactNode[] = [line];
|
||||
if (index < lines.length - 1) {
|
||||
nodes.push(<br key={`${baseKeyPrefix}-br-${line}`} />);
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
};
|
||||
|
||||
const searchTerm = highlight.toLowerCase().trim();
|
||||
|
||||
if (!searchTerm) return <span>{text}</span>;
|
||||
if (!searchTerm) {
|
||||
return <span>{renderTextWithNewlines(text, "full-text")}</span>;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -20,12 +35,17 @@ export const HighlightText = ({
|
||||
|
||||
text.replace(regex, (match: string, offset: number) => {
|
||||
if (offset > lastIndex) {
|
||||
parts.push(<span key={`pre-${lastIndex}`}>{text.substring(lastIndex, offset)}</span>);
|
||||
const preMatchText = text.substring(lastIndex, offset);
|
||||
parts.push(
|
||||
<span key={`pre-${lastIndex}`}>
|
||||
{renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
|
||||
{match}
|
||||
{renderTextWithNewlines(match, `match-${offset}`)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -35,7 +55,12 @@ export const HighlightText = ({
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={`post-${lastIndex}`}>{text.substring(lastIndex)}</span>);
|
||||
const postMatchText = text.substring(lastIndex);
|
||||
parts.push(
|
||||
<span key={`post-${lastIndex}`}>
|
||||
{renderTextWithNewlines(postMatchText, `post-${lastIndex}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return parts;
|
||||
|
||||
@@ -350,6 +350,24 @@ export const ROUTE_PATHS = Object.freeze({
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/secret-scanning/$projectId/_secret-scanning-layout/findings"
|
||||
)
|
||||
},
|
||||
Pam: {
|
||||
AccountsPage: setRoute(
|
||||
"/projects/pam/$projectId/accounts",
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/accounts"
|
||||
),
|
||||
ResourcesPage: setRoute(
|
||||
"/projects/pam/$projectId/resources",
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/resources"
|
||||
),
|
||||
SessionsPage: setRoute(
|
||||
"/projects/pam/$projectId/sessions",
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/"
|
||||
),
|
||||
PamSessionByIDPage: setRoute(
|
||||
"/projects/pam/$projectId/sessions/$sessionId",
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/$sessionId"
|
||||
)
|
||||
},
|
||||
Public: {
|
||||
ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId"),
|
||||
ViewSecretRequestByIDPage: setRoute(
|
||||
|
||||
@@ -187,6 +187,19 @@ export enum ProjectPermissionCommitsActions {
|
||||
PerformRollback = "perform-rollback"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPamAccountActions {
|
||||
Access = "access",
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPamSessionActions {
|
||||
Read = "read"
|
||||
// Terminate = "terminate"
|
||||
}
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
identityId: string;
|
||||
};
|
||||
@@ -208,7 +221,8 @@ export type ConditionalProjectPermissionSubject =
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.SecretRotation
|
||||
| ProjectPermissionSub.SecretEvents
|
||||
| ProjectPermissionSub.AppConnections;
|
||||
| ProjectPermissionSub.AppConnections
|
||||
| ProjectPermissionSub.PamAccounts;
|
||||
|
||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||
[PermissionConditionOperators.$EQ]: "equal to",
|
||||
@@ -289,7 +303,11 @@ export enum ProjectPermissionSub {
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
SecretScanningConfigs = "secret-scanning-configs",
|
||||
SecretEvents = "secret-events",
|
||||
AppConnections = "app-connections"
|
||||
AppConnections = "app-connections",
|
||||
PamFolders = "pam-folders",
|
||||
PamResources = "pam-resources",
|
||||
PamAccounts = "pam-accounts",
|
||||
PamSessions = "pam-sessions"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -350,6 +368,12 @@ export type PkiTemplateSubjectFields = {
|
||||
// (dangtony98): consider adding [commonName] as a subject field in the future
|
||||
};
|
||||
|
||||
export type PamAccountSubjectFields = {
|
||||
resourceName: string;
|
||||
accountName: string;
|
||||
accountPath: string;
|
||||
};
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionSecretActions,
|
||||
@@ -475,6 +499,16 @@ export type ProjectPermissionSet =
|
||||
| ProjectPermissionSub.AppConnections
|
||||
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
|
||||
)
|
||||
];
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PamFolders]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PamResources]
|
||||
| [
|
||||
ProjectPermissionPamAccountActions,
|
||||
(
|
||||
| ProjectPermissionSub.PamAccounts
|
||||
| (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
|
||||
)
|
||||
]
|
||||
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
|
||||
|
||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||
|
||||
@@ -82,6 +82,8 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
|
||||
return "/projects/cert-management/$projectId/subscribers" as const;
|
||||
case ProjectType.SecretScanning:
|
||||
return `/projects/${type}/$projectId/data-sources` as const;
|
||||
case ProjectType.PAM:
|
||||
return `/projects/${type}/$projectId/accounts` as const;
|
||||
default:
|
||||
return `/projects/${type}/$projectId/overview` as const;
|
||||
}
|
||||
@@ -93,7 +95,8 @@ export const getProjectTitle = (type: ProjectType) => {
|
||||
[ProjectType.KMS]: "Key Management",
|
||||
[ProjectType.CertificateManager]: "Cert Management",
|
||||
[ProjectType.SSH]: "SSH",
|
||||
[ProjectType.SecretScanning]: "Secret Scanning"
|
||||
[ProjectType.SecretScanning]: "Secret Scanning",
|
||||
[ProjectType.PAM]: "PAM"
|
||||
};
|
||||
return titleConvert[type];
|
||||
};
|
||||
@@ -104,7 +107,8 @@ export const getProjectLottieIcon = (type: ProjectType) => {
|
||||
[ProjectType.KMS]: "unlock",
|
||||
[ProjectType.CertificateManager]: "note",
|
||||
[ProjectType.SSH]: "terminal",
|
||||
[ProjectType.SecretScanning]: "secret-scan"
|
||||
[ProjectType.SecretScanning]: "secret-scan",
|
||||
[ProjectType.PAM]: "groups"
|
||||
};
|
||||
return titleConvert[type];
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProjectType } from "../projects/types";
|
||||
import { EventType, UserAgentType } from "./enums";
|
||||
|
||||
export const secretEvents: EventType[] = [
|
||||
@@ -246,7 +247,26 @@ export const eventToNameMap: { [K in EventType]: string } = {
|
||||
|
||||
[EventType.CREATE_ORG_ROLE]: "Create Org Role",
|
||||
[EventType.UPDATE_ORG_ROLE]: "Update Org Role",
|
||||
[EventType.DELETE_ORG_ROLE]: "Delete Org Role"
|
||||
[EventType.DELETE_ORG_ROLE]: "Delete Org Role",
|
||||
|
||||
[EventType.PAM_SESSION_START]: "PAM Session Start",
|
||||
[EventType.PAM_SESSION_LOGS_UPDATE]: "PAM Session Logs Update",
|
||||
[EventType.PAM_SESSION_END]: "PAM Session End",
|
||||
[EventType.PAM_SESSION_GET]: "PAM Session Get",
|
||||
[EventType.PAM_SESSION_LIST]: "PAM Session List",
|
||||
[EventType.PAM_FOLDER_CREATE]: "PAM Folder Create",
|
||||
[EventType.PAM_FOLDER_UPDATE]: "PAM Folder Update",
|
||||
[EventType.PAM_FOLDER_DELETE]: "PAM Folder Delete",
|
||||
[EventType.PAM_ACCOUNT_LIST]: "PAM Account List",
|
||||
[EventType.PAM_ACCOUNT_ACCESS]: "PAM Account Access",
|
||||
[EventType.PAM_ACCOUNT_CREATE]: "PAM Account Create",
|
||||
[EventType.PAM_ACCOUNT_UPDATE]: "PAM Account Update",
|
||||
[EventType.PAM_ACCOUNT_DELETE]: "PAM Account Delete",
|
||||
[EventType.PAM_RESOURCE_LIST]: "PAM Resource List",
|
||||
[EventType.PAM_RESOURCE_GET]: "PAM Resource Get",
|
||||
[EventType.PAM_RESOURCE_CREATE]: "PAM Resource Create",
|
||||
[EventType.PAM_RESOURCE_UPDATE]: "PAM Resource Update",
|
||||
[EventType.PAM_RESOURCE_DELETE]: "PAM Resource Delete"
|
||||
};
|
||||
|
||||
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
|
||||
@@ -258,3 +278,35 @@ export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
|
||||
[UserAgentType.PYTHON_SDK]: "InfisicalPythonSDK",
|
||||
[UserAgentType.OTHER]: "Other"
|
||||
};
|
||||
|
||||
const sharedProjectEvents = [
|
||||
EventType.ADD_PROJECT_MEMBER,
|
||||
EventType.REMOVE_PROJECT_MEMBER,
|
||||
EventType.CREATE_PROJECT_ROLE,
|
||||
EventType.UPDATE_PROJECT_ROLE,
|
||||
EventType.DELETE_PROJECT_ROLE
|
||||
];
|
||||
|
||||
export const projectToEventsMap: Partial<Record<ProjectType, EventType[]>> = {
|
||||
[ProjectType.PAM]: [
|
||||
...sharedProjectEvents,
|
||||
EventType.PAM_SESSION_START,
|
||||
EventType.PAM_SESSION_LOGS_UPDATE,
|
||||
EventType.PAM_SESSION_END,
|
||||
EventType.PAM_SESSION_GET,
|
||||
EventType.PAM_SESSION_LIST,
|
||||
EventType.PAM_FOLDER_CREATE,
|
||||
EventType.PAM_FOLDER_UPDATE,
|
||||
EventType.PAM_FOLDER_DELETE,
|
||||
EventType.PAM_ACCOUNT_LIST,
|
||||
EventType.PAM_ACCOUNT_ACCESS,
|
||||
EventType.PAM_ACCOUNT_CREATE,
|
||||
EventType.PAM_ACCOUNT_UPDATE,
|
||||
EventType.PAM_ACCOUNT_DELETE,
|
||||
EventType.PAM_RESOURCE_LIST,
|
||||
EventType.PAM_RESOURCE_GET,
|
||||
EventType.PAM_RESOURCE_CREATE,
|
||||
EventType.PAM_RESOURCE_UPDATE,
|
||||
EventType.PAM_RESOURCE_DELETE
|
||||
]
|
||||
};
|
||||
|
||||
@@ -240,5 +240,24 @@ export enum EventType {
|
||||
|
||||
CREATE_ORG_ROLE = "create-org-role",
|
||||
UPDATE_ORG_ROLE = "update-org-role",
|
||||
DELETE_ORG_ROLE = "delete-org-role"
|
||||
DELETE_ORG_ROLE = "delete-org-role",
|
||||
|
||||
PAM_SESSION_START = "pam-session-start",
|
||||
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
|
||||
PAM_SESSION_END = "pam-session-end",
|
||||
PAM_SESSION_GET = "pam-session-get",
|
||||
PAM_SESSION_LIST = "pam-session-list",
|
||||
PAM_FOLDER_CREATE = "pam-folder-create",
|
||||
PAM_FOLDER_UPDATE = "pam-folder-update",
|
||||
PAM_FOLDER_DELETE = "pam-folder-delete",
|
||||
PAM_ACCOUNT_LIST = "pam-account-list",
|
||||
PAM_ACCOUNT_ACCESS = "pam-account-access",
|
||||
PAM_ACCOUNT_CREATE = "pam-account-create",
|
||||
PAM_ACCOUNT_UPDATE = "pam-account-update",
|
||||
PAM_ACCOUNT_DELETE = "pam-account-delete",
|
||||
PAM_RESOURCE_LIST = "pam-resource-list",
|
||||
PAM_RESOURCE_GET = "pam-resource-get",
|
||||
PAM_RESOURCE_CREATE = "pam-resource-create",
|
||||
PAM_RESOURCE_UPDATE = "pam-resource-update",
|
||||
PAM_RESOURCE_DELETE = "pam-resource-delete"
|
||||
}
|
||||
|
||||
10
frontend/src/hooks/api/pam/enums.ts
Normal file
10
frontend/src/hooks/api/pam/enums.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export enum PamResourceType {
|
||||
Postgres = "postgres"
|
||||
}
|
||||
|
||||
export enum PamSessionStatus {
|
||||
Starting = "starting",
|
||||
Active = "active",
|
||||
Ended = "ended",
|
||||
Terminated = "terminated"
|
||||
}
|
||||
5
frontend/src/hooks/api/pam/index.ts
Normal file
5
frontend/src/hooks/api/pam/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./enums";
|
||||
export * from "./maps";
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
export * from "./types";
|
||||
8
frontend/src/hooks/api/pam/maps.ts
Normal file
8
frontend/src/hooks/api/pam/maps.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PamResourceType } from "./enums";
|
||||
|
||||
export const PAM_RESOURCE_TYPE_MAP: Record<
|
||||
PamResourceType,
|
||||
{ name: string; image: string; size?: number }
|
||||
> = {
|
||||
[PamResourceType.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }
|
||||
};
|
||||
174
frontend/src/hooks/api/pam/mutations.tsx
Normal file
174
frontend/src/hooks/api/pam/mutations.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { pamKeys } from "./queries";
|
||||
import {
|
||||
TCreatePamAccountDTO,
|
||||
TCreatePamFolderDTO,
|
||||
TCreatePamResourceDTO,
|
||||
TDeletePamAccountDTO,
|
||||
TDeletePamFolderDTO,
|
||||
TDeletePamResourceDTO,
|
||||
TPamAccount,
|
||||
TPamFolder,
|
||||
TPamResource,
|
||||
TUpdatePamAccountDTO,
|
||||
TUpdatePamFolderDTO,
|
||||
TUpdatePamResourceDTO
|
||||
} from "./types";
|
||||
|
||||
// Resources
|
||||
export const useCreatePamResource = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ resourceType, ...params }: TCreatePamResourceDTO) => {
|
||||
const { data } = await apiRequest.post<{ resource: TPamResource }>(
|
||||
`/api/v1/pam/resources/${resourceType}`,
|
||||
params
|
||||
);
|
||||
|
||||
return data.resource;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePamResource = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ resourceId, resourceType, ...params }: TUpdatePamResourceDTO) => {
|
||||
const { data } = await apiRequest.patch<{ resource: TPamResource }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}`,
|
||||
params
|
||||
);
|
||||
|
||||
return data.resource;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePamResource = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ resourceId, resourceType }: TDeletePamResourceDTO) => {
|
||||
const { data } = await apiRequest.delete<{ resource: TPamResource }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}`
|
||||
);
|
||||
|
||||
return data.resource;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export const useCreatePamAccount = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ resourceId, resourceType, ...params }: TCreatePamAccountDTO) => {
|
||||
const { data } = await apiRequest.post<{ account: TPamAccount }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}/accounts`,
|
||||
params
|
||||
);
|
||||
|
||||
return data.account;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePamAccount = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
resourceId,
|
||||
resourceType,
|
||||
accountId,
|
||||
...params
|
||||
}: TUpdatePamAccountDTO) => {
|
||||
const { data } = await apiRequest.patch<{ account: TPamAccount }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}`,
|
||||
params
|
||||
);
|
||||
|
||||
return data.account;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePamAccount = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ resourceId, resourceType, accountId }: TDeletePamAccountDTO) => {
|
||||
const { data } = await apiRequest.delete<{ account: TPamAccount }>(
|
||||
`/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}`
|
||||
);
|
||||
|
||||
return data.account;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Folders
|
||||
export const useCreatePamFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (params: TCreatePamFolderDTO) => {
|
||||
const { data } = await apiRequest.post<{ folder: TPamFolder }>("/api/v1/pam/folders", params);
|
||||
|
||||
return data.folder;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePamFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ folderId, ...params }: TUpdatePamFolderDTO) => {
|
||||
const { data } = await apiRequest.patch<{ folder: TPamFolder }>(
|
||||
`/api/v1/pam/folders/${folderId}`,
|
||||
params
|
||||
);
|
||||
|
||||
return data.folder;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePamFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ folderId }: TDeletePamFolderDTO) => {
|
||||
const { data } = await apiRequest.delete<{ folder: TPamFolder }>(
|
||||
`/api/v1/pam/folders/${folderId}`
|
||||
);
|
||||
|
||||
return data.folder;
|
||||
},
|
||||
onSuccess: ({ projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
138
frontend/src/hooks/api/pam/queries.tsx
Normal file
138
frontend/src/hooks/api/pam/queries.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TPamResourceOption } from "./types/resource-options";
|
||||
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
|
||||
|
||||
export const pamKeys = {
|
||||
all: ["pam"] as const,
|
||||
resource: () => [...pamKeys.all, "resource"] as const,
|
||||
account: () => [...pamKeys.all, "account"] as const,
|
||||
session: () => [...pamKeys.all, "session"] as const,
|
||||
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
|
||||
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
|
||||
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
|
||||
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
|
||||
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
|
||||
};
|
||||
|
||||
// Resources
|
||||
export const useListPamResourceOptions = (
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TPamResourceOption[],
|
||||
unknown,
|
||||
TPamResourceOption[],
|
||||
ReturnType<typeof pamKeys.listResourceOptions>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.listResourceOptions(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ resourceOptions: TPamResourceOption[] }>(
|
||||
"/api/v1/pam/resources/options"
|
||||
);
|
||||
|
||||
return data.resourceOptions;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useListPamResources = (
|
||||
projectId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TPamResource[],
|
||||
unknown,
|
||||
TPamResource[],
|
||||
ReturnType<typeof pamKeys.listResources>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.listResources(projectId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ resources: TPamResource[] }>(
|
||||
"/api/v1/pam/resources",
|
||||
{ params: { projectId } }
|
||||
);
|
||||
|
||||
return data.resources;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export const useListPamAccounts = (
|
||||
projectId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
{ accounts: TPamAccount[]; folders: TPamFolder[] },
|
||||
unknown,
|
||||
{ accounts: TPamAccount[]; folders: TPamFolder[] },
|
||||
ReturnType<typeof pamKeys.listAccounts>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.listAccounts(projectId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>(
|
||||
"/api/v1/pam/accounts",
|
||||
{ params: { projectId } }
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
// Sessions
|
||||
export const useGetPamSessionById = (
|
||||
sessionId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<TPamSession, unknown, TPamSession, ReturnType<typeof pamKeys.getSession>>,
|
||||
"queryKey" | "queryFn" | "enabled"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.getSession(sessionId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ session: TPamSession }>(
|
||||
`/api/v1/pam/sessions/${sessionId}`
|
||||
);
|
||||
|
||||
return data.session;
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useListPamSessions = (
|
||||
projectId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<TPamSession[], unknown, TPamSession[], ReturnType<typeof pamKeys.listSessions>>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pamKeys.listSessions(projectId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ sessions: TPamSession[] }>("/api/v1/pam/sessions", {
|
||||
params: { projectId }
|
||||
});
|
||||
|
||||
return data.sessions;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
17
frontend/src/hooks/api/pam/types/base-account.ts
Normal file
17
frontend/src/hooks/api/pam/types/base-account.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
|
||||
export interface TBasePamAccount {
|
||||
id: string;
|
||||
projectId: string;
|
||||
folderId?: string | null;
|
||||
resourceId: string;
|
||||
resource: {
|
||||
id: string;
|
||||
name: string;
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
name: string;
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
8
frontend/src/hooks/api/pam/types/base-resource.ts
Normal file
8
frontend/src/hooks/api/pam/types/base-resource.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface TBasePamResource {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
gatewayId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
97
frontend/src/hooks/api/pam/types/index.ts
Normal file
97
frontend/src/hooks/api/pam/types/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { PamResourceType, PamSessionStatus } from "../enums";
|
||||
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
|
||||
|
||||
export * from "./postgres-resource";
|
||||
|
||||
export type TPamResource = TPostgresResource;
|
||||
|
||||
export type TPamAccount = TPostgresAccount;
|
||||
|
||||
export type TPamFolder = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TPamSession = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
accountId?: string | null;
|
||||
resourceType: PamResourceType;
|
||||
resourceName: string;
|
||||
accountName: string;
|
||||
userId?: string | null;
|
||||
actorName: string;
|
||||
actorEmail: string;
|
||||
actorIp: string;
|
||||
actorUserAgent: string;
|
||||
status: PamSessionStatus;
|
||||
expiresAt?: string | null;
|
||||
startedAt?: string | null;
|
||||
endedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
commandLogs: {
|
||||
input: string;
|
||||
output: string;
|
||||
timestamp: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Resource DTOs
|
||||
export type TCreatePamResourceDTO = Pick<
|
||||
TPamResource,
|
||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
||||
>;
|
||||
|
||||
export type TUpdatePamResourceDTO = Partial<
|
||||
Pick<TPamResource, "name" | "connectionDetails" | "gatewayId">
|
||||
> & {
|
||||
resourceId: string;
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
|
||||
export type TDeletePamResourceDTO = {
|
||||
resourceId: string;
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
|
||||
// Account DTOs
|
||||
export type TCreatePamAccountDTO = Pick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
|
||||
> & {
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
|
||||
export type TUpdatePamAccountDTO = Partial<
|
||||
Pick<TPamAccount, "name" | "description" | "credentials">
|
||||
> & {
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
|
||||
export type TDeletePamAccountDTO = {
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
|
||||
// Folder DTOs
|
||||
export type TCreatePamFolderDTO = Pick<
|
||||
TPamFolder,
|
||||
"name" | "description" | "parentId" | "projectId"
|
||||
>;
|
||||
|
||||
export type TUpdatePamFolderDTO = Partial<Pick<TPamFolder, "name" | "description">> & {
|
||||
folderId: string;
|
||||
};
|
||||
|
||||
export type TDeletePamFolderDTO = {
|
||||
folderId: string;
|
||||
};
|
||||
14
frontend/src/hooks/api/pam/types/postgres-resource.ts
Normal file
14
frontend/src/hooks/api/pam/types/postgres-resource.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource";
|
||||
import { TBasePamAccount } from "./base-account";
|
||||
import { TBasePamResource } from "./base-resource";
|
||||
|
||||
// Resources
|
||||
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
|
||||
connectionDetails: TBaseSqlConnectionDetails;
|
||||
};
|
||||
|
||||
// Accounts
|
||||
export type TPostgresAccount = TBasePamAccount & {
|
||||
credentials: TBaseSqlCredentials;
|
||||
};
|
||||
11
frontend/src/hooks/api/pam/types/resource-options.ts
Normal file
11
frontend/src/hooks/api/pam/types/resource-options.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
|
||||
export type TPamResourceOptionBase = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TPostgresResourceOption = TPamResourceOptionBase & {
|
||||
resource: PamResourceType.Postgres;
|
||||
};
|
||||
|
||||
export type TPamResourceOption = TPostgresResourceOption;
|
||||
12
frontend/src/hooks/api/pam/types/shared/sql-resource.ts
Normal file
12
frontend/src/hooks/api/pam/types/shared/sql-resource.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type TBaseSqlConnectionDetails = {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
sslEnabled: boolean;
|
||||
sslRejectUnauthorized: boolean;
|
||||
};
|
||||
|
||||
export type TBaseSqlCredentials = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
@@ -13,7 +13,8 @@ export enum ProjectType {
|
||||
CertificateManager = "cert-manager",
|
||||
KMS = "kms",
|
||||
SSH = "ssh",
|
||||
SecretScanning = "secret-scanning"
|
||||
SecretScanning = "secret-scanning",
|
||||
PAM = "pam"
|
||||
}
|
||||
|
||||
export enum ProjectUserMembershipTemporaryMode {
|
||||
|
||||
@@ -58,4 +58,5 @@ export type SubscriptionPlan = {
|
||||
cardDeclined?: boolean;
|
||||
cardDeclinedReason?: string;
|
||||
machineIdentityAuthTemplates: boolean;
|
||||
pam: boolean;
|
||||
};
|
||||
|
||||
195
frontend/src/layouts/PamLayout/PamLayout.tsx
Normal file
195
frontend/src/layouts/PamLayout/PamLayout.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
faBook,
|
||||
faBoxOpen,
|
||||
faCog,
|
||||
faDisplay,
|
||||
faHome,
|
||||
faUser,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||
import { useProject, useProjectPermission, useSubscription } from "@app/context";
|
||||
|
||||
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
|
||||
import { useEffect } from "react";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
|
||||
export const PamLayout = () => {
|
||||
const { currentProject } = useProject();
|
||||
const { subscription } = useSubscription();
|
||||
const { assumedPrivilegeDetails } = useProjectPermission();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]);
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription && !subscription.pam) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}, [subscription]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
|
||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||
<motion.div
|
||||
key="menu-project-items"
|
||||
initial={{ x: -150 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -150 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
|
||||
>
|
||||
<nav className="items-between flex h-full flex-col overflow-y-auto dark:[color-scheme:dark]">
|
||||
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
|
||||
<Lottie className="inline-block h-5 w-5 shrink-0" icon="groups" />
|
||||
PAM
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Menu>
|
||||
<MenuGroup title="Resources">
|
||||
<Link
|
||||
to="/projects/pam/$projectId/accounts"
|
||||
params={{
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
Accounts
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/projects/pam/$projectId/resources"
|
||||
params={{
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faBoxOpen} />
|
||||
</div>
|
||||
Resources
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/projects/pam/$projectId/sessions"
|
||||
params={{
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faDisplay} />
|
||||
</div>
|
||||
Sessions
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Link>
|
||||
</MenuGroup>
|
||||
<MenuGroup title="Others">
|
||||
<Link
|
||||
to="/projects/pam/$projectId/access-management"
|
||||
params={{
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faUsers} />
|
||||
</div>
|
||||
Access Management
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/projects/pam/$projectId/audit-logs"
|
||||
params={{
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faBook} />
|
||||
</div>
|
||||
Audit Logs
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/projects/pam/$projectId/settings"
|
||||
params={{
|
||||
projectId: currentProject.id
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<MenuItem isSelected={isActive}>
|
||||
<div className="mx-1 flex gap-2">
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon icon={faCog} />
|
||||
</div>
|
||||
Settings
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Link>
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
</div>
|
||||
<div>
|
||||
<Menu>
|
||||
<Link to="/organization/projects">
|
||||
<MenuItem
|
||||
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||
leftIcon={
|
||||
<div className="w-6">
|
||||
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Organization Home
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</Menu>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 p-4 pt-8">
|
||||
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("upgradePlan", isOpen);
|
||||
}}
|
||||
text="You can use PAM if you switch to a paid Infisical plan."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
frontend/src/layouts/PamLayout/index.tsx
Normal file
1
frontend/src/layouts/PamLayout/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { PamLayout } from "./PamLayout";
|
||||
@@ -164,7 +164,10 @@ export const PkiSyncRow = ({
|
||||
</div>
|
||||
</Td>
|
||||
{subscriberId ? (
|
||||
<PkiSyncTableCell primaryText={pkiSync.subscriber?.name || subscriberId} secondaryText="PKI Subscriber" />
|
||||
<PkiSyncTableCell
|
||||
primaryText={pkiSync.subscriber?.name || subscriberId}
|
||||
secondaryText="PKI Subscriber"
|
||||
/>
|
||||
) : (
|
||||
<Td>
|
||||
<Tooltip content="The PKI subscriber for this sync has been deleted. Configure a new source or remove this sync.">
|
||||
|
||||
@@ -24,12 +24,13 @@ import { useOrganization } from "@app/context";
|
||||
import { useGetUserProjects } from "@app/hooks/api";
|
||||
import {
|
||||
eventToNameMap,
|
||||
projectToEventsMap,
|
||||
secretEvents,
|
||||
userAgentTypeToNameMap
|
||||
} from "@app/hooks/api/auditLogs/constants";
|
||||
import { EventType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { UserAgentType } from "@app/hooks/api/auth/types";
|
||||
import { Project } from "@app/hooks/api/projects/types";
|
||||
import { Project, ProjectType } from "@app/hooks/api/projects/types";
|
||||
|
||||
import { LogFilterItem } from "./LogFilterItem";
|
||||
import { auditLogFilterFormSchema, Presets, TAuditLogFilterFormData } from "./types";
|
||||
@@ -94,10 +95,20 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
|
||||
const selectedProject = project ?? watch("project");
|
||||
|
||||
const currentSelectedEventTypes = selectedEventTypes ?? [];
|
||||
const hasSecretEventFilter = currentSelectedEventTypes.some((eventType) =>
|
||||
secretEvents.includes(eventType)
|
||||
);
|
||||
const showSecretsSection =
|
||||
selectedEventTypes?.some(
|
||||
(eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS
|
||||
) || selectedEventTypes?.length === 0;
|
||||
project?.type !== ProjectType.PAM &&
|
||||
(hasSecretEventFilter || currentSelectedEventTypes.length === 0);
|
||||
|
||||
const filteredEventTypes = useMemo(() => {
|
||||
const projectEvents = project?.type ? projectToEventsMap[project.type] : undefined;
|
||||
if (!projectEvents) return eventTypes;
|
||||
|
||||
return eventTypes.filter((v) => projectEvents.includes(v.value as EventType));
|
||||
}, [project]);
|
||||
|
||||
const availableEnvironments = useMemo(() => {
|
||||
if (!selectedProject) return [];
|
||||
@@ -166,7 +177,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="thin-scrollbar inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{selectedEventTypes?.length === 1
|
||||
? eventTypes.find(
|
||||
? filteredEventTypes.find(
|
||||
(eventType) => eventType.value === selectedEventTypes[0]
|
||||
)?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
@@ -181,8 +192,8 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
className="thin-scrollbar z-[100] max-h-80 overflow-hidden"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{eventTypes && eventTypes.length > 0 ? (
|
||||
eventTypes.map((eventType) => {
|
||||
{filteredEventTypes.length > 0 ? (
|
||||
filteredEventTypes.map((eventType) => {
|
||||
const isSelected = selectedEventTypes?.includes(
|
||||
eventType.value as EventType
|
||||
);
|
||||
@@ -190,7 +201,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) =>
|
||||
eventTypes.length > 1 && event.preventDefault()
|
||||
filteredEventTypes.length > 1 && event.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
|
||||
@@ -63,6 +63,10 @@ const PROJECT_TYPE_MENU_ITEMS = [
|
||||
{
|
||||
label: "Secret Scanning",
|
||||
value: ProjectType.SecretScanning
|
||||
},
|
||||
{
|
||||
label: "PAM",
|
||||
value: ProjectType.PAM
|
||||
}
|
||||
];
|
||||
|
||||
@@ -131,12 +135,12 @@ const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => {
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="mt-2 grid grid-cols-5 gap-4">
|
||||
<div className="mt-2 grid grid-cols-3 gap-3">
|
||||
{PROJECT_TYPE_MENU_ITEMS.map((el) => (
|
||||
<div
|
||||
key={el.value}
|
||||
className={twMerge(
|
||||
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
|
||||
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 px-2 py-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
|
||||
field.value === el.value && "border-primary-400 bg-mineshaft-600 opacity-100"
|
||||
)}
|
||||
onClick={() => field.onChange(el.value)}
|
||||
|
||||
34
frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx
Normal file
34
frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { PamAccountsSection } from "./components/PamAccountsSection";
|
||||
|
||||
export const PamAccountsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "PAM" })}</title>
|
||||
</Helmet>
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionPamAccountActions.Read}
|
||||
a={ProjectPermissionSub.PamAccounts}
|
||||
>
|
||||
<div className="h-full bg-bunker-800">
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader title="Accounts" description="View, access, and manage accounts." />
|
||||
<PamAccountsSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectPermissionCan>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Button } from "@app/components/v2";
|
||||
|
||||
export enum AccountView {
|
||||
Flat = "flat",
|
||||
Nested = "nested"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: AccountView;
|
||||
onChange: (value: AccountView) => void;
|
||||
};
|
||||
|
||||
export const AccountViewToggle = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<div className="flex gap-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
onChange(AccountView.Flat);
|
||||
}}
|
||||
size="xs"
|
||||
className={`${
|
||||
value === AccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
Hide Folders
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
onChange(AccountView.Nested);
|
||||
}}
|
||||
size="xs"
|
||||
className={`${
|
||||
value === AccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
Show Folders
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import ms from "ms";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormLabel, Input, Modal, ModalClose, ModalContent } from "@app/components/v2";
|
||||
import { TPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
account?: TPamAccount;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
|
||||
const [duration, setDuration] = useState("4h");
|
||||
|
||||
const isDurationValid = useMemo(() => ms(duration || "1s") > 0, [duration]);
|
||||
|
||||
const command = useMemo(
|
||||
() =>
|
||||
account
|
||||
? `infisical pam access ${account.id}${duration ? ` --duration ${duration}` : ""}`
|
||||
: "",
|
||||
[account, duration]
|
||||
);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const copyCommand = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
|
||||
createNotification({
|
||||
text: "Command copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Access Account"
|
||||
subTitle={`Access ${account.name} using a CLI command.`}
|
||||
>
|
||||
<FormLabel
|
||||
label="Duration"
|
||||
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
|
||||
/>
|
||||
<Input
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="permanent"
|
||||
isError={!isDurationValid}
|
||||
/>
|
||||
<FormLabel label="CLI Command" className="mt-4" />
|
||||
<Input value={command} isDisabled />
|
||||
<a
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>Install the Infisical CLI</span>
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
|
||||
</a>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
isDisabled={!isDurationValid}
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
onClick={copyCommand}
|
||||
>
|
||||
Copy Command
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
export const genericAccountFieldsSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 64, field: "Name" }),
|
||||
description: z.string().max(512).nullable().optional()
|
||||
});
|
||||
|
||||
export const GenericAccountFields = () => {
|
||||
const {
|
||||
formState: { errors },
|
||||
control
|
||||
} = useFormContext<{ name: string; description: string }>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
helperText="Name must be slug-friendly"
|
||||
errorText={errors.name?.message}
|
||||
isError={Boolean(errors.name?.message)}
|
||||
label="Name"
|
||||
>
|
||||
<Input autoFocus placeholder="my-account" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
errorText={errors.name?.message}
|
||||
isError={Boolean(errors.name?.message)}
|
||||
label="Description"
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
PamResourceType,
|
||||
TPamAccount,
|
||||
useCreatePamAccount,
|
||||
useUpdatePamAccount
|
||||
} from "@app/hooks/api/pam";
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { PamAccountHeader } from "../PamAccountHeader";
|
||||
import { PostgresAccountForm } from "./PostgresAccountForm";
|
||||
|
||||
type FormProps = {
|
||||
onComplete: (account: TPamAccount) => void;
|
||||
};
|
||||
|
||||
type CreateFormProps = FormProps & {
|
||||
projectId: string;
|
||||
resourceId: string;
|
||||
resourceType: PamResourceType;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
type UpdateFormProps = FormProps & {
|
||||
account: TPamAccount;
|
||||
};
|
||||
|
||||
const CreateForm = ({
|
||||
onComplete,
|
||||
projectId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
folderId
|
||||
}: CreateFormProps) => {
|
||||
const createPamAccount = useCreatePamAccount();
|
||||
|
||||
console.log({ folderId });
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
) => {
|
||||
try {
|
||||
const account = await createPamAccount.mutateAsync({
|
||||
...formData,
|
||||
folderId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
projectId
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully created account",
|
||||
type: "success"
|
||||
});
|
||||
onComplete(account);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
title: "Failed to create account",
|
||||
text: err.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresAccountForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${resourceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
const updatePamAccount = useUpdatePamAccount();
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
) => {
|
||||
try {
|
||||
const updatedAccount = await updatePamAccount.mutateAsync({
|
||||
accountId: account.id,
|
||||
resourceId: account.resourceId,
|
||||
resourceType: account.resource.resourceType,
|
||||
...formData
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully updated account",
|
||||
type: "success"
|
||||
});
|
||||
onComplete(updatedAccount);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
title: "Failed to update account",
|
||||
text: err.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (account.resource.resourceType) {
|
||||
case PamResourceType.Postgres:
|
||||
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onBack?: () => void;
|
||||
projectId: string;
|
||||
} & FormProps &
|
||||
(
|
||||
| {
|
||||
account: TPamAccount;
|
||||
resourceId?: undefined;
|
||||
resourceName?: undefined;
|
||||
resourceType?: undefined;
|
||||
folderId?: undefined;
|
||||
}
|
||||
| {
|
||||
account?: undefined;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceType: PamResourceType;
|
||||
folderId?: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const PamAccountForm = ({ onBack, projectId, ...props }: Props) => {
|
||||
const { account, resourceName, resourceType } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PamAccountHeader
|
||||
resourceName={account ? account.resource.name : resourceName}
|
||||
resourceType={account ? account.resource.resourceType : resourceType}
|
||||
onBack={onBack}
|
||||
/>
|
||||
{account ? (
|
||||
<UpdateForm {...props} account={account} />
|
||||
) : (
|
||||
<CreateForm {...props} projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { TPostgresAccount } from "@app/hooks/api/pam";
|
||||
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TPostgresAccount;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: BaseSqlAccountSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: account
|
||||
? {
|
||||
...account,
|
||||
credentials: {
|
||||
...account.credentials,
|
||||
password: "******"
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormControl, Input } from "@app/components/v2";
|
||||
|
||||
export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
name="credentials.username"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Username"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.password"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Password"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
onFocus={(e) => {
|
||||
if (isUpdate && field.value === "******") {
|
||||
field.onChange("");
|
||||
}
|
||||
e.target.type = "text";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (isUpdate && field.value === "") {
|
||||
field.onChange("******");
|
||||
}
|
||||
e.target.type = "password";
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const BaseSqlAccountSchema = z.object({
|
||||
username: z.string().trim().min(1, "Username required"),
|
||||
password: z.string().trim().min(1, "Password required")
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PAM_RESOURCE_TYPE_MAP, PamResourceType } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
resourceName: string;
|
||||
resourceType: PamResourceType;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export const PamAccountHeader = ({ resourceName, resourceType, onBack }: Props) => {
|
||||
const details = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
|
||||
<img
|
||||
alt={`${details.name} logo`}
|
||||
src={`/images/integrations/${details.image}`}
|
||||
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center text-mineshaft-300">{resourceName}</div>
|
||||
<p className="text-sm leading-4 text-mineshaft-400">{details.name} resource</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto mt-1 text-xs text-mineshaft-400 underline underline-offset-2 hover:text-mineshaft-300"
|
||||
onClick={onBack}
|
||||
>
|
||||
Select another resource
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
faBoxOpen,
|
||||
faCheck,
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faRightToBracket,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
account: TPamAccount;
|
||||
onAccess: (resource: TPamAccount) => void;
|
||||
onUpdate: (resource: TPamAccount) => void;
|
||||
onDelete: (resource: TPamAccount) => void;
|
||||
search: string;
|
||||
};
|
||||
|
||||
export const PamAccountRow = ({ account, search, onAccess, onUpdate, onDelete }: Props) => {
|
||||
const { id, name } = account;
|
||||
|
||||
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
const handleCopyId = useCallback(
|
||||
(idToCopy: string) => {
|
||||
setIsIdCopied.on();
|
||||
navigator.clipboard.writeText(idToCopy);
|
||||
|
||||
createNotification({
|
||||
text: "Account ID copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[isIdCopied]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tr className={twMerge("group h-10")} key={`account-${id}`}>
|
||||
<Td>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<img alt={resourceTypeName} src={`/images/integrations/${image}`} className="size-5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
<HighlightText text={name} highlight={search} />
|
||||
</span>
|
||||
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-bunker-300/20 text-bunker-300">
|
||||
<FontAwesomeIcon icon={faBoxOpen} />
|
||||
<span>
|
||||
<HighlightText text={account.resource.name} highlight={search} />
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPamAccountActions.Access}
|
||||
a={ProjectPermissionSub.PamAccounts}
|
||||
>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faRightToBracket} />}
|
||||
onClick={() => onAccess(account)}
|
||||
size="xs"
|
||||
>
|
||||
Access
|
||||
</Button>
|
||||
</ProjectPermissionCan>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyId(id);
|
||||
}}
|
||||
>
|
||||
Copy Account ID
|
||||
</DropdownMenuItem>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPamAccountActions.Edit}
|
||||
a={ProjectPermissionSub.PamAccounts}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={() => onUpdate(account)}
|
||||
>
|
||||
Edit Account
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPamAccountActions.Delete}
|
||||
a={ProjectPermissionSub.PamAccounts}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={() => onDelete(account)}
|
||||
>
|
||||
Delete Account
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListPamAccounts } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAccountsTable } from "./PamAccountsTable";
|
||||
|
||||
export const PamAccountsSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data, isPending } = useListPamAccounts(currentProject.id, {
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
if (isPending) return <ContentLoader />;
|
||||
|
||||
return (
|
||||
<PamAccountsTable
|
||||
projectId={currentProject.id}
|
||||
accounts={data?.accounts || []}
|
||||
folders={data?.folders || []}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faAngleDown,
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faFilter,
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { PAM_RESOURCE_TYPE_MAP, TPamAccount, TPamFolder } from "@app/hooks/api/pam";
|
||||
|
||||
import { AccountView, AccountViewToggle } from "./AccountViewToggle";
|
||||
import { PamAccessAccountModal } from "./PamAccessAccountModal";
|
||||
import { PamAccountRow } from "./PamAccountRow";
|
||||
import { PamAddAccountModal } from "./PamAddAccountModal";
|
||||
import { PamAddFolderModal } from "./PamAddFolderModal";
|
||||
import { PamDeleteAccountModal } from "./PamDeleteAccountModal";
|
||||
import { PamDeleteFolderModal } from "./PamDeleteFolderModal";
|
||||
import { PamFolderRow } from "./PamFolderRow";
|
||||
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
|
||||
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
|
||||
import { useSubscription } from "@app/context";
|
||||
|
||||
enum OrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
resource: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
accounts: TPamAccount[];
|
||||
folders: TPamFolder[];
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"misc",
|
||||
"addFolder",
|
||||
"updateFolder",
|
||||
"deleteFolder",
|
||||
"addAccount",
|
||||
"accessAccount",
|
||||
"updateAccount",
|
||||
"deleteAccount"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
search: initSearch,
|
||||
accountPath,
|
||||
accountView: initAccountView
|
||||
} = useSearch({
|
||||
from: ROUTE_PATHS.Pam.AccountsPage.id
|
||||
});
|
||||
|
||||
const [accountView, setAccountView] = useState<AccountView>(initAccountView ?? AccountView.Flat);
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
resource: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
|
||||
|
||||
const { foldersByParentId, pathMap } = useMemo(() => {
|
||||
const foldersById: Record<string, TPamFolder> = {};
|
||||
const tempFoldersByParentId: Record<string, TPamFolder[]> = { null: [] };
|
||||
const tempPathMap: Record<string, string> = { "/": "null" };
|
||||
|
||||
folders.forEach((folder) => {
|
||||
foldersById[folder.id] = folder;
|
||||
if (!tempFoldersByParentId[folder.parentId || "null"]) {
|
||||
tempFoldersByParentId[folder.parentId || "null"] = [];
|
||||
}
|
||||
tempFoldersByParentId[folder.parentId || "null"].push(folder);
|
||||
});
|
||||
|
||||
const buildPaths = (parentId: string | null, currentPath: string) => {
|
||||
(tempFoldersByParentId[parentId || "null"] || []).forEach((folder) => {
|
||||
const newPath = `${currentPath}${folder.name}/`;
|
||||
tempPathMap[newPath] = folder.id;
|
||||
buildPaths(folder.id, newPath);
|
||||
});
|
||||
};
|
||||
|
||||
buildPaths(null, "/");
|
||||
|
||||
return { foldersByParentId: tempFoldersByParentId, pathMap: tempPathMap };
|
||||
}, [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();
|
||||
|
||||
return (
|
||||
name.toLowerCase().includes(searchValue) ||
|
||||
resourceName.toLowerCase().includes(searchValue) ||
|
||||
(description || "").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]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredAccounts.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const currentPageData = useMemo(
|
||||
() => filteredAccounts.slice(offset, perPage * page),
|
||||
[filteredAccounts, offset, perPage, page]
|
||||
);
|
||||
|
||||
const handleSort = (column: OrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: OrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered = Boolean(filters.resource.length);
|
||||
|
||||
const handleFolderClick = (folder: TPamFolder) => {
|
||||
if (accountView === AccountView.Flat) {
|
||||
return;
|
||||
}
|
||||
const newPath = `${accountPath}${folder.name}/`;
|
||||
navigate({ search: (prev) => ({ ...prev, accountPath: newPath }) });
|
||||
};
|
||||
|
||||
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
|
||||
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
||||
|
||||
const uniqueResources = useMemo(() => {
|
||||
const resourceMap = new Map<string, TPamAccount["resource"]>();
|
||||
accounts.forEach((account) => {
|
||||
resourceMap.set(account.resource.id, account.resource);
|
||||
});
|
||||
return Array.from(resourceMap.values());
|
||||
}, [accounts]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
|
||||
{(isAllowed) =>
|
||||
isAllowed && (
|
||||
<AccountViewToggle
|
||||
value={accountView}
|
||||
onChange={(e) => {
|
||||
setAccountView(e);
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
accountView: e === AccountView.Flat ? undefined : e,
|
||||
accountPath: e === AccountView.Flat ? "/" : prev.accountPath
|
||||
})
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</ProjectPermissionCan>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
const newSearch = e.target.value;
|
||||
setSearch(newSearch);
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, search: newSearch || undefined }),
|
||||
replace: true
|
||||
});
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search accounts..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter accounts"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 min-w-10 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Resource</DropdownMenuLabel>
|
||||
{uniqueResources.length ? (
|
||||
uniqueResources.map((resource) => {
|
||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const newResources = filters.resource.includes(resource.id)
|
||||
? filters.resource.filter((a) => a !== resource.id)
|
||||
: [...filters.resource, resource.id];
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
resource: newResources
|
||||
}));
|
||||
}}
|
||||
key={resource.id}
|
||||
icon={
|
||||
filters.resource.includes(resource.id) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${name} resource`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{resource.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Account Resources</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPamAccountActions.Create}
|
||||
a={ProjectPermissionSub.PamAccounts}
|
||||
>
|
||||
{(isAllowedToCreateAccounts) => (
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addAccount")}
|
||||
isDisabled={!isAllowedToCreateAccounts || !subscription.pam}
|
||||
className={`h-10 transition-colors ${accountView === AccountView.Flat ? "" : "rounded-r-none"}`}
|
||||
>
|
||||
Add Account
|
||||
</Button>
|
||||
|
||||
{accountView !== AccountView.Flat && (
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
ariaLabel="add-folder-or-import"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={5}>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.PamFolders}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} className="pr-2" />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Accounts
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{accountView !== AccountView.Flat &&
|
||||
foldersToRender.map((folder) => (
|
||||
<PamFolderRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
search={search}
|
||||
onClick={() => handleFolderClick(folder)}
|
||||
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
|
||||
/>
|
||||
))}
|
||||
{currentPageData.map((account) => (
|
||||
<PamAccountRow
|
||||
key={account.id}
|
||||
account={account}
|
||||
search={search}
|
||||
onAccess={(e) => {
|
||||
handlePopUpOpen("accessAccount", e);
|
||||
}}
|
||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredAccounts.length) && (
|
||||
<Pagination
|
||||
count={filteredAccounts.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{isContentEmpty && (
|
||||
<EmptyState
|
||||
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
|
||||
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<PamDeleteFolderModal
|
||||
isOpen={popUp.deleteFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||
folder={popUp.deleteFolder.data}
|
||||
/>
|
||||
<PamUpdateFolderModal
|
||||
isOpen={popUp.updateFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
|
||||
folder={popUp.updateFolder.data}
|
||||
/>
|
||||
<PamAddFolderModal
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
projectId={projectId}
|
||||
currentFolderId={effectiveFolderIdForFiltering}
|
||||
/>
|
||||
<PamAccessAccountModal
|
||||
isOpen={popUp.accessAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("accessAccount", isOpen)}
|
||||
account={popUp.accessAccount.data}
|
||||
/>
|
||||
<PamDeleteAccountModal
|
||||
isOpen={popUp.deleteAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deleteAccount", isOpen)}
|
||||
account={popUp.deleteAccount.data}
|
||||
/>
|
||||
<PamUpdateAccountModal
|
||||
isOpen={popUp.updateAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("updateAccount", isOpen)}
|
||||
account={popUp.updateAccount.data}
|
||||
/>
|
||||
<PamAddAccountModal
|
||||
isOpen={popUp.addAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
|
||||
projectId={projectId}
|
||||
currentFolderId={effectiveFolderIdForFiltering}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { PamResourceType, TPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAccountForm } from "./PamAccountForm/PamAccountForm";
|
||||
import { ResourceSelect } from "./ResourceSelect";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
projectId: string;
|
||||
onComplete?: (account: TPamAccount) => void;
|
||||
currentFolderId: string | null;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onComplete: (account: TPamAccount) => void;
|
||||
projectId: string;
|
||||
currentFolderId: string | null;
|
||||
};
|
||||
|
||||
const Content = ({ onComplete, projectId, currentFolderId }: ContentProps) => {
|
||||
const [selectedResource, setSelectedResource] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
resourceType: PamResourceType;
|
||||
} | null>(null);
|
||||
|
||||
if (selectedResource) {
|
||||
return (
|
||||
<PamAccountForm
|
||||
onComplete={onComplete}
|
||||
onBack={() => setSelectedResource(null)}
|
||||
resourceId={selectedResource.id}
|
||||
resourceName={selectedResource.name}
|
||||
resourceType={selectedResource.resourceType}
|
||||
projectId={projectId}
|
||||
folderId={currentFolderId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />;
|
||||
};
|
||||
|
||||
export const PamAddAccountModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
onComplete,
|
||||
currentFolderId
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Add Account"
|
||||
subTitle="Select a resource to add an account under."
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<Content
|
||||
projectId={projectId}
|
||||
onComplete={(account) => {
|
||||
if (onComplete) onComplete(account);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { TPamFolder, useCreatePamFolder } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamFolderForm } from "./PamFolderForm";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
projectId: string;
|
||||
currentFolderId: string | null;
|
||||
};
|
||||
|
||||
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
|
||||
const createPamFolder = useCreatePamFolder();
|
||||
|
||||
console.log({ currentFolderId });
|
||||
|
||||
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
|
||||
try {
|
||||
await createPamFolder.mutateAsync({
|
||||
...formData,
|
||||
parentId: currentFolderId,
|
||||
projectId
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully created folder",
|
||||
type: "success"
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
title: "Failed to create folder",
|
||||
text: err.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent className="max-w-2xl" title="Create Folder">
|
||||
<PamFolderForm onSubmit={onSubmit} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { TPamAccount, useDeletePamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
account?: TPamAccount;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const PamDeleteAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
|
||||
const deletePamAccount = useDeletePamAccount();
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const {
|
||||
id: accountId,
|
||||
name,
|
||||
resourceId,
|
||||
resource: { resourceType }
|
||||
} = account;
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deletePamAccount.mutateAsync({
|
||||
accountId,
|
||||
resourceId,
|
||||
resourceType
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted account",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete account",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteActionModal
|
||||
isOpen={isOpen}
|
||||
onChange={onOpenChange}
|
||||
title={`Are you sure you want to delete ${name}?`}
|
||||
deleteKey={name}
|
||||
onDeleteApproved={handleDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { TPamFolder, useDeletePamFolder } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
folder?: TPamFolder;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const PamDeleteFolderModal = ({ isOpen, onOpenChange, folder }: Props) => {
|
||||
const deletePamFolder = useDeletePamFolder();
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
const { id: folderId, name } = folder;
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deletePamFolder.mutateAsync({
|
||||
folderId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted folder",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete folder",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteActionModal
|
||||
isOpen={isOpen}
|
||||
onChange={onOpenChange}
|
||||
title={`Are you sure you want to delete ${name}?`}
|
||||
deleteKey={name}
|
||||
onDeleteApproved={handleDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, TextArea } from "@app/components/v2";
|
||||
import { TPamFolder } from "@app/hooks/api/pam";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
folder?: TPamFolder;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 64, field: "Name" }),
|
||||
description: z.string().max(512).optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const PamFolderForm = ({ folder, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(folder);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: folder
|
||||
? {
|
||||
name: folder.name,
|
||||
description: folder.description || ""
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isDirty, errors }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
helperText="Name must be slug-friendly"
|
||||
errorText={errors.name?.message}
|
||||
isError={Boolean(errors.name?.message)}
|
||||
label="Name"
|
||||
>
|
||||
<Input autoFocus placeholder="my-folder" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
errorText={errors.name?.message}
|
||||
isError={Boolean(errors.name?.message)}
|
||||
label="Description"
|
||||
isOptional
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Folder" : "Create Folder"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { faEdit, faEllipsisV, faFolder, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TPamFolder } from "@app/hooks/api/pam";
|
||||
|
||||
type Props = {
|
||||
folder: TPamFolder;
|
||||
onUpdate: (folder: TPamFolder) => void;
|
||||
onDelete: (folder: TPamFolder) => void;
|
||||
onClick: () => void;
|
||||
search: string;
|
||||
};
|
||||
|
||||
export const PamFolderRow = ({ folder, onClick, onDelete, onUpdate, search }: Props) => {
|
||||
return (
|
||||
<Tr
|
||||
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex w-5 justify-center">
|
||||
<FontAwesomeIcon icon={faFolder} className="size-4 text-yellow-700" />
|
||||
</div>
|
||||
<span>
|
||||
<HighlightText text={folder.name} highlight={search} />
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex h-[22px] justify-end">
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="hidden w-6 group-hover:flex data-[state=open]:!flex"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.PamFolders}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdate(folder);
|
||||
}}
|
||||
>
|
||||
Edit Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.PamFolders}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(folder);
|
||||
}}
|
||||
>
|
||||
Delete Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { components, OptionProps } from "react-select";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Badge } from "@app/components/v2";
|
||||
import { PAM_RESOURCE_TYPE_MAP, PamResourceType } from "@app/hooks/api/pam";
|
||||
|
||||
export const PamResourceOption = ({
|
||||
isSelected,
|
||||
children,
|
||||
...props
|
||||
}: OptionProps<{ id: string; name: string; resourceType: PamResourceType }>) => {
|
||||
const isCreateOption = props.data.id === "_create";
|
||||
|
||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[props.data.resourceType];
|
||||
|
||||
return (
|
||||
<components.Option isSelected={isSelected} {...props}>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
{isCreateOption ? (
|
||||
<div className="flex items-center gap-x-1 text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faPlus} size="sm" />
|
||||
<span className="mr-auto">Create New Resource</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate">{children}</p>
|
||||
<div className="ml-2 mr-auto">
|
||||
<Badge className="flex h-5 items-center gap-1 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
|
||||
<img
|
||||
alt={`${name} logo`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="size-3"
|
||||
/>
|
||||
{name}
|
||||
</Badge>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { TPamAccount } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAccountForm } from "./PamAccountForm/PamAccountForm";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
account?: TPamAccount;
|
||||
};
|
||||
|
||||
export const PamUpdateAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent className="max-w-2xl" title="Edit Account" subTitle="Update account details.">
|
||||
<PamAccountForm
|
||||
onComplete={() => onOpenChange(false)}
|
||||
account={account}
|
||||
projectId={account.projectId}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { TPamFolder, useUpdatePamFolder } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamFolderForm } from "./PamFolderForm";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
folder?: TPamFolder;
|
||||
};
|
||||
|
||||
export const PamUpdateFolderModal = ({ isOpen, onOpenChange, folder }: Props) => {
|
||||
const updatePamFolder = useUpdatePamFolder();
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
|
||||
try {
|
||||
await updatePamFolder.mutateAsync({
|
||||
...formData,
|
||||
folderId: folder.id
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully updated folder",
|
||||
type: "success"
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
title: "Failed to updated folder",
|
||||
text: err.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent className="max-w-2xl" title="Edit Account" subTitle="Update account details.">
|
||||
<PamFolderForm onSubmit={onSubmit} folder={folder} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
|
||||
import { PamResourceOption } from "./PamResourceOption";
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: FormData) => void;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
resource: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
resourceType: z.nativeEnum(PamResourceType)
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
const { isPending, data: resources } = useListPamResources(projectId);
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const { handleSubmit, control, setValue } = form;
|
||||
|
||||
const canCreateResource = permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
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 (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
|
||||
handlePopUpOpen("addResource");
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
}}
|
||||
isLoading={isPending}
|
||||
options={[
|
||||
...(canCreateResource
|
||||
? [
|
||||
{
|
||||
id: "_create",
|
||||
name: "Create Resource",
|
||||
// This is just to make typescript happy. Does not actually do anything
|
||||
resourceType: PamResourceType.Postgres
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(resources ?? [])
|
||||
]}
|
||||
placeholder="Select resource..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id}
|
||||
components={{ Option: PamResourceOption }}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="resource"
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button className="mr-4" size="sm" type="submit" colorSchema="secondary">
|
||||
Continue
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
<PamAddResourceModal
|
||||
isOpen={popUp.addResource.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addResource", isOpen)}
|
||||
projectId={projectId}
|
||||
onComplete={(resource) => setValue("resource", resource)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
49
frontend/src/pages/pam/PamAccountsPage/route.tsx
Normal file
49
frontend/src/pages/pam/PamAccountsPage/route.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AccountView } from "./components/AccountViewToggle";
|
||||
import { PamAccountsPage } from "./PamAccountsPage";
|
||||
|
||||
const PamAccountsPageQueryParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
accountView: z.nativeEnum(AccountView).optional(),
|
||||
accountPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/accounts"
|
||||
)({
|
||||
validateSearch: zodValidator(PamAccountsPageQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ accountPath: "/" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const accountPathSegments = search.accountPath.split("/").filter(Boolean);
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Accounts",
|
||||
link: linkOptions({
|
||||
to: "/projects/pam/$projectId/accounts",
|
||||
params: () => params as never,
|
||||
search: (prev) => ({ ...prev, accountPath: "/" })
|
||||
})
|
||||
},
|
||||
...accountPathSegments.map((segment, index) => {
|
||||
const newPath = `/${accountPathSegments.slice(0, index + 1).join("/")}/`;
|
||||
return {
|
||||
label: segment,
|
||||
link: linkOptions({
|
||||
to: "/projects/pam/$projectId/accounts",
|
||||
params: () => params as never,
|
||||
search: (prev) => ({ ...prev, accountPath: newPath })
|
||||
})
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
},
|
||||
component: PamAccountsPage
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user