Compare commits
97 Commits
developmen
...
cli-ssh-ag
Author | SHA1 | Date | |
---|---|---|---|
f5a0641671 | |||
f97f98b2c3 | |||
89848a2f5c | |||
1936f7cc4f | |||
1adeb5a70d | |||
058475fc3f | |||
ee4eb7f84b | |||
8122433f5c | |||
a0411e3ba8 | |||
f3cf1a3f50 | |||
b4b417658f | |||
fed99a14a8 | |||
d4cfee99a6 | |||
e70ca57510 | |||
06f321e4bf | |||
3c3fcd0db8 | |||
21eb2bed7e | |||
31a21a432d | |||
381960b0bd | |||
7eb05afe2a | |||
0b54948b15 | |||
39e598e408 | |||
b735618601 | |||
3a5e862def | |||
d1c4a9c75a | |||
5532844ee7 | |||
dd5aab973f | |||
ced12baf5d | |||
7db1e62654 | |||
0ab3ae442e | |||
ed9472efc8 | |||
e094844601 | |||
e761b49964 | |||
6a8be75b79 | |||
a92e61575d | |||
761007208d | |||
cc3e0d1922 | |||
765280eef6 | |||
215761ca6b | |||
0977ff1e36 | |||
c6081900a4 | |||
86800c0cdb | |||
1fa99e5585 | |||
8f5bb44ff4 | |||
3f70f08e8c | |||
078eaff164 | |||
221aa99374 | |||
6a681dcf6a | |||
b99b98b6a4 | |||
d7271b9631 | |||
379e526200 | |||
1f151a9b05 | |||
6b2eb9c6c9 | |||
be36827392 | |||
68a3291235 | |||
471f47d260 | |||
ccb757ec3e | |||
b669b0a9f8 | |||
9e768640cd | |||
35f7420447 | |||
c6a0e36318 | |||
181ba75f2a | |||
c00f6601bd | |||
111605a945 | |||
2ac110f00e | |||
0366506213 | |||
e3d29b637d | |||
9cd0dc8970 | |||
f8f5000bad | |||
40919ccf59 | |||
44303aca6a | |||
4bd50c3548 | |||
fb253d00eb | |||
097512c691 | |||
1cbf030e6c | |||
f5920f416a | |||
3b2154bab4 | |||
7c8f2e5548 | |||
c5816014a6 | |||
a730b16318 | |||
cc3d132f5d | |||
48174e2500 | |||
7cf297344b | |||
42249726d4 | |||
ec1ce3dc06 | |||
82a4b89bb5 | |||
ff3d8c896b | |||
6e720c2f64 | |||
5b618b07fa | |||
a5a1f57284 | |||
8327f6154e | |||
20a9fc113c | |||
8edfa9ad0b | |||
00ce755996 | |||
3b2173a098 | |||
07d9398aad | |||
4fc8c509ac |
@ -137,6 +137,7 @@ RUN apt-get update && apt-get install -y \
|
||||
freetds-dev \
|
||||
freetds-bin \
|
||||
tdsodbc \
|
||||
openssh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Configure ODBC in production
|
||||
|
@ -139,7 +139,8 @@ RUN apk --update add \
|
||||
freetds-dev \
|
||||
bash \
|
||||
curl \
|
||||
git
|
||||
git \
|
||||
openssh
|
||||
|
||||
# Configure ODBC in production
|
||||
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
|
||||
|
@ -7,7 +7,8 @@ WORKDIR /app
|
||||
RUN apk --update add \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
g++ \
|
||||
openssh
|
||||
|
||||
# install dependencies for TDS driver (required for SAP ASE dynamic secrets)
|
||||
RUN apk add --no-cache \
|
||||
|
@ -17,7 +17,8 @@ RUN apk --update add \
|
||||
openssl-dev \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
g++ \
|
||||
openssh
|
||||
|
||||
# install dependencies for TDS driver (required for SAP ASE dynamic secrets)
|
||||
RUN apk add --no-cache \
|
||||
|
@ -22,8 +22,10 @@ export const mockQueue = (): TQueueServiceFactory => {
|
||||
listen: (name, event) => {
|
||||
events[name] = event;
|
||||
},
|
||||
getRepeatableJobs: async () => [],
|
||||
clearQueue: async () => {},
|
||||
stopJobById: async () => {},
|
||||
stopRepeatableJobByJobId: async () => true
|
||||
stopRepeatableJobByJobId: async () => true,
|
||||
stopRepeatableJobByKey: async () => true
|
||||
};
|
||||
};
|
||||
|
4
backend/src/@types/fastify.d.ts
vendored
@ -31,6 +31,8 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap
|
||||
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
|
||||
import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service";
|
||||
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
||||
import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
|
||||
import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service";
|
||||
import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
|
||||
import { TAuthMode } from "@app/server/plugins/auth/inject-identity";
|
||||
import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service";
|
||||
@ -177,6 +179,8 @@ declare module "fastify" {
|
||||
auditLogStream: TAuditLogStreamServiceFactory;
|
||||
certificate: TCertificateServiceFactory;
|
||||
certificateTemplate: TCertificateTemplateServiceFactory;
|
||||
sshCertificateAuthority: TSshCertificateAuthorityServiceFactory;
|
||||
sshCertificateTemplate: TSshCertificateTemplateServiceFactory;
|
||||
certificateAuthority: TCertificateAuthorityServiceFactory;
|
||||
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
|
||||
certificateEst: TCertificateEstServiceFactory;
|
||||
|
40
backend/src/@types/knex.d.ts
vendored
@ -317,6 +317,21 @@ import {
|
||||
TSlackIntegrations,
|
||||
TSlackIntegrationsInsert,
|
||||
TSlackIntegrationsUpdate,
|
||||
TSshCertificateAuthorities,
|
||||
TSshCertificateAuthoritiesInsert,
|
||||
TSshCertificateAuthoritiesUpdate,
|
||||
TSshCertificateAuthoritySecrets,
|
||||
TSshCertificateAuthoritySecretsInsert,
|
||||
TSshCertificateAuthoritySecretsUpdate,
|
||||
TSshCertificateBodies,
|
||||
TSshCertificateBodiesInsert,
|
||||
TSshCertificateBodiesUpdate,
|
||||
TSshCertificates,
|
||||
TSshCertificatesInsert,
|
||||
TSshCertificatesUpdate,
|
||||
TSshCertificateTemplates,
|
||||
TSshCertificateTemplatesInsert,
|
||||
TSshCertificateTemplatesUpdate,
|
||||
TSuperAdmin,
|
||||
TSuperAdminInsert,
|
||||
TSuperAdminUpdate,
|
||||
@ -378,6 +393,31 @@ declare module "knex/types/tables" {
|
||||
interface Tables {
|
||||
[TableName.Users]: KnexOriginal.CompositeTableType<TUsers, TUsersInsert, TUsersUpdate>;
|
||||
[TableName.Groups]: KnexOriginal.CompositeTableType<TGroups, TGroupsInsert, TGroupsUpdate>;
|
||||
[TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType<
|
||||
TSshCertificateAuthorities,
|
||||
TSshCertificateAuthoritiesInsert,
|
||||
TSshCertificateAuthoritiesUpdate
|
||||
>;
|
||||
[TableName.SshCertificateAuthoritySecret]: KnexOriginal.CompositeTableType<
|
||||
TSshCertificateAuthoritySecrets,
|
||||
TSshCertificateAuthoritySecretsInsert,
|
||||
TSshCertificateAuthoritySecretsUpdate
|
||||
>;
|
||||
[TableName.SshCertificateTemplate]: KnexOriginal.CompositeTableType<
|
||||
TSshCertificateTemplates,
|
||||
TSshCertificateTemplatesInsert,
|
||||
TSshCertificateTemplatesUpdate
|
||||
>;
|
||||
[TableName.SshCertificate]: KnexOriginal.CompositeTableType<
|
||||
TSshCertificates,
|
||||
TSshCertificatesInsert,
|
||||
TSshCertificatesUpdate
|
||||
>;
|
||||
[TableName.SshCertificateBody]: KnexOriginal.CompositeTableType<
|
||||
TSshCertificateBodies,
|
||||
TSshCertificateBodiesInsert,
|
||||
TSshCertificateBodiesUpdate
|
||||
>;
|
||||
[TableName.CertificateAuthority]: KnexOriginal.CompositeTableType<
|
||||
TCertificateAuthorities,
|
||||
TCertificateAuthoritiesInsert,
|
||||
|
99
backend/src/db/migrations/20241216013357_ssh-mgmt.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.SshCertificateAuthority))) {
|
||||
await knex.schema.createTable(TableName.SshCertificateAuthority, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.string("status").notNullable(); // active / disabled
|
||||
t.string("friendlyName").notNullable();
|
||||
t.string("keyAlgorithm").notNullable();
|
||||
});
|
||||
await createOnUpdateTrigger(knex, TableName.SshCertificateAuthority);
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.SshCertificateAuthoritySecret))) {
|
||||
await knex.schema.createTable(TableName.SshCertificateAuthoritySecret, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.uuid("sshCaId").notNullable().unique();
|
||||
t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE");
|
||||
t.binary("encryptedPrivateKey").notNullable();
|
||||
});
|
||||
await createOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret);
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.SshCertificateTemplate))) {
|
||||
await knex.schema.createTable(TableName.SshCertificateTemplate, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.uuid("sshCaId").notNullable();
|
||||
t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE");
|
||||
t.string("status").notNullable(); // active / disabled
|
||||
t.string("name").notNullable();
|
||||
t.string("ttl").notNullable();
|
||||
t.string("maxTTL").notNullable();
|
||||
t.specificType("allowedUsers", "text[]").notNullable();
|
||||
t.specificType("allowedHosts", "text[]").notNullable();
|
||||
t.boolean("allowUserCertificates").notNullable();
|
||||
t.boolean("allowHostCertificates").notNullable();
|
||||
t.boolean("allowCustomKeyIds").notNullable();
|
||||
});
|
||||
await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate);
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.SshCertificate))) {
|
||||
await knex.schema.createTable(TableName.SshCertificate, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.uuid("sshCaId").notNullable();
|
||||
t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL");
|
||||
t.uuid("sshCertificateTemplateId");
|
||||
t.foreign("sshCertificateTemplateId")
|
||||
.references("id")
|
||||
.inTable(TableName.SshCertificateTemplate)
|
||||
.onDelete("SET NULL");
|
||||
t.string("serialNumber").notNullable().unique();
|
||||
t.string("certType").notNullable(); // user or host
|
||||
t.specificType("principals", "text[]").notNullable();
|
||||
t.string("keyId").notNullable();
|
||||
t.datetime("notBefore").notNullable();
|
||||
t.datetime("notAfter").notNullable();
|
||||
});
|
||||
await createOnUpdateTrigger(knex, TableName.SshCertificate);
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.SshCertificateBody))) {
|
||||
await knex.schema.createTable(TableName.SshCertificateBody, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.uuid("sshCertId").notNullable().unique();
|
||||
t.foreign("sshCertId").references("id").inTable(TableName.SshCertificate).onDelete("CASCADE");
|
||||
t.binary("encryptedCertificate").notNullable();
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.SshCertificateBody);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.SshCertificateBody);
|
||||
await dropOnUpdateTrigger(knex, TableName.SshCertificateBody);
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.SshCertificate);
|
||||
await dropOnUpdateTrigger(knex, TableName.SshCertificate);
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.SshCertificateTemplate);
|
||||
await dropOnUpdateTrigger(knex, TableName.SshCertificateTemplate);
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.SshCertificateAuthoritySecret);
|
||||
await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret);
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.SshCertificateAuthority);
|
||||
await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthority);
|
||||
}
|
@ -107,6 +107,11 @@ export * from "./secrets";
|
||||
export * from "./secrets-v2";
|
||||
export * from "./service-tokens";
|
||||
export * from "./slack-integrations";
|
||||
export * from "./ssh-certificate-authorities";
|
||||
export * from "./ssh-certificate-authority-secrets";
|
||||
export * from "./ssh-certificate-bodies";
|
||||
export * from "./ssh-certificate-templates";
|
||||
export * from "./ssh-certificates";
|
||||
export * from "./super-admin";
|
||||
export * from "./totp-configs";
|
||||
export * from "./trusted-ips";
|
||||
|
@ -2,6 +2,11 @@ import { z } from "zod";
|
||||
|
||||
export enum TableName {
|
||||
Users = "users",
|
||||
SshCertificateAuthority = "ssh_certificate_authorities",
|
||||
SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets",
|
||||
SshCertificateTemplate = "ssh_certificate_templates",
|
||||
SshCertificate = "ssh_certificates",
|
||||
SshCertificateBody = "ssh_certificate_bodies",
|
||||
CertificateAuthority = "certificate_authorities",
|
||||
CertificateTemplateEstConfig = "certificate_template_est_configs",
|
||||
CertificateAuthorityCert = "certificate_authority_certs",
|
||||
@ -205,5 +210,6 @@ export enum IdentityAuthMethod {
|
||||
export enum ProjectType {
|
||||
SecretManager = "secret-manager",
|
||||
CertificateManager = "cert-manager",
|
||||
KMS = "kms"
|
||||
KMS = "kms",
|
||||
SSH = "ssh"
|
||||
}
|
||||
|
24
backend/src/db/schemas/ssh-certificate-authorities.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// 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 SshCertificateAuthoritiesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
projectId: z.string(),
|
||||
status: z.string(),
|
||||
friendlyName: z.string(),
|
||||
keyAlgorithm: z.string()
|
||||
});
|
||||
|
||||
export type TSshCertificateAuthorities = z.infer<typeof SshCertificateAuthoritiesSchema>;
|
||||
export type TSshCertificateAuthoritiesInsert = Omit<z.input<typeof SshCertificateAuthoritiesSchema>, TImmutableDBKeys>;
|
||||
export type TSshCertificateAuthoritiesUpdate = Partial<
|
||||
Omit<z.input<typeof SshCertificateAuthoritiesSchema>, TImmutableDBKeys>
|
||||
>;
|
27
backend/src/db/schemas/ssh-certificate-authority-secrets.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// 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 SshCertificateAuthoritySecretsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
sshCaId: z.string().uuid(),
|
||||
encryptedPrivateKey: zodBuffer
|
||||
});
|
||||
|
||||
export type TSshCertificateAuthoritySecrets = z.infer<typeof SshCertificateAuthoritySecretsSchema>;
|
||||
export type TSshCertificateAuthoritySecretsInsert = Omit<
|
||||
z.input<typeof SshCertificateAuthoritySecretsSchema>,
|
||||
TImmutableDBKeys
|
||||
>;
|
||||
export type TSshCertificateAuthoritySecretsUpdate = Partial<
|
||||
Omit<z.input<typeof SshCertificateAuthoritySecretsSchema>, TImmutableDBKeys>
|
||||
>;
|
22
backend/src/db/schemas/ssh-certificate-bodies.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 { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SshCertificateBodiesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
sshCertId: z.string().uuid(),
|
||||
encryptedCertificate: zodBuffer
|
||||
});
|
||||
|
||||
export type TSshCertificateBodies = z.infer<typeof SshCertificateBodiesSchema>;
|
||||
export type TSshCertificateBodiesInsert = Omit<z.input<typeof SshCertificateBodiesSchema>, TImmutableDBKeys>;
|
||||
export type TSshCertificateBodiesUpdate = Partial<Omit<z.input<typeof SshCertificateBodiesSchema>, TImmutableDBKeys>>;
|
30
backend/src/db/schemas/ssh-certificate-templates.ts
Normal file
@ -0,0 +1,30 @@
|
||||
// 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 SshCertificateTemplatesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
sshCaId: z.string().uuid(),
|
||||
status: z.string(),
|
||||
name: z.string(),
|
||||
ttl: z.string(),
|
||||
maxTTL: z.string(),
|
||||
allowedUsers: z.string().array(),
|
||||
allowedHosts: z.string().array(),
|
||||
allowUserCertificates: z.boolean(),
|
||||
allowHostCertificates: z.boolean(),
|
||||
allowCustomKeyIds: z.boolean()
|
||||
});
|
||||
|
||||
export type TSshCertificateTemplates = z.infer<typeof SshCertificateTemplatesSchema>;
|
||||
export type TSshCertificateTemplatesInsert = Omit<z.input<typeof SshCertificateTemplatesSchema>, TImmutableDBKeys>;
|
||||
export type TSshCertificateTemplatesUpdate = Partial<
|
||||
Omit<z.input<typeof SshCertificateTemplatesSchema>, TImmutableDBKeys>
|
||||
>;
|
26
backend/src/db/schemas/ssh-certificates.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 { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SshCertificatesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
sshCaId: z.string().uuid(),
|
||||
sshCertificateTemplateId: z.string().uuid().nullable().optional(),
|
||||
serialNumber: z.string(),
|
||||
certType: z.string(),
|
||||
principals: z.string().array(),
|
||||
keyId: z.string(),
|
||||
notBefore: z.date(),
|
||||
notAfter: z.date()
|
||||
});
|
||||
|
||||
export type TSshCertificates = z.infer<typeof SshCertificatesSchema>;
|
||||
export type TSshCertificatesInsert = Omit<z.input<typeof SshCertificatesSchema>, TImmutableDBKeys>;
|
||||
export type TSshCertificatesUpdate = Partial<Omit<z.input<typeof SshCertificatesSchema>, TImmutableDBKeys>>;
|
@ -25,6 +25,9 @@ import { registerSecretRotationRouter } from "./secret-rotation-router";
|
||||
import { registerSecretScanningRouter } from "./secret-scanning-router";
|
||||
import { registerSecretVersionRouter } from "./secret-version-router";
|
||||
import { registerSnapshotRouter } from "./snapshot-router";
|
||||
import { registerSshCaRouter } from "./ssh-certificate-authority-router";
|
||||
import { registerSshCertRouter } from "./ssh-certificate-router";
|
||||
import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router";
|
||||
import { registerTrustedIpRouter } from "./trusted-ip-router";
|
||||
import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router";
|
||||
|
||||
@ -68,6 +71,15 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
{ prefix: "/pki" }
|
||||
);
|
||||
|
||||
await server.register(
|
||||
async (sshRouter) => {
|
||||
await sshRouter.register(registerSshCaRouter, { prefix: "/ca" });
|
||||
await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" });
|
||||
await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" });
|
||||
},
|
||||
{ prefix: "/ssh" }
|
||||
);
|
||||
|
||||
await server.register(
|
||||
async (ssoRouter) => {
|
||||
await ssoRouter.register(registerSamlRouter);
|
||||
|
279
backend/src/ee/routes/v1/ssh-certificate-authority-router.ts
Normal file
@ -0,0 +1,279 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema";
|
||||
import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types";
|
||||
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
|
||||
import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
|
||||
export const registerSshCaRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Create SSH CA",
|
||||
body: z.object({
|
||||
projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId),
|
||||
friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName),
|
||||
keyAlgorithm: z
|
||||
.nativeEnum(CertKeyAlgorithm)
|
||||
.default(CertKeyAlgorithm.RSA_2048)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
ca: sanitizedSshCa.extend({
|
||||
publicKey: z.string()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const ca = await server.services.sshCertificateAuthority.createSshCa({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: ca.projectId,
|
||||
event: {
|
||||
type: EventType.CREATE_SSH_CA,
|
||||
metadata: {
|
||||
sshCaId: ca.id,
|
||||
friendlyName: ca.friendlyName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ca
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sshCaId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Get SSH CA",
|
||||
params: z.object({
|
||||
sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET.sshCaId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
ca: sanitizedSshCa.extend({
|
||||
publicKey: z.string()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const ca = await server.services.sshCertificateAuthority.getSshCaById({
|
||||
caId: req.params.sshCaId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: ca.projectId,
|
||||
event: {
|
||||
type: EventType.GET_SSH_CA,
|
||||
metadata: {
|
||||
sshCaId: ca.id,
|
||||
friendlyName: ca.friendlyName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ca
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sshCaId/public-key",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get public key of SSH CA",
|
||||
params: z.object({
|
||||
sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_PUBLIC_KEY.sshCaId)
|
||||
}),
|
||||
response: {
|
||||
200: z.string()
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const publicKey = await server.services.sshCertificateAuthority.getSshCaPublicKey({
|
||||
caId: req.params.sshCaId
|
||||
});
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:sshCaId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Update SSH CA",
|
||||
params: z.object({
|
||||
sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId)
|
||||
}),
|
||||
body: z.object({
|
||||
friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName),
|
||||
status: z.nativeEnum(SshCaStatus).optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
ca: sanitizedSshCa.extend({
|
||||
publicKey: z.string()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const ca = await server.services.sshCertificateAuthority.updateSshCaById({
|
||||
caId: req.params.sshCaId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: ca.projectId,
|
||||
event: {
|
||||
type: EventType.UPDATE_SSH_CA,
|
||||
metadata: {
|
||||
sshCaId: ca.id,
|
||||
friendlyName: ca.friendlyName,
|
||||
status: ca.status as SshCaStatus
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ca
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:sshCaId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Delete SSH CA",
|
||||
params: z.object({
|
||||
sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.DELETE.sshCaId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
ca: sanitizedSshCa
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const ca = await server.services.sshCertificateAuthority.deleteSshCaById({
|
||||
caId: req.params.sshCaId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: ca.projectId,
|
||||
event: {
|
||||
type: EventType.DELETE_SSH_CA,
|
||||
metadata: {
|
||||
sshCaId: ca.id,
|
||||
friendlyName: ca.friendlyName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ca
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sshCaId/certificate-templates",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Get list of certificate templates for the SSH CA",
|
||||
params: z.object({
|
||||
sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_CERTIFICATE_TEMPLATES.sshCaId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateTemplates: sanitizedSshCertificateTemplate.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { certificateTemplates, ca } = await server.services.sshCertificateAuthority.getSshCaCertificateTemplates({
|
||||
caId: req.params.sshCaId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: ca.projectId,
|
||||
event: {
|
||||
type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES,
|
||||
metadata: {
|
||||
sshCaId: ca.id,
|
||||
friendlyName: ca.friendlyName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificateTemplates
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
164
backend/src/ee/routes/v1/ssh-certificate-router.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
|
||||
import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
|
||||
export const registerSshCertRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/sign",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Sign SSH public key",
|
||||
body: z.object({
|
||||
certificateTemplateId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certificateTemplateId),
|
||||
publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey),
|
||||
certType: z
|
||||
.nativeEnum(SshCertType)
|
||||
.default(SshCertType.USER)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certType),
|
||||
principals: z
|
||||
.array(z.string().transform((val) => val.trim()))
|
||||
.nonempty("Principals array must not be empty")
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.principals),
|
||||
ttl: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.ttl),
|
||||
keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.keyId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.serialNumber),
|
||||
signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.signedKey)
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { serialNumber, signedPublicKey, certificateTemplate, ttl, keyId } =
|
||||
await server.services.sshCertificateAuthority.signSshKey({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.SIGN_SSH_KEY,
|
||||
metadata: {
|
||||
certificateTemplateId: certificateTemplate.id,
|
||||
certType: req.body.certType,
|
||||
principals: req.body.principals,
|
||||
ttl: String(ttl),
|
||||
keyId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
serialNumber,
|
||||
signedKey: signedPublicKey
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/issue",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Issue SSH credentials (certificate + key)",
|
||||
body: z.object({
|
||||
certificateTemplateId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId),
|
||||
keyAlgorithm: z
|
||||
.nativeEnum(CertKeyAlgorithm)
|
||||
.default(CertKeyAlgorithm.RSA_2048)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm),
|
||||
certType: z
|
||||
.nativeEnum(SshCertType)
|
||||
.default(SshCertType.USER)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certType),
|
||||
principals: z
|
||||
.array(z.string().transform((val) => val.trim()))
|
||||
.nonempty("Principals array must not be empty")
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.principals),
|
||||
ttl: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.ttl),
|
||||
keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.serialNumber),
|
||||
signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.signedKey),
|
||||
privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey),
|
||||
publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey),
|
||||
keyAlgorithm: z
|
||||
.nativeEnum(CertKeyAlgorithm)
|
||||
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm)
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { serialNumber, signedPublicKey, privateKey, publicKey, certificateTemplate, ttl, keyId } =
|
||||
await server.services.sshCertificateAuthority.issueSshCreds({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.ISSUE_SSH_CREDS,
|
||||
metadata: {
|
||||
certificateTemplateId: certificateTemplate.id,
|
||||
keyAlgorithm: req.body.keyAlgorithm,
|
||||
certType: req.body.certType,
|
||||
principals: req.body.principals,
|
||||
ttl: String(ttl),
|
||||
keyId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
serialNumber,
|
||||
signedKey: signedPublicKey,
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
258
backend/src/ee/routes/v1/ssh-certificate-template-router.ts
Normal file
@ -0,0 +1,258 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
|
||||
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
|
||||
import {
|
||||
isValidHostPattern,
|
||||
isValidUserPattern
|
||||
} from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-validators";
|
||||
import { SSH_CERTIFICATE_TEMPLATES } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerSshCertificateTemplateRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:certificateTemplateId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.GET.certificateTemplateId)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedSshCertificateTemplate
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateTemplate = await server.services.sshCertificateTemplate.getSshCertTemplate({
|
||||
id: req.params.certificateTemplateId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: certificateTemplate.projectId,
|
||||
event: {
|
||||
type: EventType.GET_SSH_CERTIFICATE_TEMPLATE,
|
||||
metadata: {
|
||||
certificateTemplateId: certificateTemplate.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return certificateTemplate;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z
|
||||
.object({
|
||||
sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(36)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Name must be a valid slug"
|
||||
})
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name),
|
||||
ttl: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||
.default("1h")
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.CREATE.ttl),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Max TTL must be a positive number")
|
||||
.default("30d")
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.CREATE.maxTTL),
|
||||
allowedUsers: z
|
||||
.array(z.string().refine(isValidUserPattern, "Invalid user pattern"))
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedUsers),
|
||||
allowedHosts: z
|
||||
.array(z.string().refine(isValidHostPattern, "Invalid host pattern"))
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedHosts),
|
||||
allowUserCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowUserCertificates),
|
||||
allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates),
|
||||
allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds)
|
||||
})
|
||||
.refine((data) => ms(data.maxTTL) > ms(data.ttl), {
|
||||
message: "Max TLL must be greater than TTL",
|
||||
path: ["maxTTL"]
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedSshCertificateTemplate
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { certificateTemplate, ca } = await server.services.sshCertificateTemplate.createSshCertTemplate({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: ca.projectId,
|
||||
event: {
|
||||
type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE,
|
||||
metadata: {
|
||||
certificateTemplateId: certificateTemplate.id,
|
||||
sshCaId: ca.id,
|
||||
name: certificateTemplate.name,
|
||||
ttl: certificateTemplate.ttl,
|
||||
maxTTL: certificateTemplate.maxTTL,
|
||||
allowedUsers: certificateTemplate.allowedUsers,
|
||||
allowedHosts: certificateTemplate.allowedHosts,
|
||||
allowUserCertificates: certificateTemplate.allowUserCertificates,
|
||||
allowHostCertificates: certificateTemplate.allowHostCertificates,
|
||||
allowCustomKeyIds: certificateTemplate.allowCustomKeyIds
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return certificateTemplate;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:certificateTemplateId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
status: z.nativeEnum(SshCertTemplateStatus).optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(36)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Slug must be a valid slug"
|
||||
})
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name),
|
||||
ttl: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.ttl),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.refine((val) => ms(val) > 0, "Max TTL must be a positive number")
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.maxTTL),
|
||||
allowedUsers: z
|
||||
.array(z.string().refine(isValidUserPattern, "Invalid user pattern"))
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedUsers),
|
||||
allowedHosts: z
|
||||
.array(z.string().refine(isValidHostPattern, "Invalid host pattern"))
|
||||
.optional()
|
||||
.describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedHosts),
|
||||
allowUserCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowUserCertificates),
|
||||
allowHostCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowHostCertificates),
|
||||
allowCustomKeyIds: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowCustomKeyIds)
|
||||
}),
|
||||
params: z.object({
|
||||
certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedSshCertificateTemplate
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { certificateTemplate, projectId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({
|
||||
...req.body,
|
||||
id: req.params.certificateTemplateId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE,
|
||||
metadata: {
|
||||
status: certificateTemplate.status as SshCertTemplateStatus,
|
||||
certificateTemplateId: certificateTemplate.id,
|
||||
sshCaId: certificateTemplate.sshCaId,
|
||||
name: certificateTemplate.name,
|
||||
ttl: certificateTemplate.ttl,
|
||||
maxTTL: certificateTemplate.maxTTL,
|
||||
allowedUsers: certificateTemplate.allowedUsers,
|
||||
allowedHosts: certificateTemplate.allowedHosts,
|
||||
allowUserCertificates: certificateTemplate.allowUserCertificates,
|
||||
allowHostCertificates: certificateTemplate.allowHostCertificates,
|
||||
allowCustomKeyIds: certificateTemplate.allowCustomKeyIds
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return certificateTemplate;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:certificateTemplateId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId)
|
||||
}),
|
||||
response: {
|
||||
200: sanitizedSshCertificateTemplate
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateTemplate = await server.services.sshCertificateTemplate.deleteSshCertTemplate({
|
||||
id: req.params.certificateTemplateId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: certificateTemplate.projectId,
|
||||
event: {
|
||||
type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE,
|
||||
metadata: {
|
||||
certificateTemplateId: certificateTemplate.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return certificateTemplate;
|
||||
}
|
||||
});
|
||||
};
|
@ -2,9 +2,12 @@ import {
|
||||
TCreateProjectTemplateDTO,
|
||||
TUpdateProjectTemplateDTO
|
||||
} from "@app/ee/services/project-template/project-template-types";
|
||||
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
|
||||
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
|
||||
import { SymmetricEncryption } from "@app/lib/crypto/cipher";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
|
||||
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
||||
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
|
||||
@ -143,6 +146,17 @@ export enum EventType {
|
||||
SECRET_APPROVAL_REQUEST = "secret-approval-request",
|
||||
SECRET_APPROVAL_CLOSED = "secret-approval-closed",
|
||||
SECRET_APPROVAL_REOPENED = "secret-approval-reopened",
|
||||
SIGN_SSH_KEY = "sign-ssh-key",
|
||||
ISSUE_SSH_CREDS = "issue-ssh-creds",
|
||||
CREATE_SSH_CA = "create-ssh-certificate-authority",
|
||||
GET_SSH_CA = "get-ssh-certificate-authority",
|
||||
UPDATE_SSH_CA = "update-ssh-certificate-authority",
|
||||
DELETE_SSH_CA = "delete-ssh-certificate-authority",
|
||||
GET_SSH_CA_CERTIFICATE_TEMPLATES = "get-ssh-certificate-authority-certificate-templates",
|
||||
CREATE_SSH_CERTIFICATE_TEMPLATE = "create-ssh-certificate-template",
|
||||
UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template",
|
||||
DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template",
|
||||
GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template",
|
||||
CREATE_CA = "create-certificate-authority",
|
||||
GET_CA = "get-certificate-authority",
|
||||
UPDATE_CA = "update-certificate-authority",
|
||||
@ -1206,6 +1220,117 @@ interface SecretApprovalRequest {
|
||||
};
|
||||
}
|
||||
|
||||
interface SignSshKey {
|
||||
type: EventType.SIGN_SSH_KEY;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl: string;
|
||||
keyId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IssueSshCreds {
|
||||
type: EventType.ISSUE_SSH_CREDS;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
keyAlgorithm: CertKeyAlgorithm;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl: string;
|
||||
keyId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSshCa {
|
||||
type: EventType.CREATE_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSshCa {
|
||||
type: EventType.GET_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSshCa {
|
||||
type: EventType.UPDATE_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
status: SshCaStatus;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSshCa {
|
||||
type: EventType.DELETE_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSshCaCertificateTemplates {
|
||||
type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSshCertificateTemplate {
|
||||
type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
sshCaId: string;
|
||||
name: string;
|
||||
ttl: string;
|
||||
maxTTL: string;
|
||||
allowedUsers: string[];
|
||||
allowedHosts: string[];
|
||||
allowUserCertificates: boolean;
|
||||
allowHostCertificates: boolean;
|
||||
allowCustomKeyIds: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSshCertificateTemplate {
|
||||
type: EventType.GET_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSshCertificateTemplate {
|
||||
type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
sshCaId: string;
|
||||
name: string;
|
||||
status: SshCertTemplateStatus;
|
||||
ttl: string;
|
||||
maxTTL: string;
|
||||
allowedUsers: string[];
|
||||
allowedHosts: string[];
|
||||
allowUserCertificates: boolean;
|
||||
allowHostCertificates: boolean;
|
||||
allowCustomKeyIds: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSshCertificateTemplate {
|
||||
type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateCa {
|
||||
type: EventType.CREATE_CA;
|
||||
metadata: {
|
||||
@ -1837,6 +1962,17 @@ export type Event =
|
||||
| SecretApprovalClosed
|
||||
| SecretApprovalRequest
|
||||
| SecretApprovalReopened
|
||||
| SignSshKey
|
||||
| IssueSshCreds
|
||||
| CreateSshCa
|
||||
| GetSshCa
|
||||
| UpdateSshCa
|
||||
| DeleteSshCa
|
||||
| GetSshCaCertificateTemplates
|
||||
| CreateSshCertificateTemplate
|
||||
| UpdateSshCertificateTemplate
|
||||
| GetSshCertificateTemplate
|
||||
| DeleteSshCertificateTemplate
|
||||
| CreateCa
|
||||
| GetCa
|
||||
| UpdateCa
|
||||
|
@ -54,6 +54,9 @@ export enum ProjectPermissionSub {
|
||||
CertificateAuthorities = "certificate-authorities",
|
||||
Certificates = "certificates",
|
||||
CertificateTemplates = "certificate-templates",
|
||||
SshCertificateAuthorities = "ssh-certificate-authorities",
|
||||
SshCertificates = "ssh-certificates",
|
||||
SshCertificateTemplates = "ssh-certificate-templates",
|
||||
PkiAlerts = "pki-alerts",
|
||||
PkiCollections = "pki-collections",
|
||||
Kms = "kms",
|
||||
@ -132,6 +135,9 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
|
||||
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
|
||||
@ -338,6 +344,28 @@ const GeneralPermissionSchema = [
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z
|
||||
.literal(ProjectPermissionSub.SshCertificateAuthorities)
|
||||
.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.SshCertificates).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.SshCertificateTemplates)
|
||||
.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.PkiAlerts).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
@ -480,7 +508,10 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.Certificates,
|
||||
ProjectPermissionSub.CertificateTemplates,
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
ProjectPermissionSub.SshCertificateAuthorities,
|
||||
ProjectPermissionSub.SshCertificates,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
].forEach((el) => {
|
||||
can(
|
||||
[
|
||||
@ -665,6 +696,11 @@ const buildMemberPermissionRules = () => {
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts);
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities);
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates);
|
||||
can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates);
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionCmekActions.Create,
|
||||
@ -707,6 +743,9 @@ const buildViewerPermissionRules = () => {
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates);
|
||||
can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
@ -0,0 +1,66 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateTemplateDALFactory = ReturnType<typeof sshCertificateTemplateDALFactory>;
|
||||
|
||||
export const sshCertificateTemplateDALFactory = (db: TDbClient) => {
|
||||
const sshCertificateTemplateOrm = ormify(db, TableName.SshCertificateTemplate);
|
||||
|
||||
const getById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate)
|
||||
.join(
|
||||
TableName.SshCertificateAuthority,
|
||||
`${TableName.SshCertificateAuthority}.id`,
|
||||
`${TableName.SshCertificateTemplate}.sshCaId`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`)
|
||||
.where(`${TableName.SshCertificateTemplate}.id`, "=", id)
|
||||
.select(selectAllTableCols(TableName.SshCertificateTemplate))
|
||||
.select(
|
||||
db.ref("projectId").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority)
|
||||
)
|
||||
.first();
|
||||
|
||||
return certTemplate;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get SSH certificate template by ID" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the SSH certificate template named [name] within project with id [projectId]
|
||||
*/
|
||||
const getByName = async (name: string, projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate)
|
||||
.join(
|
||||
TableName.SshCertificateAuthority,
|
||||
`${TableName.SshCertificateAuthority}.id`,
|
||||
`${TableName.SshCertificateTemplate}.sshCaId`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`)
|
||||
.where(`${TableName.SshCertificateTemplate}.name`, "=", name)
|
||||
.where(`${TableName.Project}.id`, "=", projectId)
|
||||
.select(selectAllTableCols(TableName.SshCertificateTemplate))
|
||||
.select(
|
||||
db.ref("projectId").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority)
|
||||
)
|
||||
.first();
|
||||
|
||||
return certTemplate;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get SSH certificate template by name" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...sshCertificateTemplateOrm, getById, getByName };
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { SshCertificateTemplatesSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({
|
||||
id: true,
|
||||
sshCaId: true,
|
||||
status: true,
|
||||
name: true,
|
||||
ttl: true,
|
||||
maxTTL: true,
|
||||
allowedUsers: true,
|
||||
allowedHosts: true,
|
||||
allowCustomKeyIds: true,
|
||||
allowUserCertificates: true,
|
||||
allowHostCertificates: true
|
||||
});
|
248
backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts
Normal file
@ -0,0 +1,248 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import ms from "ms";
|
||||
|
||||
import { ProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal";
|
||||
import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal";
|
||||
import {
|
||||
SshCertTemplateStatus,
|
||||
TCreateSshCertTemplateDTO,
|
||||
TDeleteSshCertTemplateDTO,
|
||||
TGetSshCertTemplateDTO,
|
||||
TUpdateSshCertTemplateDTO
|
||||
} from "./ssh-certificate-template-types";
|
||||
|
||||
type TSshCertificateTemplateServiceFactoryDep = {
|
||||
sshCertificateTemplateDAL: Pick<
|
||||
TSshCertificateTemplateDALFactory,
|
||||
"transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById"
|
||||
>;
|
||||
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TSshCertificateTemplateServiceFactory = ReturnType<typeof sshCertificateTemplateServiceFactory>;
|
||||
|
||||
export const sshCertificateTemplateServiceFactory = ({
|
||||
sshCertificateTemplateDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
permissionService
|
||||
}: TSshCertificateTemplateServiceFactoryDep) => {
|
||||
const createSshCertTemplate = async ({
|
||||
sshCaId,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TCreateSshCertTemplateDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(sshCaId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH CA with ID ${sshCaId} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
if (ms(ttl) > ms(maxTTL)) {
|
||||
throw new BadRequestError({
|
||||
message: "TTL cannot be greater than max TTL"
|
||||
});
|
||||
}
|
||||
|
||||
const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => {
|
||||
const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.projectId, tx);
|
||||
if (existingTemplate) {
|
||||
throw new BadRequestError({
|
||||
message: `SSH certificate template with name ${name} already exists`
|
||||
});
|
||||
}
|
||||
|
||||
const certificateTemplate = await sshCertificateTemplateDAL.create(
|
||||
{
|
||||
sshCaId,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds,
|
||||
status: SshCertTemplateStatus.ACTIVE
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return certificateTemplate;
|
||||
});
|
||||
|
||||
return { certificateTemplate: newCertificateTemplate, ca };
|
||||
};
|
||||
|
||||
const updateSshCertTemplate = async ({
|
||||
id,
|
||||
status,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateSshCertTemplateDTO) => {
|
||||
const certTemplate = await sshCertificateTemplateDAL.getById(id);
|
||||
if (!certTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH certificate template with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
certTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => {
|
||||
if (name) {
|
||||
const existingTemplate = await sshCertificateTemplateDAL.getByName(name, certTemplate.projectId, tx);
|
||||
if (existingTemplate && existingTemplate.id !== id) {
|
||||
throw new BadRequestError({
|
||||
message: `SSH certificate template with name ${name} already exists`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) {
|
||||
throw new BadRequestError({
|
||||
message: "TTL cannot be greater than max TTL"
|
||||
});
|
||||
}
|
||||
|
||||
const certificateTemplate = await sshCertificateTemplateDAL.updateById(
|
||||
id,
|
||||
{
|
||||
status,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return certificateTemplate;
|
||||
});
|
||||
|
||||
return {
|
||||
certificateTemplate: updatedCertificateTemplate,
|
||||
projectId: certTemplate.projectId
|
||||
};
|
||||
};
|
||||
|
||||
const deleteSshCertTemplate = async ({
|
||||
id,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TDeleteSshCertTemplateDTO) => {
|
||||
const certificateTemplate = await sshCertificateTemplateDAL.getById(id);
|
||||
if (!certificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH certificate template with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
certificateTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
await sshCertificateTemplateDAL.deleteById(certificateTemplate.id);
|
||||
|
||||
return certificateTemplate;
|
||||
};
|
||||
|
||||
const getSshCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshCertTemplateDTO) => {
|
||||
const certTemplate = await sshCertificateTemplateDAL.getById(id);
|
||||
if (!certTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH certificate template with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
certTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
return certTemplate;
|
||||
};
|
||||
|
||||
return {
|
||||
createSshCertTemplate,
|
||||
updateSshCertTemplate,
|
||||
deleteSshCertTemplate,
|
||||
getSshCertTemplate
|
||||
};
|
||||
};
|
@ -0,0 +1,39 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum SshCertTemplateStatus {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled"
|
||||
}
|
||||
|
||||
export type TCreateSshCertTemplateDTO = {
|
||||
sshCaId: string;
|
||||
name: string;
|
||||
ttl: string;
|
||||
maxTTL: string;
|
||||
allowUserCertificates: boolean;
|
||||
allowHostCertificates: boolean;
|
||||
allowedUsers: string[];
|
||||
allowedHosts: string[];
|
||||
allowCustomKeyIds: boolean;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateSshCertTemplateDTO = {
|
||||
id: string;
|
||||
status?: SshCertTemplateStatus;
|
||||
name?: string;
|
||||
ttl?: string;
|
||||
maxTTL?: string;
|
||||
allowUserCertificates?: boolean;
|
||||
allowHostCertificates?: boolean;
|
||||
allowedUsers?: string[];
|
||||
allowedHosts?: string[];
|
||||
allowCustomKeyIds?: boolean;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSshCertTemplateDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDeleteSshCertTemplateDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
14
backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// Validates usernames or wildcard (*)
|
||||
export const isValidUserPattern = (value: string): boolean => {
|
||||
// Matches valid Linux usernames or a wildcard (*)
|
||||
const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/;
|
||||
return userRegex.test(value);
|
||||
};
|
||||
|
||||
// Validates hostnames, wildcard domains, or IP addresses
|
||||
export const isValidHostPattern = (value: string): boolean => {
|
||||
// Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses
|
||||
const hostRegex =
|
||||
/^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/;
|
||||
return hostRegex.test(value);
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateBodyDALFactory = ReturnType<typeof sshCertificateBodyDALFactory>;
|
||||
|
||||
export const sshCertificateBodyDALFactory = (db: TDbClient) => {
|
||||
const sshCertificateBodyOrm = ormify(db, TableName.SshCertificateBody);
|
||||
return sshCertificateBodyOrm;
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateDALFactory = ReturnType<typeof sshCertificateDALFactory>;
|
||||
|
||||
export const sshCertificateDALFactory = (db: TDbClient) => {
|
||||
const sshCertificateOrm = ormify(db, TableName.SshCertificate);
|
||||
|
||||
const countSshCertificatesInProject = async (projectId: string) => {
|
||||
try {
|
||||
interface CountResult {
|
||||
count: string;
|
||||
}
|
||||
|
||||
const query = db
|
||||
.replicaNode()(TableName.SshCertificate)
|
||||
.join(
|
||||
TableName.SshCertificateAuthority,
|
||||
`${TableName.SshCertificate}.sshCaId`,
|
||||
`${TableName.SshCertificateAuthority}.id`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.SshCertificateAuthority}.projectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.Project}.id`, projectId);
|
||||
|
||||
const count = await query.count("*").first();
|
||||
|
||||
return parseInt((count as unknown as CountResult).count || "0", 10);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Count all SSH certificates in project" });
|
||||
}
|
||||
};
|
||||
return {
|
||||
...sshCertificateOrm,
|
||||
countSshCertificatesInProject
|
||||
};
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import { SshCertificatesSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedSshCertificate = SshCertificatesSchema.pick({
|
||||
id: true,
|
||||
sshCaId: true,
|
||||
sshCertificateTemplateId: true,
|
||||
serialNumber: true,
|
||||
certType: true,
|
||||
publicKey: true,
|
||||
principals: true,
|
||||
keyId: true,
|
||||
notBefore: true,
|
||||
notAfter: true
|
||||
});
|
10
backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateAuthorityDALFactory = ReturnType<typeof sshCertificateAuthorityDALFactory>;
|
||||
|
||||
export const sshCertificateAuthorityDALFactory = (db: TDbClient) => {
|
||||
const sshCaOrm = ormify(db, TableName.SshCertificateAuthority);
|
||||
return sshCaOrm;
|
||||
};
|
376
backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import { execFile } from "child_process";
|
||||
import crypto from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import ms from "ms";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
|
||||
import { TSshCertificateTemplates } from "@app/db/schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
|
||||
import {
|
||||
isValidHostPattern,
|
||||
isValidUserPattern
|
||||
} from "../ssh-certificate-template/ssh-certificate-template-validators";
|
||||
import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/* eslint-disable no-bitwise */
|
||||
export const createSshCertSerialNumber = () => {
|
||||
const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits
|
||||
randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range)
|
||||
return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm].
|
||||
* We use this function because the key format generated by `ssh-keygen` is unique.
|
||||
*/
|
||||
export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
|
||||
const privateKeyFile = path.join(tempDir, "id_key");
|
||||
const publicKeyFile = `${privateKeyFile}.pub`;
|
||||
|
||||
let keyType: string;
|
||||
let keyBits: string;
|
||||
|
||||
switch (keyAlgorithm) {
|
||||
case CertKeyAlgorithm.RSA_2048:
|
||||
keyType = "rsa";
|
||||
keyBits = "2048";
|
||||
break;
|
||||
case CertKeyAlgorithm.RSA_4096:
|
||||
keyType = "rsa";
|
||||
keyBits = "4096";
|
||||
break;
|
||||
case CertKeyAlgorithm.ECDSA_P256:
|
||||
keyType = "ecdsa";
|
||||
keyBits = "256";
|
||||
break;
|
||||
case CertKeyAlgorithm.ECDSA_P384:
|
||||
keyType = "ecdsa";
|
||||
keyBits = "384";
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate the SSH key pair
|
||||
// The "-N ''" sets an empty passphrase
|
||||
// The keys are created in the temporary directory
|
||||
await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""]);
|
||||
|
||||
// Read the generated keys
|
||||
const publicKey = await fs.readFile(publicKeyFile, "utf8");
|
||||
const privateKey = await fs.readFile(privateKeyFile, "utf8");
|
||||
|
||||
return { publicKey, privateKey };
|
||||
} finally {
|
||||
// Cleanup the temporary directory and all its contents
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the SSH public key for the given SSH private key.
|
||||
*/
|
||||
export const getSshPublicKey = async (privateKey: string) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
|
||||
const privateKeyFile = path.join(tempDir, "id_key");
|
||||
try {
|
||||
await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
// Run ssh-keygen to extract the public key
|
||||
const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" });
|
||||
return stdout.trim();
|
||||
} finally {
|
||||
// Ensure that files and the temporary directory are cleaned up
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate type based on the SSH certificate template configuration.
|
||||
*/
|
||||
export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => {
|
||||
if (!template.allowUserCertificates && certType === SshCertType.USER) {
|
||||
throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" });
|
||||
}
|
||||
|
||||
if (!template.allowHostCertificates && certType === SshCertType.HOST) {
|
||||
throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate principals based on the SSH certificate template configuration.
|
||||
*/
|
||||
export const validateSshCertificatePrincipals = (
|
||||
certType: SshCertType,
|
||||
template: TSshCertificateTemplates,
|
||||
principals: string[]
|
||||
) => {
|
||||
/**
|
||||
* Validate and sanitize a principal string
|
||||
*/
|
||||
const validatePrincipal = (principal: string) => {
|
||||
const sanitized = principal.trim();
|
||||
|
||||
// basic checks for empty or control characters
|
||||
if (sanitized.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "Principal cannot be an empty string."
|
||||
});
|
||||
}
|
||||
|
||||
if (/\r|\n|\t|\0/.test(sanitized)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' contains invalid whitespace or control characters.`
|
||||
});
|
||||
}
|
||||
|
||||
// disallow whitespace anywhere
|
||||
if (/\s/.test(sanitized)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' cannot contain whitespace.`
|
||||
});
|
||||
}
|
||||
|
||||
// restrict allowed characters to letters, digits, dot, underscore, and hyphen
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.`
|
||||
});
|
||||
}
|
||||
|
||||
// disallow leading hyphen to avoid potential argument-like inputs
|
||||
if (sanitized.startsWith("-")) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' cannot start with a hyphen.`
|
||||
});
|
||||
}
|
||||
|
||||
// length restriction (adjust as needed)
|
||||
if (sanitized.length > 64) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' is too long.`
|
||||
});
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
// Sanitize and validate all principals using the helper
|
||||
const sanitizedPrincipals = principals.map(validatePrincipal);
|
||||
|
||||
switch (certType) {
|
||||
case SshCertType.USER: {
|
||||
if (template.allowedUsers.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "No allowed users are configured in the SSH certificate template."
|
||||
});
|
||||
}
|
||||
|
||||
const allowsAllUsers = template.allowedUsers.includes("*") ?? false;
|
||||
|
||||
sanitizedPrincipals.forEach((principal) => {
|
||||
if (principal === "*") {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '*' is not allowed for user certificates.`
|
||||
});
|
||||
}
|
||||
if (allowsAllUsers && !isValidUserPattern(principal)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' does not match a valid user pattern.`
|
||||
});
|
||||
}
|
||||
if (!allowsAllUsers && !template.allowedUsers.includes(principal)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' is not in the list of allowed users.`
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case SshCertType.HOST: {
|
||||
if (template.allowedHosts.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "No allowed hosts are configured in the SSH certificate template."
|
||||
});
|
||||
}
|
||||
|
||||
const allowsAllHosts = template.allowedHosts.includes("*") ?? false;
|
||||
|
||||
sanitizedPrincipals.forEach((principal) => {
|
||||
if (principal.includes("*")) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' with wildcards is not allowed for host certificates.`
|
||||
});
|
||||
}
|
||||
if (allowsAllHosts && !isValidHostPattern(principal)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' does not match a valid host pattern.`
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!allowsAllHosts &&
|
||||
!template.allowedHosts.some((allowedHost) => {
|
||||
if (allowedHost.startsWith("*.")) {
|
||||
const baseDomain = allowedHost.slice(2); // Remove the leading "*."
|
||||
return principal.endsWith(`.${baseDomain}`);
|
||||
}
|
||||
return principal === allowedHost;
|
||||
})
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' is not in the list of allowed hosts or domains.`
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate TTL based on the SSH certificate template configuration.
|
||||
*/
|
||||
export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => {
|
||||
if (!ttl) {
|
||||
// use default template ttl
|
||||
return Math.ceil(ms(template.ttl) / 1000);
|
||||
}
|
||||
|
||||
if (ms(ttl) > ms(template.maxTTL)) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed TTL validation due to TTL being greater than configured max TTL on template"
|
||||
});
|
||||
}
|
||||
|
||||
return Math.ceil(ms(ttl) / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate key ID to ensure
|
||||
* that it only contains alphanumeric characters with no spaces.
|
||||
*/
|
||||
export const validateSshCertificateKeyId = (keyId: string) => {
|
||||
const regex = /^[A-Za-z0-9-]+$/;
|
||||
if (!regex.test(keyId)) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces."
|
||||
});
|
||||
}
|
||||
|
||||
if (keyId.length > 50) {
|
||||
throw new BadRequestError({
|
||||
message: "keyId can only be up to 50 characters long."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the format of the SSH public key
|
||||
*/
|
||||
const validateSshPublicKey = async (publicKey: string) => {
|
||||
const validPrefixes = ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"];
|
||||
const startsWithValidPrefix = validPrefixes.some((prefix) => publicKey.startsWith(`${prefix} `));
|
||||
if (!startsWithValidPrefix) {
|
||||
throw new BadRequestError({ message: "Failed to validate SSH public key format: unsupported key type." });
|
||||
}
|
||||
|
||||
// write the key to a temp file and run `ssh-keygen -l -f`
|
||||
// check to see if OpenSSH can read/interpret the public key
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-pubkey-"));
|
||||
const pubKeyFile = path.join(tempDir, "key.pub");
|
||||
|
||||
try {
|
||||
await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 });
|
||||
await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to validate SSH public key format: could not be parsed."
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an SSH certificate for a user or host.
|
||||
*/
|
||||
export const createSshCert = async ({
|
||||
template,
|
||||
caPrivateKey,
|
||||
clientPublicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl,
|
||||
certType
|
||||
}: TCreateSshCertDTO) => {
|
||||
// validate if the requested [certType] is allowed under the template configuration
|
||||
validateSshCertificateType(template, certType);
|
||||
|
||||
// validate if the requested [principals] are valid for the given [certType] under the template configuration
|
||||
validateSshCertificatePrincipals(certType, template, principals);
|
||||
|
||||
// validate if the requested TTL is valid under the template configuration
|
||||
const ttl = validateSshCertificateTtl(template, requestedTtl);
|
||||
|
||||
validateSshCertificateKeyId(keyId);
|
||||
await validateSshPublicKey(clientPublicKey);
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-"));
|
||||
|
||||
const publicKeyFile = path.join(tempDir, "user_key.pub");
|
||||
const privateKeyFile = path.join(tempDir, "ca_key");
|
||||
const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub");
|
||||
|
||||
const serialNumber = createSshCertSerialNumber();
|
||||
|
||||
// Build `ssh-keygen` arguments for signing
|
||||
// Using an array avoids shell injection issues
|
||||
const sshKeygenArgs = [
|
||||
certType === "host" ? "-h" : null, // host certificate if needed
|
||||
"-s",
|
||||
privateKeyFile, // path to SSH CA private key
|
||||
"-I",
|
||||
keyId, // identity (key ID)
|
||||
"-n",
|
||||
principals.join(","), // principals
|
||||
"-V",
|
||||
`+${ttl}s`, // validity (TTL in seconds)
|
||||
"-z",
|
||||
serialNumber, // serial number
|
||||
publicKeyFile // public key file to sign
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
try {
|
||||
// Write public and private keys to the temp directory
|
||||
await fs.writeFile(publicKeyFile, clientPublicKey, { mode: 0o600 });
|
||||
await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 });
|
||||
|
||||
// Execute the signing process
|
||||
await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" });
|
||||
|
||||
// Read the signed public key from the generated cert file
|
||||
const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8");
|
||||
|
||||
return { serialNumber, signedPublicKey, ttl };
|
||||
} finally {
|
||||
// Cleanup the temporary directory and all its contents
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { SshCertificateAuthoritiesSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({
|
||||
id: true,
|
||||
projectId: true,
|
||||
friendlyName: true,
|
||||
status: true,
|
||||
keyAlgorithm: true
|
||||
});
|
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateAuthoritySecretDALFactory = ReturnType<typeof sshCertificateAuthoritySecretDALFactory>;
|
||||
|
||||
export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => {
|
||||
const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret);
|
||||
return sshCaSecretOrm;
|
||||
};
|
523
backend/src/ee/services/ssh/ssh-certificate-authority-service.ts
Normal file
@ -0,0 +1,523 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
|
||||
import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
|
||||
import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal";
|
||||
import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
|
||||
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types";
|
||||
import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns";
|
||||
import {
|
||||
SshCaStatus,
|
||||
TCreateSshCaDTO,
|
||||
TDeleteSshCaDTO,
|
||||
TGetSshCaCertificateTemplatesDTO,
|
||||
TGetSshCaDTO,
|
||||
TGetSshCaPublicKeyDTO,
|
||||
TIssueSshCredsDTO,
|
||||
TSignSshKeyDTO,
|
||||
TUpdateSshCaDTO
|
||||
} from "./ssh-certificate-authority-types";
|
||||
|
||||
type TSshCertificateAuthorityServiceFactoryDep = {
|
||||
sshCertificateAuthorityDAL: Pick<
|
||||
TSshCertificateAuthorityDALFactory,
|
||||
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne"
|
||||
>;
|
||||
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "create" | "findOne">;
|
||||
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find" | "getById">;
|
||||
sshCertificateDAL: Pick<TSshCertificateDALFactory, "create" | "transaction">;
|
||||
sshCertificateBodyDAL: Pick<TSshCertificateBodyDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TSshCertificateAuthorityServiceFactory = ReturnType<typeof sshCertificateAuthorityServiceFactory>;
|
||||
|
||||
export const sshCertificateAuthorityServiceFactory = ({
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateAuthoritySecretDAL,
|
||||
sshCertificateTemplateDAL,
|
||||
sshCertificateDAL,
|
||||
sshCertificateBodyDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
}: TSshCertificateAuthorityServiceFactoryDep) => {
|
||||
/**
|
||||
* Generates a new SSH CA
|
||||
*/
|
||||
const createSshCa = async ({
|
||||
projectId,
|
||||
friendlyName,
|
||||
keyAlgorithm,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TCreateSshCaDTO) => {
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => {
|
||||
const ca = await sshCertificateAuthorityDAL.create(
|
||||
{
|
||||
projectId,
|
||||
friendlyName,
|
||||
status: SshCaStatus.ACTIVE,
|
||||
keyAlgorithm
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm);
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
await sshCertificateAuthoritySecretDAL.create(
|
||||
{
|
||||
sshCaId: ca.id,
|
||||
encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return { ...ca, publicKey };
|
||||
});
|
||||
|
||||
return newCa;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return SSH CA with id [caId]
|
||||
*/
|
||||
const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: ca.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
|
||||
|
||||
return { ...ca, publicKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Return public key of SSH CA with id [caId]
|
||||
*/
|
||||
const getSshCaPublicKey = async ({ caId }: TGetSshCaPublicKeyDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: ca.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update SSH CA with id [caId]
|
||||
* Note: Used to enable/disable CA
|
||||
*/
|
||||
const updateSshCaById = async ({
|
||||
caId,
|
||||
friendlyName,
|
||||
status,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TUpdateSshCaDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status });
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: ca.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
|
||||
|
||||
return { ...updatedCa, publicKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete SSH CA with id [caId]
|
||||
*/
|
||||
const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId);
|
||||
|
||||
return deletedCa;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return SSH certificate and corresponding new SSH public-private key pair where
|
||||
* SSH public key is signed using CA behind SSH certificate with name [templateName].
|
||||
*/
|
||||
const issueSshCreds = async ({
|
||||
certificateTemplateId,
|
||||
keyAlgorithm,
|
||||
certType,
|
||||
principals,
|
||||
ttl: requestedTtl,
|
||||
keyId: requestedKeyId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TIssueSshCredsDTO) => {
|
||||
const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId);
|
||||
if (!sshCertificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: "No SSH certificate template found with specified name"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
sshCertificateTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificates
|
||||
);
|
||||
|
||||
if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH CA is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH certificate template is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
// set [keyId] depending on if [allowCustomKeyIds] is true or false
|
||||
const keyId = sshCertificateTemplate.allowCustomKeyIds
|
||||
? requestedKeyId ?? `${actor}-${actorId}`
|
||||
: `${actor}-${actorId}`;
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
// create user key pair
|
||||
const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm);
|
||||
|
||||
const { serialNumber, signedPublicKey, ttl } = await createSshCert({
|
||||
template: sshCertificateTemplate,
|
||||
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
|
||||
clientPublicKey: publicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl,
|
||||
certType
|
||||
});
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const encryptedCertificate = secretManagerEncryptor({
|
||||
plainText: Buffer.from(signedPublicKey, "utf8")
|
||||
}).cipherTextBlob;
|
||||
|
||||
await sshCertificateDAL.transaction(async (tx) => {
|
||||
const cert = await sshCertificateDAL.create(
|
||||
{
|
||||
sshCaId: sshCertificateTemplate.sshCaId,
|
||||
sshCertificateTemplateId: sshCertificateTemplate.id,
|
||||
serialNumber,
|
||||
certType,
|
||||
principals,
|
||||
keyId,
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(Date.now() + ttl * 1000)
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await sshCertificateBodyDAL.create(
|
||||
{
|
||||
sshCertId: cert.id,
|
||||
encryptedCertificate
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
serialNumber,
|
||||
signedPublicKey,
|
||||
privateKey,
|
||||
publicKey,
|
||||
certificateTemplate: sshCertificateTemplate,
|
||||
ttl,
|
||||
keyId
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return SSH certificate by signing SSH public key [publicKey]
|
||||
* using CA behind SSH certificate template with name [templateName]
|
||||
*/
|
||||
const signSshKey = async ({
|
||||
certificateTemplateId,
|
||||
publicKey,
|
||||
certType,
|
||||
principals,
|
||||
ttl: requestedTtl,
|
||||
keyId: requestedKeyId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TSignSshKeyDTO) => {
|
||||
const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId);
|
||||
if (!sshCertificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: "No SSH certificate template found with specified name"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
sshCertificateTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificates
|
||||
);
|
||||
|
||||
if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH CA is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH certificate template is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
// set [keyId] depending on if [allowCustomKeyIds] is true or false
|
||||
const keyId = sshCertificateTemplate.allowCustomKeyIds
|
||||
? requestedKeyId ?? `${actor}-${actorId}`
|
||||
: `${actor}-${actorId}`;
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const { serialNumber, signedPublicKey, ttl } = await createSshCert({
|
||||
template: sshCertificateTemplate,
|
||||
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
|
||||
clientPublicKey: publicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl,
|
||||
certType
|
||||
});
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const encryptedCertificate = secretManagerEncryptor({
|
||||
plainText: Buffer.from(signedPublicKey, "utf8")
|
||||
}).cipherTextBlob;
|
||||
|
||||
await sshCertificateDAL.transaction(async (tx) => {
|
||||
const cert = await sshCertificateDAL.create(
|
||||
{
|
||||
sshCaId: sshCertificateTemplate.sshCaId,
|
||||
sshCertificateTemplateId: sshCertificateTemplate.id,
|
||||
serialNumber,
|
||||
certType,
|
||||
principals,
|
||||
keyId,
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(Date.now() + ttl * 1000)
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await sshCertificateBodyDAL.create(
|
||||
{
|
||||
sshCertId: cert.id,
|
||||
encryptedCertificate
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId };
|
||||
};
|
||||
|
||||
const getSshCaCertificateTemplates = async ({
|
||||
caId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TGetSshCaCertificateTemplatesDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId });
|
||||
|
||||
return {
|
||||
certificateTemplates,
|
||||
ca
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
issueSshCreds,
|
||||
signSshKey,
|
||||
createSshCa,
|
||||
getSshCaById,
|
||||
getSshCaPublicKey,
|
||||
updateSshCaById,
|
||||
deleteSshCaById,
|
||||
getSshCaCertificateTemplates
|
||||
};
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import { TSshCertificateTemplates } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
|
||||
export enum SshCaStatus {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled"
|
||||
}
|
||||
|
||||
export enum SshCertType {
|
||||
USER = "user",
|
||||
HOST = "host"
|
||||
}
|
||||
|
||||
export type TCreateSshCaDTO = {
|
||||
friendlyName: string;
|
||||
keyAlgorithm: CertKeyAlgorithm;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetSshCaDTO = {
|
||||
caId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSshCaPublicKeyDTO = {
|
||||
caId: string;
|
||||
};
|
||||
|
||||
export type TUpdateSshCaDTO = {
|
||||
caId: string;
|
||||
friendlyName?: string;
|
||||
status?: SshCaStatus;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDeleteSshCaDTO = {
|
||||
caId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TIssueSshCredsDTO = {
|
||||
certificateTemplateId: string;
|
||||
keyAlgorithm: CertKeyAlgorithm;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl?: string;
|
||||
keyId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TSignSshKeyDTO = {
|
||||
certificateTemplateId: string;
|
||||
publicKey: string;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl?: string;
|
||||
keyId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSshCaCertificateTemplatesDTO = {
|
||||
caId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateSshCertDTO = {
|
||||
template: TSshCertificateTemplates;
|
||||
caPrivateKey: string;
|
||||
clientPublicKey: string;
|
||||
keyId: string;
|
||||
principals: string[];
|
||||
requestedTtl?: string;
|
||||
certType: SshCertType;
|
||||
};
|
@ -492,6 +492,17 @@ export const PROJECTS = {
|
||||
LIST_INTEGRATION_AUTHORIZATION: {
|
||||
workspaceId: "The ID of the project to list integration auths for."
|
||||
},
|
||||
LIST_SSH_CAS: {
|
||||
projectId: "The ID of the project to list SSH CAs for."
|
||||
},
|
||||
LIST_SSH_CERTIFICATES: {
|
||||
projectId: "The ID of the project to list SSH certificates for.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.",
|
||||
limit: "The number of SSH certificates to return."
|
||||
},
|
||||
LIST_SSH_CERTIFICATE_TEMPLATES: {
|
||||
projectId: "The ID of the project to list SSH certificate templates for."
|
||||
},
|
||||
LIST_CAS: {
|
||||
slug: "The slug of the project to list CAs for.",
|
||||
status: "The status of the CA to filter by.",
|
||||
@ -1126,6 +1137,7 @@ export const INTEGRATION = {
|
||||
shouldAutoRedeploy: "Used by Render to trigger auto deploy.",
|
||||
secretGCPLabel: "The label for GCP secrets.",
|
||||
secretAWSTag: "The tags for AWS secrets.",
|
||||
azureLabel: "Define which label to assign to secrets created in Azure App Configuration.",
|
||||
githubVisibility:
|
||||
"Define where the secrets from the Github Integration should be visible. Option 'selected' lets you directly define which repositories to sync secrets to.",
|
||||
githubVisibilityRepoIds:
|
||||
@ -1186,6 +1198,84 @@ export const AUDIT_LOG_STREAMS = {
|
||||
}
|
||||
};
|
||||
|
||||
export const SSH_CERTIFICATE_AUTHORITIES = {
|
||||
CREATE: {
|
||||
projectId: "The ID of the project to create the SSH CA in.",
|
||||
friendlyName: "A friendly name for the SSH CA.",
|
||||
keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA."
|
||||
},
|
||||
GET: {
|
||||
sshCaId: "The ID of the SSH CA to get."
|
||||
},
|
||||
GET_PUBLIC_KEY: {
|
||||
sshCaId: "The ID of the SSH CA to get the public key for."
|
||||
},
|
||||
UPDATE: {
|
||||
sshCaId: "The ID of the SSH CA to update.",
|
||||
friendlyName: "A friendly name for the SSH CA to update to.",
|
||||
status: "The status of the SSH CA to update to. This can be one of active or disabled."
|
||||
},
|
||||
DELETE: {
|
||||
sshCaId: "The ID of the SSH CA to delete."
|
||||
},
|
||||
GET_CERTIFICATE_TEMPLATES: {
|
||||
sshCaId: "The ID of the SSH CA to get the certificate templates for."
|
||||
},
|
||||
SIGN_SSH_KEY: {
|
||||
certificateTemplateId: "The ID of the SSH certificate template to sign the SSH public key with.",
|
||||
publicKey: "The SSH public key to sign.",
|
||||
certType: "The type of certificate to issue. This can be one of user or host.",
|
||||
principals: "The list of principals (usernames, hostnames) to include in the certificate.",
|
||||
ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.",
|
||||
keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.",
|
||||
serialNumber: "The serial number of the issued SSH certificate.",
|
||||
signedKey: "The SSH certificate or signed SSH public key."
|
||||
},
|
||||
ISSUE_SSH_CREDENTIALS: {
|
||||
certificateTemplateId: "The ID of the SSH certificate template to issue the SSH credentials with.",
|
||||
keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.",
|
||||
certType: "The type of certificate to issue. This can be one of user or host.",
|
||||
principals: "The list of principals (usernames, hostnames) to include in the certificate.",
|
||||
ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.",
|
||||
keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.",
|
||||
serialNumber: "The serial number of the issued SSH certificate.",
|
||||
signedKey: "The SSH certificate or signed SSH public key.",
|
||||
privateKey: "The private key corresponding to the issued SSH certificate.",
|
||||
publicKey: "The public key of the issued SSH certificate."
|
||||
}
|
||||
};
|
||||
|
||||
export const SSH_CERTIFICATE_TEMPLATES = {
|
||||
GET: {
|
||||
certificateTemplateId: "The ID of the SSH certificate template to get."
|
||||
},
|
||||
CREATE: {
|
||||
sshCaId: "The ID of the SSH CA to associate the certificate template with.",
|
||||
name: "The name of the certificate template.",
|
||||
ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...",
|
||||
maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...",
|
||||
allowedUsers: "The list of allowed users for certificates issued under this template.",
|
||||
allowedHosts: "The list of allowed hosts for certificates issued under this template.",
|
||||
allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.",
|
||||
allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.",
|
||||
allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template."
|
||||
},
|
||||
UPDATE: {
|
||||
certificateTemplateId: "The ID of the SSH certificate template to update.",
|
||||
name: "The name of the certificate template.",
|
||||
ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...",
|
||||
maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...",
|
||||
allowedUsers: "The list of allowed users for certificates issued under this template.",
|
||||
allowedHosts: "The list of allowed hosts for certificates issued under this template.",
|
||||
allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.",
|
||||
allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.",
|
||||
allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template."
|
||||
},
|
||||
DELETE: {
|
||||
certificateTemplateId: "The ID of the SSH certificate template to delete."
|
||||
}
|
||||
};
|
||||
|
||||
export const CERTIFICATE_AUTHORITIES = {
|
||||
CREATE: {
|
||||
projectSlug: "Slug of the project to create the CA in.",
|
||||
|
@ -20,11 +20,12 @@ export const withTransaction = <K extends object>(db: Knex, dal: K) => ({
|
||||
|
||||
export type TFindFilter<R extends object = object> = Partial<R> & {
|
||||
$in?: Partial<{ [k in keyof R]: R[k][] }>;
|
||||
$notNull?: Array<keyof R>;
|
||||
$search?: Partial<{ [k in keyof R]: R[k] }>;
|
||||
$complex?: TKnexDynamicOperator<R>;
|
||||
};
|
||||
export const buildFindFilter =
|
||||
<R extends object = object>({ $in, $search, $complex, ...filter }: TFindFilter<R>) =>
|
||||
<R extends object = object>({ $in, $notNull, $search, $complex, ...filter }: TFindFilter<R>) =>
|
||||
(bd: Knex.QueryBuilder<R, R>) => {
|
||||
void bd.where(filter);
|
||||
if ($in) {
|
||||
@ -34,6 +35,13 @@ export const buildFindFilter =
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($notNull?.length) {
|
||||
$notNull.forEach((key) => {
|
||||
void bd.whereNotNull(key as never);
|
||||
});
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
Object.entries($search).forEach(([key, val]) => {
|
||||
if (val) {
|
||||
|
@ -317,6 +317,13 @@ export const queueServiceFactory = (
|
||||
}
|
||||
};
|
||||
|
||||
const getRepeatableJobs = (name: QueueName, startOffset?: number, endOffset?: number) => {
|
||||
const q = queueContainer[name];
|
||||
if (!q) throw new Error(`Queue '${name}' not initialized`);
|
||||
|
||||
return q.getRepeatableJobs(startOffset, endOffset);
|
||||
};
|
||||
|
||||
const stopRepeatableJobByJobId = async <T extends QueueName>(name: T, jobId: string) => {
|
||||
const q = queueContainer[name];
|
||||
const job = await q.getJob(jobId);
|
||||
@ -326,6 +333,11 @@ export const queueServiceFactory = (
|
||||
return q.removeRepeatableByKey(job.repeatJobKey);
|
||||
};
|
||||
|
||||
const stopRepeatableJobByKey = async <T extends QueueName>(name: T, repeatJobKey: string) => {
|
||||
const q = queueContainer[name];
|
||||
return q.removeRepeatableByKey(repeatJobKey);
|
||||
};
|
||||
|
||||
const stopJobById = async <T extends QueueName>(name: T, jobId: string) => {
|
||||
const q = queueContainer[name];
|
||||
const job = await q.getJob(jobId);
|
||||
@ -349,8 +361,10 @@ export const queueServiceFactory = (
|
||||
shutdown,
|
||||
stopRepeatableJob,
|
||||
stopRepeatableJobByJobId,
|
||||
stopRepeatableJobByKey,
|
||||
clearQueue,
|
||||
stopJobById,
|
||||
getRepeatableJobs,
|
||||
startPg,
|
||||
queuePg
|
||||
};
|
||||
|
@ -75,6 +75,13 @@ import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-da
|
||||
import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal";
|
||||
import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
|
||||
import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal";
|
||||
import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
|
||||
import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
|
||||
import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
|
||||
import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal";
|
||||
import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
|
||||
import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
|
||||
import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service";
|
||||
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
|
||||
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
@ -345,6 +352,12 @@ export const registerRoutes = async (
|
||||
const dynamicSecretDAL = dynamicSecretDALFactory(db);
|
||||
const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db);
|
||||
|
||||
const sshCertificateDAL = sshCertificateDALFactory(db);
|
||||
const sshCertificateBodyDAL = sshCertificateBodyDALFactory(db);
|
||||
const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db);
|
||||
const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db);
|
||||
const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db);
|
||||
|
||||
const kmsDAL = kmskeyDALFactory(db);
|
||||
const internalKmsDAL = internalKmsDALFactory(db);
|
||||
const externalKmsDAL = externalKmsDALFactory(db);
|
||||
@ -538,7 +551,11 @@ export const registerRoutes = async (
|
||||
|
||||
const orgService = orgServiceFactory({
|
||||
userAliasDAL,
|
||||
queueService,
|
||||
identityMetadataDAL,
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
folderDAL,
|
||||
licenseService,
|
||||
samlConfigDAL,
|
||||
orgRoleDAL,
|
||||
@ -559,6 +576,7 @@ export const registerRoutes = async (
|
||||
groupDAL,
|
||||
orgBotDAL,
|
||||
oidcConfigDAL,
|
||||
loginService,
|
||||
projectBotService
|
||||
});
|
||||
const signupService = authSignupServiceFactory({
|
||||
@ -707,6 +725,22 @@ export const registerRoutes = async (
|
||||
queueService
|
||||
});
|
||||
|
||||
const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateAuthoritySecretDAL,
|
||||
sshCertificateTemplateDAL,
|
||||
sshCertificateDAL,
|
||||
sshCertificateBodyDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const sshCertificateTemplateService = sshCertificateTemplateServiceFactory({
|
||||
sshCertificateTemplateDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const certificateAuthorityService = certificateAuthorityServiceFactory({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
@ -776,10 +810,58 @@ export const registerRoutes = async (
|
||||
projectTemplateDAL
|
||||
});
|
||||
|
||||
const integrationAuthService = integrationAuthServiceFactory({
|
||||
integrationAuthDAL,
|
||||
integrationDAL,
|
||||
permissionService,
|
||||
projectBotService,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const secretQueueService = secretQueueFactory({
|
||||
keyStore,
|
||||
queueService,
|
||||
secretDAL,
|
||||
folderDAL,
|
||||
integrationAuthService,
|
||||
projectBotService,
|
||||
integrationDAL,
|
||||
secretImportDAL,
|
||||
projectEnvDAL,
|
||||
webhookDAL,
|
||||
orgDAL,
|
||||
auditLogService,
|
||||
userDAL,
|
||||
projectMembershipDAL,
|
||||
smtpService,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
secretVersionDAL,
|
||||
secretBlindIndexDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
kmsService,
|
||||
secretVersionV2BridgeDAL,
|
||||
secretV2BridgeDAL,
|
||||
secretVersionTagV2BridgeDAL,
|
||||
secretRotationDAL,
|
||||
integrationAuthDAL,
|
||||
snapshotDAL,
|
||||
snapshotSecretV2BridgeDAL,
|
||||
secretApprovalRequestDAL,
|
||||
projectKeyDAL,
|
||||
projectUserMembershipRoleDAL,
|
||||
orgService
|
||||
});
|
||||
|
||||
const projectService = projectServiceFactory({
|
||||
permissionService,
|
||||
projectDAL,
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
queueService,
|
||||
projectQueue: projectQueueService,
|
||||
projectBotService,
|
||||
identityProjectDAL,
|
||||
identityOrgMembershipDAL,
|
||||
projectKeyDAL,
|
||||
@ -795,6 +877,9 @@ export const registerRoutes = async (
|
||||
certificateDAL,
|
||||
pkiAlertDAL,
|
||||
pkiCollectionDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateDAL,
|
||||
sshCertificateTemplateDAL,
|
||||
projectUserMembershipRoleDAL,
|
||||
identityProjectMembershipRoleDAL,
|
||||
keyStore,
|
||||
@ -859,48 +944,6 @@ export const registerRoutes = async (
|
||||
projectDAL
|
||||
});
|
||||
|
||||
const integrationAuthService = integrationAuthServiceFactory({
|
||||
integrationAuthDAL,
|
||||
integrationDAL,
|
||||
permissionService,
|
||||
projectBotService,
|
||||
kmsService
|
||||
});
|
||||
const secretQueueService = secretQueueFactory({
|
||||
keyStore,
|
||||
queueService,
|
||||
secretDAL,
|
||||
folderDAL,
|
||||
integrationAuthService,
|
||||
projectBotService,
|
||||
integrationDAL,
|
||||
secretImportDAL,
|
||||
projectEnvDAL,
|
||||
webhookDAL,
|
||||
orgDAL,
|
||||
auditLogService,
|
||||
userDAL,
|
||||
projectMembershipDAL,
|
||||
smtpService,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
secretVersionDAL,
|
||||
secretBlindIndexDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
kmsService,
|
||||
secretVersionV2BridgeDAL,
|
||||
secretV2BridgeDAL,
|
||||
secretVersionTagV2BridgeDAL,
|
||||
secretRotationDAL,
|
||||
integrationAuthDAL,
|
||||
snapshotDAL,
|
||||
snapshotSecretV2BridgeDAL,
|
||||
secretApprovalRequestDAL,
|
||||
projectKeyDAL,
|
||||
projectUserMembershipRoleDAL,
|
||||
orgService
|
||||
});
|
||||
const secretImportService = secretImportServiceFactory({
|
||||
licenseService,
|
||||
projectBotService,
|
||||
@ -1229,6 +1272,7 @@ export const registerRoutes = async (
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
secretVersionDAL,
|
||||
secretDAL,
|
||||
secretFolderVersionDAL: folderVersionDAL,
|
||||
snapshotDAL,
|
||||
identityAccessTokenDAL,
|
||||
@ -1376,6 +1420,8 @@ export const registerRoutes = async (
|
||||
auditLog: auditLogService,
|
||||
auditLogStream: auditLogStreamService,
|
||||
certificate: certificateService,
|
||||
sshCertificateAuthority: sshCertificateAuthorityService,
|
||||
sshCertificateTemplate: sshCertificateTemplateService,
|
||||
certificateAuthority: certificateAuthorityService,
|
||||
certificateTemplate: certificateTemplateService,
|
||||
certificateAuthorityCrl: certificateAuthorityCrlService,
|
||||
|
@ -1185,4 +1185,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
|
||||
return { spaces };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:integrationAuthId/circleci/organizations",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
integrationAuthId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
organizations: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
projects: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
id: z.string()
|
||||
})
|
||||
.array(),
|
||||
contexts: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
id: z.string()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const organizations = await server.services.integrationAuth.getCircleCIOrganizations({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.integrationAuthId
|
||||
});
|
||||
return { organizations };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -16,6 +16,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
|
||||
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
|
||||
|
||||
import { integrationAuthPubSchema } from "../sanitizedSchemas";
|
||||
|
||||
@ -29,9 +30,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
organizations: OrganizationsSchema.extend({
|
||||
orgAuthMethod: z.string()
|
||||
}).array()
|
||||
organizations: sanitizedOrganizationSchema
|
||||
.extend({
|
||||
orgAuthMethod: z.string()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -137,7 +137,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((value) => value === "true"),
|
||||
type: z.enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, "all"]).optional()
|
||||
type: z
|
||||
.enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"])
|
||||
.optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
import { ORGANIZATIONS } from "@app/lib/api-docs";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ActorType, AuthMode } from "@app/services/auth/auth-type";
|
||||
@ -363,21 +364,35 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
organization: OrganizationsSchema
|
||||
organization: OrganizationsSchema,
|
||||
accessToken: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
|
||||
handler: async (req) => {
|
||||
handler: async (req, res) => {
|
||||
if (req.auth.actor !== ActorType.USER) return;
|
||||
|
||||
const organization = await server.services.org.deleteOrganizationById(
|
||||
req.permission.id,
|
||||
req.params.organizationId,
|
||||
req.permission.authMethod,
|
||||
req.permission.orgId
|
||||
);
|
||||
return { organization };
|
||||
const cfg = getConfig();
|
||||
|
||||
const { organization, tokens } = await server.services.org.deleteOrganizationById({
|
||||
userId: req.permission.id,
|
||||
orgId: req.params.organizationId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
authorizationHeader: req.headers.authorization,
|
||||
userAgentHeader: req.headers["user-agent"],
|
||||
ipAddress: req.realIp
|
||||
});
|
||||
|
||||
void res.setCookie("jid", tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
secure: cfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
return { organization, accessToken: tokens.accessToken };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -10,6 +10,9 @@ import {
|
||||
} from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types";
|
||||
import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema";
|
||||
import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema";
|
||||
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
|
||||
import { PROJECTS } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
@ -500,4 +503,101 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
return { certificateTemplates };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectId/ssh-certificates",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().default(0).describe(PROJECTS.LIST_SSH_CERTIFICATES.offset),
|
||||
limit: z.coerce.number().default(25).describe(PROJECTS.LIST_SSH_CERTIFICATES.limit)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificates: z.array(sanitizedSshCertificate),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { certificates, totalCount } = await server.services.project.listProjectSshCertificates({
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actor: req.permission.type,
|
||||
projectId: req.params.projectId,
|
||||
offset: req.query.offset,
|
||||
limit: req.query.limit
|
||||
});
|
||||
|
||||
return { certificates, totalCount };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectId/ssh-certificate-templates",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CERTIFICATE_TEMPLATES.projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateTemplates: z.array(sanitizedSshCertificateTemplate)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { certificateTemplates } = await server.services.project.listProjectSshCertificateTemplates({
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actor: req.permission.type,
|
||||
projectId: req.params.projectId
|
||||
});
|
||||
|
||||
return { certificateTemplates };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectId/ssh-cas",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
cas: z.array(sanitizedSshCa)
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const cas = await server.services.project.listProjectSshCas({
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actor: req.permission.type,
|
||||
projectId: req.params.projectId
|
||||
});
|
||||
|
||||
return { cas };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
||||
import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
||||
import { ApiKeysSchema } from "@app/db/schemas/api-keys";
|
||||
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
|
||||
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
|
||||
|
||||
export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@ -134,7 +135,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
description: "Return organizations that current user is part of",
|
||||
response: {
|
||||
200: z.object({
|
||||
organizations: OrganizationsSchema.array()
|
||||
organizations: sanitizedOrganizationSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -12,9 +12,12 @@ export type TTokenDALFactory = ReturnType<typeof tokenDALFactory>;
|
||||
export const tokenDALFactory = (db: TDbClient) => {
|
||||
const authOrm = ormify(db, TableName.AuthTokens);
|
||||
|
||||
const findOneTokenSession = async (filter: Partial<TAuthTokenSessions>): Promise<TAuthTokenSessions | undefined> => {
|
||||
const findOneTokenSession = async (
|
||||
filter: Partial<TAuthTokenSessions>,
|
||||
tx?: Knex
|
||||
): Promise<TAuthTokenSessions | undefined> => {
|
||||
try {
|
||||
const doc = await db.replicaNode()(TableName.AuthTokenSession).where(filter).first();
|
||||
const doc = await (tx || db.replicaNode())(TableName.AuthTokenSession).where(filter).first();
|
||||
return doc;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindOneTokenSession" });
|
||||
@ -54,10 +57,11 @@ export const tokenDALFactory = (db: TDbClient) => {
|
||||
const insertTokenSession = async (
|
||||
userId: string,
|
||||
ip: string,
|
||||
userAgent: string
|
||||
userAgent: string,
|
||||
tx?: Knex
|
||||
): Promise<TAuthTokenSessions | undefined> => {
|
||||
try {
|
||||
const [session] = await db(TableName.AuthTokenSession)
|
||||
const [session] = await (tx || db)(TableName.AuthTokenSession)
|
||||
.insert({
|
||||
userId,
|
||||
ip,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import bcrypt from "bcrypt";
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
@ -123,14 +124,13 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
|
||||
return deletedToken?.[0];
|
||||
};
|
||||
|
||||
const getUserTokenSession = async ({
|
||||
userId,
|
||||
ip,
|
||||
userAgent
|
||||
}: TIssueAuthTokenDTO): Promise<TAuthTokenSessions | undefined> => {
|
||||
let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent });
|
||||
const getUserTokenSession = async (
|
||||
{ userId, ip, userAgent }: TIssueAuthTokenDTO,
|
||||
tx?: Knex
|
||||
): Promise<TAuthTokenSessions | undefined> => {
|
||||
let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent }, tx);
|
||||
if (!session) {
|
||||
session = await tokenDAL.insertTokenSession(userId, ip, userAgent);
|
||||
session = await tokenDAL.insertTokenSession(userId, ip, userAgent, tx);
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
@ -1,5 +1,6 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TUsers, UserDeviceSchema } from "@app/db/schemas";
|
||||
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
|
||||
@ -50,13 +51,13 @@ export const authLoginServiceFactory = ({
|
||||
* Not exported. This is to update user device list
|
||||
* If new device is found. Will be saved and a mail will be send
|
||||
*/
|
||||
const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string) => {
|
||||
const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string, tx?: Knex) => {
|
||||
const devices = await UserDeviceSchema.parseAsync(user.devices || []);
|
||||
const isDeviceSeen = devices.some((device) => device.ip === ip && device.userAgent === userAgent);
|
||||
|
||||
if (!isDeviceSeen) {
|
||||
const newDeviceList = devices.concat([{ ip, userAgent }]);
|
||||
await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) });
|
||||
await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }, tx);
|
||||
if (user.email) {
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.NewDeviceJoin,
|
||||
@ -97,30 +98,36 @@ export const authLoginServiceFactory = ({
|
||||
* Check user device and send mail if new device
|
||||
* generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled
|
||||
*/
|
||||
const generateUserTokens = async ({
|
||||
user,
|
||||
ip,
|
||||
userAgent,
|
||||
organizationId,
|
||||
authMethod,
|
||||
isMfaVerified,
|
||||
mfaMethod
|
||||
}: {
|
||||
user: TUsers;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
organizationId?: string;
|
||||
authMethod: AuthMethod;
|
||||
isMfaVerified?: boolean;
|
||||
mfaMethod?: MfaMethod;
|
||||
}) => {
|
||||
const cfg = getConfig();
|
||||
await updateUserDeviceSession(user, ip, userAgent);
|
||||
const tokenSession = await tokenService.getUserTokenSession({
|
||||
userAgent,
|
||||
const generateUserTokens = async (
|
||||
{
|
||||
user,
|
||||
ip,
|
||||
userId: user.id
|
||||
});
|
||||
userAgent,
|
||||
organizationId,
|
||||
authMethod,
|
||||
isMfaVerified,
|
||||
mfaMethod
|
||||
}: {
|
||||
user: TUsers;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
organizationId?: string;
|
||||
authMethod: AuthMethod;
|
||||
isMfaVerified?: boolean;
|
||||
mfaMethod?: MfaMethod;
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
const cfg = getConfig();
|
||||
await updateUserDeviceSession(user, ip, userAgent, tx);
|
||||
const tokenSession = await tokenService.getUserTokenSession(
|
||||
{
|
||||
userAgent,
|
||||
ip,
|
||||
userId: user.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (!tokenSession) throw new Error("Failed to create token");
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
|
||||
/* eslint-disable no-bitwise */
|
||||
export const createSerialNumber = () => {
|
||||
const randomBytes = crypto.randomBytes(20);
|
||||
const randomBytes = crypto.randomBytes(20); // 20 bytes = 160 bits
|
||||
randomBytes[0] &= 0x7f; // ensure the first bit is 0
|
||||
return randomBytes.toString("hex");
|
||||
};
|
||||
|
@ -0,0 +1,5 @@
|
||||
export type TCircleCIContext = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
@ -17,6 +17,8 @@ import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TGenericPermission, TProjectPermission } from "@app/lib/types";
|
||||
|
||||
import { TIntegrationDALFactory } from "../integration/integration-dal";
|
||||
@ -24,6 +26,7 @@ import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||
import { getApps } from "./integration-app-list";
|
||||
import { TCircleCIContext } from "./integration-app-types";
|
||||
import { TIntegrationAuthDALFactory } from "./integration-auth-dal";
|
||||
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
|
||||
import {
|
||||
@ -31,6 +34,7 @@ import {
|
||||
TBitbucketEnvironment,
|
||||
TBitbucketWorkspace,
|
||||
TChecklyGroups,
|
||||
TCircleCIOrganization,
|
||||
TDeleteIntegrationAuthByIdDTO,
|
||||
TDeleteIntegrationAuthsDTO,
|
||||
TDuplicateGithubIntegrationAuthDTO,
|
||||
@ -42,6 +46,7 @@ import {
|
||||
TIntegrationAuthBitbucketEnvironmentsDTO,
|
||||
TIntegrationAuthBitbucketWorkspaceDTO,
|
||||
TIntegrationAuthChecklyGroupsDTO,
|
||||
TIntegrationAuthCircleCIOrganizationDTO,
|
||||
TIntegrationAuthGithubEnvsDTO,
|
||||
TIntegrationAuthGithubOrgsDTO,
|
||||
TIntegrationAuthHerokuPipelinesDTO,
|
||||
@ -1578,6 +1583,120 @@ export const integrationAuthServiceFactory = ({
|
||||
return [];
|
||||
};
|
||||
|
||||
const getCircleCIOrganizations = async ({
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
id
|
||||
}: TIntegrationAuthCircleCIOrganizationDTO) => {
|
||||
const integrationAuth = await integrationAuthDAL.findById(id);
|
||||
if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
integrationAuth.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
|
||||
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId);
|
||||
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
|
||||
|
||||
const { data: organizations }: { data: TCircleCIOrganization[] } = await request.get(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`,
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": `${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let projects: {
|
||||
orgName: string;
|
||||
projectName: string;
|
||||
projectId?: string;
|
||||
}[] = [];
|
||||
|
||||
try {
|
||||
const projectRes = (
|
||||
await request.get<{ reponame: string; username: string; vcs_url: string }[]>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`,
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
}
|
||||
)
|
||||
).data;
|
||||
|
||||
projects = projectRes.map((a) => ({
|
||||
orgName: a.username, // username maps to unique organization name in CircleCI
|
||||
projectName: a.reponame, // reponame maps to project name within an organization in CircleCI
|
||||
projectId: a.vcs_url.split("/").pop() // vcs_url maps to the project id in CircleCI
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
const projectsByOrg = groupBy(
|
||||
projects.map((p) => ({
|
||||
orgName: p.orgName,
|
||||
name: p.projectName,
|
||||
id: p.projectId as string
|
||||
})),
|
||||
(p) => p.orgName
|
||||
);
|
||||
|
||||
const getOrgContexts = async (orgSlug: string) => {
|
||||
type NextPageToken = string | null | undefined;
|
||||
|
||||
try {
|
||||
const contexts: TCircleCIContext[] = [];
|
||||
let nextPageToken: NextPageToken;
|
||||
|
||||
while (nextPageToken !== null) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { data } = await request.get<{
|
||||
items: TCircleCIContext[];
|
||||
next_page_token: NextPageToken;
|
||||
}>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context`, {
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Accept-Encoding": "application/json"
|
||||
},
|
||||
params: new URLSearchParams({
|
||||
"owner-slug": orgSlug,
|
||||
...(nextPageToken ? { "page-token": nextPageToken } : {})
|
||||
})
|
||||
});
|
||||
|
||||
contexts.push(...data.items);
|
||||
nextPageToken = data.next_page_token;
|
||||
}
|
||||
|
||||
return contexts?.map((context) => ({
|
||||
name: context.name,
|
||||
id: context.id
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return Promise.all(
|
||||
organizations.map(async (org) => ({
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
projects: projectsByOrg[org.name] ?? [],
|
||||
contexts: (await getOrgContexts(org.slug)) ?? []
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const deleteIntegrationAuths = async ({
|
||||
projectId,
|
||||
integration,
|
||||
@ -1790,6 +1909,7 @@ export const integrationAuthServiceFactory = ({
|
||||
getTeamcityBuildConfigs,
|
||||
getBitbucketWorkspaces,
|
||||
getBitbucketEnvironments,
|
||||
getCircleCIOrganizations,
|
||||
getIntegrationAccessToken,
|
||||
duplicateIntegrationAuth,
|
||||
getOctopusDeploySpaces,
|
||||
|
@ -128,6 +128,10 @@ export type TGetIntegrationAuthTeamCityBuildConfigDTO = {
|
||||
appId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TIntegrationAuthCircleCIOrganizationDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TVercelBranches = {
|
||||
ref: string;
|
||||
lastCommit: string;
|
||||
@ -189,6 +193,14 @@ export type TTeamCityBuildConfig = {
|
||||
webUrl: string;
|
||||
};
|
||||
|
||||
export type TCircleCIOrganization = {
|
||||
id: string;
|
||||
vcsType: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type TIntegrationsWithEnvironment = TIntegrations & {
|
||||
environment?:
|
||||
| {
|
||||
@ -215,6 +227,11 @@ export enum OctopusDeployScope {
|
||||
// add tenant, variable set, etc.
|
||||
}
|
||||
|
||||
export enum CircleCiScope {
|
||||
Project = "project",
|
||||
Context = "context"
|
||||
}
|
||||
|
||||
export type TOctopusDeployVariableSet = {
|
||||
Id: string;
|
||||
OwnerId: string;
|
||||
|
@ -76,7 +76,6 @@ export enum IntegrationUrls {
|
||||
RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2",
|
||||
FLYIO_API_URL = "https://api.fly.io/graphql",
|
||||
CIRCLECI_API_URL = "https://circleci.com/api",
|
||||
DATABRICKS_API_URL = "https:/xxxx.com/api",
|
||||
TRAVISCI_API_URL = "https://api.travis-ci.com",
|
||||
SUPABASE_API_URL = "https://api.supabase.com",
|
||||
LARAVELFORGE_API_URL = "https://forge.laravel.com",
|
||||
@ -218,9 +217,9 @@ export const getIntegrationOptions = async () => {
|
||||
docsLink: ""
|
||||
},
|
||||
{
|
||||
name: "Circle CI",
|
||||
name: "CircleCI",
|
||||
slug: "circleci",
|
||||
image: "Circle CI.png",
|
||||
image: "CircleCI.png",
|
||||
isAvailable: true,
|
||||
type: "pat",
|
||||
clientId: "",
|
||||
|
@ -0,0 +1,35 @@
|
||||
export const isAzureKeyVaultReference = (uri: string) => {
|
||||
const tryJsonDecode = () => {
|
||||
try {
|
||||
return (JSON.parse(uri) as { uri: string }).uri || uri;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanUri = tryJsonDecode();
|
||||
|
||||
if (!cleanUri.startsWith("https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!cleanUri.includes(".vault.azure.net/secrets/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Check for non-empty string between https:// and .vault.azure.net/secrets/
|
||||
const parts = cleanUri.split(".vault.azure.net/secrets/");
|
||||
const vaultName = parts[0].replace("https://", "");
|
||||
if (!vaultName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Check for non-empty secret name
|
||||
const secretParts = parts[1].split("/");
|
||||
const secretName = secretParts[0];
|
||||
if (!secretName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
@ -39,13 +39,19 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/
|
||||
import { TIntegrationDALFactory } from "../integration/integration-dal";
|
||||
import { IntegrationMetadataSchema } from "../integration/integration-schema";
|
||||
import { IntegrationAuthMetadataSchema } from "./integration-auth-schema";
|
||||
import { OctopusDeployScope, TIntegrationsWithEnvironment, TOctopusDeployVariableSet } from "./integration-auth-types";
|
||||
import {
|
||||
CircleCiScope,
|
||||
OctopusDeployScope,
|
||||
TIntegrationsWithEnvironment,
|
||||
TOctopusDeployVariableSet
|
||||
} from "./integration-auth-types";
|
||||
import {
|
||||
IntegrationInitialSyncBehavior,
|
||||
IntegrationMappingBehavior,
|
||||
Integrations,
|
||||
IntegrationUrls
|
||||
} from "./integration-list";
|
||||
import { isAzureKeyVaultReference } from "./integration-sync-secret-fns";
|
||||
|
||||
const getSecretKeyValuePair = (secrets: Record<string, { value: string | null; comment?: string } | null>) =>
|
||||
Object.keys(secrets).reduce<Record<string, string | null | undefined>>((prev, key) => {
|
||||
@ -320,11 +326,12 @@ const syncSecretsAzureAppConfig = async ({
|
||||
};
|
||||
|
||||
const metadata = IntegrationMetadataSchema.parse(integration.metadata);
|
||||
const azureAppConfigSecrets = (
|
||||
await getCompleteAzureAppConfigValues(
|
||||
`${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix || ""}*`
|
||||
)
|
||||
).reduce(
|
||||
|
||||
const azureAppConfigValuesUrl = `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix}*${
|
||||
metadata.azureLabel ? `&label=${metadata.azureLabel}` : ""
|
||||
}`;
|
||||
|
||||
const azureAppConfigSecrets = (await getCompleteAzureAppConfigValues(azureAppConfigValuesUrl)).reduce(
|
||||
(accum, entry) => {
|
||||
accum[entry.key] = entry.value;
|
||||
|
||||
@ -405,14 +412,24 @@ const syncSecretsAzureAppConfig = async ({
|
||||
}
|
||||
|
||||
// create or update secrets on Azure App Config
|
||||
|
||||
for await (const key of Object.keys(secrets)) {
|
||||
if (!(key in azureAppConfigSecrets) || secrets[key]?.value !== azureAppConfigSecrets[key]) {
|
||||
await request.put(
|
||||
`${integration.app}/kv/${key}?api-version=2023-11-01`,
|
||||
{
|
||||
value: secrets[key]?.value
|
||||
value: secrets[key]?.value,
|
||||
...(isAzureKeyVaultReference(secrets[key]?.value || "") && {
|
||||
content_type: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8"
|
||||
})
|
||||
},
|
||||
{
|
||||
...(metadata.azureLabel && {
|
||||
params: {
|
||||
label: metadata.azureLabel
|
||||
}
|
||||
}),
|
||||
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
@ -432,6 +449,11 @@ const syncSecretsAzureAppConfig = async ({
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
...(metadata.azureLabel && {
|
||||
params: {
|
||||
label: metadata.azureLabel
|
||||
}
|
||||
}),
|
||||
// we force IPV4 because docker setup fails with ipv6
|
||||
httpsAgent: new https.Agent({
|
||||
family: 4
|
||||
@ -2245,102 +2267,174 @@ const syncSecretsCircleCI = async ({
|
||||
secrets: Record<string, { value: string; comment?: string }>;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
const getProjectSlug = async () => {
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const projectDetails = (
|
||||
await request.get<{ slug: string }>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`,
|
||||
requestConfig
|
||||
if (integration.scope === CircleCiScope.Context) {
|
||||
// sync secrets to CircleCI
|
||||
await Promise.all(
|
||||
Object.keys(secrets).map(async (key) =>
|
||||
request.put(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`,
|
||||
{
|
||||
value: secrets[key].value
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
)
|
||||
).data;
|
||||
)
|
||||
);
|
||||
|
||||
return projectDetails.slug;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.response?.data?.message !== "Not Found") {
|
||||
throw new Error("Failed to get project slug from CircleCI during first attempt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
// get secrets from CircleCI
|
||||
const getSecretsRes = async () => {
|
||||
type EnvVars = {
|
||||
variable: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
context_id: string;
|
||||
};
|
||||
|
||||
// For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization
|
||||
try {
|
||||
const circleCiOrganization = (
|
||||
await request.get<{ slug: string; name: string }[]>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`,
|
||||
requestConfig
|
||||
)
|
||||
).data;
|
||||
let nextPageToken: string | null | undefined;
|
||||
const envVars: EnvVars[] = [];
|
||||
|
||||
// Case 1: This is a new integration where the organization name is stored under `integration.owner`
|
||||
if (integration.owner) {
|
||||
const org = circleCiOrganization.find((o) => o.name === integration.owner);
|
||||
if (org) {
|
||||
return `${org.slug}/${integration.app}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one
|
||||
return `${circleCiOrganization[0].slug}/${integration.app}`;
|
||||
} catch (err) {
|
||||
throw new Error("Failed to get project slug from CircleCI during second attempt.");
|
||||
}
|
||||
};
|
||||
|
||||
const projectSlug = await getProjectSlug();
|
||||
|
||||
// sync secrets to CircleCI
|
||||
await Promise.all(
|
||||
Object.keys(secrets).map(async (key) =>
|
||||
request.post(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`,
|
||||
{
|
||||
name: key,
|
||||
value: secrets[key].value
|
||||
},
|
||||
{
|
||||
while (nextPageToken !== null) {
|
||||
const res = await request.get<{
|
||||
items: EnvVars[];
|
||||
next_page_token: string | null;
|
||||
}>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, {
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
"Accept-Encoding": "application/json"
|
||||
},
|
||||
params: nextPageToken
|
||||
? new URLSearchParams({
|
||||
"page-token": nextPageToken
|
||||
})
|
||||
: undefined
|
||||
});
|
||||
|
||||
// get secrets from CircleCI
|
||||
const getSecretsRes = (
|
||||
await request.get<{ items: { name: string }[] }>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`,
|
||||
{
|
||||
envVars.push(...res.data.items);
|
||||
nextPageToken = res.data.next_page_token;
|
||||
}
|
||||
|
||||
return envVars;
|
||||
};
|
||||
|
||||
// delete secrets from CircleCI
|
||||
await Promise.all(
|
||||
(await getSecretsRes()).map(async (sec) => {
|
||||
if (!(sec.variable in secrets)) {
|
||||
return request.delete(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`,
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const getProjectSlug = async () => {
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
}
|
||||
)
|
||||
).data?.items;
|
||||
};
|
||||
|
||||
// delete secrets from CircleCI
|
||||
await Promise.all(
|
||||
getSecretsRes.map(async (sec) => {
|
||||
if (!(sec.name in secrets)) {
|
||||
return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, {
|
||||
try {
|
||||
const projectDetails = (
|
||||
await request.get<{ slug: string }>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`,
|
||||
requestConfig
|
||||
)
|
||||
).data;
|
||||
|
||||
return projectDetails.slug;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.response?.data?.message !== "Not Found") {
|
||||
throw new Error("Failed to get project slug from CircleCI during first attempt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization
|
||||
try {
|
||||
const circleCiOrganization = (
|
||||
await request.get<{ slug: string; name: string }[]>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`,
|
||||
requestConfig
|
||||
)
|
||||
).data;
|
||||
|
||||
// Case 1: This is a new integration where the organization name is stored under `integration.owner`
|
||||
if (integration.owner) {
|
||||
const org = circleCiOrganization.find((o) => o.name === integration.owner);
|
||||
if (org) {
|
||||
return `${org.slug}/${integration.app}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one
|
||||
return `${circleCiOrganization[0].slug}/${integration.app}`;
|
||||
} catch (err) {
|
||||
throw new Error("Failed to get project slug from CircleCI during second attempt.");
|
||||
}
|
||||
};
|
||||
|
||||
const projectSlug = await getProjectSlug();
|
||||
|
||||
// sync secrets to CircleCI
|
||||
await Promise.all(
|
||||
Object.keys(secrets).map(async (key) =>
|
||||
request.post(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`,
|
||||
{
|
||||
name: key,
|
||||
value: secrets[key].value
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// get secrets from CircleCI
|
||||
const getSecretsRes = (
|
||||
await request.get<{ items: { name: string }[] }>(
|
||||
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`,
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Content-Type": "application/json"
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
).data?.items;
|
||||
|
||||
// delete secrets from CircleCI
|
||||
await Promise.all(
|
||||
getSecretsRes.map(async (sec) => {
|
||||
if (!(sec.name in secrets)) {
|
||||
return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, {
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -35,6 +35,8 @@ export const IntegrationMetadataSchema = z.object({
|
||||
.optional()
|
||||
.describe(INTEGRATION.CREATE.metadata.secretAWSTag),
|
||||
|
||||
azureLabel: z.string().optional().describe(INTEGRATION.CREATE.metadata.azureLabel),
|
||||
|
||||
githubVisibility: z
|
||||
.union([z.literal("selected"), z.literal("private"), z.literal("all")])
|
||||
.optional()
|
||||
|
16
backend/src/services/org/org-schema.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { OrganizationsSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
customerId: true,
|
||||
slug: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
authEnforced: true,
|
||||
scimEnabled: true,
|
||||
kmsDefaultKeyId: true,
|
||||
defaultMembershipRole: true,
|
||||
enforceMfa: true,
|
||||
selectedMfaMethod: true
|
||||
});
|
@ -31,11 +31,13 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedErro
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { isDisposableEmail } from "@app/lib/validator";
|
||||
import { TQueueServiceFactory } from "@app/queue";
|
||||
import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns";
|
||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
||||
|
||||
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
||||
import { TAuthLoginFactory } from "../auth/auth-login-service";
|
||||
import { ActorAuthMethod, ActorType, AuthMethod, AuthModeJwtTokenPayload, AuthTokenType } from "../auth/auth-type";
|
||||
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||
import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal";
|
||||
@ -47,6 +49,10 @@ import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||
import { fnDeleteProjectSecretReminders } from "../secret/secret-fns";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
|
||||
@ -69,6 +75,9 @@ import {
|
||||
|
||||
type TOrgServiceFactoryDep = {
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "delete">;
|
||||
secretDAL: Pick<TSecretDALFactory, "find">;
|
||||
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId">;
|
||||
orgDAL: TOrgDALFactory;
|
||||
orgBotDAL: TOrgBotDALFactory;
|
||||
orgRoleDAL: TOrgRoleDALFactory;
|
||||
@ -97,6 +106,8 @@ type TOrgServiceFactoryDep = {
|
||||
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "updateById">;
|
||||
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "create">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
loginService: Pick<TAuthLoginFactory, "generateUserTokens">;
|
||||
};
|
||||
|
||||
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
|
||||
@ -104,6 +115,9 @@ export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
|
||||
export const orgServiceFactory = ({
|
||||
userAliasDAL,
|
||||
orgDAL,
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
folderDAL,
|
||||
userDAL,
|
||||
groupDAL,
|
||||
orgRoleDAL,
|
||||
@ -124,7 +138,9 @@ export const orgServiceFactory = ({
|
||||
projectBotDAL,
|
||||
projectUserMembershipRoleDAL,
|
||||
identityMetadataDAL,
|
||||
projectBotService
|
||||
projectBotService,
|
||||
queueService,
|
||||
loginService
|
||||
}: TOrgServiceFactoryDep) => {
|
||||
/*
|
||||
* Get organization details by the organization id
|
||||
@ -419,24 +435,88 @@ export const orgServiceFactory = ({
|
||||
/*
|
||||
* Delete organization by id
|
||||
* */
|
||||
const deleteOrganizationById = async (
|
||||
userId: string,
|
||||
orgId: string,
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string | undefined
|
||||
) => {
|
||||
const deleteOrganizationById = async ({
|
||||
userId,
|
||||
authorizationHeader,
|
||||
userAgentHeader,
|
||||
ipAddress,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: {
|
||||
userId: string;
|
||||
authorizationHeader?: string;
|
||||
userAgentHeader?: string;
|
||||
ipAddress: string;
|
||||
orgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string | undefined;
|
||||
}) => {
|
||||
const { membership } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
|
||||
if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin)
|
||||
if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) {
|
||||
throw new ForbiddenRequestError({
|
||||
name: "DeleteOrganizationById",
|
||||
message: "Insufficient privileges"
|
||||
});
|
||||
|
||||
const organization = await orgDAL.deleteById(orgId);
|
||||
if (organization.customerId) {
|
||||
await licenseService.removeOrgCustomer(organization.customerId);
|
||||
}
|
||||
return organization;
|
||||
|
||||
if (!authorizationHeader) {
|
||||
throw new UnauthorizedError({ name: "Authorization header not set on request." });
|
||||
}
|
||||
|
||||
if (!userAgentHeader) {
|
||||
throw new BadRequestError({ name: "User agent not set on request." });
|
||||
}
|
||||
|
||||
const cfg = getConfig();
|
||||
const authToken = authorizationHeader.replace("Bearer ", "");
|
||||
|
||||
const decodedToken = jwt.verify(authToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload;
|
||||
if (!decodedToken.authMethod) throw new UnauthorizedError({ name: "Auth method not found on existing token" });
|
||||
|
||||
const response = await orgDAL.transaction(async (tx) => {
|
||||
const projects = await projectDAL.find({ orgId }, { tx });
|
||||
|
||||
for await (const project of projects) {
|
||||
await fnDeleteProjectSecretReminders(project.id, {
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
queueService,
|
||||
projectBotService,
|
||||
folderDAL
|
||||
});
|
||||
}
|
||||
|
||||
const deletedOrg = await orgDAL.deleteById(orgId, tx);
|
||||
|
||||
if (deletedOrg.customerId) {
|
||||
await licenseService.removeOrgCustomer(deletedOrg.customerId);
|
||||
}
|
||||
|
||||
// Generate new tokens without the organization ID present
|
||||
const user = await userDAL.findById(userId, tx);
|
||||
const { access: accessToken, refresh: refreshToken } = await loginService.generateUserTokens(
|
||||
{
|
||||
user,
|
||||
authMethod: decodedToken.authMethod,
|
||||
ip: ipAddress,
|
||||
userAgent: userAgentHeader,
|
||||
isMfaVerified: decodedToken.isMfaVerified,
|
||||
mfaMethod: decodedToken.mfaMethod
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
organization: deletedOrg,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
/*
|
||||
* Org membership management
|
||||
|
@ -51,7 +51,7 @@ export const projectDALFactory = (db: TDbClient) => {
|
||||
.join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.Project}.orgId`, orgId)
|
||||
.andWhere((qb) => {
|
||||
if (projectType) {
|
||||
if (projectType !== "all") {
|
||||
void qb.where(`${TableName.Project}.type`, projectType);
|
||||
}
|
||||
})
|
||||
|
@ -8,12 +8,16 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
|
||||
import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types";
|
||||
import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
|
||||
import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
|
||||
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||
@ -28,13 +32,17 @@ import { TOrgServiceFactory } from "../org/org-service";
|
||||
import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal";
|
||||
import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal";
|
||||
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||
import { getPredefinedRoles } from "../project-role/project-role-fns";
|
||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||
import { fnDeleteProjectSecretReminders } from "../secret/secret-fns";
|
||||
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal";
|
||||
import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
@ -52,6 +60,9 @@ import {
|
||||
TListProjectCertificateTemplatesDTO,
|
||||
TListProjectCertsDTO,
|
||||
TListProjectsDTO,
|
||||
TListProjectSshCasDTO,
|
||||
TListProjectSshCertificatesDTO,
|
||||
TListProjectSshCertificateTemplatesDTO,
|
||||
TLoadProjectKmsBackupDTO,
|
||||
TToggleProjectAutoCapitalizationDTO,
|
||||
TUpdateAuditLogsRetentionDTO,
|
||||
@ -74,7 +85,10 @@ type TProjectServiceFactoryDep = {
|
||||
projectDAL: TProjectDALFactory;
|
||||
projectQueue: TProjectQueueFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
folderDAL: TSecretFolderDALFactory;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "insertMany" | "findByProjectId">;
|
||||
secretDAL: Pick<TSecretDALFactory, "find">;
|
||||
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">;
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
identityProjectDAL: TIdentityProjectDALFactory;
|
||||
@ -89,9 +103,14 @@ type TProjectServiceFactoryDep = {
|
||||
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
|
||||
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
|
||||
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
|
||||
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "find">;
|
||||
sshCertificateDAL: Pick<TSshCertificateDALFactory, "find" | "countSshCertificatesInProject">;
|
||||
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find">;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
|
||||
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
||||
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
||||
projectBotDAL: Pick<TProjectBotDALFactory, "create">;
|
||||
@ -112,9 +131,13 @@ export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
|
||||
|
||||
export const projectServiceFactory = ({
|
||||
projectDAL,
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
projectQueue,
|
||||
projectKeyDAL,
|
||||
permissionService,
|
||||
queueService,
|
||||
projectBotService,
|
||||
orgDAL,
|
||||
userDAL,
|
||||
folderDAL,
|
||||
@ -132,6 +155,9 @@ export const projectServiceFactory = ({
|
||||
certificateTemplateDAL,
|
||||
pkiCollectionDAL,
|
||||
pkiAlertDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateDAL,
|
||||
sshCertificateTemplateDAL,
|
||||
keyStore,
|
||||
kmsService,
|
||||
projectBotDAL,
|
||||
@ -424,6 +450,14 @@ export const projectServiceFactory = ({
|
||||
await userDAL.deleteById(projectGhostUser.id, tx);
|
||||
}
|
||||
|
||||
await fnDeleteProjectSecretReminders(project.id, {
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
queueService,
|
||||
projectBotService,
|
||||
folderDAL
|
||||
});
|
||||
|
||||
return delProject;
|
||||
});
|
||||
|
||||
@ -441,7 +475,12 @@ export const projectServiceFactory = ({
|
||||
const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type);
|
||||
|
||||
if (includeRoles) {
|
||||
const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod);
|
||||
const { permission } = await permissionService.getUserOrgPermission(
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
// `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls.
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
|
||||
@ -896,6 +935,118 @@ export const projectServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of SSH CAs for project
|
||||
*/
|
||||
const listProjectSshCas = async ({
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
projectId
|
||||
}: TListProjectSshCasDTO) => {
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const cas = await sshCertificateAuthorityDAL.find(
|
||||
{
|
||||
projectId
|
||||
},
|
||||
{ sort: [["updatedAt", "desc"]] }
|
||||
);
|
||||
|
||||
return cas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of SSH certificates for project
|
||||
*/
|
||||
const listProjectSshCertificates = async ({
|
||||
limit = 25,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
projectId
|
||||
}: TListProjectSshCertificatesDTO) => {
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
|
||||
|
||||
const cas = await sshCertificateAuthorityDAL.find({
|
||||
projectId
|
||||
});
|
||||
|
||||
const certificates = await sshCertificateDAL.find(
|
||||
{
|
||||
$in: {
|
||||
sshCaId: cas.map((ca) => ca.id)
|
||||
}
|
||||
},
|
||||
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
||||
);
|
||||
|
||||
const count = await sshCertificateDAL.countSshCertificatesInProject(projectId);
|
||||
|
||||
return { certificates, totalCount: count };
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of SSH certificate templates for project
|
||||
*/
|
||||
const listProjectSshCertificateTemplates = async ({
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
projectId
|
||||
}: TListProjectSshCertificateTemplatesDTO) => {
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
const cas = await sshCertificateAuthorityDAL.find({
|
||||
projectId
|
||||
});
|
||||
|
||||
const certificateTemplates = await sshCertificateTemplateDAL.find({
|
||||
$in: {
|
||||
sshCaId: cas.map((ca) => ca.id)
|
||||
}
|
||||
});
|
||||
|
||||
return { certificateTemplates };
|
||||
};
|
||||
|
||||
const updateProjectKmsKey = async ({
|
||||
projectId,
|
||||
kms,
|
||||
@ -1129,6 +1280,9 @@ export const projectServiceFactory = ({
|
||||
listProjectAlerts,
|
||||
listProjectPkiCollections,
|
||||
listProjectCertificateTemplates,
|
||||
listProjectSshCas,
|
||||
listProjectSshCertificates,
|
||||
listProjectSshCertificateTemplates,
|
||||
updateVersionLimit,
|
||||
updateAuditLogsRetention,
|
||||
updateProjectKmsKey,
|
||||
|
@ -132,6 +132,13 @@ export type TGetProjectKmsKey = TProjectPermission;
|
||||
|
||||
export type TListProjectCertificateTemplatesDTO = TProjectPermission;
|
||||
|
||||
export type TListProjectSshCasDTO = TProjectPermission;
|
||||
export type TListProjectSshCertificateTemplatesDTO = TProjectPermission;
|
||||
export type TListProjectSshCertificatesDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetProjectSlackConfig = TProjectPermission;
|
||||
|
||||
export type TUpdateProjectSlackConfig = {
|
||||
|
@ -5,6 +5,7 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||
import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal";
|
||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||
import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
|
||||
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
|
||||
import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal";
|
||||
@ -16,6 +17,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
|
||||
identityUniversalAuthClientSecretDAL: Pick<TIdentityUaClientSecretDALFactory, "removeExpiredClientSecrets">;
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "pruneExcessVersions">;
|
||||
secretVersionV2DAL: Pick<TSecretVersionV2DALFactory, "pruneExcessVersions">;
|
||||
secretDAL: Pick<TSecretDALFactory, "pruneSecretReminders">;
|
||||
secretFolderVersionDAL: Pick<TSecretFolderVersionDALFactory, "pruneExcessVersions">;
|
||||
snapshotDAL: Pick<TSnapshotDALFactory, "pruneExcessSnapshots">;
|
||||
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets">;
|
||||
@ -30,6 +32,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
snapshotDAL,
|
||||
secretVersionDAL,
|
||||
secretFolderVersionDAL,
|
||||
secretDAL,
|
||||
identityAccessTokenDAL,
|
||||
secretSharingDAL,
|
||||
secretVersionV2DAL,
|
||||
@ -37,6 +40,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
||||
queueService.start(QueueName.DailyResourceCleanUp, async () => {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
||||
await secretDAL.pruneSecretReminders(queueService);
|
||||
await auditLogDAL.pruneAuditLog();
|
||||
await identityAccessTokenDAL.removeExpiredTokens();
|
||||
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
||||
|
@ -5,6 +5,8 @@ import { TDbClient } from "@app/db";
|
||||
import { SecretsSchema, SecretType, TableName, TSecrets, TSecretsUpdate } from "@app/db/schemas";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
export type TSecretDALFactory = ReturnType<typeof secretDALFactory>;
|
||||
|
||||
@ -339,6 +341,94 @@ export const secretDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pruneSecretReminders = async (queueService: TQueueServiceFactory) => {
|
||||
const REMINDER_PRUNE_BATCH_SIZE = 5_000;
|
||||
const MAX_RETRY_ON_FAILURE = 3;
|
||||
let numberOfRetryOnFailure = 0;
|
||||
let deletedReminderCount = 0;
|
||||
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: secret reminders started`);
|
||||
|
||||
try {
|
||||
const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder);
|
||||
const reminderJobs = repeatableJobs
|
||||
.map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key }))
|
||||
.filter(Boolean);
|
||||
|
||||
if (reminderJobs.length === 0) {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: no reminder jobs found`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) {
|
||||
try {
|
||||
const batchIds = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE).map((r) => r.secretId);
|
||||
|
||||
const payload = {
|
||||
$in: {
|
||||
id: batchIds
|
||||
}
|
||||
};
|
||||
|
||||
const opts = {
|
||||
limit: REMINDER_PRUNE_BATCH_SIZE
|
||||
};
|
||||
|
||||
// Find existing secrets with pagination
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [secrets, secretsV2] = await Promise.all([
|
||||
ormify(db, TableName.Secret).find(payload, opts),
|
||||
ormify(db, TableName.SecretV2).find(payload, opts)
|
||||
]);
|
||||
|
||||
const foundSecretIds = new Set([
|
||||
...secrets.map((secret) => secret.id),
|
||||
...secretsV2.map((secret) => secret.id)
|
||||
]);
|
||||
|
||||
// Find IDs that don't exist in either table
|
||||
const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId));
|
||||
|
||||
// Delete reminders for non-existent secrets
|
||||
for (const secretId of secretIdsNotFound) {
|
||||
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
|
||||
|
||||
if (jobKey) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
|
||||
deletedReminderCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
numberOfRetryOnFailure = 0;
|
||||
} catch (error) {
|
||||
numberOfRetryOnFailure += 1;
|
||||
logger.error(error, `Failed to process batch at offset ${offset}`);
|
||||
|
||||
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Retry the current batch
|
||||
offset -= REMINDER_PRUNE_BATCH_SIZE;
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure));
|
||||
}
|
||||
|
||||
// Small delay between batches
|
||||
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to complete secret reminder pruning");
|
||||
} finally {
|
||||
logger.info(
|
||||
`${QueueName.DailyResourceCleanUp}: secret reminders completed. Deleted ${deletedReminderCount} reminders`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...secretOrm,
|
||||
update,
|
||||
@ -352,6 +442,7 @@ export const secretDALFactory = (db: TDbClient) => {
|
||||
findByBlindIndexes,
|
||||
upsertSecretReferences,
|
||||
findReferencedSecretReferences,
|
||||
findAllProjectSecretValues
|
||||
findAllProjectSecretValues,
|
||||
pruneSecretReminders
|
||||
};
|
||||
};
|
||||
|
@ -19,9 +19,11 @@ import {
|
||||
decryptSymmetric128BitHexKeyUTF8,
|
||||
encryptSymmetric128BitHexKeyUTF8
|
||||
} from "@app/lib/crypto";
|
||||
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy, unique } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import {
|
||||
fnSecretBulkInsert as fnSecretV2BridgeBulkInsert,
|
||||
fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate,
|
||||
@ -31,8 +33,10 @@ import {
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { getBotKeyFnFactory } from "../project-bot/project-bot-fns";
|
||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { TSecretDALFactory } from "./secret-dal";
|
||||
import {
|
||||
TCreateManySecretsRawFn,
|
||||
@ -1138,3 +1142,49 @@ export const decryptSecretWithBot = (
|
||||
secretComment
|
||||
};
|
||||
};
|
||||
|
||||
type TFnDeleteProjectSecretReminders = {
|
||||
secretDAL: Pick<TSecretDALFactory, "find">;
|
||||
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId">;
|
||||
};
|
||||
|
||||
export const fnDeleteProjectSecretReminders = async (
|
||||
projectId: string,
|
||||
{ secretDAL, secretV2BridgeDAL, queueService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders
|
||||
) => {
|
||||
const projectFolders = await folderDAL.findByProjectId(projectId);
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId, false);
|
||||
|
||||
const projectSecrets = shouldUseSecretV2Bridge
|
||||
? await secretV2BridgeDAL.find({
|
||||
$in: { folderId: projectFolders.map((folder) => folder.id) },
|
||||
$notNull: ["reminderRepeatDays"]
|
||||
})
|
||||
: await secretDAL.find({
|
||||
$in: { folderId: projectFolders.map((folder) => folder.id) },
|
||||
$notNull: ["secretReminderRepeatDays"]
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
for await (const secret of projectSecrets) {
|
||||
const repeatDays = shouldUseSecretV2Bridge
|
||||
? (secret as { reminderRepeatDays: number }).reminderRepeatDays
|
||||
: (secret as { secretReminderRepeatDays: number }).secretReminderRepeatDays;
|
||||
|
||||
// We're using the queue service directly to get around conflicting imports.
|
||||
if (repeatDays) {
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.SecretReminder,
|
||||
QueueJobs.SecretReminder,
|
||||
{
|
||||
// on prod it this will be in days, in development this will be second
|
||||
every: appCfg.NODE_ENV === "development" ? secondsToMillis(repeatDays) : daysToMillisecond(repeatDays)
|
||||
},
|
||||
`reminder-${secret.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -248,7 +248,9 @@ export const secretQueueFactory = ({
|
||||
? secondsToMillis(newSecret.secretReminderRepeatDays)
|
||||
: daysToMillisecond(newSecret.secretReminderRepeatDays),
|
||||
immediately: true
|
||||
}
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
|
@ -491,8 +491,8 @@ export const secretServiceFactory = ({
|
||||
secretDAL
|
||||
});
|
||||
|
||||
const deletedSecret = await secretDAL.transaction(async (tx) =>
|
||||
fnSecretBulkDelete({
|
||||
const deletedSecret = await secretDAL.transaction(async (tx) => {
|
||||
const secrets = await fnSecretBulkDelete({
|
||||
projectId,
|
||||
folderId,
|
||||
actorId,
|
||||
@ -505,8 +505,19 @@ export const secretServiceFactory = ({
|
||||
}
|
||||
],
|
||||
tx
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
for await (const secret of secrets) {
|
||||
if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) {
|
||||
await secretQueueService.removeSecretReminder({
|
||||
repeatDays: secret.secretReminderRepeatDays,
|
||||
secretId: secret.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return secrets;
|
||||
});
|
||||
|
||||
if (inputSecret.type === SecretType.Shared) {
|
||||
await snapshotService.performSnapshot(folderId);
|
||||
@ -971,8 +982,8 @@ export const secretServiceFactory = ({
|
||||
secretDAL
|
||||
});
|
||||
|
||||
const secretsDeleted = await secretDAL.transaction(async (tx) =>
|
||||
fnSecretBulkDelete({
|
||||
const secretsDeleted = await secretDAL.transaction(async (tx) => {
|
||||
const secrets = await fnSecretBulkDelete({
|
||||
secretDAL,
|
||||
secretQueueService,
|
||||
inputSecrets: inputSecrets.map(({ type, secretName }) => ({
|
||||
@ -983,8 +994,19 @@ export const secretServiceFactory = ({
|
||||
folderId,
|
||||
actorId,
|
||||
tx
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
for await (const secret of secrets) {
|
||||
if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) {
|
||||
await secretQueueService.removeSecretReminder({
|
||||
repeatDays: secret.secretReminderRepeatDays,
|
||||
secretId: secret.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return secrets;
|
||||
});
|
||||
|
||||
await snapshotService.performSnapshot(folderId);
|
||||
await secretQueueService.syncSecrets({
|
||||
|
12
cli/go.mod
@ -10,7 +10,7 @@ require (
|
||||
github.com/fatih/semgroup v1.2.0
|
||||
github.com/gitleaks/go-gitdiff v0.8.0
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/infisical/go-sdk v0.4.3
|
||||
github.com/infisical/go-sdk v0.4.7
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a
|
||||
github.com/muesli/mango-cobra v1.2.0
|
||||
@ -23,8 +23,8 @@ require (
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/crypto v0.25.0
|
||||
golang.org/x/term v0.22.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/term v0.27.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@ -93,9 +93,9 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/api v0.188.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect
|
||||
|
24
cli/go.sum
@ -265,8 +265,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
|
||||
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/infisical/go-sdk v0.4.3 h1:O5ZJ2eCBAZDE9PIAfBPq9Utb2CgQKrhmj9R0oFTRu4U=
|
||||
github.com/infisical/go-sdk v0.4.3/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M=
|
||||
github.com/infisical/go-sdk v0.4.7 h1:+cxIdDfciMh0Syxbxbqjhvz9/ShnN1equ2zqlVQYGtw=
|
||||
github.com/infisical/go-sdk v0.4.7/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@ -453,8 +453,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -564,8 +564,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -620,16 +620,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -643,8 +643,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
609
cli/packages/cmd/ssh.go
Normal file
@ -0,0 +1,609 @@
|
||||
/*
|
||||
Copyright (c) 2023 Infisical Inc.
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/api"
|
||||
"github.com/Infisical/infisical-merge/packages/config"
|
||||
"github.com/Infisical/infisical-merge/packages/util"
|
||||
infisicalSdk "github.com/infisical/go-sdk"
|
||||
infisicalSdkUtil "github.com/infisical/go-sdk/packages/util"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
var sshCmd = &cobra.Command{
|
||||
Example: `infisical ssh`,
|
||||
Short: "Used to issue SSH credentials",
|
||||
Use: "ssh",
|
||||
DisableFlagsInUseLine: true,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
|
||||
var sshIssueCredentialsCmd = &cobra.Command{
|
||||
Example: `ssh issue-credentials`,
|
||||
Short: "Used to issue SSH credentials against a certificate template",
|
||||
Use: "issue-credentials",
|
||||
DisableFlagsInUseLine: true,
|
||||
Args: cobra.NoArgs,
|
||||
Run: issueCredentials,
|
||||
}
|
||||
|
||||
var sshSignKeyCmd = &cobra.Command{
|
||||
Example: `ssh sign-key`,
|
||||
Short: "Used to sign a SSH public key against a certificate template",
|
||||
Use: "sign-key",
|
||||
DisableFlagsInUseLine: true,
|
||||
Args: cobra.NoArgs,
|
||||
Run: signKey,
|
||||
}
|
||||
|
||||
var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{
|
||||
infisicalSdkUtil.RSA2048: "id_rsa_2048",
|
||||
infisicalSdkUtil.RSA4096: "id_rsa_4096",
|
||||
infisicalSdkUtil.ECDSAP256: "id_ecdsa_p256",
|
||||
infisicalSdkUtil.ECDSAP384: "id_ecdsa_p384",
|
||||
}
|
||||
|
||||
func isValidKeyAlgorithm(algo infisicalSdkUtil.CertKeyAlgorithm) bool {
|
||||
_, exists := algoToFileName[algo]
|
||||
return exists
|
||||
}
|
||||
|
||||
func isValidCertType(certType infisicalSdkUtil.SshCertType) bool {
|
||||
switch certType {
|
||||
case infisicalSdkUtil.UserCert, infisicalSdkUtil.HostCert:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeToFile(filePath string, content string, perm os.FileMode) error {
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Write the content to the file
|
||||
err := os.WriteFile(filePath, []byte(content), perm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addCredentialsToAgent(privateKeyContent, certContent string) error {
|
||||
// Parse the private key
|
||||
privateKey, err := ssh.ParseRawPrivateKey([]byte(privateKeyContent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse the certificate
|
||||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certContent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, ok := pubKey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
return fmt.Errorf("parsed key is not a certificate")
|
||||
}
|
||||
// Calculate LifetimeSecs based on certificate's valid-to time
|
||||
validUntil := time.Unix(int64(cert.ValidBefore), 0)
|
||||
now := time.Now()
|
||||
|
||||
// Handle ValidBefore as either a timestamp or an enumeration
|
||||
// SSH certificates use ValidBefore as a timestamp unless set to 0 or ~0
|
||||
if cert.ValidBefore == ssh.CertTimeInfinity {
|
||||
// If certificate never expires, set default lifetime to 1 year (can adjust as needed)
|
||||
validUntil = now.Add(365 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
// Calculate the duration until expiration
|
||||
lifetime := validUntil.Sub(now)
|
||||
if lifetime <= 0 {
|
||||
return fmt.Errorf("certificate is already expired")
|
||||
}
|
||||
|
||||
// Convert duration to seconds
|
||||
lifetimeSecs := uint32(lifetime.Seconds())
|
||||
|
||||
// Connect to the SSH agent
|
||||
socket := os.Getenv("SSH_AUTH_SOCK")
|
||||
if socket == "" {
|
||||
return fmt.Errorf("SSH_AUTH_SOCK not set")
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SSH agent: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
agentClient := agent.NewClient(conn)
|
||||
|
||||
// Add the key with certificate to the agent
|
||||
err = agentClient.Add(agent.AddedKey{
|
||||
PrivateKey: privateKey,
|
||||
Certificate: cert,
|
||||
Comment: "Added via Infisical CLI",
|
||||
LifetimeSecs: lifetimeSecs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add key to agent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueCredentials(cmd *cobra.Command, args []string) {
|
||||
|
||||
token, err := util.GetInfisicalToken(cmd)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
var infisicalToken string
|
||||
|
||||
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
|
||||
infisicalToken = token.Token
|
||||
} else {
|
||||
util.RequireLogin()
|
||||
util.RequireLocalWorkspaceFile()
|
||||
|
||||
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to authenticate")
|
||||
}
|
||||
|
||||
if loggedInUserDetails.LoginExpired {
|
||||
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
|
||||
}
|
||||
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
|
||||
}
|
||||
|
||||
certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
if certificateTemplateId == "" {
|
||||
util.PrintErrorMessageAndExit("You must set the --certificateTemplateId flag")
|
||||
}
|
||||
|
||||
principalsStr, err := cmd.Flags().GetString("principals")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
// Check if the input string is empty before splitting
|
||||
if principalsStr == "" {
|
||||
util.HandleError(fmt.Errorf("no principals provided"), "The 'principals' flag cannot be empty")
|
||||
}
|
||||
|
||||
// Convert the comma-delimited string into a slice of strings
|
||||
principals := strings.Split(principalsStr, ",")
|
||||
for i, principal := range principals {
|
||||
principals[i] = strings.TrimSpace(principal)
|
||||
}
|
||||
|
||||
keyAlgorithm, err := cmd.Flags().GetString("keyAlgorithm")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse keyAlgorithm flag")
|
||||
}
|
||||
|
||||
if !isValidKeyAlgorithm(infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)) {
|
||||
util.HandleError(fmt.Errorf("invalid keyAlgorithm: %s", keyAlgorithm),
|
||||
"Valid values: RSA_2048, RSA_4096, EC_prime256v1, EC_secp384r1")
|
||||
}
|
||||
|
||||
certType, err := cmd.Flags().GetString("certType")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
if !isValidCertType(infisicalSdkUtil.SshCertType(certType)) {
|
||||
util.HandleError(fmt.Errorf("invalid certType: %s", certType),
|
||||
"Valid values: user, host")
|
||||
}
|
||||
|
||||
ttl, err := cmd.Flags().GetString("ttl")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
keyId, err := cmd.Flags().GetString("keyId")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
outFilePath, err := cmd.Flags().GetString("outFilePath")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
addToAgent, err := cmd.Flags().GetBool("addToAgent")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse addToAgent flag")
|
||||
}
|
||||
|
||||
if outFilePath == "" && addToAgent == false {
|
||||
util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command")
|
||||
}
|
||||
|
||||
var (
|
||||
outputDir string
|
||||
privateKeyPath string
|
||||
publicKeyPath string
|
||||
signedKeyPath string
|
||||
)
|
||||
|
||||
if outFilePath != "" {
|
||||
// Expand ~ to home directory if present
|
||||
if strings.HasPrefix(outFilePath, "~") {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to resolve home directory")
|
||||
}
|
||||
outFilePath = strings.Replace(outFilePath, "~", homeDir, 1)
|
||||
}
|
||||
|
||||
// Check if outFilePath ends with "-cert.pub"
|
||||
if strings.HasSuffix(outFilePath, "-cert.pub") {
|
||||
// Treat outFilePath as the signed key path
|
||||
signedKeyPath = outFilePath
|
||||
|
||||
// Derive the base name by removing "-cert.pub"
|
||||
baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub")
|
||||
|
||||
// Set the output directory
|
||||
outputDir = filepath.Dir(outFilePath)
|
||||
|
||||
// Define private and public key paths
|
||||
privateKeyPath = filepath.Join(outputDir, baseName)
|
||||
publicKeyPath = filepath.Join(outputDir, baseName+".pub")
|
||||
} else {
|
||||
// Treat outFilePath as a directory
|
||||
outputDir = outFilePath
|
||||
|
||||
// Check if the directory exists; if not, create it
|
||||
info, err := os.Stat(outputDir)
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(outputDir, 0755)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to create output directory")
|
||||
}
|
||||
} else if err != nil {
|
||||
util.HandleError(err, "Failed to access output directory")
|
||||
} else if !info.IsDir() {
|
||||
util.PrintErrorMessageAndExit("The provided --outFilePath is not a directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define file names based on key algorithm
|
||||
fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)]
|
||||
|
||||
// Define file paths
|
||||
privateKeyPath = filepath.Join(outputDir, fileName)
|
||||
publicKeyPath = filepath.Join(outputDir, fileName+".pub")
|
||||
signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub")
|
||||
|
||||
// If outFilePath ends with "-cert.pub", ensure the signedKeyPath is set
|
||||
if strings.HasSuffix(outFilePath, "-cert.pub") {
|
||||
// Ensure the signedKeyPath was set
|
||||
if signedKeyPath == "" {
|
||||
util.HandleError(fmt.Errorf("signedKeyPath is not set correctly"), "Internal error")
|
||||
}
|
||||
} else {
|
||||
// Ensure all paths are set
|
||||
if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" {
|
||||
util.HandleError(fmt.Errorf("file paths are not set correctly"), "Internal error")
|
||||
}
|
||||
}
|
||||
|
||||
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
|
||||
SiteUrl: config.INFISICAL_URL,
|
||||
UserAgent: api.USER_AGENT,
|
||||
AutoTokenRefresh: false,
|
||||
})
|
||||
infisicalClient.Auth().SetAccessToken(infisicalToken)
|
||||
|
||||
creds, err := infisicalClient.Ssh().IssueCredentials(infisicalSdk.IssueSshCredsOptions{
|
||||
CertificateTemplateID: certificateTemplateId,
|
||||
Principals: principals,
|
||||
KeyAlgorithm: infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm),
|
||||
CertType: infisicalSdkUtil.SshCertType(certType),
|
||||
TTL: ttl,
|
||||
KeyID: keyId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to issue SSH credentials")
|
||||
}
|
||||
|
||||
if outFilePath != "" {
|
||||
// If signedKeyPath wasn't set in the directory scenario, set it now
|
||||
if signedKeyPath == "" {
|
||||
fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)]
|
||||
signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub")
|
||||
}
|
||||
|
||||
if privateKeyPath == "" {
|
||||
privateKeyPath = filepath.Join(outputDir, algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)])
|
||||
}
|
||||
err = writeToFile(privateKeyPath, creds.PrivateKey, 0600)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to write Private Key to file")
|
||||
}
|
||||
|
||||
if publicKeyPath == "" {
|
||||
publicKeyPath = privateKeyPath + ".pub"
|
||||
}
|
||||
err = writeToFile(publicKeyPath, creds.PublicKey, 0644)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to write Public Key to file")
|
||||
}
|
||||
|
||||
err = writeToFile(signedKeyPath, creds.SignedKey, 0644)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to write Signed Key to file")
|
||||
}
|
||||
|
||||
fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath)
|
||||
}
|
||||
|
||||
// Add SSH credentials to the SSH agent if needed
|
||||
if addToAgent {
|
||||
// Call the helper function to handle add-to-agent flow
|
||||
err := addCredentialsToAgent(creds.PrivateKey, creds.SignedKey)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to add keys to SSH agent")
|
||||
} else {
|
||||
fmt.Println("The SSH key and certificate have been successfully added to your ssh-agent.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signKey(cmd *cobra.Command, args []string) {
|
||||
|
||||
token, err := util.GetInfisicalToken(cmd)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
var infisicalToken string
|
||||
|
||||
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
|
||||
infisicalToken = token.Token
|
||||
} else {
|
||||
util.RequireLogin()
|
||||
util.RequireLocalWorkspaceFile()
|
||||
|
||||
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to authenticate")
|
||||
}
|
||||
|
||||
if loggedInUserDetails.LoginExpired {
|
||||
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
|
||||
}
|
||||
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
|
||||
}
|
||||
|
||||
certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
if certificateTemplateId == "" {
|
||||
util.PrintErrorMessageAndExit("You must set the --certificateTemplateId flag")
|
||||
}
|
||||
|
||||
publicKey, err := cmd.Flags().GetString("publicKey")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
publicKeyFilePath, err := cmd.Flags().GetString("publicKeyFilePath")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
if publicKey == "" && publicKeyFilePath == "" {
|
||||
util.HandleError(fmt.Errorf("either --publicKey or --publicKeyFilePath must be provided"), "Invalid input")
|
||||
}
|
||||
|
||||
if publicKey != "" && publicKeyFilePath != "" {
|
||||
util.HandleError(fmt.Errorf("only one of --publicKey or --publicKeyFile can be provided"), "Invalid input")
|
||||
}
|
||||
|
||||
if publicKeyFilePath != "" {
|
||||
if strings.HasPrefix(publicKeyFilePath, "~") {
|
||||
// Expand the tilde (~) to the user's home directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to resolve home directory")
|
||||
}
|
||||
publicKeyFilePath = strings.Replace(publicKeyFilePath, "~", homeDir, 1)
|
||||
}
|
||||
|
||||
// Ensure the file has a .pub extension
|
||||
if !strings.HasSuffix(publicKeyFilePath, ".pub") {
|
||||
util.HandleError(fmt.Errorf("public key file must have a .pub extension"), "Invalid input")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(publicKeyFilePath)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to read public key file")
|
||||
}
|
||||
|
||||
publicKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(publicKey) == "" {
|
||||
util.HandleError(fmt.Errorf("Public key is empty"), "Invalid input")
|
||||
}
|
||||
|
||||
principalsStr, err := cmd.Flags().GetString("principals")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
// Check if the input string is empty before splitting
|
||||
if principalsStr == "" {
|
||||
util.HandleError(fmt.Errorf("no principals provided"), "The 'principals' flag cannot be empty")
|
||||
}
|
||||
|
||||
// Convert the comma-delimited string into a slice of strings
|
||||
principals := strings.Split(principalsStr, ",")
|
||||
for i, principal := range principals {
|
||||
principals[i] = strings.TrimSpace(principal)
|
||||
}
|
||||
|
||||
certType, err := cmd.Flags().GetString("certType")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
if !isValidCertType(infisicalSdkUtil.SshCertType(certType)) {
|
||||
util.HandleError(fmt.Errorf("invalid certType: %s", certType),
|
||||
"Valid values: user, host")
|
||||
}
|
||||
|
||||
ttl, err := cmd.Flags().GetString("ttl")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
keyId, err := cmd.Flags().GetString("keyId")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
outFilePath, err := cmd.Flags().GetString("outFilePath")
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to parse flag")
|
||||
}
|
||||
|
||||
var (
|
||||
outputDir string
|
||||
signedKeyPath string
|
||||
)
|
||||
|
||||
if outFilePath == "" {
|
||||
// Use current working directory
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to get current working directory")
|
||||
}
|
||||
|
||||
// check if public key path exists
|
||||
if publicKeyFilePath == "" {
|
||||
util.PrintErrorMessageAndExit("--outFilePath must be specified when --publicKeyFilePath is not provided")
|
||||
}
|
||||
|
||||
outputDir = filepath.Dir(publicKeyFilePath)
|
||||
// Derive the base name by removing "-cert.pub"
|
||||
baseName := strings.TrimSuffix(filepath.Base(publicKeyFilePath), ".pub")
|
||||
signedKeyPath = filepath.Join(outputDir, baseName+"-cert.pub")
|
||||
} else {
|
||||
// Expand ~ to home directory if present
|
||||
if strings.HasPrefix(outFilePath, "~") {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to resolve home directory")
|
||||
}
|
||||
outFilePath = strings.Replace(outFilePath, "~", homeDir, 1)
|
||||
}
|
||||
|
||||
// Check if outFilePath ends with "-cert.pub"
|
||||
if !strings.HasSuffix(outFilePath, "-cert.pub") {
|
||||
util.PrintErrorMessageAndExit("--outFilePath must end with -cert.pub")
|
||||
}
|
||||
|
||||
// Extract the directory from outFilePath
|
||||
outputDir = filepath.Dir(outFilePath)
|
||||
|
||||
// Validate the output directory
|
||||
info, err := os.Stat(outputDir)
|
||||
if os.IsNotExist(err) {
|
||||
// Directory does not exist; attempt to create it
|
||||
err = os.MkdirAll(outputDir, 0755)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to create output directory")
|
||||
}
|
||||
} else if err != nil {
|
||||
// Other errors accessing the directory
|
||||
util.HandleError(err, "Failed to access output directory")
|
||||
} else if !info.IsDir() {
|
||||
// Path exists but is not a directory
|
||||
util.PrintErrorMessageAndExit("The provided --outFilePath's directory is not valid")
|
||||
}
|
||||
|
||||
signedKeyPath = outFilePath
|
||||
}
|
||||
|
||||
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
|
||||
SiteUrl: config.INFISICAL_URL,
|
||||
UserAgent: api.USER_AGENT,
|
||||
AutoTokenRefresh: false,
|
||||
})
|
||||
infisicalClient.Auth().SetAccessToken(infisicalToken)
|
||||
|
||||
creds, err := infisicalClient.Ssh().SignKey(infisicalSdk.SignSshPublicKeyOptions{
|
||||
CertificateTemplateID: certificateTemplateId,
|
||||
PublicKey: publicKey,
|
||||
Principals: principals,
|
||||
CertType: infisicalSdkUtil.SshCertType(certType),
|
||||
TTL: ttl,
|
||||
KeyID: keyId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to sign SSH public key")
|
||||
}
|
||||
|
||||
err = writeToFile(signedKeyPath, creds.SignedKey, 0644)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Failed to write Signed Key to file")
|
||||
}
|
||||
|
||||
fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath)
|
||||
}
|
||||
|
||||
func init() {
|
||||
sshSignKeyCmd.Flags().String("token", "", "Issue SSH certificate using machine identity access token")
|
||||
sshSignKeyCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue the SSH certificate for")
|
||||
sshSignKeyCmd.Flags().String("publicKey", "", "The public key to sign")
|
||||
sshSignKeyCmd.Flags().String("publicKeyFilePath", "", "The file path to the public key file to sign")
|
||||
sshSignKeyCmd.Flags().String("outFilePath", "", "The path to write the SSH certificate to such as ~/.ssh/id_rsa-cert.pub. If not provided, the credentials will be saved to the directory of the specified public key file path or the current working directory")
|
||||
sshSignKeyCmd.Flags().String("principals", "", "The principals that the certificate should be signed for")
|
||||
sshSignKeyCmd.Flags().String("certType", string(infisicalSdkUtil.UserCert), "The cert type for the created certificate")
|
||||
sshSignKeyCmd.Flags().String("ttl", "", "The ttl for the created certificate")
|
||||
sshSignKeyCmd.Flags().String("keyId", "", "The keyId that the created certificate should have")
|
||||
sshCmd.AddCommand(sshSignKeyCmd)
|
||||
|
||||
sshIssueCredentialsCmd.Flags().String("token", "", "Issue SSH credentials using machine identity access token")
|
||||
sshIssueCredentialsCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue SSH credentials for")
|
||||
sshIssueCredentialsCmd.Flags().String("principals", "", "The principals to issue SSH credentials for")
|
||||
sshIssueCredentialsCmd.Flags().String("keyAlgorithm", string(infisicalSdkUtil.RSA2048), "The key algorithm to issue SSH credentials for")
|
||||
sshIssueCredentialsCmd.Flags().String("certType", string(infisicalSdkUtil.UserCert), "The cert type to issue SSH credentials for")
|
||||
sshIssueCredentialsCmd.Flags().String("ttl", "", "The ttl to issue SSH credentials for")
|
||||
sshIssueCredentialsCmd.Flags().String("keyId", "", "The keyId to issue SSH credentials for")
|
||||
sshIssueCredentialsCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be saved to the current working directory")
|
||||
sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent")
|
||||
sshCmd.AddCommand(sshIssueCredentialsCmd)
|
||||
rootCmd.AddCommand(sshCmd)
|
||||
}
|
4
docs/api-reference/endpoints/ssh/ca/create.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/ssh/ca"
|
||||
---
|
4
docs/api-reference/endpoints/ssh/ca/delete.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/ssh/ca/{sshCaId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List templates"
|
||||
openapi: "GET /api/v1/ssh/ca/{sshCaId}/certificate-templates"
|
||||
---
|
4
docs/api-reference/endpoints/ssh/ca/list.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v2/workspace/{projectId}/ssh-cas"
|
||||
---
|
4
docs/api-reference/endpoints/ssh/ca/public-key.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Retrieve public key"
|
||||
openapi: "GET /api/v1/ssh/ca/{sshCaId}/public-key"
|
||||
---
|
4
docs/api-reference/endpoints/ssh/ca/read.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Retrieve"
|
||||
openapi: "GET /api/v1/ssh/ca/{sshCaId}"
|
||||
---
|
4
docs/api-reference/endpoints/ssh/ca/update.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/ssh/ca/{sshCaId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/ssh/certificate-templates"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/ssh/certificate-templates/{certificateTemplateId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v2/workspace/{projectId}/ssh-certificate-templates"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Retrieve"
|
||||
openapi: "GET /api/v1/ssh/certificate-templates/{certificateTemplateId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/ssh/certificate-templates/{certificateTemplateId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Issue SSH Credentials"
|
||||
openapi: "POST /api/v1/ssh/certificates/issue"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Sign SSH Public Key"
|
||||
openapi: "POST /api/v1/ssh/certificates/sign"
|
||||
---
|
209
docs/documentation/platform/ssh.mdx
Normal file
@ -0,0 +1,209 @@
|
||||
---
|
||||
title: "Infisical SSH"
|
||||
sidebarTitle: "Infisical SSH"
|
||||
description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure."
|
||||
---
|
||||
|
||||
## Concept
|
||||
|
||||
Infisical can be used to issue SSH certificates to clients to provide short-lived, secure SSH access to infrastructure;
|
||||
this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management,
|
||||
unauthorized access, and SSH key sprawl.
|
||||
|
||||
The following concepts are useful to know when working with Infisical SSH:
|
||||
|
||||
- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates.
|
||||
- Certificate Template: A set of policies bound to a SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access).
|
||||
- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure.
|
||||
|
||||
<div align="center">
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[SSH CA]
|
||||
A --> B[Certificate Template A]
|
||||
A --> C[Certificate Template N]
|
||||
B --> D[SSH Certificate A]
|
||||
C --> E[SSH Certificate N]
|
||||
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
When using Infisical SSH to provision client access to a remote host, an operator must create a SSH CA in Infisical; a certificate template under it,
|
||||
specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA.
|
||||
|
||||
When a client needs access to a host, they authenticate with Infisical and request a SSH certificate (and optionally key pair)
|
||||
to be used to access the host for a time-bound session as part of the SSH operation.
|
||||
|
||||
## Client Workflow
|
||||
|
||||
The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair)
|
||||
supplied by Infisical.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client
|
||||
participant Infisical as Infisical (SSH CA)
|
||||
participant Host as Remote Host
|
||||
|
||||
Note over Client,Client: Step 1: Client Authentication with Infisical
|
||||
Client->>Infisical: Send credential(s) to authenticate with Infisical
|
||||
|
||||
Infisical-->>Client: Return access token
|
||||
|
||||
Note over Client,Infisical: Step 2: SSH Certificate Request
|
||||
Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign
|
||||
|
||||
Infisical-->>Client: Return signed SSH certificate (and optionally key pair)
|
||||
|
||||
Note over Client,Client: Step 3: SSH Operation
|
||||
Client->>Host: SSH into Host using the SSH certificate
|
||||
|
||||
Host-->>Client: Grant access to the host
|
||||
```
|
||||
|
||||
At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host.
|
||||
|
||||
To be more specific:
|
||||
|
||||
1. The client authenticates with Infisical; this can be done using a machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities).
|
||||
2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair in conjunction with the certificate.
|
||||
3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host.
|
||||
|
||||
<Note>
|
||||
Note that the workflow above requires an operator to perform additional
|
||||
configuration on the remote host to trust SSH certificates issued by
|
||||
Infisical.
|
||||
</Note>
|
||||
|
||||
## Guide to Configuring Infisical SSH
|
||||
|
||||
In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates
|
||||
as part of the SSH operation.
|
||||
|
||||
<Steps>
|
||||
<Step title="Configuring Infisical SSH">
|
||||
1.1. Start by creating a SSH project in the SSH tab of your organization.
|
||||
|
||||

|
||||
|
||||
1.2. Next, create a CA in the **Certificate Authorities** tab of the
|
||||
project.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Here's some guidance on each field:
|
||||
|
||||
- Friendly Name: A friendly name for the CA; this is only for display.
|
||||
- Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`.
|
||||
|
||||
1.3. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA.
|
||||
|
||||
A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA.
|
||||
|
||||
With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 year.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Here's some guidance on each field:
|
||||
|
||||
- SSH Template Name: A name for the certificate template; this must be a valid slug.
|
||||
- Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username.
|
||||
- Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname.
|
||||
- Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request.
|
||||
- Max TTL: The maximum TTL for certificates issued under this template.
|
||||
- Allow User Certificates: Whether or not to allow issuance of user certificates.
|
||||
- Allow Host Certificates: Whether or not to allow issuance of host certificates.
|
||||
- Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request.
|
||||
|
||||
1.4. Finally, add the user(s) you wish to be able to request a SSH certificate to the SSH project through the **Access Control** tab.
|
||||
|
||||
</Step>
|
||||
<Step title="Configuring the remote host">
|
||||
|
||||
2.1. Begin by downloading the CA's public key from the CA's details section.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
The CA's public key can also be retrieved programmatically via API by making a `GET` request to the `/ssh/ca/<ca-id>/public-key` endpoint.
|
||||
</Note>
|
||||
|
||||
2.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`.
|
||||
|
||||
This would result in the file at the path `/etc/ssh/ca.pub`.
|
||||
|
||||
2.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host.
|
||||
|
||||
```bash
|
||||
TrustedUserCAKeys /etc/ssh/ca.pub
|
||||
|
||||
PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com
|
||||
```
|
||||
|
||||
2.4. Finally, reload the SSH daemon on the remote host to apply the changes.
|
||||
|
||||
```bash
|
||||
sudo systemctl reload sshd
|
||||
```
|
||||
|
||||
At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Guide to Using Infisical SSH to Access a Host
|
||||
|
||||
We show how to obtain a SSH certificate and use it for a client to access a host via CLI:
|
||||
|
||||
<Note>
|
||||
The subsequent guide assumes the following prerequisites:
|
||||
|
||||
- SSH Agent is running: The `ssh-agent` must be actively running on the host machine.
|
||||
- OpenSSH is installed: The system should have OpenSSH installed; this includes
|
||||
both the `ssh` client and `ssh-agent`.
|
||||
- `SSH_AUTH_SOCK` environment variable
|
||||
is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that
|
||||
`ssh-agent` uses for communication.
|
||||
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Authenticate with Infisical">
|
||||
|
||||
```bash
|
||||
infisical login
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step title="Obtain a SSH certificate and load it into the SSH agent">
|
||||
Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent.
|
||||
```bash
|
||||
infisical ssh issue-credentials --certificateTemplateId=<certificate-template-id> --principals=<username> --addToAgent
|
||||
```
|
||||
|
||||
Here's some guidance on each flag:
|
||||
|
||||
- `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate.
|
||||
- `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate.
|
||||
|
||||
</Step>
|
||||
<Step title="SSH into the host">
|
||||
Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent.
|
||||
|
||||
```bash
|
||||
ssh username@hostname
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
Note that the above workflow can be executed via API or other client methods
|
||||
such as SDK.
|
||||
</Note>
|
Before ![]() (image error) Size: 162 KiB After ![]() (image error) Size: 494 KiB ![]() ![]() |
After ![]() (image error) Size: 537 KiB |
After ![]() (image error) Size: 538 KiB |
Before ![]() (image error) Size: 339 KiB After ![]() (image error) Size: 555 KiB ![]() ![]() |
BIN
docs/images/platform/ssh/ssh-ca-public-key.png
Normal file
After ![]() (image error) Size: 665 KiB |
BIN
docs/images/platform/ssh/ssh-create-ca-1.png
Normal file
After ![]() (image error) Size: 597 KiB |
BIN
docs/images/platform/ssh/ssh-create-ca-2.png
Normal file
After ![]() (image error) Size: 277 KiB |
BIN
docs/images/platform/ssh/ssh-create-template-1.png
Normal file
After ![]() (image error) Size: 665 KiB |
BIN
docs/images/platform/ssh/ssh-create-template-2.png
Normal file
After ![]() (image error) Size: 430 KiB |
BIN
docs/images/platform/ssh/ssh-project.png
Normal file
After ![]() (image error) Size: 698 KiB |
@ -11,21 +11,30 @@ Prerequisites:
|
||||
<Step title="Authorize Infisical for CircleCI">
|
||||
Obtain an API token in User Settings > Personal API Tokens
|
||||
|
||||

|
||||

|
||||
|
||||
Navigate to your project's integrations tab in Infisical.
|
||||
|
||||

|
||||

|
||||
|
||||
Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account.
|
||||
|
||||

|
||||

|
||||
|
||||
</Step>
|
||||
<Step title="Start integration">
|
||||
Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI.
|
||||
Select which Infisical environment secrets you want to sync to which CircleCI project or context.
|
||||
<Tabs>
|
||||
<Tab title="Project">
|
||||

|
||||
</Tab>
|
||||
<Tab title="Context">
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Finally, press create integration to start syncing secrets to CircleCI.
|
||||

|
||||
|
||||

|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
</Steps>
|
||||
|
@ -43,13 +43,28 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git
|
||||
|
||||
**Namespace-scoped Installation**
|
||||
|
||||
The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access.
|
||||
The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. This is useful for:
|
||||
|
||||
- **Enhanced Security**: Limit the operator's permissions to only specific namespaces instead of cluster-wide access
|
||||
- **Multi-tenant Clusters**: Run separate operator instances for different teams or applications
|
||||
- **Resource Isolation**: Ensure operators in different namespaces don't interfere with each other
|
||||
- **Development & Testing**: Run development and production operators side by side in isolated namespaces
|
||||
|
||||
**Note**: For multiple namespace-scoped installations, only the first installation should install CRDs. Subsequent installations should set `installCRDs: false` to avoid conflicts.
|
||||
|
||||
```bash
|
||||
helm install operator infisical-helm-charts/secrets-operator \
|
||||
--namespace your-namespace \
|
||||
--set scopedNamespace=your-namespace \
|
||||
# First namespace installation (with CRDs)
|
||||
helm install operator-namespace1 infisical-helm-charts/secrets-operator \
|
||||
--namespace first-namespace \
|
||||
--set scopedNamespace=first-namespace \
|
||||
--set scopedRBAC=true
|
||||
|
||||
# Subsequent namespace installations
|
||||
helm install operator-namespace2 infisical-helm-charts/secrets-operator \
|
||||
--namespace another-namespace \
|
||||
--set scopedNamespace=another-namespace \
|
||||
--set scopedRBAC=true \
|
||||
--set installCRDs=false
|
||||
```
|
||||
|
||||
When scoped to a namespace, the operator will:
|
||||
@ -61,14 +76,19 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git
|
||||
The default configuration gives cluster-wide access:
|
||||
|
||||
```yaml
|
||||
installCRDs: true # Install CRDs (set to false for additional namespace installations)
|
||||
scopedNamespace: "" # Empty for cluster-wide access
|
||||
scopedRBAC: false # Cluster-wide permissions
|
||||
```
|
||||
|
||||
If you want to install operators in multiple namespaces simultaneously:
|
||||
- Make sure to set `installCRDs: false` for all but one of the installations to avoid conflicts, as CRDs are cluster-wide resources.
|
||||
- Use unique release names for each installation (e.g., operator-namespace1, operator-namespace2).
|
||||
|
||||
</Tab>
|
||||
<Tab title="Kubectl">
|
||||
For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version.
|
||||
Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags).
|
||||
<Tab title="Kubectl">
|
||||
For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version.
|
||||
Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags).
|
||||
|
||||
The command below will install the most recent version of the Kubernetes operator.
|
||||
However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version.
|
||||
@ -714,6 +734,7 @@ Define secret keys and their corresponding templates.
|
||||
Each data value uses a Golang template with access to all secrets retrieved from the specified scope.
|
||||
|
||||
Secrets are structured as follows:
|
||||
|
||||
```golang
|
||||
type TemplateSecret struct {
|
||||
Value string `json:"value"`
|
||||
@ -722,6 +743,7 @@ type TemplateSecret struct {
|
||||
```
|
||||
|
||||
#### Example template configuration:
|
||||
|
||||
```golang
|
||||
managedSecretReference:
|
||||
secretName: managed-secret
|
||||
@ -733,19 +755,23 @@ type TemplateSecret struct {
|
||||
```
|
||||
|
||||
When you run the following command:
|
||||
|
||||
```bash
|
||||
kubectl get secret managed-secret -o jsonpath='{.data}'
|
||||
```
|
||||
|
||||
You'll receive Kubernetes secrets output that includes the NEW_KEY:
|
||||
|
||||
```bash
|
||||
{... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="}
|
||||
```
|
||||
|
||||
When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be:
|
||||
|
||||
```bash
|
||||
{"NEW_KEY":"LyBoZWxsbw=="}
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="managedSecretReference.creationPolicy">
|
||||
Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator.
|
||||
@ -805,9 +831,9 @@ type: Opaque
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Apply the Infisical CRD to your cluster
|
||||
### Apply the InfisicalSecret CRD to your cluster
|
||||
|
||||
Once you have configured the Infisical CRD with the required fields, you can apply it to your cluster.
|
||||
Once you have configured the InfisicalSecret CRD with the required fields, you can apply it to your cluster.
|
||||
After applying, you should notice that the managed secret has been created in the desired namespace your specified.
|
||||
|
||||
```
|
||||
@ -1006,12 +1032,12 @@ stringData:
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
## Auto redeployment
|
||||
### Auto redeployment
|
||||
|
||||
Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed.
|
||||
To address this, we added functionality to automatically redeploy your deployment when its managed secret updates.
|
||||
|
||||
### Enabling auto redeploy
|
||||
#### Enabling auto redeploy
|
||||
|
||||
To enable auto redeployment you simply have to add the following annotation to the deployment that consumes a managed secret
|
||||
|
||||
@ -1054,6 +1080,419 @@ spec:
|
||||
When a secret change occurs, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update.
|
||||
Then, for each deployment that has this annotation present, a rolling update will be triggered.
|
||||
</Info>
|
||||
|
||||
## Push Secrets to Infisical
|
||||
|
||||
|
||||
### Example usage
|
||||
|
||||
Below is a sample InfisicalPushSecret CRD that pushes secrets defined in a Kubernetes secret to Infisical.
|
||||
|
||||
After filling out the fields in the InfisicalPushSecret CRD, you can apply it directly to your cluster.
|
||||
|
||||
Before applying the InfisicalPushSecret CRD, you need to create a Kubernetes secret containing the secrets you want to push to Infisical. An example can be seen below the InfisicalPushSecret CRD.
|
||||
|
||||
```bash
|
||||
kubectl apply -f source-secret.yaml
|
||||
```
|
||||
|
||||
After applying the soruce-secret.yaml file, you are ready to apply the InfisicalPushSecret CRD.
|
||||
|
||||
```bash
|
||||
kubectl apply -f infisical-push-secret.yaml
|
||||
```
|
||||
|
||||
After applying the InfisicalPushSecret CRD, you should notice that the secrets you have defined in your source-secret.yaml file have been pushed to your specified destination in Infisical.
|
||||
|
||||
```yaml infisical-push-secret.yaml
|
||||
apiVersion: secrets.infisical.com/v1alpha1
|
||||
kind: InfisicalPushSecret
|
||||
metadata:
|
||||
name: infisical-push-secret-demo
|
||||
spec:
|
||||
resyncInterval: 1m
|
||||
hostAPI: https://app.infisical.com/api
|
||||
|
||||
# Optional, defaults to no replacement.
|
||||
updatePolicy: Replace # If set to replace, existing secrets inside Infisical will be replaced by the value of the PushSecret on sync.
|
||||
|
||||
# Optional, defaults to no deletion.
|
||||
deletionPolicy: Delete # If set to delete, the secret(s) inside Infisical managed by the operator, will be deleted if the InfisicalPushSecret CRD is deleted.
|
||||
|
||||
destination:
|
||||
projectId: <project-id>
|
||||
environmentSlug: <env-slug>
|
||||
secretsPath: <secret-path>
|
||||
|
||||
push:
|
||||
secret:
|
||||
secretName: push-secret-demo # Secret CRD
|
||||
secretNamespace: default
|
||||
|
||||
# Only have one authentication method defined or you are likely to run into authentication issues.
|
||||
# Remove all except one authentication method.
|
||||
authentication:
|
||||
awsIamAuth:
|
||||
identityId: <machine-identity-id>
|
||||
azureAuth:
|
||||
identityId: <machine-identity-id>
|
||||
gcpIamAuth:
|
||||
identityId: <machine-identity-id>
|
||||
serviceAccountKeyFilePath: </path-to-service-account-key-file.json>
|
||||
gcpIdTokenAuth:
|
||||
identityId: <machine-identity-id>
|
||||
kubernetesAuth:
|
||||
identityId: <machine-identity-id>
|
||||
serviceAccountRef:
|
||||
name: <secret-name>
|
||||
namespace: <secret-namespace>
|
||||
universalAuth:
|
||||
credentialsRef:
|
||||
secretName: <secret-name> # universal-auth-credentials
|
||||
secretNamespace: <secret-namespace> # default
|
||||
```
|
||||
|
||||
```yaml source-secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: push-secret-demo
|
||||
namespace: default
|
||||
stringData: # can also be "data", but needs to be base64 encoded
|
||||
API_KEY: some-api-key
|
||||
DATABASE_URL: postgres://127.0.0.1:5432
|
||||
ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab
|
||||
```
|
||||
|
||||
### InfisicalPushSecret CRD properties
|
||||
|
||||
<Accordion title="hostAPI">
|
||||
If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to
|
||||
` https://your-self-hosted-instace.com/api`
|
||||
|
||||
When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud.
|
||||
|
||||
<Accordion title="Advanced use case">
|
||||
If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet.
|
||||
To achieve this, use the following address for the hostAPI field:
|
||||
|
||||
``` bash
|
||||
http://<backend-svc-name>.<namespace>.svc.cluster.local:4000/api
|
||||
```
|
||||
|
||||
Make sure to replace `<backend-svc-name>` and `<namespace>` with the appropriate values for your backend service and namespace.
|
||||
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="resyncInterval">
|
||||
|
||||
The `resyncInterval` is a string-formatted duration that defines the time between each resync.
|
||||
|
||||
The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time.
|
||||
|
||||
The following units are supported:
|
||||
- `s` for seconds (must be at least 5 seconds)
|
||||
- `m` for minutes
|
||||
- `h` for hours
|
||||
- `d` for days
|
||||
- `w` for weeks
|
||||
|
||||
The default value is `1m` (1 minute).
|
||||
|
||||
Valid intervals examples:
|
||||
```yaml
|
||||
resyncInterval: 5s # 10 seconds
|
||||
resyncInterval: 10s # 10 seconds
|
||||
resyncInterval: 5m # 5 minutes
|
||||
resyncInterval: 1h # 1 hour
|
||||
resyncInterval: 1d # 1 day
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="updatePolicy">
|
||||
|
||||
The field is optional and will default to `None` if not defined.
|
||||
|
||||
The update policy defines how the operator should handle conflicting secrets when pushing secrets to Infisical.
|
||||
|
||||
Valid values are `None` and `Replace`.
|
||||
|
||||
Behavior of each policy:
|
||||
- `None`: The operator will not override existing secrets in Infisical. If a secret with the same key already exists, the operator will skip pushing that secret, and the secret will not be managed by the operator.
|
||||
- `Replace`: The operator will replace existing secrets in Infisical with the new secrets. If a secret with the same key already exists, the operator will update the secret with the new value.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
updatePolicy: Replace
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="deletionPolicy">
|
||||
|
||||
This field is optional and will default to `None` if not defined.
|
||||
|
||||
The deletion policy defines what the operator should do in case the InfisicalPushSecret CRD is deleted.
|
||||
|
||||
Valid values are `None` and `Delete`.
|
||||
|
||||
Behavior of each policy:
|
||||
- `None`: The operator will not delete the secrets in Infisical when the InfisicalPushSecret CRD is deleted.
|
||||
- `Delete`: The operator will delete the secrets in Infisical that are managed by the operator when the InfisicalPushSecret CRD is deleted.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
deletionPolicy: Delete
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="destination">
|
||||
The `destination` field is used to specify where you want to create the secrets in Infisical. The required fields are `projectId`, `environmentSlug`, and `secretsPath`.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
destination:
|
||||
projectId: <project-id>
|
||||
environmentSlug: <env-slug>
|
||||
secretsPath: <secrets-path>
|
||||
```
|
||||
|
||||
<Accordion title="destination.projectId">
|
||||
The project ID where you want to create the secrets in Infisical.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="destination.environmentSlug">
|
||||
The environment slug where you want to create the secrets in Infisical.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="destination.secretsPath">
|
||||
The path where you want to create the secrets in Infisical. The root path is `/`.
|
||||
</Accordion>
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="push">
|
||||
The `push` field is used to define what you want to push to Infisical. Currently the operator only supports pushing Kubernetes secrets to Infisical. An example of the `push` field is shown below.
|
||||
|
||||
|
||||
|
||||
<Accordion title="secret">
|
||||
The `secret` field is used to define the Kubernetes secret you want to push to Infisical. The required fields are `secretName` and `secretNamespace`.
|
||||
|
||||
|
||||
|
||||
Example usage of the `push.secret` field:
|
||||
|
||||
```yaml infisical-push-secret.yaml
|
||||
push:
|
||||
secret:
|
||||
secretName: push-secret-demo
|
||||
secretNamespace: default
|
||||
```
|
||||
|
||||
```yaml push-secret-demo.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: push-secret-demo
|
||||
namespace: default
|
||||
# Pass in the secrets you wish to push to Infisical
|
||||
stringData:
|
||||
API_KEY: some-api-key
|
||||
DATABASE_URL: postgres://127.0.0.1:5432
|
||||
ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="authentication">
|
||||
|
||||
The `authentication` field dictates which authentication method to use when pushing secrets to Infisical.
|
||||
The available authentication methods are `universalAuth`, `kubernetesAuth`, `awsIamAuth`, `azureAuth`, `gcpIdTokenAuth`, and `gcpIamAuth`.
|
||||
|
||||
|
||||
<Accordion title="universalAuth">
|
||||
The universal authentication method is one of the easiest ways to get started with Infisical. Universal Auth works anywhere and is not tied to any specific cloud provider.
|
||||
[Read more about Universal Auth](/documentation/platform/identities/universal-auth).
|
||||
|
||||
Valid fields:
|
||||
- `identityId`: The identity ID of the machine identity you created.
|
||||
- `credentialsRef`: The name and namespace of the Kubernetes secret that stores the service token.
|
||||
- `credentialsRef.secretName`: The name of the Kubernetes secret.
|
||||
- `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
# infisical-push-secret.yaml
|
||||
spec:
|
||||
universalAuth:
|
||||
credentialsRef:
|
||||
secretName: <secret-name>
|
||||
secretNamespace: <secret-namespace>
|
||||
```
|
||||
|
||||
```yaml
|
||||
# machine-identity-credentials.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: universal-auth-credentials
|
||||
type: Opaque
|
||||
stringData:
|
||||
clientId: <machine-identity-client-id>
|
||||
clientSecret: <machine-identity-client-secret>
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="kubernetesAuth">
|
||||
The Kubernetes machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within a Kubernetes environment.
|
||||
[Read more about Kubernetes Auth](/documentation/platform/identities/kubernetes-auth).
|
||||
Valid fields:
|
||||
- `identityId`: The identity ID of the machine identity you created.
|
||||
- `serviceAccountRef`: The name and namespace of the service account that will be used to authenticate with Infisical.
|
||||
- `serviceAccountRef.name`: The name of the service account.
|
||||
- `serviceAccountRef.namespace`: The namespace of the service account.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
kubernetesAuth:
|
||||
identityId: <machine-identity-id>
|
||||
serviceAccountRef:
|
||||
name: <secret-name>
|
||||
namespace: <secret-namespace>
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="awsIamAuth">
|
||||
The AWS IAM machine identity authentication method is used to authenticate with Infisical.
|
||||
[Read more about AWS IAM Auth](/documentation/platform/identities/aws-auth).
|
||||
|
||||
Valid fields:
|
||||
- `identityId`: The identity ID of the machine identity you created.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
authentication:
|
||||
awsIamAuth:
|
||||
identityId: <machine-identity-id>
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="azureAuth">
|
||||
The AWS IAM machine identity authentication method is used to authenticate with Infisical. Azure Auth can only be used from within an Azure environment.
|
||||
[Read more about Azure Auth](/documentation/platform/identities/azure-auth).
|
||||
|
||||
Valid fields:
|
||||
- `identityId`: The identity ID of the machine identity you created.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
authentication:
|
||||
azureAuth:
|
||||
identityId: <machine-identity-id>
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="gcpIamAuth">
|
||||
The GCP IAM machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used both within and outside GCP environments.
|
||||
[Read more about Azure Auth](/documentation/platform/identities/gcp-auth).
|
||||
|
||||
|
||||
Valid fields:
|
||||
- `identityId`: The identity ID of the machine identity you created.
|
||||
- `serviceAccountKeyFilePath`: The path to the GCP service account key file.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
gcpIamAuth:
|
||||
identityId: <machine-identity-id>
|
||||
serviceAccountKeyFilePath: </path-to-service-account-key-file.json>
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="gcpIdTokenAuth">
|
||||
The GCP ID Token machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within GCP environments.
|
||||
[Read more about Azure Auth](/documentation/platform/identities/gcp-auth).
|
||||
|
||||
Valid fields:
|
||||
- `identityId`: The identity ID of the machine identity you created.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
gcpIdTokenAuth:
|
||||
identityId: <machine-identity-id>
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
<Accordion title="tls">
|
||||
This block defines the TLS settings to use for connecting to the Infisical
|
||||
instance.
|
||||
|
||||
Fields:
|
||||
<Accordion title="caRef">
|
||||
This block defines the reference to the CA certificate to use for connecting to the Infisical instance with SSL/TLS.
|
||||
|
||||
Valid fields:
|
||||
- `secretName`: The name of the Kubernetes secret containing the CA certificate to use for connecting to the Infisical instance with SSL/TLS.
|
||||
- `secretNamespace`: The namespace of the Kubernetes secret containing the CA certificate to use for connecting to the Infisical instance with SSL/TLS.
|
||||
- `key`: The name of the key in the Kubernetes secret which contains the value of the CA certificate to use for connecting to the Infisical instance with SSL/TLS.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
tls:
|
||||
caRef:
|
||||
secretName: custom-ca-certificate
|
||||
secretNamespace: default
|
||||
key: ca.crt
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
### Applying the InfisicalPushSecret CRD to your cluster
|
||||
|
||||
Once you have configured the `InfisicalPushSecret` CRD with the required fields, you can apply it to your cluster.
|
||||
After applying, you should notice that the secrets have been pushed to Infisical.
|
||||
|
||||
```bash
|
||||
kubectl apply -f source-push-secret.yaml # The secret that you're referencing in the InfisicalPushSecret CRD push.secret field
|
||||
kubectl apply -f example-infisical-push-secret-crd.yaml # The InfisicalPushSecret CRD itself
|
||||
```
|
||||
|
||||
### Connecting to instances with private/self-signed certificate
|
||||
|
||||
To connect to Infisical instances behind a private/self-signed certificate, you can configure the TLS settings in the `InfisicalPushSecret` CRD
|
||||
to point to a CA certificate stored in a Kubernetes secret resource.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
hostAPI: https://app.infisical.com/api
|
||||
resyncInterval: 30s
|
||||
tls:
|
||||
caRef:
|
||||
secretName: custom-ca-certificate
|
||||
secretNamespace: default
|
||||
key: ca.crt
|
||||
authentication:
|
||||
# ...
|
||||
```
|
||||
|
||||
|
||||
## Global configuration
|
||||
|
||||
To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap.
|
||||
|
@ -114,6 +114,7 @@
|
||||
"documentation/platform/pki/alerting"
|
||||
]
|
||||
},
|
||||
"documentation/platform/ssh",
|
||||
{
|
||||
"group": "Key Management (KMS)",
|
||||
"pages": [
|
||||
@ -846,6 +847,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Infisical SSH",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Certificates",
|
||||
"pages": [
|
||||
"api-reference/endpoints/ssh/certificates/issue-credentials",
|
||||
"api-reference/endpoints/ssh/certificates/sign-key"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Certificate Authorities",
|
||||
"pages": [
|
||||
"api-reference/endpoints/ssh/ca/list",
|
||||
"api-reference/endpoints/ssh/ca/create",
|
||||
"api-reference/endpoints/ssh/ca/read",
|
||||
"api-reference/endpoints/ssh/ca/update",
|
||||
"api-reference/endpoints/ssh/ca/delete",
|
||||
"api-reference/endpoints/ssh/ca/public-key",
|
||||
"api-reference/endpoints/ssh/ca/list-certificate-templates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Certificate Templates",
|
||||
"pages": [
|
||||
"api-reference/endpoints/ssh/certificate-templates/list",
|
||||
"api-reference/endpoints/ssh/certificate-templates/create",
|
||||
"api-reference/endpoints/ssh/certificate-templates/read",
|
||||
"api-reference/endpoints/ssh/certificate-templates/update",
|
||||
"api-reference/endpoints/ssh/certificate-templates/delete"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Infisical KMS",
|
||||
"pages": [
|
||||
|
0
frontend/public/images/integrations/Circle CI.png → frontend/public/images/integrations/CircleCI.png
Before ![]() (image error) Size: 7.8 KiB After ![]() (image error) Size: 7.8 KiB ![]() ![]() |
@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { createNotification } from "../notifications";
|
||||
import { IconButton, Select, SelectItem, Tooltip } from "../v2";
|
||||
@ -69,7 +70,11 @@ export default function NavHeader({
|
||||
<div className="mr-2 flex h-5 w-5 min-w-[1.25rem] items-center justify-center rounded-md bg-primary text-sm text-black">
|
||||
{currentOrg?.name?.charAt(0)}
|
||||
</div>
|
||||
<Link passHref legacyBehavior href={`/org/${currentOrg?.id}/overview`}>
|
||||
<Link
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={`/org/${currentOrg?.id}/${ProjectType.SecretManager}/overview`}
|
||||
>
|
||||
<a className="truncate pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
|
||||
{currentOrg?.name}
|
||||
</a>
|
||||
@ -93,7 +98,10 @@ export default function NavHeader({
|
||||
<Link
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={{ pathname: "/project/[id]/secrets/overview", query: { id: router.query.id } }}
|
||||
href={{
|
||||
pathname: `/${ProjectType.SecretManager}/[id]/secrets/overview`,
|
||||
query: { id: router.query.id }
|
||||
}}
|
||||
>
|
||||
<a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a>
|
||||
</Link>
|
||||
@ -130,7 +138,7 @@ export default function NavHeader({
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={{
|
||||
pathname: "/project/[id]/secrets/[env]",
|
||||
pathname: `/${ProjectType.SecretManager}/[id]/secrets/[env]`,
|
||||
query: { id: router.query.id, env: router.query.env }
|
||||
}}
|
||||
>
|
||||
@ -199,7 +207,10 @@ export default function NavHeader({
|
||||
<Link
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={{ pathname: "/project/[id]/secrets/[env]", query }}
|
||||
href={{
|
||||
pathname: `/${ProjectType.SecretManager}/[id]/secrets/[env]`,
|
||||
query
|
||||
}}
|
||||
>
|
||||
<a
|
||||
className={twMerge(
|
||||
|