Compare commits

..

69 Commits

Author SHA1 Message Date
Daniel Hougaard
074446df1f Update agent.go 2025-05-20 14:32:07 +04:00
Maidul Islam
0b6bc4c1f0 update spend 2025-05-19 21:58:19 -07:00
Maidul Islam
abbe7bbd0c Merge pull request #3627 from Infisical/fix-breaking-schema-changes--for-k8s
Allow Hyphens in k8s
2025-05-19 18:26:09 -07:00
Maidul Islam
565340dc50 fix lint 2025-05-19 18:13:45 -07:00
Maidul Islam
36c428f152 allow hyphens in host name 2025-05-19 17:45:12 -07:00
Maidul Islam
f97826ea82 allow hyphens in host name 2025-05-19 17:42:42 -07:00
Maidul Islam
0f5cbf055c remove limit 2025-05-19 17:27:47 -07:00
x032205
b960ee61d7 Merge pull request #3624 from Infisical/product-select-docs
add product select to docs + change the heading
2025-05-19 17:16:38 -04:00
x032205
0b98a214a7 ui tweaks 2025-05-19 17:15:42 -04:00
x032205
599c2226e4 Merge pull request #3615 from Infisical/ENG-2787
feat(org): Shared Secret limits for org
2025-05-19 16:26:10 -04:00
x032205
27486e7600 Merge pull request #3625 from Infisical/ENG-2795
fix secret rollback not tainting form
2025-05-19 16:17:26 -04:00
x032205
979e9efbcb fix lint issue 2025-05-19 15:52:50 -04:00
x032205
1097ec64b2 ui improvements 2025-05-19 15:40:07 -04:00
x032205
93fe9929b7 fix secret rollback not tainting form 2025-05-19 15:22:24 -04:00
x032205
aca654a993 Update docs/documentation/platform/organization.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-19 13:38:34 -04:00
x032205
b5cf237a4a add product select to docs + change the heading 2025-05-19 13:35:35 -04:00
x032205
6efb630200 Moved secret share limits to secret share settings 2025-05-19 12:32:22 -04:00
x032205
151ede6cbf Merge 2025-05-19 12:20:02 -04:00
x032205
931ee1e8da Merge pull request #3616 from Infisical/ENG-2783
feat(secret-sharing): Specify Emails
2025-05-19 12:12:07 -04:00
x032205
0401793d38 Changed "token" param to "hash" and used hex encoding for URL 2025-05-19 10:48:58 -04:00
x032205
0613c12508 Merge pull request #3618 from Infisical/fix-bundle-for-old-certs 2025-05-18 13:29:31 -04:00
Daniel Hougaard
60d3ffac5d Merge pull request #3620 from Infisical/daniel/k8s-auth-fix
fix(identities-auth): fixed kubernetes auth login
2025-05-17 22:18:52 +04:00
Daniel Hougaard
5e192539a1 Update identity-kubernetes-auth-service.ts 2025-05-17 22:13:49 +04:00
Daniel Hougaard
021a8ddace Update identity-kubernetes-auth-service.ts 2025-05-17 22:06:51 +04:00
x032205
f92aba14cd Merge pull request #3619 from Infisical/fix-padding
Org Products Padding Fix
2025-05-17 13:11:56 -04:00
x032205
fdeefcdfcf padding to match similar container 2025-05-17 13:10:15 -04:00
x032205
645f70f770 tweaks 2025-05-17 13:05:09 -04:00
x032205
923feb81f3 fix bundle endpoint for old certs 2025-05-17 12:44:05 -04:00
x032205
16c51af340 review fixes 2025-05-17 02:17:41 -04:00
x032205
9fd37ca456 greptile review fixes 2025-05-17 01:51:05 -04:00
x032205
92bebf7d84 feat(secret-sharing): Specify Emails 2025-05-17 00:54:40 -04:00
x032205
df053bbae9 Merge pull request #3611 from Infisical/ENG-2782
feat(project): Enable / Disable Secret Sharing
2025-05-16 18:58:39 -04:00
x032205
42319f01a7 greptile review fixes 2025-05-16 18:54:57 -04:00
x032205
0ea9f9b60d feat(org): Shared Secret limits for org 2025-05-16 18:36:02 -04:00
Scott Wilson
16eefe5bac Merge pull request #3610 from Infisical/sso-empty-state
improvement(sso-page): Add empty display for SSO general tab if no SSO is enabled
2025-05-16 10:10:16 -07:00
Daniel Hougaard
b984111a73 Merge pull request #3612 from Infisical/daniel/cli-auth-fix
fix(auth): cli auth bug
2025-05-16 17:29:21 +04:00
Daniel Hougaard
677ff62b5c fix(auth): cli auth bug 2025-05-16 17:22:18 +04:00
Daniel Hougaard
8cc2e08f24 fix(auth): cli auth bug 2025-05-16 16:58:01 +04:00
Maidul Islam
d90178f49a Merge pull request #3590 from Infisical/daniel/k8s-auth-gateway
feat(gateway): gateway support for identities
2025-05-16 00:10:16 -07:00
x032205
ad50cff184 Update frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/SecretSharingSection.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-16 00:21:30 -04:00
x032205
8e43d2a994 feat(project): Enable / Disable Secret Sharing 2025-05-16 00:08:55 -04:00
x032205
7074fdbac3 Merge pull request #3609 from Infisical/ENG-2736
feat(org-settings): Option to hide certain products from the sidebar
2025-05-15 23:24:14 -04:00
Scott Wilson
ef70de1e0b fix: add noopenner to doc link 2025-05-15 20:05:56 -07:00
Scott Wilson
7e9ee7b5e3 fix: add empty display for sso general tab if no sso is enabled 2025-05-15 20:01:08 -07:00
x032205
517c613d05 migration fix 2025-05-15 22:50:09 -04:00
x032205
ae8cf06ec6 greptile review fixes 2025-05-15 21:05:39 -04:00
x032205
818778ddc5 Update frontend/src/pages/organization/SettingsPage/components/OrgProductSelectSection/OrgProductSelectSection.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-15 21:01:46 -04:00
x032205
2e12d9a13c Update frontend/src/pages/organization/SettingsPage/components/OrgGeneralTab/OrgGeneralTab.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-15 21:01:30 -04:00
x032205
e678c9d1cf remove comments 2025-05-15 20:49:01 -04:00
x032205
da0b07ce2a added the other two products and small UI tweaks 2025-05-15 20:45:32 -04:00
x032205
3306a9ca69 Merge pull request #3608 from Infisical/key-schema-tweak
allow underscores in key schema
2025-05-15 18:55:45 -04:00
Maidul Islam
e9af34a6ba Merge pull request #3607 from Infisical/key-schema-doc-tweaks
feat(docs): Key Schema Tweaks
2025-05-15 15:51:23 -07:00
x032205
3de8ed169f allow underscores in key schema 2025-05-15 18:49:30 -04:00
Scott Wilson
d1eb350bdd Merge pull request #3606 from Infisical/oidc-groups-claim-handle-string
improvement(oidc-group-membership-mapping): Update OIDC group claims to handle single group string
2025-05-15 14:47:46 -07:00
Scott Wilson
0c1ccf7c2e fix: update oidc group claims to handle single group string 2025-05-15 14:39:07 -07:00
x032205
d268f52a1c small ui tweak 2025-05-15 16:50:37 -04:00
x032205
c519cee5d1 frontend 2025-05-15 16:32:57 -04:00
Maidul Islam
b55a39dd24 Merge pull request #3604 from Infisical/misc/add-identity-support-for-audit-log-retention
misc: add identity support for audit log retention
2025-05-15 09:25:49 -07:00
x032205
c7dc595e1a doc overview update 2025-05-15 12:05:06 -04:00
Daniel Hougaard
be26dc9872 requested changes 2025-05-15 16:55:36 +04:00
Daniel Hougaard
aaeb6e73fe requested changes 2025-05-15 16:06:20 +04:00
Daniel Hougaard
cd028ae133 Update 20250212191958_create-gateway.ts 2025-05-14 16:01:07 +04:00
Daniel Hougaard
63c71fabcd fix: migrate project gateway 2025-05-14 16:00:27 +04:00
Daniel Hougaard
e90166f1f0 Merge branch 'heads/main' into daniel/k8s-auth-gateway 2025-05-14 14:26:05 +04:00
Daniel Hougaard
8adf4787b9 Update 20250513081738_remove-gateway-project-link.ts 2025-05-13 15:31:13 +04:00
Daniel Hougaard
a12522db55 requested changes 2025-05-13 15:18:23 +04:00
Daniel Hougaard
49ab487dc2 Update organization-permissions.mdx 2025-05-13 15:04:21 +04:00
Daniel Hougaard
daf0731580 feat(gateways): decouple gateways from projects 2025-05-13 14:59:58 +04:00
Daniel Hougaard
fb2b64cb19 feat(identities/k8s): gateway support 2025-05-12 15:19:42 +04:00
98 changed files with 2324 additions and 899 deletions

View File

@@ -0,0 +1,25 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasGatewayIdColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayId");
if (!hasGatewayIdColumn) {
await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => {
table.uuid("gatewayId").nullable();
table.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("SET NULL");
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasGatewayIdColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayId");
if (hasGatewayIdColumn) {
await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => {
table.dropForeign("gatewayId");
table.dropColumn("gatewayId");
});
}
}

View File

@@ -0,0 +1,110 @@
import { Knex } from "knex";
import { inMemoryKeyStore } from "@app/keystore/memory";
import { selectAllTableCols } from "@app/lib/knex";
import { initLogger } from "@app/lib/logger";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TableName } from "../schemas";
import { getMigrationEnvConfig } from "./utils/env-config";
import { getMigrationEncryptionServices } from "./utils/services";
// Note(daniel): We aren't dropping tables or columns in this migrations so we can easily rollback if needed.
// In the future we need to drop the projectGatewayId on the dynamic secrets table, and drop the project_gateways table entirely.
const BATCH_SIZE = 500;
export async function up(knex: Knex): Promise<void> {
// eslint-disable-next-line no-param-reassign
knex.replicaNode = () => {
return knex;
};
if (!(await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId"))) {
await knex.schema.alterTable(TableName.DynamicSecret, (table) => {
table.uuid("gatewayId").nullable();
table.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("SET NULL");
table.index("gatewayId");
});
const existingDynamicSecretsWithProjectGatewayId = await knex(TableName.DynamicSecret)
.select(selectAllTableCols(TableName.DynamicSecret))
.whereNotNull(`${TableName.DynamicSecret}.projectGatewayId`)
.join(TableName.ProjectGateway, `${TableName.ProjectGateway}.id`, `${TableName.DynamicSecret}.projectGatewayId`)
.whereNotNull(`${TableName.ProjectGateway}.gatewayId`)
.select(
knex.ref("projectId").withSchema(TableName.ProjectGateway).as("projectId"),
knex.ref("gatewayId").withSchema(TableName.ProjectGateway).as("projectGatewayGatewayId")
);
initLogger();
const envConfig = getMigrationEnvConfig();
const keyStore = inMemoryKeyStore();
const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex });
const updatedDynamicSecrets = await Promise.all(
existingDynamicSecretsWithProjectGatewayId.map(async (existingDynamicSecret) => {
if (!existingDynamicSecret.projectGatewayGatewayId) {
const result = {
...existingDynamicSecret,
gatewayId: null
};
const { projectId, projectGatewayGatewayId, ...rest } = result;
return rest;
}
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: existingDynamicSecret.projectId
});
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: existingDynamicSecret.projectId
});
let decryptedStoredInput = JSON.parse(
secretManagerDecryptor({ cipherTextBlob: Buffer.from(existingDynamicSecret.encryptedInput) }).toString()
) as object;
// We're not removing the existing projectGatewayId from the input so we can easily rollback without having to re-encrypt the input
decryptedStoredInput = {
...decryptedStoredInput,
gatewayId: existingDynamicSecret.projectGatewayGatewayId
};
const encryptedInput = secretManagerEncryptor({
plainText: Buffer.from(JSON.stringify(decryptedStoredInput))
}).cipherTextBlob;
const result = {
...existingDynamicSecret,
encryptedInput,
gatewayId: existingDynamicSecret.projectGatewayGatewayId
};
const { projectId, projectGatewayGatewayId, ...rest } = result;
return rest;
})
);
for (let i = 0; i < updatedDynamicSecrets.length; i += BATCH_SIZE) {
// eslint-disable-next-line no-await-in-loop
await knex(TableName.DynamicSecret)
.insert(updatedDynamicSecrets.slice(i, i + BATCH_SIZE))
.onConflict("id")
.merge();
}
}
}
export async function down(knex: Knex): Promise<void> {
// no re-encryption needed as we keep the old projectGatewayId in the input
if (await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId")) {
await knex.schema.alterTable(TableName.DynamicSecret, (table) => {
table.dropForeign("gatewayId");
table.dropColumn("gatewayId");
});
}
}

View File

@@ -0,0 +1,53 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const columns = await knex.table(TableName.Organization).columnInfo();
await knex.schema.alterTable(TableName.Organization, (t) => {
if (!columns.secretsProductEnabled) {
t.boolean("secretsProductEnabled").defaultTo(true);
}
if (!columns.pkiProductEnabled) {
t.boolean("pkiProductEnabled").defaultTo(true);
}
if (!columns.kmsProductEnabled) {
t.boolean("kmsProductEnabled").defaultTo(true);
}
if (!columns.sshProductEnabled) {
t.boolean("sshProductEnabled").defaultTo(true);
}
if (!columns.scannerProductEnabled) {
t.boolean("scannerProductEnabled").defaultTo(true);
}
if (!columns.shareSecretsProductEnabled) {
t.boolean("shareSecretsProductEnabled").defaultTo(true);
}
});
}
export async function down(knex: Knex): Promise<void> {
const columns = await knex.table(TableName.Organization).columnInfo();
await knex.schema.alterTable(TableName.Organization, (t) => {
if (columns.secretsProductEnabled) {
t.dropColumn("secretsProductEnabled");
}
if (columns.pkiProductEnabled) {
t.dropColumn("pkiProductEnabled");
}
if (columns.kmsProductEnabled) {
t.dropColumn("kmsProductEnabled");
}
if (columns.sshProductEnabled) {
t.dropColumn("sshProductEnabled");
}
if (columns.scannerProductEnabled) {
t.dropColumn("scannerProductEnabled");
}
if (columns.shareSecretsProductEnabled) {
t.dropColumn("shareSecretsProductEnabled");
}
});
}

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
if (!hasSecretSharingColumn) {
await knex.schema.table(TableName.Project, (table) => {
table.boolean("secretSharing").notNullable().defaultTo(true);
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
if (hasSecretSharingColumn) {
await knex.schema.table(TableName.Project, (table) => {
table.dropColumn("secretSharing");
});
}
}

View File

@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
if (!hasLifetimeColumn || !hasViewLimitColumn) {
await knex.schema.alterTable(TableName.Organization, (t) => {
if (!hasLifetimeColumn) {
t.integer("maxSharedSecretLifetime").nullable().defaultTo(2592000); // 30 days in seconds
}
if (!hasViewLimitColumn) {
t.integer("maxSharedSecretViewLimit").nullable();
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
if (hasLifetimeColumn || hasViewLimitColumn) {
await knex.schema.alterTable(TableName.Organization, (t) => {
if (hasLifetimeColumn) {
t.dropColumn("maxSharedSecretLifetime");
}
if (hasViewLimitColumn) {
t.dropColumn("maxSharedSecretViewLimit");
}
});
}
}

View File

@@ -0,0 +1,43 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
if (!hasEncryptedSalt || !hasAuthorizedEmails) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
// These two columns are only needed when secrets are shared with a specific list of emails
if (!hasEncryptedSalt) {
t.binary("encryptedSalt").nullable();
}
if (!hasAuthorizedEmails) {
t.json("authorizedEmails").nullable();
}
});
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
if (hasEncryptedSalt || hasAuthorizedEmails) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasEncryptedSalt) {
t.dropColumn("encryptedSalt");
}
if (hasAuthorizedEmails) {
t.dropColumn("authorizedEmails");
}
});
}
}
}

View File

@@ -27,7 +27,8 @@ export const DynamicSecretsSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
encryptedInput: zodBuffer,
projectGatewayId: z.string().uuid().nullable().optional()
projectGatewayId: z.string().uuid().nullable().optional(),
gatewayId: z.string().uuid().nullable().optional()
});
export type TDynamicSecrets = z.infer<typeof DynamicSecretsSchema>;

View File

@@ -29,7 +29,8 @@ export const IdentityKubernetesAuthsSchema = z.object({
allowedNames: z.string(),
allowedAudience: z.string(),
encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(),
encryptedKubernetesCaCertificate: zodBuffer.nullable().optional()
encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(),
gatewayId: z.string().uuid().nullable().optional()
});
export type TIdentityKubernetesAuths = z.infer<typeof IdentityKubernetesAuthsSchema>;

View File

@@ -28,7 +28,15 @@ export const OrganizationsSchema = z.object({
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
privilegeUpgradeInitiatedAt: z.date().nullable().optional(),
bypassOrgAuthEnabled: z.boolean().default(false),
userTokenExpiration: z.string().nullable().optional()
userTokenExpiration: z.string().nullable().optional(),
secretsProductEnabled: z.boolean().default(true).nullable().optional(),
pkiProductEnabled: z.boolean().default(true).nullable().optional(),
kmsProductEnabled: z.boolean().default(true).nullable().optional(),
sshProductEnabled: z.boolean().default(true).nullable().optional(),
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
maxSharedSecretViewLimit: z.number().nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false),
hasDeleteProtection: z.boolean().default(false).nullable().optional()
hasDeleteProtection: z.boolean().default(false).nullable().optional(),
secretSharing: z.boolean().default(true)
});
export type TProjects = z.infer<typeof ProjectsSchema>;

View File

@@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({
password: z.string().nullable().optional(),
encryptedSecret: zodBuffer.nullable().optional(),
identifier: z.string().nullable().optional(),
type: z.string().default("share")
type: z.string().default("share"),
encryptedSalt: zodBuffer.nullable().optional(),
authorizedEmails: z.unknown().nullable().optional()
});
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;

View File

@@ -121,14 +121,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => {
identity: z.object({
name: z.string(),
id: z.string()
}),
projects: z
.object({
name: z.string(),
id: z.string(),
slug: z.string()
})
.array()
})
}).array()
})
}
@@ -158,17 +151,15 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => {
identity: z.object({
name: z.string(),
id: z.string()
}),
projectGatewayId: z.string()
})
}).array()
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]),
handler: async (req) => {
const gateways = await server.services.gateway.getProjectGateways({
projectId: req.params.projectId,
projectPermission: req.permission
const gateways = await server.services.gateway.listGateways({
orgPermission: req.permission
});
return { gateways };
}
@@ -216,8 +207,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => {
id: z.string()
}),
body: z.object({
name: slugSchema({ field: "name" }).optional(),
projectIds: z.string().array().optional()
name: slugSchema({ field: "name" }).optional()
}),
response: {
200: z.object({
@@ -230,8 +220,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => {
const gateway = await server.services.gateway.updateGatewayById({
orgPermission: req.permission,
id: req.params.id,
name: req.body.name,
projectIds: req.body.projectIds
name: req.body.name
});
return { gateway };
}

View File

@@ -17,7 +17,8 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold
import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal";
import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue";
import { TProjectGatewayDALFactory } from "../gateway/project-gateway-dal";
import { TGatewayDALFactory } from "../gateway/gateway-dal";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TDynamicSecretDALFactory } from "./dynamic-secret-dal";
import {
DynamicSecretStatus,
@@ -44,9 +45,9 @@ type TDynamicSecretServiceFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
projectGatewayDAL: Pick<TProjectGatewayDALFactory, "findOne">;
gatewayDAL: Pick<TGatewayDALFactory, "findOne" | "find">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
};
@@ -62,7 +63,7 @@ export const dynamicSecretServiceFactory = ({
dynamicSecretQueueService,
projectDAL,
kmsService,
projectGatewayDAL,
gatewayDAL,
resourceMetadataDAL
}: TDynamicSecretServiceFactoryDep) => {
const create = async ({
@@ -117,15 +118,31 @@ export const dynamicSecretServiceFactory = ({
const inputs = await selectedProvider.validateProviderInputs(provider.inputs);
let selectedGatewayId: string | null = null;
if (inputs && typeof inputs === "object" && "projectGatewayId" in inputs && inputs.projectGatewayId) {
const projectGatewayId = inputs.projectGatewayId as string;
if (inputs && typeof inputs === "object" && "gatewayId" in inputs && inputs.gatewayId) {
const gatewayId = inputs.gatewayId as string;
const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId });
if (!projectGateway)
const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId });
if (!gateway) {
throw new NotFoundError({
message: `Project gateway with ${projectGatewayId} not found`
message: `Gateway with ID ${gatewayId} not found`
});
selectedGatewayId = projectGateway.id;
}
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor,
actorId,
gateway.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionGatewayActions.AttachGateways,
OrgPermissionSubjects.Gateway
);
selectedGatewayId = gateway.id;
}
const isConnected = await selectedProvider.validateConnection(provider.inputs);
@@ -146,7 +163,7 @@ export const dynamicSecretServiceFactory = ({
defaultTTL,
folderId: folder.id,
name,
projectGatewayId: selectedGatewayId
gatewayId: selectedGatewayId
},
tx
);
@@ -255,20 +272,30 @@ export const dynamicSecretServiceFactory = ({
const updatedInput = await selectedProvider.validateProviderInputs(newInput);
let selectedGatewayId: string | null = null;
if (
updatedInput &&
typeof updatedInput === "object" &&
"projectGatewayId" in updatedInput &&
updatedInput?.projectGatewayId
) {
const projectGatewayId = updatedInput.projectGatewayId as string;
if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) {
const gatewayId = updatedInput.gatewayId as string;
const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId });
if (!projectGateway)
const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId });
if (!gateway) {
throw new NotFoundError({
message: `Project gateway with ${projectGatewayId} not found`
message: `Gateway with ID ${gatewayId} not found`
});
selectedGatewayId = projectGateway.id;
}
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor,
actorId,
gateway.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionGatewayActions.AttachGateways,
OrgPermissionSubjects.Gateway
);
selectedGatewayId = gateway.id;
}
const isConnected = await selectedProvider.validateConnection(newInput);
@@ -284,7 +311,7 @@ export const dynamicSecretServiceFactory = ({
defaultTTL,
name: newName ?? name,
status: null,
projectGatewayId: selectedGatewayId
gatewayId: selectedGatewayId
},
tx
);

View File

@@ -18,7 +18,7 @@ import { SqlDatabaseProvider } from "./sql-database";
import { TotpProvider } from "./totp";
type TBuildDynamicSecretProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTls">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
export const buildDynamicSecretProviders = ({

View File

@@ -137,7 +137,7 @@ export const DynamicSecretSqlDBSchema = z.object({
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional(),
projectGatewayId: z.string().nullable().optional()
gatewayId: z.string().nullable().optional()
});
export const DynamicSecretCassandraSchema = z.object({

View File

@@ -112,14 +112,14 @@ const generateUsername = (provider: SqlProviders) => {
};
type TSqlDatabaseProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTls">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.projectGatewayId));
const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId));
validateHandlebarTemplate("SQL creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val)
});
@@ -168,7 +168,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
providerInputs: z.infer<typeof DynamicSecretSqlDBSchema>,
gatewayCallback: (host: string, port: number) => Promise<void>
) => {
const relayDetails = await gatewayService.fnGetGatewayClientTls(providerInputs.projectGatewayId as string);
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
await withGatewayProxy(
async (port) => {
@@ -202,7 +202,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
await db.destroy();
};
if (providerInputs.projectGatewayId) {
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
@@ -238,7 +238,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
await db.destroy();
}
};
if (providerInputs.projectGatewayId) {
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
@@ -265,7 +265,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
await db.destroy();
}
};
if (providerInputs.projectGatewayId) {
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
@@ -301,7 +301,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
await db.destroy();
}
};
if (providerInputs.projectGatewayId) {
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();

View File

@@ -1,37 +1,34 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { GatewaysSchema, TableName, TGateways } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import {
buildFindFilter,
ormify,
selectAllTableCols,
sqlNestRelationships,
TFindFilter,
TFindOpt
} from "@app/lib/knex";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
export type TGatewayDALFactory = ReturnType<typeof gatewayDALFactory>;
export const gatewayDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.Gateway);
const find = async (filter: TFindFilter<TGateways>, { offset, limit, sort, tx }: TFindOpt<TGateways> = {}) => {
const find = async (
filter: TFindFilter<TGateways> & { orgId?: string },
{ offset, limit, sort, tx }: TFindOpt<TGateways> = {}
) => {
try {
const query = (tx || db)(TableName.Gateway)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter))
.where(buildFindFilter(filter, TableName.Gateway, ["orgId"]))
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`)
.leftJoin(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`)
.leftJoin(TableName.Project, `${TableName.Project}.id`, `${TableName.ProjectGateway}.projectId`)
.join(
TableName.IdentityOrgMembership,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.Gateway}.identityId`
)
.select(selectAllTableCols(TableName.Gateway))
.select(
db.ref("name").withSchema(TableName.Identity).as("identityName"),
db.ref("name").withSchema(TableName.Project).as("projectName"),
db.ref("slug").withSchema(TableName.Project).as("projectSlug"),
db.ref("id").withSchema(TableName.Project).as("projectId")
);
.select(db.ref("orgId").withSchema(TableName.IdentityOrgMembership).as("identityOrgId"))
.select(db.ref("name").withSchema(TableName.Identity).as("identityName"));
if (filter.orgId) {
void query.where(`${TableName.IdentityOrgMembership}.orgId`, filter.orgId);
}
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
@@ -39,48 +36,16 @@ export const gatewayDALFactory = (db: TDbClient) => {
}
const docs = await query;
return sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (data) => ({
...GatewaysSchema.parse(data),
identity: { id: data.identityId, name: data.identityName }
}),
childrenMapper: [
{
key: "projectId",
label: "projects" as const,
mapper: ({ projectId, projectName, projectSlug }) => ({
id: projectId,
name: projectName,
slug: projectSlug
})
}
]
});
return docs.map((el) => ({
...GatewaysSchema.parse(el),
orgId: el.identityOrgId as string, // todo(daniel): figure out why typescript is not inferring this as a string
identity: { id: el.identityId, name: el.identityName }
}));
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.Gateway}: Find` });
}
};
const findByProjectId = async (projectId: string, tx?: Knex) => {
try {
const query = (tx || db)(TableName.Gateway)
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`)
.join(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`)
.select(selectAllTableCols(TableName.Gateway))
.select(
db.ref("name").withSchema(TableName.Identity).as("identityName"),
db.ref("id").withSchema(TableName.ProjectGateway).as("projectGatewayId")
)
.where({ [`${TableName.ProjectGateway}.projectId` as "projectId"]: projectId });
const docs = await query;
return docs.map((el) => ({ ...el, identity: { id: el.identityId, name: el.identityName } }));
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.Gateway}: Find by project id` });
}
};
return { ...orm, find, findByProjectId };
return { ...orm, find };
};

View File

@@ -4,7 +4,6 @@ import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { z } from "zod";
import { ActionProjectType } from "@app/db/schemas";
import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
@@ -27,17 +26,14 @@ import { TGatewayDALFactory } from "./gateway-dal";
import {
TExchangeAllocatedRelayAddressDTO,
TGetGatewayByIdDTO,
TGetProjectGatewayByIdDTO,
THeartBeatDTO,
TListGatewaysDTO,
TUpdateGatewayByIdDTO
} from "./gateway-types";
import { TOrgGatewayConfigDALFactory } from "./org-gateway-config-dal";
import { TProjectGatewayDALFactory } from "./project-gateway-dal";
type TGatewayServiceFactoryDep = {
gatewayDAL: TGatewayDALFactory;
projectGatewayDAL: TProjectGatewayDALFactory;
orgGatewayConfigDAL: Pick<TOrgGatewayConfigDALFactory, "findOne" | "create" | "transaction" | "findById">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures" | "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "decryptWithRootKey">;
@@ -57,8 +53,7 @@ export const gatewayServiceFactory = ({
kmsService,
permissionService,
orgGatewayConfigDAL,
keyStore,
projectGatewayDAL
keyStore
}: TGatewayServiceFactoryDep) => {
const $validateOrgAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => {
// if (!licenseService.onPremFeatures.gateway) {
@@ -526,7 +521,7 @@ export const gatewayServiceFactory = ({
return gateway;
};
const updateGatewayById = async ({ orgPermission, id, name, projectIds }: TUpdateGatewayByIdDTO) => {
const updateGatewayById = async ({ orgPermission, id, name }: TUpdateGatewayByIdDTO) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
@@ -543,15 +538,6 @@ export const gatewayServiceFactory = ({
const [gateway] = await gatewayDAL.update({ id, orgGatewayRootCaId: orgGatewayConfig.id }, { name });
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` });
if (projectIds) {
await projectGatewayDAL.transaction(async (tx) => {
await projectGatewayDAL.delete({ gatewayId: gateway.id }, tx);
await projectGatewayDAL.insertMany(
projectIds.map((el) => ({ gatewayId: gateway.id, projectId: el })),
tx
);
});
}
return gateway;
};
@@ -576,27 +562,7 @@ export const gatewayServiceFactory = ({
return gateway;
};
const getProjectGateways = async ({ projectId, projectPermission }: TGetProjectGatewayByIdDTO) => {
await permissionService.getProjectPermission({
projectId,
actor: projectPermission.type,
actorId: projectPermission.id,
actorOrgId: projectPermission.orgId,
actorAuthMethod: projectPermission.authMethod,
actionProjectType: ActionProjectType.Any
});
const gateways = await gatewayDAL.findByProjectId(projectId);
return gateways;
};
// this has no permission check and used for dynamic secrets directly
// assumes permission check is already done
const fnGetGatewayClientTls = async (projectGatewayId: string) => {
const projectGateway = await projectGatewayDAL.findById(projectGatewayId);
if (!projectGateway) throw new NotFoundError({ message: `Project gateway with ID ${projectGatewayId} not found.` });
const { gatewayId } = projectGateway;
const fnGetGatewayClientTlsByGatewayId = async (gatewayId: string) => {
const gateway = await gatewayDAL.findById(gatewayId);
if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found.` });
@@ -645,8 +611,7 @@ export const gatewayServiceFactory = ({
getGatewayById,
updateGatewayById,
deleteGatewayById,
getProjectGateways,
fnGetGatewayClientTls,
fnGetGatewayClientTlsByGatewayId,
heartbeat
};
};

View File

@@ -20,7 +20,6 @@ export type TGetGatewayByIdDTO = {
export type TUpdateGatewayByIdDTO = {
id: string;
name?: string;
projectIds?: string[];
orgPermission: OrgServiceActor;
};

View File

@@ -1,10 +0,0 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TProjectGatewayDALFactory = ReturnType<typeof projectGatewayDALFactory>;
export const projectGatewayDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ProjectGateway);
return orm;
};

View File

@@ -714,13 +714,15 @@ export const oidcConfigServiceFactory = ({
}
}
const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined);
oidcLogin({
email: claims.email,
externalId: claims.sub,
firstName: claims.given_name ?? "",
lastName: claims.family_name ?? "",
orgId: org.id,
groups: claims.groups as string[] | undefined,
groups,
callbackPort,
manageGroupMemberships: oidcCfg.manageGroupMemberships
})

View File

@@ -41,7 +41,8 @@ export enum OrgPermissionGatewayActions {
CreateGateways = "create-gateways",
ListGateways = "list-gateways",
EditGateways = "edit-gateways",
DeleteGateways = "delete-gateways"
DeleteGateways = "delete-gateways",
AttachGateways = "attach-gateways"
}
export enum OrgPermissionIdentityActions {
@@ -337,6 +338,7 @@ const buildAdminPermission = () => {
can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.EditGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
@@ -378,6 +380,7 @@ const buildMemberPermission = () => {
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway);
return rules;
};

View File

@@ -393,6 +393,7 @@ export const KUBERNETES_AUTH = {
allowedNames: "The comma-separated list of trusted service account names that can authenticate with Infisical.",
allowedAudience:
"The optional audience claim that the service account JWT token must have to authenticate with Infisical.",
gatewayId: "The ID of the gateway to use when performing kubernetes API requests.",
accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.",
accessTokenTTL: "The lifetime for an access token in seconds.",
accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.",
@@ -409,6 +410,7 @@ export const KUBERNETES_AUTH = {
allowedNames: "The new comma-separated list of trusted service account names that can authenticate with Infisical.",
allowedAudience:
"The new optional audience claim that the service account JWT token must have to authenticate with Infisical.",
gatewayId: "The ID of the gateway to use when performing kubernetes API requests.",
accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.",
accessTokenTTL: "The new lifetime for an acccess token in seconds.",
accessTokenMaxTTL: "The new maximum lifetime for an acccess token in seconds.",
@@ -606,7 +608,8 @@ export const PROJECTS = {
projectDescription: "An optional description label for the project.",
autoCapitalization: "Disable or enable auto-capitalization for the project.",
slug: "An optional slug for the project. (must be unique within the organization)",
hasDeleteProtection: "Enable or disable delete protection for the project."
hasDeleteProtection: "Enable or disable delete protection for the project.",
secretSharing: "Enable or disable secret sharing for the project."
},
GET_KEY: {
workspaceId: "The ID of the project to get the key from."

View File

@@ -174,6 +174,8 @@ const setupProxyServer = async ({
return new Promise((resolve, reject) => {
const server = net.createServer();
let streamClosed = false;
// eslint-disable-next-line @typescript-eslint/no-misused-promises
server.on("connection", async (clientConn) => {
try {
@@ -202,9 +204,15 @@ const setupProxyServer = async ({
// Handle client connection close
clientConn.on("end", () => {
writer.close().catch((err) => {
logger.error(err);
});
if (!streamClosed) {
try {
writer.close().catch((err) => {
logger.debug(err, "Error closing writer (already closed)");
});
} catch (error) {
logger.debug(error, "Error in writer close");
}
}
});
clientConn.on("error", (clientConnErr) => {
@@ -249,14 +257,29 @@ const setupProxyServer = async ({
setupCopy();
// Handle connection closure
clientConn.on("close", () => {
stream.destroy().catch((err) => {
proxyErrorMsg.push((err as Error)?.message);
});
if (!streamClosed) {
streamClosed = true;
stream.destroy().catch((err) => {
logger.debug(err, "Stream already destroyed during close event");
});
}
});
const cleanup = async () => {
clientConn?.destroy();
await stream.destroy();
try {
clientConn?.destroy();
} catch (err) {
logger.debug(err, "Error destroying client connection");
}
if (!streamClosed) {
streamClosed = true;
try {
await stream.destroy();
} catch (err) {
logger.debug(err, "Error destroying stream (might be already closed)");
}
}
};
clientConn.on("error", (clientConnErr) => {
@@ -301,8 +324,17 @@ const setupProxyServer = async ({
server,
port: address.port,
cleanup: async () => {
server.close();
await quicClient?.destroy();
try {
server.close();
} catch (err) {
logger.debug(err, "Error closing server");
}
try {
await quicClient?.destroy();
} catch (err) {
logger.debug(err, "Error destroying QUIC client");
}
},
getProxyError: () => proxyErrorMsg.join(",")
});
@@ -320,10 +352,10 @@ interface ProxyOptions {
orgId: string;
}
export const withGatewayProxy = async (
callback: (port: number) => Promise<void>,
export const withGatewayProxy = async <T>(
callback: (port: number) => Promise<T>,
options: ProxyOptions
): Promise<void> => {
): Promise<T> => {
const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options;
// Setup the proxy server
@@ -339,7 +371,7 @@ export const withGatewayProxy = async (
try {
// Execute the callback with the allocated port
await callback(port);
return await callback(port);
} catch (err) {
const proxyErrorMessage = getProxyError();
if (proxyErrorMessage) {

View File

@@ -32,13 +32,13 @@ export const buildFindFilter =
<R extends object = object>(
{ $in, $notNull, $search, $complex, ...filter }: TFindFilter<R>,
tableName?: TableName,
excludeKeys?: Array<keyof R>
excludeKeys?: string[]
) =>
(bd: Knex.QueryBuilder<R, R>) => {
const processedFilter = tableName
? Object.fromEntries(
Object.entries(filter)
.filter(([key]) => !excludeKeys || !excludeKeys.includes(key as keyof R))
.filter(([key]) => !excludeKeys || !excludeKeys.includes(key))
.map(([key, value]) => [`${tableName}.${key}`, value])
)
: filter;

View File

@@ -32,7 +32,6 @@ import { externalKmsServiceFactory } from "@app/ee/services/external-kms/externa
import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal";
import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal";
import { projectGatewayDALFactory } from "@app/ee/services/gateway/project-gateway-dal";
import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal";
import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";
import { groupDALFactory } from "@app/ee/services/group/group-dal";
@@ -439,7 +438,6 @@ export const registerRoutes = async (
const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db);
const gatewayDAL = gatewayDALFactory(db);
const projectGatewayDAL = projectGatewayDALFactory(db);
const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db);
const githubOrgSyncDAL = githubOrgSyncDALFactory(db);
@@ -1422,12 +1420,24 @@ export const registerRoutes = async (
identityUaDAL,
licenseService
});
const gatewayService = gatewayServiceFactory({
permissionService,
gatewayDAL,
kmsService,
licenseService,
orgGatewayConfigDAL,
keyStore
});
const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({
identityKubernetesAuthDAL,
identityOrgMembershipDAL,
identityAccessTokenDAL,
permissionService,
licenseService,
gatewayService,
gatewayDAL,
kmsService
});
const identityGcpAuthService = identityGcpAuthServiceFactory({
@@ -1490,16 +1500,6 @@ export const registerRoutes = async (
identityDAL
});
const gatewayService = gatewayServiceFactory({
permissionService,
gatewayDAL,
kmsService,
licenseService,
orgGatewayConfigDAL,
keyStore,
projectGatewayDAL
});
const dynamicSecretProviders = buildDynamicSecretProviders({
gatewayService
});
@@ -1521,7 +1521,7 @@ export const registerRoutes = async (
permissionService,
licenseService,
kmsService,
projectGatewayDAL,
gatewayDAL,
resourceMetadataDAL
});

View File

@@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
pitVersionLimit: true,
kmsCertificateKeyId: true,
auditLogsRetentionDays: true,
hasDeleteProtection: true
hasDeleteProtection: true,
secretSharing: true
});
export const SanitizedTagSchema = SecretTagsSchema.pick({

View File

@@ -131,8 +131,8 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey),
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey),
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
})
}
@@ -518,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
})
}

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { IdentityKubernetesAuthsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, KUBERNETES_AUTH } from "@app/lib/api-docs";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
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";
@@ -21,7 +22,8 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick(
kubernetesHost: true,
allowedNamespaces: true,
allowedNames: true,
allowedAudience: true
allowedAudience: true,
gatewayId: true
}).extend({
caCert: z.string(),
tokenReviewerJwt: z.string().optional().nullable()
@@ -100,12 +102,32 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
}),
body: z
.object({
kubernetesHost: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.kubernetesHost),
kubernetesHost: z
.string()
.trim()
.min(1)
.describe(KUBERNETES_AUTH.ATTACH.kubernetesHost)
.refine(
(val) =>
characterValidator([
CharacterType.Alphabets,
CharacterType.Numbers,
CharacterType.Colon,
CharacterType.Period,
CharacterType.ForwardSlash,
CharacterType.Hyphen
])(val),
{
message:
"Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes."
}
),
caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert),
tokenReviewerJwt: z.string().trim().optional().describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt),
allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation
allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames),
allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience),
gatewayId: z.string().uuid().optional().nullable().describe(KUBERNETES_AUTH.ATTACH.gatewayId),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
@@ -199,12 +221,36 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
}),
body: z
.object({
kubernetesHost: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.kubernetesHost),
kubernetesHost: z
.string()
.trim()
.min(1)
.optional()
.describe(KUBERNETES_AUTH.UPDATE.kubernetesHost)
.refine(
(val) => {
if (!val) return true;
return characterValidator([
CharacterType.Alphabets,
CharacterType.Numbers,
CharacterType.Colon,
CharacterType.Period,
CharacterType.ForwardSlash,
CharacterType.Hyphen
])(val);
},
{
message:
"Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes."
}
),
caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert),
tokenReviewerJwt: z.string().trim().nullable().optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt),
allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation
allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames),
allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience),
gatewayId: z.string().uuid().optional().nullable().describe(KUBERNETES_AUTH.UPDATE.gatewayId),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()

View File

@@ -275,6 +275,23 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
},
{ message: "Duration value must be at least 1" }
)
.optional(),
secretsProductEnabled: z.boolean().optional(),
pkiProductEnabled: z.boolean().optional(),
kmsProductEnabled: z.boolean().optional(),
sshProductEnabled: z.boolean().optional(),
scannerProductEnabled: z.boolean().optional(),
shareSecretsProductEnabled: z.boolean().optional(),
maxSharedSecretLifetime: z
.number()
.min(300, "Max Shared Secret lifetime cannot be under 5 minutes")
.max(2592000, "Max Shared Secret lifetime cannot exceed 30 days")
.optional(),
maxSharedSecretViewLimit: z
.number()
.min(1, "Max Shared Secret view count cannot be lower than 1")
.max(1000, "Max Shared Secret view count cannot exceed 1000")
.nullable()
.optional()
}),
response: {

View File

@@ -346,7 +346,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
})
.optional()
.describe(PROJECTS.UPDATE.slug)
.describe(PROJECTS.UPDATE.slug),
secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing)
}),
response: {
200: z.object({
@@ -366,7 +367,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
description: req.body.description,
autoCapitalization: req.body.autoCapitalization,
hasDeleteProtection: req.body.hasDeleteProtection,
slug: req.body.slug
slug: req.body.slug,
secretSharing: req.body.secretSharing
},
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,

View File

@@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}),
body: z.object({
hashedHex: z.string().min(1).optional(),
password: z.string().optional()
password: z.string().optional(),
email: z.string().optional(),
hash: z.string().optional()
}),
response: {
200: z.object({
@@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
sharedSecretId: req.params.id,
hashedHex: req.body.hashedHex,
password: req.body.password,
orgId: req.permission?.orgId
orgId: req.permission?.orgId,
email: req.body.email,
hash: req.body.hash
});
if (sharedSecret.secret?.orgId) {
@@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
secretValue: z.string(),
expiresAt: z.string(),
expiresAfterViews: z.number().min(1).optional(),
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
emails: z.string().email().array().max(100).optional()
}),
response: {
200: z.object({

View File

@@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
kmsService,
kmsId
}: TBuildCertificateChainDTO) => {
if (!encryptedCertificateChain && (!caCert || !caCertChain)) {
if (!encryptedCertificateChain && !caCert) {
return null;
}

View File

@@ -29,6 +29,7 @@ import {
TGetCertPrivateKeyDTO,
TRevokeCertDTO
} from "./certificate-types";
import { NotFoundError } from "@app/lib/errors";
type TCertificateServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
@@ -337,18 +338,27 @@ export const certificateServiceFactory = ({
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
});
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: ca.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
let privateKey: string | null = null;
try {
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: ca.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
privateKey = certPrivateKey;
} catch (e) {
// Skip NotFound errors but throw all others
if (!(e instanceof NotFoundError)) {
throw e;
}
}
return {
certificate,
certificateChain,
privateKey: certPrivateKey,
privateKey,
serialNumber,
cert,
ca

View File

@@ -4,8 +4,14 @@ import https from "https";
import jwt from "jsonwebtoken";
import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas";
import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import {
OrgPermissionGatewayActions,
OrgPermissionIdentityActions,
OrgPermissionSubjects
} from "@app/ee/services/permission/org-permission";
import {
constructPermissionErrorMessage,
validatePrivilegeChangeOperation
@@ -13,6 +19,7 @@ import {
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import { withGatewayProxy } from "@app/lib/gateway";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -43,6 +50,8 @@ type TIdentityKubernetesAuthServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayService: TGatewayServiceFactory;
gatewayDAL: Pick<TGatewayDALFactory, "find">;
};
export type TIdentityKubernetesAuthServiceFactory = ReturnType<typeof identityKubernetesAuthServiceFactory>;
@@ -53,8 +62,45 @@ export const identityKubernetesAuthServiceFactory = ({
identityAccessTokenDAL,
permissionService,
licenseService,
gatewayService,
gatewayDAL,
kmsService
}: TIdentityKubernetesAuthServiceFactoryDep) => {
const $gatewayProxyWrapper = async <T>(
inputs: {
gatewayId: string;
targetHost: string;
targetPort: number;
},
gatewayCallback: (host: string, port: number) => Promise<T>
): Promise<T> => {
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
const callbackResult = await withGatewayProxy(
async (port) => {
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
const res = await gatewayCallback("https://localhost", port);
return res;
},
{
targetHost: inputs.targetHost,
targetPort: inputs.targetPort,
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
return callbackResult;
};
const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => {
const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId });
if (!identityKubernetesAuth) {
@@ -92,46 +138,65 @@ export const identityKubernetesAuthServiceFactory = ({
tokenReviewerJwt = serviceAccountJwt;
}
const { data } = await axios
.post<TCreateTokenReviewResponse>(
`${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`,
{
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
}
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenReviewerJwt}`
},
signal: AbortSignal.timeout(10000),
timeout: 10000,
// if ca cert, rejectUnauthorized: true
httpsAgent: new https.Agent({
ca: caCert,
rejectUnauthorized: !!caCert
})
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
const { message } = err?.response?.data as unknown as { message?: string };
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
const baseUrl = port ? `${host}:${port}` : host;
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
const res = await axios
.post<TCreateTokenReviewResponse>(
`${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`,
{
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
}
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenReviewerJwt}`
},
signal: AbortSignal.timeout(10000),
timeout: 10000,
// if ca cert, rejectUnauthorized: true
httpsAgent: new https.Agent({
ca: caCert,
rejectUnauthorized: !!caCert
})
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
const { message } = err?.response?.data as unknown as { message?: string };
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
}
throw err;
});
throw err;
});
return res.data;
};
const [k8sHost, k8sPort] = identityKubernetesAuth.kubernetesHost.split(":");
const data = identityKubernetesAuth.gatewayId
? await $gatewayProxyWrapper(
{
gatewayId: identityKubernetesAuth.gatewayId,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443
},
tokenReviewCallback
)
: await tokenReviewCallback();
if ("error" in data.status)
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" });
@@ -222,6 +287,7 @@ export const identityKubernetesAuthServiceFactory = ({
const attachKubernetesAuth = async ({
identityId,
gatewayId,
kubernetesHost,
caCert,
tokenReviewerJwt,
@@ -280,6 +346,27 @@ export const identityKubernetesAuthServiceFactory = ({
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
if (gatewayId) {
const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId });
if (!gateway) {
throw new NotFoundError({
message: `Gateway with ID ${gatewayId} not found`
});
}
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionGatewayActions.AttachGateways,
OrgPermissionSubjects.Gateway
);
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: identityMembershipOrg.orgId
@@ -296,6 +383,7 @@ export const identityKubernetesAuthServiceFactory = ({
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
gatewayId,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps),
encryptedKubernetesTokenReviewerJwt: tokenReviewerJwt
? encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob
@@ -318,6 +406,7 @@ export const identityKubernetesAuthServiceFactory = ({
allowedNamespaces,
allowedNames,
allowedAudience,
gatewayId,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
@@ -373,11 +462,33 @@ export const identityKubernetesAuthServiceFactory = ({
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
if (gatewayId) {
const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId });
if (!gateway) {
throw new NotFoundError({
message: `Gateway with ID ${gatewayId} not found`
});
}
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionGatewayActions.AttachGateways,
OrgPermissionSubjects.Gateway
);
}
const updateQuery: TIdentityKubernetesAuthsUpdate = {
kubernetesHost,
allowedNamespaces,
allowedNames,
allowedAudience,
gatewayId,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,

View File

@@ -13,6 +13,7 @@ export type TAttachKubernetesAuthDTO = {
allowedNamespaces: string;
allowedNames: string;
allowedAudience: string;
gatewayId?: string | null;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
@@ -28,6 +29,7 @@ export type TUpdateKubernetesAuthDTO = {
allowedNamespaces?: string;
allowedNames?: string;
allowedAudience?: string;
gatewayId?: string | null;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;

View File

@@ -18,5 +18,13 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
privilegeUpgradeInitiatedByUsername: true,
privilegeUpgradeInitiatedAt: true,
bypassOrgAuthEnabled: true,
userTokenExpiration: true
userTokenExpiration: true,
secretsProductEnabled: true,
pkiProductEnabled: true,
kmsProductEnabled: true,
sshProductEnabled: true,
scannerProductEnabled: true,
shareSecretsProductEnabled: true,
maxSharedSecretLifetime: true,
maxSharedSecretViewLimit: true
});

View File

@@ -355,7 +355,15 @@ export const orgServiceFactory = ({
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled,
userTokenExpiration
userTokenExpiration,
secretsProductEnabled,
pkiProductEnabled,
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -457,7 +465,15 @@ export const orgServiceFactory = ({
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled,
userTokenExpiration
userTokenExpiration,
secretsProductEnabled,
pkiProductEnabled,
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;

View File

@@ -75,6 +75,14 @@ export type TUpdateOrgDTO = {
allowSecretSharingOutsideOrganization: boolean;
bypassOrgAuthEnabled: boolean;
userTokenExpiration: string;
secretsProductEnabled: boolean;
pkiProductEnabled: boolean;
kmsProductEnabled: boolean;
sshProductEnabled: boolean;
scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
}>;
} & TOrgPermission;

View File

@@ -658,7 +658,8 @@ export const projectServiceFactory = ({
autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization,
hasDeleteProtection: update.hasDeleteProtection,
slug: update.slug
slug: update.slug,
secretSharing: update.secretSharing
});
return updatedProject;

View File

@@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
autoCapitalization?: boolean;
hasDeleteProtection?: boolean;
slug?: string;
secretSharing?: boolean;
};
} & Omit<TProjectPermission, "projectId">;

View File

@@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { SecretSharingAccessType } from "@app/lib/types";
import { isUuidV4 } from "@app/lib/validator";
@@ -60,7 +61,9 @@ export const secretSharingServiceFactory = ({
}
const fiveMins = 5 * 60 * 1000;
if (expiryTime - currentTime < fiveMins) {
// 1 second buffer
if (expiryTime - currentTime + 1000 < fiveMins) {
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
}
};
@@ -76,8 +79,11 @@ export const secretSharingServiceFactory = ({
password,
accessType,
expiresAt,
expiresAfterViews
expiresAfterViews,
emails
}: TCreateSharedSecretDTO) => {
const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt);
@@ -93,7 +99,46 @@ export const secretSharingServiceFactory = ({
throw new BadRequestError({ message: "Shared secret value too long" });
}
// Check lifetime is within org allowance
const expiresAtTimestamp = new Date(expiresAt).getTime();
const lifetime = expiresAtTimestamp - new Date().getTime();
// org.maxSharedSecretLifetime is in seconds
if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) {
throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" });
}
// Check max view count is within org allowance
if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) {
throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" });
}
const encryptWithRoot = kmsService.encryptWithRootKey();
let salt: string | undefined;
let encryptedSalt: Buffer | undefined;
const orgEmails = [];
if (emails && emails.length > 0) {
const allOrgMembers = await orgDAL.findAllOrgMembers(orgId);
// Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org
for (const email of emails) {
if (allOrgMembers.some((v) => v.user.email === email)) {
orgEmails.push(email);
// If the email is not part of the org, but access type / org settings require it
} else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) {
throw new BadRequestError({
message: "Organization does not allow sharing secrets to members outside of this organization"
});
}
}
// Generate salt for signing email hashes (if emails are provided)
salt = crypto.randomBytes(32).toString("hex");
encryptedSalt = encryptWithRoot(Buffer.from(salt));
}
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const id = crypto.randomBytes(32).toString("hex");
@@ -112,11 +157,45 @@ export const secretSharingServiceFactory = ({
expiresAfterViews,
userId: actorId,
orgId,
accessType
accessType,
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
encryptedSalt
});
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
// Loop through recipients and send out emails with unique access links
if (emails && salt) {
const user = await userDAL.findById(actorId);
if (!user) {
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
}
for await (const email of emails) {
try {
const hmac = crypto.createHmac("sha256", salt).update(email);
const hash = hmac.digest("hex");
// Only show the username to emails which are part of the organization
const respondentUsername = orgEmails.includes(email) ? user.username : undefined;
await smtpService.sendMail({
recipients: [email],
subjectLine: "A secret has been shared with you",
substitutions: {
name,
respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}`
},
template: SmtpTemplates.SecretRequestCompleted
});
} catch (e) {
logger.error(e, "Failed to send shared secret URL to a recipient's email.");
}
}
}
return { id: idToReturn };
};
@@ -390,8 +469,15 @@ export const secretSharingServiceFactory = ({
});
};
/** Get's password-less secret. validates all secret's requested (must be fresh). */
const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => {
/** Gets password-less secret. validates all secret's requested (must be fresh). */
const getSharedSecretById = async ({
sharedSecretId,
hashedHex,
orgId,
password,
email,
hash
}: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
id: sharedSecretId,
@@ -438,6 +524,32 @@ export const secretSharingServiceFactory = ({
});
}
const decryptWithRoot = kmsService.decryptWithRootKey();
if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) {
// Verify both params were passed
if (!email || !hash) {
throw new BadRequestError({
message: "This secret is email protected. Parameters must include email and hash."
});
// Verify that email is authorized to view shared secret
} else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) {
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
// Verify that hash matches
} else {
const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString();
const hmac = crypto.createHmac("sha256", salt).update(email);
const rebuiltHash = hmac.digest("hex");
if (rebuiltHash !== hash) {
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
}
}
}
// Password checks
const isPasswordProtected = Boolean(sharedSecret.password);
const hasProvidedPassword = Boolean(password);
if (isPasswordProtected) {
@@ -452,7 +564,6 @@ export const secretSharingServiceFactory = ({
// If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption.
let decryptedSecretValue: Buffer | undefined;
if (sharedSecret.encryptedSecret) {
const decryptWithRoot = kmsService.decryptWithRootKey();
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
}

View File

@@ -22,6 +22,7 @@ export type TSharedSecretPermission = {
accessType?: SecretSharingAccessType;
name?: string;
password?: string;
emails?: string[];
};
export type TCreatePublicSharedSecretDTO = {
@@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = {
hashedHex?: string;
orgId?: string;
password?: string;
// For secrets shared with specific emails
email?: string;
hash?: string;
};
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {

View File

@@ -28,9 +28,9 @@ const BaseSyncOptionsSchema = <T extends AnyZodObject | undefined = undefined>({
keySchema: z
.string()
.optional()
.refine((val) => !val || new RE2(/^(?:[a-zA-Z0-9\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9\-/]*)$/).test(val), {
.refine((val) => !val || new RE2(/^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/).test(val), {
message:
"Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, slashes, and the {{secretKey}} placeholder."
"Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder."
})
.describe(SecretSyncs.SYNC_OPTIONS(destination).keySchema),
disableSecretDeletion: z.boolean().optional().describe(SecretSyncs.SYNC_OPTIONS(destination).disableSecretDeletion)

View File

@@ -884,6 +884,12 @@ func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId
if err != nil {
log.Error().Msgf("unable to process template because %v", err)
// case: if exit-after-auth is true, it should exit the agent once an error on secret fetching occurs with the appropriate exit code (1)
// previous behavior would exit after 25 sec with status code 0, even if this step errors
if tm.exitAfterAuth {
os.Exit(1)
}
} else {
if (existingEtag != currentEtag) || firstRun {

View File

@@ -6,9 +6,14 @@ description: "The guide to spending money at Infisical."
Fairly frequently, you might run into situations when you need to spend company money.
<Note>
Please spend money in a way that you think is in the best interest of the company.
</Note>
# Expensing Meals
As a perk of working at Infisical, we cover some of your meal expenses.
HQ team members: meals and unlimited snacks are provided on-site at no cost.
Remote team members: a food stipend is allocated based on location.
# Trivial expenses
@@ -18,6 +23,10 @@ This means expenses that are:
1. Non-recurring AND less than $75/month in total.
2. Recurring AND less than $20/month.
<Note>
Please spend money in a way that you think is in the best interest of the company.
</Note>
## Saving receipts
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.

View File

@@ -158,14 +158,4 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t
To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list.
![Gateway List](../../../images/platform/gateways/gateway-list.png)
</Step>
<Step title="Link Gateway to Projects">
To enable Infisical features like dynamic secrets or secret rotation to access private resources through the Gateway, you need to link the Gateway to the relevant projects.
Start by accessing the **Gateway settings** then locate the Gateway in the list, click the options menu (**:**), and select **Edit Details**.
![Edit Gateway Option](../../../images/platform/gateways/edit-gateway.png)
In the edit modal that appears, choose the projects you want the Gateway to access and click **Save** to confirm your selections.
![Project Assignment Modal](../../../images/platform/gateways/assign-project.png)
Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project.
</Step>
</Steps>

View File

@@ -20,6 +20,7 @@ The **Settings** page lets you manage information about your organization includ
- **Slug**: The slug of your organization.
- **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified.
- **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization.
- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes.
![organization settings general](../../images/platform/organization/organization-settings-general.png)
@@ -43,7 +44,7 @@ In the **Organization Roles** tab, you can edit current or create new custom rol
<Info>
Note that Role-Based Access Management (RBAC) is partly a paid feature.
Infisical provides immutable roles like `admin`, `member`, etc.
at the organization and project level for free.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

After

Width:  |  Height:  |  Size: 993 KiB

View File

@@ -97,24 +97,22 @@ via the UI or API for the third-party service you intend to sync secrets to.
## Key Schemas
Key Schemas let you control how Infisical names your secret keys when syncing to external destinations. This makes it clear which secrets are managed by Infisical and prevents accidental changes to unrelated secrets.
Key Schemas transform your secret keys by applying a prefix, suffix, or format pattern during sync to external destinations. This makes it clear which secrets are managed by Infisical and prevents accidental changes to unrelated secrets.
A Key Schema adds a prefix, suffix, or format to your secrets before they reach the destination.
This example demonstrates key behavior if the schema was set to `INFISICAL_{{secretKey}}`:
**Example:**
- Infisical key: `SECRET_1`
- Schema: `INFISICAL_{{secretKey}}`
- Synced key: `INFISICAL_SECRET_1`
<div align="center">
```mermaid
graph LR
A[SECRET_1] --> T["Syncs as"] --> B[INFISICAL_SECRET_1]
style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px
style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px
style T fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:2px,font-size:12px
A[Infisical: **SECRET_1**] -->|Apply Schema| B[Destination: **INFISICAL_SECRET_1**]
style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px
style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px
```
</div>
<Note>
When importing secrets from the destination into infisical, the schema is stripped from imported secret keys.
When importing secrets from the destination into Infisical, the schema is stripped from imported secret keys.
</Note>

View File

@@ -218,3 +218,4 @@ Supports conditions and permission inversion
| `create-gateways` | Add new gateways to organization |
| `edit-gateways` | Modify existing gateway settings |
| `delete-gateways` | Remove gateways from organization |
| `attach-gateways` | Attach gateways to resources |

View File

@@ -144,6 +144,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
<a
href="https://infisical.com/docs/integrations/secret-syncs/overview#key-schemas"
target="_blank"
rel="noopener noreferrer"
>
Key Schema
</a>{" "}

View File

@@ -13,10 +13,11 @@ export const BaseSecretSyncSchema = <T extends AnyZodObject | undefined = undefi
.string()
.optional()
.refine(
(val) => !val || /^(?:[a-zA-Z0-9\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9\-/]*)$/.test(val),
(val) =>
!val || /^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/.test(val),
{
message:
"Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, slashes, and the {{secretKey}} placeholder."
"Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder."
}
)
});

View File

@@ -123,6 +123,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
return (
<SelectPrimitive.Item
{...props}
disabled={isDisabled}
className={twMerge(
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
isSelected && "bg-primary",

View File

@@ -12,7 +12,8 @@ export enum OrgGatewayPermissionActions {
CreateGateways = "create-gateways",
ListGateways = "list-gateways",
EditGateways = "edit-gateways",
DeleteGateways = "delete-gateways"
DeleteGateways = "delete-gateways",
AttachGateways = "attach-gateways"
}
export enum OrgPermissionSubjects {

View File

@@ -48,7 +48,7 @@ export const useGetCertBundle = (serialNumber: string) => {
certificate: string;
certificateChain: string;
serialNumber: string;
privateKey: string;
privateKey: string | null;
}>(`/api/v1/pki/certificates/${serialNumber}/bundle`);
return data;
},

View File

@@ -20,8 +20,8 @@ export const useDeleteGatewayById = () => {
export const useUpdateGatewayById = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, name, projectIds }: TUpdateGatewayDTO) => {
return apiRequest.patch(`/api/v1/gateways/${id}`, { name, projectIds });
mutationFn: ({ id, name }: TUpdateGatewayDTO) => {
return apiRequest.patch(`/api/v1/gateways/${id}`, { name });
},
onSuccess: () => {
queryClient.invalidateQueries(gatewaysQueryKeys.list());

View File

@@ -2,7 +2,7 @@ import { queryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TGateway, TListProjectGatewayDTO, TProjectGateway } from "./types";
import { TGateway } from "./types";
export const gatewaysQueryKeys = {
allKey: () => ["gateways"],
@@ -14,20 +14,5 @@ export const gatewaysQueryKeys = {
const { data } = await apiRequest.get<{ gateways: TGateway[] }>("/api/v1/gateways");
return data.gateways;
}
}),
listProjectGatewayKey: ({ projectId }: TListProjectGatewayDTO) => [
...gatewaysQueryKeys.allKey(),
"list",
{ projectId }
],
listProjectGateways: ({ projectId }: TListProjectGatewayDTO) =>
queryOptions({
queryKey: gatewaysQueryKeys.listProjectGatewayKey({ projectId }),
queryFn: async () => {
const { data } = await apiRequest.get<{ gateways: TProjectGateway[] }>(
`/api/v1/gateways/projects/${projectId}`
);
return data.gateways;
}
})
};

View File

@@ -11,39 +11,13 @@ export type TGateway = {
name: string;
id: string;
};
projects: {
name: string;
id: string;
slug: string;
}[];
};
export type TProjectGateway = {
id: string;
identityId: string;
name: string;
createdAt: string;
updatedAt: string;
issuedAt: string;
serialNumber: string;
heartbeat: string;
projectGatewayId: string;
identity: {
name: string;
id: string;
};
};
export type TUpdateGatewayDTO = {
id: string;
name?: string;
projectIds?: string[];
};
export type TDeleteGatewayDTO = {
id: string;
};
export type TListProjectGatewayDTO = {
projectId: string;
};

View File

@@ -840,7 +840,8 @@ export const useAddIdentityKubernetesAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
gatewayId
}) => {
const {
data: { identityKubernetesAuth }
@@ -856,7 +857,8 @@ export const useAddIdentityKubernetesAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
gatewayId
}
);
@@ -945,7 +947,8 @@ export const useUpdateIdentityKubernetesAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
gatewayId
}) => {
const {
data: { identityKubernetesAuth }
@@ -961,7 +964,8 @@ export const useUpdateIdentityKubernetesAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
gatewayId
}
);

View File

@@ -388,6 +388,7 @@ export type IdentityKubernetesAuth = {
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: IdentityTrustedIp[];
gatewayId?: string | null;
};
export type AddIdentityKubernetesAuthDTO = {
@@ -398,6 +399,7 @@ export type AddIdentityKubernetesAuthDTO = {
allowedNamespaces: string;
allowedNames: string;
allowedAudience: string;
gatewayId?: string | null;
caCert: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
@@ -415,6 +417,7 @@ export type UpdateIdentityKubernetesAuthDTO = {
allowedNamespaces?: string;
allowedNames?: string;
allowedAudience?: string;
gatewayId?: string | null;
caCert?: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;

View File

@@ -112,7 +112,15 @@ export const useUpdateOrg = () => {
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled,
userTokenExpiration
userTokenExpiration,
secretsProductEnabled,
pkiProductEnabled,
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
@@ -124,7 +132,15 @@ export const useUpdateOrg = () => {
selectedMfaMethod,
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled,
userTokenExpiration
userTokenExpiration,
secretsProductEnabled,
pkiProductEnabled,
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
});
},
onSuccess: () => {

View File

@@ -20,6 +20,14 @@ export type Organization = {
allowSecretSharingOutsideOrganization?: boolean;
userTokenExpiration?: string;
userRole: string;
secretsProductEnabled: boolean;
pkiProductEnabled: boolean;
kmsProductEnabled: boolean;
sshProductEnabled: boolean;
scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
};
export type UpdateOrgDTO = {
@@ -34,6 +42,14 @@ export type UpdateOrgDTO = {
allowSecretSharingOutsideOrganization?: boolean;
bypassOrgAuthEnabled?: boolean;
userTokenExpiration?: string;
secretsProductEnabled?: boolean;
pkiProductEnabled?: boolean;
kmsProductEnabled?: boolean;
sshProductEnabled?: boolean;
scannerProductEnabled?: boolean;
shareSecretsProductEnabled?: boolean;
maxSharedSecretViewLimit?: number | null;
maxSharedSecretLifetime?: number;
};
export type BillingDetails = {

View File

@@ -11,10 +11,13 @@ export const secretSharingKeys = {
allSecretRequests: () => ["secretRequests"] as const,
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
"shared-secret",
arg
],
getSecretById: (arg: {
id: string;
hashedHex: string | null;
password?: string;
email?: string;
hash?: string;
}) => ["shared-secret", arg],
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
};
@@ -70,20 +73,34 @@ export const useGetSecretRequests = ({
export const useGetActiveSharedSecretById = ({
sharedSecretId,
hashedHex,
password
password,
email,
hash
}: {
sharedSecretId: string;
hashedHex: string | null;
password?: string;
// For secrets shared to specific emails (optional)
email?: string;
hash?: string;
}) => {
return useQuery({
queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }),
queryKey: secretSharingKeys.getSecretById({
id: sharedSecretId,
hashedHex,
password,
email,
hash
}),
queryFn: async () => {
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
{
...(hashedHex && { hashedHex }),
password
password,
email,
hash
}
);

View File

@@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
expiresAt: Date;
expiresAfterViews?: number;
accessType?: SecretSharingAccessType;
emails?: string[];
};
export type TCreateSecretRequestRequestDTO = {

View File

@@ -277,13 +277,20 @@ export const useUpdateProject = () => {
const queryClient = useQueryClient();
return useMutation<Workspace, object, UpdateProjectDTO>({
mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
mutationFn: async ({
projectID,
newProjectName,
newProjectDescription,
newSlug,
secretSharing
}) => {
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
`/api/v1/workspace/${projectID}`,
{
name: newProjectName,
description: newProjectDescription,
slug: newSlug
slug: newSlug,
secretSharing
}
);
return data.workspace;

View File

@@ -37,6 +37,7 @@ export type Workspace = {
createdAt: string;
roles?: TProjectRole[];
hasDeleteProtection: boolean;
secretSharing: boolean;
};
export type WorkspaceEnv = {
@@ -73,9 +74,10 @@ export type CreateWorkspaceDTO = {
export type UpdateProjectDTO = {
projectID: string;
newProjectName: string;
newProjectName?: string;
newProjectDescription?: string;
newSlug?: string;
secretSharing?: boolean;
};
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };

View File

@@ -268,77 +268,91 @@ export const MinimizedOrgSidebar = () => {
</DropdownMenu>
</div>
<div className="space-y-1">
<Link to="/organization/secret-manager/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(
`/organization/${ProjectType.SecretManager}`
)
}
icon="sliding-carousel"
>
Secrets
</MenuIconButton>
)}
</Link>
<Link to="/organization/cert-manager/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(
`/organization/${ProjectType.CertificateManager}`
)
}
icon="note"
>
PKI
</MenuIconButton>
)}
</Link>
<Link to="/organization/kms/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(`/organization/${ProjectType.KMS}`)
}
icon="unlock"
>
KMS
</MenuIconButton>
)}
</Link>
<Link to="/organization/ssh/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(`/organization/${ProjectType.SSH}`)
}
icon="verified"
>
SSH
</MenuIconButton>
)}
</Link>
<div className="w-full bg-mineshaft-500" style={{ height: "1px" }} />
<Link to="/organization/secret-scanning">
{({ isActive }) => (
<MenuIconButton isSelected={isActive} icon="secret-scan">
Scanner
</MenuIconButton>
)}
</Link>
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<MenuIconButton isSelected={isActive} icon="lock-closed">
Share
</MenuIconButton>
)}
</Link>
{currentOrg.secretsProductEnabled && (
<Link to="/organization/secret-manager/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(
`/organization/${ProjectType.SecretManager}`
)
}
icon="sliding-carousel"
>
Secrets
</MenuIconButton>
)}
</Link>
)}
{currentOrg.pkiProductEnabled && (
<Link to="/organization/cert-manager/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(
`/organization/${ProjectType.CertificateManager}`
)
}
icon="note"
>
PKI
</MenuIconButton>
)}
</Link>
)}
{currentOrg.kmsProductEnabled && (
<Link to="/organization/kms/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(`/organization/${ProjectType.KMS}`)
}
icon="unlock"
>
KMS
</MenuIconButton>
)}
</Link>
)}
{currentOrg.sshProductEnabled && (
<Link to="/organization/ssh/overview">
{({ isActive }) => (
<MenuIconButton
isSelected={
isActive ||
window.location.pathname.startsWith(`/organization/${ProjectType.SSH}`)
}
icon="verified"
>
SSH
</MenuIconButton>
)}
</Link>
)}
{(currentOrg.scannerProductEnabled || currentOrg.shareSecretsProductEnabled) && (
<div className="w-full bg-mineshaft-500" style={{ height: "1px" }} />
)}
{currentOrg.scannerProductEnabled && (
<Link to="/organization/secret-scanning">
{({ isActive }) => (
<MenuIconButton isSelected={isActive} icon="secret-scan">
Scanner
</MenuIconButton>
)}
</Link>
)}
{currentOrg.shareSecretsProductEnabled && (
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<MenuIconButton isSelected={isActive} icon="lock-closed">
Share
</MenuIconButton>
)}
</Link>
)}
<div className="my-1 w-full bg-mineshaft-500" style={{ height: "1px" }} />
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger

View File

@@ -10,7 +10,7 @@ import { useNavigateToSelectOrganization } from "./Login.utils";
export const LoginPage = ({ isAdmin }: { isAdmin?: boolean }) => {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [step, setStep] = useState<number | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
@@ -36,6 +36,8 @@ export const LoginPage = ({ isAdmin }: { isAdmin?: boolean }) => {
if (isLoggedIn()) {
handleRedirects();
} else {
setStep(0);
}
}, []);

View File

@@ -35,7 +35,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
certificate: string;
certificateChain: string;
serialNumber: string;
privateKey?: string;
privateKey?: string | null;
}
| undefined = canReadPrivateKey ? bundleData : bodyData;
@@ -52,7 +52,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
serialNumber={data.serialNumber}
certificate={data.certificate}
certificateChain={data.certificateChain}
privateKey={data.privateKey}
privateKey={data.privateKey || undefined}
/>
) : (
<div />

View File

@@ -3,22 +3,32 @@ import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tab,
TabList,
TabPanel,
Tabs,
TextArea
TextArea,
Tooltip
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import {
gatewaysQueryKeys,
useAddIdentityKubernetesAuth,
useGetIdentityKubernetesAuth,
useUpdateIdentityKubernetesAuth
@@ -32,6 +42,7 @@ const schema = z
.object({
kubernetesHost: z.string().min(1),
tokenReviewerJwt: z.string().optional(),
gatewayId: z.string().optional().nullable(),
allowedNames: z.string(),
allowedNamespaces: z.string(),
allowedAudience: z.string(),
@@ -79,6 +90,8 @@ export const IdentityKubernetesAuthForm = ({
const { mutateAsync: updateMutateAsync } = useUpdateIdentityKubernetesAuth();
const [tabValue, setTabValue] = useState<IdentityFormTab>(IdentityFormTab.Configuration);
const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list());
const { data } = useGetIdentityKubernetesAuth(identityId ?? "", {
enabled: isUpdate
});
@@ -96,6 +109,7 @@ export const IdentityKubernetesAuthForm = ({
tokenReviewerJwt: "",
allowedNames: "",
allowedNamespaces: "",
gatewayId: "",
allowedAudience: "",
caCert: "",
accessTokenTTL: "2592000",
@@ -120,6 +134,7 @@ export const IdentityKubernetesAuthForm = ({
allowedNamespaces: data.allowedNamespaces,
allowedAudience: data.allowedAudience,
caCert: data.caCert,
gatewayId: data.gatewayId || null,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
@@ -157,6 +172,7 @@ export const IdentityKubernetesAuthForm = ({
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
gatewayId,
accessTokenTrustedIps
}: FormData) => {
try {
@@ -172,6 +188,7 @@ export const IdentityKubernetesAuthForm = ({
allowedAudience,
caCert,
identityId,
gatewayId: gatewayId || null,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
@@ -186,6 +203,7 @@ export const IdentityKubernetesAuthForm = ({
allowedNames: allowedNames || "",
allowedNamespaces: allowedNamespaces || "",
allowedAudience: allowedAudience || "",
gatewayId: gatewayId || null,
caCert: caCert || "",
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
@@ -217,6 +235,7 @@ export const IdentityKubernetesAuthForm = ({
[
"kubernetesHost",
"tokenReviewerJwt",
"gatewayId",
"accessTokenTTL",
"accessTokenMaxTTL",
"accessTokenNumUsesLimit",
@@ -280,6 +299,62 @@ export const IdentityKubernetesAuthForm = ({
</FormControl>
)}
/>
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
isOptional
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value as string}
onValueChange={(v) => {
if (v !== "") {
onChange(v);
}
}}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewayLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(null)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
<Controller
control={control}
name="allowedNames"

View File

@@ -32,7 +32,6 @@ import {
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
@@ -128,7 +127,6 @@ export const GatewayListPage = withPermission(
<Tr>
<Th className="w-1/3">Name</Th>
<Th>Cert Issued At</Th>
<Th>Projects</Th>
<Th>Identity</Th>
<Th>
Health Check
@@ -151,13 +149,6 @@ export const GatewayListPage = withPermission(
<Tr key={el.id}>
<Td>{el.name}</Td>
<Td>{format(new Date(el.issuedAt), "yyyy-MM-dd hh:mm:ss aaa")}</Td>
<Td>
{el.projects.map((projectDetails) => (
<Tag key={projectDetails.id} size="xs">
{projectDetails.name}
</Tag>
))}
</Td>
<Td>{el.identity.name}</Td>
<Td>
{el.heartbeat

View File

@@ -3,10 +3,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { useGetUserWorkspaces, useUpdateGatewayById } from "@app/hooks/api";
import { Button, FormControl, Input } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { useUpdateGatewayById } from "@app/hooks/api";
import { TGateway } from "@app/hooks/api/gateways/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
type Props = {
gatewayDetails: TGateway;
@@ -14,13 +14,7 @@ type Props = {
};
const schema = z.object({
name: z.string(),
projects: z
.object({
id: z.string(),
name: z.string()
})
.array()
name: z.string()
});
export type FormData = z.infer<typeof schema>;
@@ -38,20 +32,13 @@ export const EditGatewayDetailsModal = ({ gatewayDetails, onClose }: Props) => {
});
const updateGatewayById = useUpdateGatewayById();
// when gateway goes to other products switch to all
const { data: secretManagerWorkspaces, isLoading: isSecretManagerLoading } = useGetUserWorkspaces(
{
type: ProjectType.SecretManager
}
);
const onFormSubmit = ({ name, projects }: FormData) => {
const onFormSubmit = ({ name }: FormData) => {
if (isSubmitting) return;
updateGatewayById.mutate(
{
id: gatewayDetails.id,
name,
projectIds: projects.map((el) => el.id)
name
},
{
onSuccess: () => {
@@ -67,6 +54,16 @@ export const EditGatewayDetailsModal = ({ gatewayDetails, onClose }: Props) => {
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<NoticeBannerV2 className="mx-auto mb-4" title="Project Linking">
<p className="mt-1 text-xs text-mineshaft-300">
Since the 15th May 2025, all gateways are automatically available for use in all projects
and you no longer need to link them.
<br />
Organization members with the &quot;Attach Gateways&quot; permission can use gateways
anywhere within the organization.
</p>
</NoticeBannerV2>
<Controller
control={control}
name="name"
@@ -76,30 +73,6 @@ export const EditGatewayDetailsModal = ({ gatewayDetails, onClose }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="projects"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Projects"
tooltipText="Select the project(s) that you'd like to add this gateway to"
errorText={error?.message}
isError={Boolean(error)}
>
<FilterableSelect
options={secretManagerWorkspaces}
placeholder="Select projects..."
value={value}
onChange={onChange}
isMulti
isLoading={isSecretManagerLoading}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mt-4 flex items-center">
<Button className="mr-4" size="sm" type="submit" isLoading={isSubmitting}>
Update

View File

@@ -1,8 +1,10 @@
import { useMemo } from "react";
import { faBan, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQuery } from "@tanstack/react-query";
import { Badge, EmptyState, Spinner, Tooltip } from "@app/components/v2";
import { useGetIdentityKubernetesAuth } from "@app/hooks/api";
import { gatewaysQueryKeys, useGetIdentityKubernetesAuth } from "@app/hooks/api";
import { IdentityKubernetesAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm";
import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
@@ -16,8 +18,14 @@ export const ViewIdentityKubernetesAuthContent = ({
onDelete,
popUp
}: ViewAuthMethodProps) => {
const { data: gateways } = useQuery(gatewaysQueryKeys.list());
const { data, isPending } = useGetIdentityKubernetesAuth(identityId);
const selectedGateway = useMemo(() => {
return gateways?.find((gateway) => gateway.id === data?.gatewayId) || null;
}, [gateways, data?.gatewayId]);
if (isPending) {
return (
<div className="flex w-full items-center justify-center">
@@ -69,6 +77,7 @@ export const ViewIdentityKubernetesAuthContent = ({
>
{data.kubernetesHost}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Gateway">{selectedGateway?.name}</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay className="col-span-2" label="Token Reviewer JWT">
{data.tokenReviewerJwt ? (
<Tooltip

View File

@@ -69,7 +69,8 @@ const orgGatewayPermissionSchema = z
[OrgGatewayPermissionActions.ListGateways]: z.boolean().optional(),
[OrgGatewayPermissionActions.EditGateways]: z.boolean().optional(),
[OrgGatewayPermissionActions.DeleteGateways]: z.boolean().optional(),
[OrgGatewayPermissionActions.CreateGateways]: z.boolean().optional()
[OrgGatewayPermissionActions.CreateGateways]: z.boolean().optional(),
[OrgGatewayPermissionActions.AttachGateways]: z.boolean().optional()
})
.optional();

View File

@@ -27,7 +27,8 @@ const PERMISSION_ACTIONS = [
{ action: OrgGatewayPermissionActions.ListGateways, label: "List Gateways" },
{ action: OrgGatewayPermissionActions.CreateGateways, label: "Create Gateways" },
{ action: OrgGatewayPermissionActions.EditGateways, label: "Edit Gateways" },
{ action: OrgGatewayPermissionActions.DeleteGateways, label: "Delete Gateways" }
{ action: OrgGatewayPermissionActions.DeleteGateways, label: "Delete Gateways" },
{ action: OrgGatewayPermissionActions.AttachGateways, label: "Attach Gateways" }
] as const;
export const OrgGatewayPermissionRow = ({ isEditable, control, setValue }: Props) => {

View File

@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
allowSecretSharingOutsideOrganization={
currentOrg?.allowSecretSharingOutsideOrganization ?? true
}
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
/>
</ModalContent>
</Modal>

View File

@@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.org.title")} />
<PageHeader title="Secret Share Settings" />
<SecretSharingSettingsTabGroup />
</div>
</div>

View File

@@ -0,0 +1,294 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useEffect } from "react";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
// Helper function to convert duration to seconds
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
switch (unit) {
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
return 0;
}
};
// Helper function to convert seconds to form lifetime value and unit
const getFormLifetimeFromSeconds = (
totalSeconds: number | null | undefined
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
const DEFAULT_LIFETIME_VALUE = 30;
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
if (totalSeconds == null || totalSeconds <= 0) {
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
}
const secondsInDay = 24 * 60 * 60;
const secondsInHour = 60 * 60;
const secondsInMinute = 60;
if (totalSeconds % secondsInDay === 0) {
const value = totalSeconds / secondsInDay;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
}
if (totalSeconds % secondsInHour === 0) {
const value = totalSeconds / secondsInHour;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
}
if (totalSeconds % secondsInMinute === 0) {
const value = totalSeconds / secondsInMinute;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
}
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
};
const formSchema = z
.object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
maxLifetimeUnit: z.enum(["m", "h", "d"], {
invalid_type_error: "Please select a valid time unit"
}),
maxViewLimit: z.string()
})
.superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data;
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
// Check max limit
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
let message = "Duration exceeds maximum allowed limit";
if (maxLifetimeUnit === "m") {
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
} else if (maxLifetimeUnit === "h") {
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
} else if (maxLifetimeUnit === "d") {
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
});
type TForm = z.infer<typeof formSchema>;
const viewLimitOptions = [
{ label: "1", value: 1 },
{ label: "Unlimited", value: -1 }
];
export const OrgSecretShareLimitSection = () => {
const { mutateAsync } = useUpdateOrg();
const { currentOrg } = useOrganization();
const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
return {
maxLifetimeValue: initialLifetime.maxLifetimeValue,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "-1"
};
};
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit,
reset
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: getDefaultFormValues()
});
useEffect(() => {
if (currentOrg) {
reset(getDefaultFormValues());
}
}, [currentOrg, reset]);
const handleFormSubmit = async (formData: TForm) => {
try {
const maxSharedSecretLifetimeSeconds = durationToSeconds(
formData.maxLifetimeValue,
formData.maxLifetimeUnit
);
await mutateAsync({
orgId: currentOrg.id,
maxSharedSecretViewLimit:
formData.maxViewLimit === "-1" ? null : Number(formData.maxViewLimit),
maxSharedSecretLifetime: maxSharedSecretLifetimeSeconds
});
createNotification({
text: "Successfully updated secret share limits",
type: "success"
});
reset(formData);
} catch {
createNotification({
text: "Failed to update secret share limits",
type: "error"
});
}
};
// Units for the dropdown with readable labels
const timeUnits = [
{ value: "m", label: "Minutes" },
{ value: "h", label: "Hours" },
{ value: "d", label: "Days" }
];
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Secret Share Limits</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
These settings establish the maximum limits for all Shared Secret parameters within this
organization. Shared secrets cannot be created with values exceeding these limits.
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="maxLifetimeValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The max amount of time that can be set before the secret share link expires."
label="Max Lifetime"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? "" : parseInt(val, 10));
}}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="maxLifetimeUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex max-w-sm">
<Controller
control={control}
name="maxViewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={!isAllowed}
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isDirty || !isAllowed}
className="mt-4"
>
Save
</Button>
</form>
)}
</OrgPermissionCan>
</div>
);
};

View File

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

View File

@@ -1,9 +1,11 @@
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
export const SecretSharingSettingsGeneralTab = () => {
return (
<div className="w-full">
<SecretSharingAllowShareToAnyone />
<OrgSecretShareLimitSection />
</div>
);
};

View File

@@ -3,14 +3,18 @@ import { useOrgPermission } from "@app/context";
import { OrgDeleteSection } from "../OrgDeleteSection";
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
import { OrgNameChangeSection } from "../OrgNameChangeSection";
import { OrgProductSelectSection } from "../OrgProductSelectSection";
export const OrgGeneralTab = () => {
const { membership } = useOrgPermission();
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgNameChangeSection />
<OrgIncidentContactsSection />
{membership && membership.role === "admin" && <OrgDeleteSection />}
</div>
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgNameChangeSection />
<OrgIncidentContactsSection />
{membership && membership.role === "admin" && <OrgDeleteSection />}
</div>
{membership && membership.role === "admin" && <OrgProductSelectSection />}
</>
);
};

View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { createNotification } from "@app/components/notifications";
import { Switch } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
export const OrgProductSelectSection = () => {
const [toggledProducts, setToggledProducts] = useState<{
[key: string]: { name: string; enabled: boolean };
}>({
secretsProductEnabled: {
name: "Secret Management",
enabled: true
},
pkiProductEnabled: {
name: "Certificate Management",
enabled: true
},
kmsProductEnabled: {
name: "KMS",
enabled: true
},
sshProductEnabled: {
name: "SSH",
enabled: true
},
scannerProductEnabled: {
name: "Scanner",
enabled: true
},
shareSecretsProductEnabled: {
name: "Share Secrets",
enabled: true
}
});
const [isLoading, setIsLoading] = useState(false);
const { currentOrg } = useOrganization();
const { mutateAsync } = useUpdateOrg();
useEffect(() => {
Object.entries(currentOrg).forEach(([key, value]) => {
if (key in toggledProducts && typeof value === "boolean") {
setToggledProducts((products) => ({
...products,
[key]: { ...products[key], enabled: value }
}));
}
});
}, [currentOrg]);
const onProductToggle = async (value: boolean, key: string) => {
setIsLoading(true);
setToggledProducts((products) => ({
...products,
[key]: { ...products[key], enabled: value }
}));
try {
await mutateAsync({
orgId: currentOrg.id,
[key]: value
});
} catch (e) {
if (axios.isAxiosError(e)) {
const { message = "Something went wrong" } = e.response?.data as { message: string };
createNotification({
type: "error",
text: message
});
}
}
setIsLoading(false);
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-6 py-5">
<h2 className="text-xl font-semibold text-mineshaft-100">Enabled Products</h2>
<p className="mb-4 text-gray-400">
Select which products are available for your organization.
</p>
<div className="grid grid-cols-2 gap-3">
{Object.entries(toggledProducts).map(([key, product]) => (
<Switch
key={key}
id={`enable-${key}`}
isDisabled={isLoading}
onCheckedChange={(value) => onProductToggle(value, key)}
isChecked={product.enabled}
className="ml-0"
containerClassName="flex-row-reverse gap-3 w-fit"
>
{product.name}
</Switch>
))}
</div>
</div>
);
};

View File

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

View File

@@ -96,61 +96,59 @@ export const OrgUserAccessTokenLimitSection = () => {
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
<div className="flex max-w-md gap-4">
<div className="flex-1">
<Controller
control={control}
name="expirationValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Expiration value"
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="expirationValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Expiration value"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="expirationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="expirationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"

View File

@@ -1,7 +1,7 @@
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Button, ContentLoader } from "@app/components/v2";
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -60,102 +60,108 @@ export const OrgSsoTab = withPermission(
const shouldShowCreateIdentityProviderView =
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
const createIdentityProviderView = (shouldDisplaySection(LoginMethod.SAML) ||
const createIdentityProviderView =
shouldDisplaySection(LoginMethod.SAML) ||
shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP)) && (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
<p className="mb-2 mt-1 text-gray-400">
Connect your identity provider to simplify user management
</p>
{shouldDisplaySection(LoginMethod.SAML) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
(shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP)) &&
"border-b border-mineshaft-500 pb-4"
)}
>
<p className="text-lg text-gray-200">SAML</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addSSO");
}}
shouldDisplaySection(LoginMethod.LDAP) ? (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
<p className="mb-2 mt-1 text-gray-400">
Connect your identity provider to simplify user management
</p>
{shouldDisplaySection(LoginMethod.SAML) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
(shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP)) &&
"border-b border-mineshaft-500 pb-4"
)}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.OIDC) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
)}
>
<p className="text-lg text-gray-200">OIDC</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
return;
}
<p className="text-lg text-gray-200">SAML</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addOIDC");
}}
handlePopUpOpen("addSSO");
}}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.OIDC) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
)}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.LDAP) && (
<div className="mt-4 flex items-center justify-between">
<p className="text-lg text-gray-200">LDAP</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
return;
}
<p className="text-lg text-gray-200">OIDC</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addLDAP");
}}
>
Connect
</Button>
</div>
)}
</div>
<SSOModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<OIDCModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</>
);
handlePopUpOpen("addOIDC");
}}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.LDAP) && (
<div className="mt-4 flex items-center justify-between">
<p className="text-lg text-gray-200">LDAP</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
return;
}
handlePopUpOpen("addLDAP");
}}
>
Connect
</Button>
</div>
)}
</div>
<SSOModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<OIDCModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</>
) : (
<EmptyState title="" iconSize="2x" className="!pb-10 pt-14">
<p className="text-center text-lg">Single Sign-On (SSO) has been disabled</p>
<p className="text-center">Contact your server administrator</p>
</EmptyState>
);
if (areConfigsLoading) {
return <ContentLoader />;

View File

@@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
Infisical
</a>
<br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p>
</div>
</div>

View File

@@ -6,7 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
@@ -33,7 +45,24 @@ const schema = z.object({
secret: z.string().min(1),
expiresIn: z.string(),
viewLimit: z.string(),
accessType: z.nativeEnum(SecretSharingAccessType).optional()
accessType: z.nativeEnum(SecretSharingAccessType).optional(),
emails: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
const emails = val
.split(",")
.map((email) => email.trim())
.filter((email) => email !== "");
if (emails.length > 100) return false;
return emails.every((email) => z.string().email().safeParse(email).success);
},
{
message: "Must be a comma-separated list of valid emails (max 100) or empty."
}
)
});
export type FormData = z.infer<typeof schema>;
@@ -42,14 +71,18 @@ type Props = {
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
value?: string;
allowSecretSharingOutsideOrganization?: boolean;
maxSharedSecretLifetime?: number;
maxSharedSecretViewLimit?: number | null;
};
export const ShareSecretForm = ({
isPublic,
value,
allowSecretSharingOutsideOrganization = true
allowSecretSharingOutsideOrganization = true,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}: Props) => {
const [secretLink, setSecretLink] = useState("");
const [secretLink, setSecretLink] = useState<string | null>(null);
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
@@ -58,6 +91,15 @@ export const ShareSecretForm = ({
const privateSharedSecretCreator = useCreateSharedSecret();
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
// Note: maxSharedSecretLifetime is in seconds
const filteredExpiresInOptions = maxSharedSecretLifetime
? expiresInOptions.filter((v) => v.value / 1000 <= maxSharedSecretLifetime)
: expiresInOptions;
const filteredViewLimitOptions = maxSharedSecretViewLimit
? viewLimitOptions.filter((v) => v.value > 0 && v.value <= maxSharedSecretViewLimit)
: viewLimitOptions;
const {
control,
reset,
@@ -66,7 +108,10 @@ export const ShareSecretForm = ({
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
secret: value || ""
secret: value || "",
viewLimit: filteredViewLimitOptions[filteredViewLimitOptions.length - 1].value.toString(),
expiresIn:
filteredExpiresInOptions[Math.min(filteredExpiresInOptions.length - 1, 2)].value.toString()
}
});
@@ -76,32 +121,45 @@ export const ShareSecretForm = ({
secret,
expiresIn,
viewLimit,
accessType
accessType,
emails
}: FormData) => {
try {
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
const processedEmails = emails ? emails.split(",").map((e) => e.trim()) : undefined;
const { id } = await createSharedSecret.mutateAsync({
name,
password,
secretValue: secret,
expiresAt,
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
accessType
accessType,
emails: processedEmails
});
const link = `${window.location.origin}/shared/secret/${id}`;
if (processedEmails && processedEmails.length > 0) {
setSecretLink("");
createNotification({
text: `Shared secret link emailed to ${processedEmails.length} user(s).`,
type: "success"
});
} else {
const link = `${window.location.origin}/shared/secret/${id}`;
setSecretLink(link);
navigator.clipboard.writeText(link);
setCopyTextSecret("secret");
createNotification({
text: "Shared secret link copied to clipboard.",
type: "success"
});
}
setSecretLink(link);
reset();
navigator.clipboard.writeText(link);
setCopyTextSecret("secret");
createNotification({
text: "Shared secret link copied to clipboard.",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
@@ -111,152 +169,256 @@ export const ShareSecretForm = ({
}
};
const hasSecretLink = Boolean(secretLink);
return !hasSecretLink ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
{!isPublic && (
if (secretLink === null)
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
{!isPublic && (
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isOptional
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="API Key"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="name"
name="secret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name (Optional)"
label="Your Secret"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2"
isRequired
>
<Input
<textarea
placeholder="Enter sensitive data to share via an encrypted link..."
{...field}
placeholder="API Key"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
disabled={value !== undefined}
/>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="secret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Your Secret"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2"
isRequired
>
<textarea
placeholder="Enter sensitive data to share via an encrypted link..."
{...field}
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
disabled={value !== undefined}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error)}
errorText={error?.message}
isOptional
>
<Input
{...field}
placeholder="Password"
type="password"
autoComplete="new-password"
autoCorrect="off"
spellCheck="false"
aria-autocomplete="none"
data-form-type="other"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue="3600000"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expiresInOptions.map(({ label, value: expiresInValue }) => (
<SelectItem value={String(expiresInValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="viewLimit"
defaultValue="-1"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{!isPublic && (
<Controller
control={control}
name="accessType"
defaultValue={SecretSharingAccessType.Organization}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error)}
errorText={error?.message}
isOptional
>
<Input
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{allowSecretSharingOutsideOrganization && (
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
)}
<SelectItem value={SecretSharingAccessType.Organization}>
People within your organization
</SelectItem>
</Select>
placeholder="Password"
type="password"
autoComplete="new-password"
autoCorrect="off"
spellCheck="false"
aria-autocomplete="none"
data-form-type="other"
/>
</FormControl>
)}
/>
)}
<Button
className="mt-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Secret Link
</Button>
</form>
) : (
{!isPublic && (
<Controller
control={control}
name="accessType"
defaultValue={SecretSharingAccessType.Organization}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
helperText={
allowSecretSharingOutsideOrganization ? undefined : (
<span className="text-red-500">Feature enforced by organization</span>
)
}
errorText={error?.message}
isError={Boolean(error)}
>
<Switch
className={`ml-0 mr-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-primary ${!allowSecretSharingOutsideOrganization ? "opacity-50" : ""}`}
thumbClassName="bg-mineshaft-800"
containerClassName="flex-row-reverse w-fit"
isChecked={
field.value === SecretSharingAccessType.Organization ||
!allowSecretSharingOutsideOrganization
}
isDisabled={!allowSecretSharingOutsideOrganization}
onCheckedChange={(v) =>
onChange(
v ? SecretSharingAccessType.Organization : SecretSharingAccessType.Anyone
)
}
id="org-access-only"
>
Limit access to people within organization
</Switch>
</FormControl>
)}
/>
)}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Advanced Settings</div>
</AccordionTrigger>
<AccordionContent childrenClassName="p-0">
<Controller
control={control}
name="expiresIn"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expires In"
errorText={error?.message}
isError={Boolean(error)}
helperText={
expiresInOptions.length !== filteredExpiresInOptions.length ? (
<span className="text-yellow-500">
Limited to{" "}
{filteredExpiresInOptions[filteredExpiresInOptions.length - 1].label} by
organization
</span>
) : undefined
}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expiresInOptions.map(({ label, value: expiresInValue }) => (
<SelectItem
value={String(expiresInValue || "")}
key={label}
isDisabled={!filteredExpiresInOptions.some((v) => v.label === label)}
>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="viewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
helperText={
viewLimitOptions.length !== filteredViewLimitOptions.length ? (
<span className="text-yellow-500">
Limited to{" "}
{filteredViewLimitOptions[filteredViewLimitOptions.length - 1].label} by
organization
</span>
) : undefined
}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem
value={String(viewLimitValue || "")}
key={label}
isDisabled={!filteredViewLimitOptions.some((v) => v.label === label)}
>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{!isPublic && (
<Controller
control={control}
name="emails"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authorized Emails"
isOptional
tooltipText="Unique secret links will be emailed to each individual. The secret will only be accessible to those links."
tooltipClassName="max-w-sm"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="user1@example.com, user2@example.com"
autoComplete="off"
/>
</FormControl>
)}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex w-full justify-end">
<Button
className="mt-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Secret Link
</Button>
</div>
</form>
);
if (secretLink === "")
return (
<>
<div className="mt-1 flex w-full items-center justify-center gap-2">
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
<span>Shared secret link has been emailed to select users.</span>
</div>
<Button
className="mt-6 w-full bg-mineshaft-700 py-3 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => setSecretLink(null)}
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
>
Share Another Secret
</Button>
</>
);
return (
<>
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{secretLink}</p>
@@ -265,7 +427,7 @@ export const ShareSecretForm = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(secretLink);
navigator.clipboard.writeText(secretLink || "");
setCopyTextSecret("Copied");
}}
>
@@ -277,7 +439,7 @@ export const ShareSecretForm = ({
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => setSecretLink("")}
onClick={() => setSecretLink(null)}
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
>
Share Another Secret

View File

@@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
Infisical
</a>
<br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p>
</div>
</div>

View File

@@ -38,6 +38,14 @@ export const ViewSharedSecretByIDPage = () => {
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.key
});
const email = useSearch({
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.email
});
const hash = useSearch({
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.hash
});
const [password, setPassword] = useState<string>();
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
@@ -49,7 +57,9 @@ export const ViewSharedSecretByIDPage = () => {
} = useGetActiveSharedSecretById({
sharedSecretId: id,
hashedHex,
password
password,
email,
hash
});
const navigate = useNavigate();
@@ -57,15 +67,16 @@ export const ViewSharedSecretByIDPage = () => {
const isUnauthorized =
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
const isForbidden =
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 403;
const isInvalidCredential =
((error as AxiosError)?.response?.data as { message: string })?.message ===
"Invalid credentials";
const isEmailUnauthorized =
((error as AxiosError)?.response?.data as { message: string })?.message ===
"Email not authorized to view secret";
useEffect(() => {
if (isUnauthorized && !isInvalidCredential) {
if (isUnauthorized && !isInvalidCredential && !isEmailUnauthorized) {
// persist current URL in session storage so that we can come back to this after successful login
sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
@@ -85,10 +96,10 @@ export const ViewSharedSecretByIDPage = () => {
});
}
if (isForbidden) {
if (error) {
createNotification({
type: "error",
text: "You do not have access to this shared secret."
text: ((error as AxiosError)?.response?.data as { message: string })?.message
});
}
}, [error]);
@@ -195,7 +206,7 @@ export const ViewSharedSecretByIDPage = () => {
Infisical
</a>
<br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
</p>
</div>
</div>

View File

@@ -7,7 +7,9 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
const SharedSecretByIDPageQuerySchema = z.object({
key: z.string().catch("")
key: z.string().catch(""),
email: z.string().optional(),
hash: z.string().optional()
});
export const Route = createFileRoute("/shared/secret/$secretId")({

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Accordion,
AccordionContent,
@@ -18,9 +19,13 @@ import {
SecretInput,
Select,
SelectItem,
TextArea
TextArea,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
@@ -61,7 +66,7 @@ const formSchema = z.object({
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional(),
projectGatewayId: z.string().optional()
gatewayId: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
@@ -164,8 +169,6 @@ export const SqlDatabaseInputForm = ({
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const { currentWorkspace } = useWorkspace();
const {
control,
setValue,
@@ -193,9 +196,7 @@ export const SqlDatabaseInputForm = ({
});
const createDynamicSecret = useCreateDynamicSecret();
const { data: projectGateways, isPending: isProjectGatewaysLoading } = useQuery(
gatewaysQueryKeys.listProjectGateways({ projectId: currentWorkspace.id })
);
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const handleCreateDynamicSecret = async ({
name,
@@ -301,40 +302,55 @@ export const SqlDatabaseInputForm = ({
Configuration
</div>
<div>
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="provider.gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
/>
</OrgPermissionCan>
</div>
<div className="flex flex-col">
<div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div>

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Accordion,
AccordionContent,
@@ -17,9 +18,11 @@ import {
SecretInput,
Select,
SelectItem,
TextArea
TextArea,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { OrgPermissionSubjects } from "@app/context";
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
@@ -60,7 +63,7 @@ const formSchema = z.object({
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional(),
projectGatewayId: z.string().optional().nullable()
gatewayId: z.string().optional().nullable()
})
.partial(),
defaultTTL: z.string().superRefine((val, ctx) => {
@@ -147,15 +150,11 @@ export const EditDynamicSecretSqlProviderForm = ({
}
});
const { currentWorkspace } = useWorkspace();
const { data: projectGateways, isPending: isProjectGatewaysLoading } = useQuery(
gatewaysQueryKeys.listProjectGateways({ projectId: currentWorkspace.id })
);
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const updateDynamicSecret = useUpdateDynamicSecret();
const selectedProjectGatewayId = watch("inputs.projectGatewayId");
const isGatewayInActive =
projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1;
const selectedGatewayId = watch("inputs.gatewayId");
const isGatewayInActive = gateways?.findIndex((el) => el.id === selectedGatewayId) === -1;
const handleUpdateDynamicSecret = async ({
inputs,
@@ -177,7 +176,7 @@ export const EditDynamicSecretSqlProviderForm = ({
defaultTTL,
inputs: {
...inputs,
projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId
gatewayId: isGatewayInActive ? null : inputs.gatewayId
},
newName: newName === dynamicSecret.name ? undefined : newName,
metadata
@@ -250,45 +249,60 @@ export const EditDynamicSecretSqlProviderForm = ({
<div>
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
<div>
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="inputs.gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedGatewayId
? `Project Gateway ${selectedGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
/>
</OrgPermissionCan>
</div>
<div className="flex flex-col">
<Controller

View File

@@ -95,7 +95,6 @@ export const SecretDetailSidebar = ({
handleSecretShare
}: Props) => {
const {
register,
control,
watch,
handleSubmit,
@@ -104,7 +103,8 @@ export const SecretDetailSidebar = ({
formState: { isDirty, isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
values: secret
values: secret,
disabled: !secret
});
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([
@@ -398,11 +398,19 @@ export const SecretDetailSidebar = ({
autoFocus={false}
/>
<Tooltip
content="You don't have permission to view the secret value."
isDisabled={!secret?.secretValueHidden}
content={
!currentWorkspace.secretSharing
? "This project does not allow secret sharing."
: "You don't have permission to view the secret value."
}
isDisabled={
!secret?.secretValueHidden && currentWorkspace.secretSharing
}
>
<Button
isDisabled={secret?.secretValueHidden}
isDisabled={
secret?.secretValueHidden || !currentWorkspace.secretSharing
}
className="px-2 py-[0.43rem] font-normal"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faShare} />}
@@ -705,14 +713,25 @@ export const SecretDetailSidebar = ({
</FormControl>
</div>
</div>
<FormControl label="Comments & Notes">
<TextArea
className="border border-mineshaft-600 bg-bunker-800 text-sm"
{...register("comment")}
readOnly={isReadOnly}
rows={5}
/>
</FormControl>
<Controller
control={control}
name="comment"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Comments & Notes"
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<TextArea
className="border border-mineshaft-600 bg-bunker-800 text-sm"
readOnly={isReadOnly}
rows={5}
{...field}
/>
</FormControl>
)}
/>
<FormControl>
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
<div className="flex items-center justify-between px-2">
@@ -913,7 +932,9 @@ export const SecretDetailSidebar = ({
variant="outline_bg"
size="sm"
className="h-8 w-8 rounded-md"
onClick={() => setValue("value", secretValue)}
onClick={() =>
setValue("value", secretValue, { shouldDirty: true })
}
>
<FontAwesomeIcon icon={faArrowRotateRight} />
</IconButton>

View File

@@ -589,7 +589,7 @@ export const SecretItem = memo(
)}
</ProjectPermissionCan>
<IconButton
isDisabled={secret.secretValueHidden}
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"

View File

@@ -10,6 +10,7 @@ import { DeleteProjectSection } from "../DeleteProjectSection";
import { EnvironmentSection } from "../EnvironmentSection";
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
import { SecretSharingSection } from "../SecretSharingSection";
import { SecretTagsSection } from "../SecretTagsSection";
export const ProjectGeneralTab = () => {
@@ -22,6 +23,7 @@ export const ProjectGeneralTab = () => {
{isSecretManager && <EnvironmentSection />}
{isSecretManager && <SecretTagsSection />}
{isSecretManager && <AutoCapitalizationSection />}
{isSecretManager && <SecretSharingSection />}
{isSecretManager && <PointInTimeVersionLimitSection />}
<AuditLogsRetentionSection />
{isSecretManager && <BackfillSecretReferenceSecretion />}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Checkbox } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api/workspace/queries";
export const SecretSharingSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: updateProject } = useUpdateProject();
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async (state: boolean) => {
setIsLoading(true);
try {
if (!currentWorkspace?.id) {
setIsLoading(false);
return;
}
await updateProject({
projectID: currentWorkspace.id,
secretSharing: state
});
createNotification({
text: `Successfully ${state ? "enabled" : "disabled"} secret sharing for this project`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update secret sharing for this project",
type: "error"
});
} finally {
setIsLoading(false);
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-3 text-xl font-semibold">Allow Secret Sharing</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<Checkbox
className="data-[state=checked]:bg-primary"
id="secretSharing"
isDisabled={!isAllowed || isLoading}
isChecked={currentWorkspace?.secretSharing ?? true}
onCheckedChange={(state) => handleToggle(state as boolean)}
>
This feature enables your project members to securely share secrets.
</Checkbox>
</div>
)}
</ProjectPermissionCan>
</div>
);
};

View File

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