mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@@ -83,6 +83,7 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua
|
|||||||
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
|
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
|
||||||
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
|
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
|
||||||
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
|
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
|
||||||
|
import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
|
||||||
import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service";
|
import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service";
|
||||||
import { TOrgServiceFactory } from "@app/services/org/org-service";
|
import { TOrgServiceFactory } from "@app/services/org/org-service";
|
||||||
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
||||||
@@ -303,6 +304,7 @@ declare module "fastify" {
|
|||||||
bus: TEventBusService;
|
bus: TEventBusService;
|
||||||
sse: TServerSentEventsService;
|
sse: TServerSentEventsService;
|
||||||
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
|
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
|
||||||
|
offlineUsageReport: TOfflineUsageReportServiceFactory;
|
||||||
};
|
};
|
||||||
// this is exclusive use for middlewares in which we need to inject data
|
// this is exclusive use for middlewares in which we need to inject data
|
||||||
// everywhere else access using service layer
|
// everywhere else access using service layer
|
||||||
|
|||||||
@@ -722,6 +722,16 @@ export const licenseServiceFactory = ({
|
|||||||
await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId));
|
await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCustomerId = () => {
|
||||||
|
if (!selfHostedLicense) return "unknown";
|
||||||
|
return selfHostedLicense?.customerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLicenseId = () => {
|
||||||
|
if (!selfHostedLicense) return "unknown";
|
||||||
|
return selfHostedLicense?.licenseId;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generateOrgCustomerId,
|
generateOrgCustomerId,
|
||||||
removeOrgCustomer,
|
removeOrgCustomer,
|
||||||
@@ -736,6 +746,8 @@ export const licenseServiceFactory = ({
|
|||||||
return onPremFeatures;
|
return onPremFeatures;
|
||||||
},
|
},
|
||||||
getPlan,
|
getPlan,
|
||||||
|
getCustomerId,
|
||||||
|
getLicenseId,
|
||||||
invalidateGetPlan,
|
invalidateGetPlan,
|
||||||
updateSubscriptionOrgMemberCount,
|
updateSubscriptionOrgMemberCount,
|
||||||
refreshPlan,
|
refreshPlan,
|
||||||
|
|||||||
@@ -291,6 +291,8 @@ import { TSmtpService } from "@app/services/smtp/smtp-service";
|
|||||||
import { invalidateCacheQueueFactory } from "@app/services/super-admin/invalidate-cache-queue";
|
import { invalidateCacheQueueFactory } from "@app/services/super-admin/invalidate-cache-queue";
|
||||||
import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
|
import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
|
||||||
import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
|
import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
|
||||||
|
import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal";
|
||||||
|
import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
|
||||||
import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal";
|
import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal";
|
||||||
import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
|
import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
|
||||||
import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
|
import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
|
||||||
@@ -385,6 +387,7 @@ export const registerRoutes = async (
|
|||||||
const reminderRecipientDAL = reminderRecipientDALFactory(db);
|
const reminderRecipientDAL = reminderRecipientDALFactory(db);
|
||||||
|
|
||||||
const integrationDAL = integrationDALFactory(db);
|
const integrationDAL = integrationDALFactory(db);
|
||||||
|
const offlineUsageReportDAL = offlineUsageReportDALFactory(db);
|
||||||
const integrationAuthDAL = integrationAuthDALFactory(db);
|
const integrationAuthDAL = integrationAuthDALFactory(db);
|
||||||
const webhookDAL = webhookDALFactory(db);
|
const webhookDAL = webhookDALFactory(db);
|
||||||
const serviceTokenDAL = serviceTokenDALFactory(db);
|
const serviceTokenDAL = serviceTokenDALFactory(db);
|
||||||
@@ -847,6 +850,11 @@ export const registerRoutes = async (
|
|||||||
tokenService
|
tokenService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const offlineUsageReportService = offlineUsageReportServiceFactory({
|
||||||
|
offlineUsageReportDAL,
|
||||||
|
licenseService
|
||||||
|
});
|
||||||
|
|
||||||
const orgAdminService = orgAdminServiceFactory({
|
const orgAdminService = orgAdminServiceFactory({
|
||||||
smtpService,
|
smtpService,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
@@ -2005,6 +2013,7 @@ export const registerRoutes = async (
|
|||||||
apiKey: apiKeyService,
|
apiKey: apiKeyService,
|
||||||
authToken: tokenService,
|
authToken: tokenService,
|
||||||
superAdmin: superAdminService,
|
superAdmin: superAdminService,
|
||||||
|
offlineUsageReport: offlineUsageReportService,
|
||||||
project: projectService,
|
project: projectService,
|
||||||
projectMembership: projectMembershipService,
|
projectMembership: projectMembershipService,
|
||||||
projectKey: projectKeyService,
|
projectKey: projectKeyService,
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
defaultAuthOrgAuthMethod: z.string().nullish(),
|
defaultAuthOrgAuthMethod: z.string().nullish(),
|
||||||
isSecretScanningDisabled: z.boolean(),
|
isSecretScanningDisabled: z.boolean(),
|
||||||
kubernetesAutoFetchServiceAccountToken: z.boolean(),
|
kubernetesAutoFetchServiceAccountToken: z.boolean(),
|
||||||
paramsFolderSecretDetectionEnabled: z.boolean()
|
paramsFolderSecretDetectionEnabled: z.boolean(),
|
||||||
|
isOfflineUsageReportsEnabled: z.boolean()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
isMigrationModeOn: serverEnvs.MAINTENANCE_MODE,
|
isMigrationModeOn: serverEnvs.MAINTENANCE_MODE,
|
||||||
isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING,
|
isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING,
|
||||||
kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN,
|
kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN,
|
||||||
paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED
|
paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED,
|
||||||
|
isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -926,4 +928,35 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
return { organizationMembership };
|
return { organizationMembership };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/usage-report/generate",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
csvContent: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
filename: z.string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: (req, res, done) => {
|
||||||
|
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
|
||||||
|
verifySuperAdmin(req, res, done);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async () => {
|
||||||
|
const result = await server.services.offlineUsageReport.generateUsageReportCSV();
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvContent: result.csvContent,
|
||||||
|
signature: result.signature,
|
||||||
|
filename: result.filename
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { ProjectType, TableName } from "@app/db/schemas";
|
||||||
|
|
||||||
|
export type TOfflineUsageReportDALFactory = ReturnType<typeof offlineUsageReportDALFactory>;
|
||||||
|
|
||||||
|
export const offlineUsageReportDALFactory = (db: TDbClient) => {
|
||||||
|
const getUserMetrics = async () => {
|
||||||
|
// Get total users and admin users
|
||||||
|
const userMetrics = (await db
|
||||||
|
.from(TableName.Users)
|
||||||
|
.select(
|
||||||
|
db.raw(
|
||||||
|
`
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users
|
||||||
|
`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where({ isGhost: false })
|
||||||
|
.first()) as { total_users: string; admin_users: string } | undefined;
|
||||||
|
|
||||||
|
// Get users by auth method
|
||||||
|
const authMethodStats = (await db
|
||||||
|
.from(TableName.Users)
|
||||||
|
.select(
|
||||||
|
db.raw(`
|
||||||
|
unnest("authMethods") as auth_method,
|
||||||
|
COUNT(*) as count
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
.where({ isGhost: false })
|
||||||
|
.whereNotNull("authMethods")
|
||||||
|
.groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>;
|
||||||
|
|
||||||
|
const usersByAuthMethod = authMethodStats.reduce(
|
||||||
|
(acc: Record<string, number>, row: { auth_method: string; count: string }) => {
|
||||||
|
acc[row.auth_method] = parseInt(row.count, 10);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: parseInt(userMetrics?.total_users || "0", 10),
|
||||||
|
adminUsers: parseInt(userMetrics?.admin_users || "0", 10),
|
||||||
|
usersByAuthMethod
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMachineIdentityMetrics = async () => {
|
||||||
|
// Get total machine identities
|
||||||
|
const identityMetrics = (await db
|
||||||
|
.from(TableName.Identity)
|
||||||
|
.select(
|
||||||
|
db.raw(
|
||||||
|
`
|
||||||
|
COUNT(*) as total_identities
|
||||||
|
`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()) as { total_identities: string } | undefined;
|
||||||
|
|
||||||
|
// Get identities by auth method
|
||||||
|
const authMethodStats = (await db
|
||||||
|
.from(TableName.Identity)
|
||||||
|
.select("authMethod")
|
||||||
|
.count("* as count")
|
||||||
|
.whereNotNull("authMethod")
|
||||||
|
.groupBy("authMethod")) as Array<{ authMethod: string; count: string }>;
|
||||||
|
|
||||||
|
const machineIdentitiesByAuthMethod = authMethodStats.reduce(
|
||||||
|
(acc: Record<string, number>, row: { authMethod: string; count: string }) => {
|
||||||
|
acc[row.authMethod] = parseInt(row.count, 10);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10),
|
||||||
|
machineIdentitiesByAuthMethod
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectMetrics = async () => {
|
||||||
|
// Get total projects and projects by type
|
||||||
|
const projectMetrics = (await db
|
||||||
|
.from(TableName.Project)
|
||||||
|
.select("type")
|
||||||
|
.count("* as count")
|
||||||
|
.groupBy("type")) as Array<{ type: string; count: string }>;
|
||||||
|
|
||||||
|
const totalProjects = projectMetrics.reduce(
|
||||||
|
(sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const projectsByType = projectMetrics.reduce(
|
||||||
|
(acc: Record<string, number>, row: { type: string; count: string }) => {
|
||||||
|
acc[row.type] = parseInt(row.count, 10);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate average secrets per project
|
||||||
|
const secretsPerProject = (await db
|
||||||
|
.from(`${TableName.SecretV2} as s`)
|
||||||
|
.select("p.id as projectId")
|
||||||
|
.count("s.id as count")
|
||||||
|
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
|
||||||
|
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
|
||||||
|
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
|
||||||
|
.where("p.type", ProjectType.SecretManager)
|
||||||
|
.groupBy("p.id")
|
||||||
|
.whereNotNull("p.id")) as Array<{ projectId: string; count: string }>;
|
||||||
|
|
||||||
|
const averageSecretsPerProject =
|
||||||
|
secretsPerProject.length > 0
|
||||||
|
? secretsPerProject.reduce(
|
||||||
|
(sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10),
|
||||||
|
0
|
||||||
|
) / secretsPerProject.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProjects,
|
||||||
|
projectsByType,
|
||||||
|
averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecretMetrics = async () => {
|
||||||
|
// Get total secrets
|
||||||
|
const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as
|
||||||
|
| { count: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10);
|
||||||
|
|
||||||
|
// Get secrets by project
|
||||||
|
const secretsByProject = (await db
|
||||||
|
.from(`${TableName.SecretV2} as s`)
|
||||||
|
.select("p.id as projectId", "p.name as projectName")
|
||||||
|
.count("s.id as secretCount")
|
||||||
|
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
|
||||||
|
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
|
||||||
|
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
|
||||||
|
.where("p.type", ProjectType.SecretManager)
|
||||||
|
.groupBy("p.id", "p.name")
|
||||||
|
.whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSecrets,
|
||||||
|
secretsByProject: secretsByProject.map(
|
||||||
|
(row: { projectId: string; projectName: string; secretCount: string }) => ({
|
||||||
|
projectId: row.projectId,
|
||||||
|
projectName: row.projectName,
|
||||||
|
secretCount: parseInt(row.secretCount, 10)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecretSyncMetrics = async () => {
|
||||||
|
const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as
|
||||||
|
| { count: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDynamicSecretMetrics = async () => {
|
||||||
|
const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as
|
||||||
|
| { count: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecretRotationMetrics = async () => {
|
||||||
|
// Check both v1 and v2 secret rotation tables
|
||||||
|
const [v1RotationsResult, v2RotationsResult] = await Promise.all([
|
||||||
|
db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>,
|
||||||
|
db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined>
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10);
|
||||||
|
const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSecretRotations: totalV1Rotations + totalV2Rotations
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getUserMetrics,
|
||||||
|
getMachineIdentityMetrics,
|
||||||
|
getProjectMetrics,
|
||||||
|
getSecretMetrics,
|
||||||
|
getSecretSyncMetrics,
|
||||||
|
getDynamicSecretMetrics,
|
||||||
|
getSecretRotationMetrics
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
|
import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal";
|
||||||
|
|
||||||
|
type TOfflineUsageReportServiceFactoryDep = {
|
||||||
|
offlineUsageReportDAL: TOfflineUsageReportDALFactory;
|
||||||
|
licenseService: Pick<TLicenseServiceFactory, "getCustomerId" | "getLicenseId">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TOfflineUsageReportServiceFactory = ReturnType<typeof offlineUsageReportServiceFactory>;
|
||||||
|
|
||||||
|
export const offlineUsageReportServiceFactory = ({
|
||||||
|
offlineUsageReportDAL,
|
||||||
|
licenseService
|
||||||
|
}: TOfflineUsageReportServiceFactoryDep) => {
|
||||||
|
const signReportContent = (content: string, licenseId: string): string => {
|
||||||
|
const contentHash = crypto.createHash("sha256").update(content).digest("hex");
|
||||||
|
const hmac = crypto.createHmac("sha256", licenseId);
|
||||||
|
hmac.update(contentHash);
|
||||||
|
return hmac.digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => {
|
||||||
|
const expectedSignature = signReportContent(content, licenseId);
|
||||||
|
return signature === expectedSignature;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateUsageReportCSV = async () => {
|
||||||
|
const cfg = getConfig();
|
||||||
|
if (!cfg.LICENSE_KEY_OFFLINE) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = licenseService.getCustomerId() as string;
|
||||||
|
const licenseId = licenseService.getLicenseId();
|
||||||
|
|
||||||
|
const [
|
||||||
|
userMetrics,
|
||||||
|
machineIdentityMetrics,
|
||||||
|
projectMetrics,
|
||||||
|
secretMetrics,
|
||||||
|
secretSyncMetrics,
|
||||||
|
dynamicSecretMetrics,
|
||||||
|
secretRotationMetrics
|
||||||
|
] = await Promise.all([
|
||||||
|
offlineUsageReportDAL.getUserMetrics(),
|
||||||
|
offlineUsageReportDAL.getMachineIdentityMetrics(),
|
||||||
|
offlineUsageReportDAL.getProjectMetrics(),
|
||||||
|
offlineUsageReportDAL.getSecretMetrics(),
|
||||||
|
offlineUsageReportDAL.getSecretSyncMetrics(),
|
||||||
|
offlineUsageReportDAL.getDynamicSecretMetrics(),
|
||||||
|
offlineUsageReportDAL.getSecretRotationMetrics()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
"Total Users",
|
||||||
|
"Admin Users",
|
||||||
|
"Total Identities",
|
||||||
|
"Total Projects",
|
||||||
|
"Total Secrets",
|
||||||
|
"Total Secret Syncs",
|
||||||
|
"Total Dynamic Secrets",
|
||||||
|
"Total Secret Rotations",
|
||||||
|
"Avg Secrets Per Project"
|
||||||
|
];
|
||||||
|
|
||||||
|
const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod);
|
||||||
|
allUserAuthMethods.forEach((method) => {
|
||||||
|
headers.push(`Users Auth ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod);
|
||||||
|
allIdentityAuthMethods.forEach((method) => {
|
||||||
|
headers.push(`Identities Auth ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allProjectTypes = Object.keys(projectMetrics.projectsByType);
|
||||||
|
allProjectTypes.forEach((type) => {
|
||||||
|
headers.push(`Projects ${type}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
headers.push("Signature");
|
||||||
|
|
||||||
|
const dataRow: (string | number)[] = [
|
||||||
|
userMetrics.totalUsers,
|
||||||
|
userMetrics.adminUsers,
|
||||||
|
machineIdentityMetrics.totalMachineIdentities,
|
||||||
|
projectMetrics.totalProjects,
|
||||||
|
secretMetrics.totalSecrets,
|
||||||
|
secretSyncMetrics.totalSecretSyncs,
|
||||||
|
dynamicSecretMetrics.totalDynamicSecrets,
|
||||||
|
secretRotationMetrics.totalSecretRotations,
|
||||||
|
projectMetrics.averageSecretsPerProject
|
||||||
|
];
|
||||||
|
|
||||||
|
allUserAuthMethods.forEach((method) => {
|
||||||
|
dataRow.push(userMetrics.usersByAuthMethod[method] || 0);
|
||||||
|
});
|
||||||
|
allIdentityAuthMethods.forEach((method) => {
|
||||||
|
dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
allProjectTypes.forEach((type) => {
|
||||||
|
dataRow.push(projectMetrics.projectsByType[type] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headersWithoutSignature = headers.slice(0, -1);
|
||||||
|
const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n");
|
||||||
|
|
||||||
|
const signature = signReportContent(contentWithoutSignature, licenseId);
|
||||||
|
dataRow.push(signature);
|
||||||
|
|
||||||
|
const csvContent = [headers.join(","), dataRow.join(",")].join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvContent,
|
||||||
|
signature,
|
||||||
|
filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
generateUsageReportCSV,
|
||||||
|
verifyReportSignature: (csvContent: string, signature: string, licenseId: string) =>
|
||||||
|
verifyReportContent(csvContent, signature, licenseId)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export interface TUsageMetrics {
|
||||||
|
// User metrics
|
||||||
|
totalUsers: number;
|
||||||
|
usersByAuthMethod: Record<string, number>;
|
||||||
|
adminUsers: number;
|
||||||
|
|
||||||
|
// Machine identity metrics
|
||||||
|
totalMachineIdentities: number;
|
||||||
|
machineIdentitiesByAuthMethod: Record<string, number>;
|
||||||
|
|
||||||
|
// Project metrics
|
||||||
|
totalProjects: number;
|
||||||
|
projectsByType: Record<string, number>;
|
||||||
|
averageSecretsPerProject: number;
|
||||||
|
|
||||||
|
// Secret metrics
|
||||||
|
totalSecrets: number;
|
||||||
|
totalSecretSyncs: number;
|
||||||
|
totalDynamicSecrets: number;
|
||||||
|
totalSecretRotations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TUsageReportMetadata {
|
||||||
|
generatedAt: string;
|
||||||
|
instanceId: string;
|
||||||
|
reportVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TUsageReport {
|
||||||
|
metadata: TUsageReportMetadata;
|
||||||
|
metrics: TUsageMetrics;
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TGenerateUsageReportDTO {
|
||||||
|
includeSignature?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TVerifyUsageReportDTO {
|
||||||
|
reportData: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
@@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => {
|
|||||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||||
FileSaver.saveAs(blob, filename);
|
FileSaver.saveAs(blob, filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const downloadFile = (content: string, filename: string, mimeType: string = "text/csv") => {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
TInvalidateCacheDTO,
|
TInvalidateCacheDTO,
|
||||||
TResendOrgInviteDTO,
|
TResendOrgInviteDTO,
|
||||||
TServerConfig,
|
TServerConfig,
|
||||||
TUpdateServerConfigDTO
|
TUpdateServerConfigDTO,
|
||||||
|
TUsageReportResponse
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const useCreateAdminUser = () => {
|
export const useCreateAdminUser = () => {
|
||||||
@@ -240,3 +241,14 @@ export const useServerAdminAccessOrg = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGenerateUsageReport = () => {
|
||||||
|
return useMutation<TUsageReportResponse, object, void>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await apiRequest.post<TUsageReportResponse>(
|
||||||
|
"/api/v1/admin/usage-report/generate"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export type TServerConfig = {
|
|||||||
fipsEnabled: boolean;
|
fipsEnabled: boolean;
|
||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
paramsFolderSecretDetectionEnabled: boolean;
|
paramsFolderSecretDetectionEnabled: boolean;
|
||||||
|
isOfflineUsageReportsEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TUpdateServerConfigDTO = {
|
export type TUpdateServerConfigDTO = {
|
||||||
@@ -146,6 +147,12 @@ export interface TGetEnvOverrides {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TUsageReportResponse = {
|
||||||
|
filename: string;
|
||||||
|
csvContent: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TCreateOrganizationDTO = {
|
export type TCreateOrganizationDTO = {
|
||||||
name: string;
|
name: string;
|
||||||
inviteAdminEmails: string[];
|
inviteAdminEmails: string[];
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { PageHeader } from "@app/components/v2";
|
import { PageHeader } from "@app/components/v2";
|
||||||
|
import { useGetServerConfig } from "@app/hooks/api/admin";
|
||||||
|
|
||||||
import { GeneralPageForm } from "./components";
|
import { GeneralPageForm, UsageReportSection } from "./components";
|
||||||
|
|
||||||
export const GeneralPage = () => {
|
export const GeneralPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: serverConfig } = useGetServerConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-bunker-800">
|
<div className="h-full bg-bunker-800">
|
||||||
@@ -19,7 +21,10 @@ export const GeneralPage = () => {
|
|||||||
title="General"
|
title="General"
|
||||||
description="Manage general settings for your Infisical instance."
|
description="Manage general settings for your Infisical instance."
|
||||||
/>
|
/>
|
||||||
<GeneralPageForm />
|
<div className="space-y-6">
|
||||||
|
<GeneralPageForm />
|
||||||
|
{serverConfig?.isOfflineUsageReportsEnabled && <UsageReportSection />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, Card, CardTitle } from "@app/components/v2";
|
||||||
|
import { downloadFile } from "@app/helpers/download";
|
||||||
|
import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation";
|
||||||
|
|
||||||
|
export const UsageReportSection = () => {
|
||||||
|
const generateUsageReport = useGenerateUsageReport();
|
||||||
|
|
||||||
|
const handleGenerateReport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await generateUsageReport.mutateAsync();
|
||||||
|
const { csvContent, filename } = response;
|
||||||
|
|
||||||
|
downloadFile(csvContent, filename, "text/csv");
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: `Usage report downloaded: "${filename}"`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate usage report:", error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to generate usage report. Please try again.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<CardTitle className="mb-4 flex items-center gap-3">
|
||||||
|
<FontAwesomeIcon icon={faFileAlt} />
|
||||||
|
Offline Usage Reports
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<div className="mb-4 text-sm text-gray-400">
|
||||||
|
Generate secure usage reports for offline license compliance and billing verification.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateReport}
|
||||||
|
className="w-fit"
|
||||||
|
isLoading={generateUsageReport.isPending}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={generateUsageReport.isPending ? faSpinner : faDownload} />}
|
||||||
|
>
|
||||||
|
{generateUsageReport.isPending ? "Generating..." : "Generate Report"}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { GeneralPageForm } from "./GeneralPageForm";
|
export { GeneralPageForm } from "./GeneralPageForm";
|
||||||
|
export { UsageReportSection } from "./UsageReportSection";
|
||||||
|
|||||||
Reference in New Issue
Block a user