Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend-go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ type Config struct {
SecretScanningOrgWhitelist string
SecretScanningGitAppSlug string

// Cross-project secret sharing
CrossProjectSecretSharing string

// License
LicenseServerURL string
LicenseServerKey string
Expand Down Expand Up @@ -562,6 +565,9 @@ func LoadConfig() (*Config, error) {
Optional(&cfg.SecretScanningOrgWhitelist, "SECRET_SCANNING_ORG_WHITELIST", "").
Optional(&cfg.SecretScanningGitAppSlug, "SECRET_SCANNING_GIT_APP_SLUG", "infisical-radar").

// Cross-project secret sharing
Optional(&cfg.CrossProjectSecretSharing, "CROSS_PROJECT_SECRET_SHARING_ORG_WHITELIST", "").

// License
Optional(&cfg.LicenseServerURL, "LICENSE_SERVER_URL", "https://portal.infisical.com").
Optional(&cfg.LicenseServerKey, "LICENSE_SERVER_KEY", "").
Expand Down
2 changes: 2 additions & 0 deletions backend/src/@types/fastify.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-tem
import { TProjectServiceFactory } from "@app/services/project/project-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service";
import { TProjectGrantServiceFactory } from "@app/services/project-grant/project-grant-service";
import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service";
import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { TReminderServiceFactory } from "@app/services/reminder/reminder-types";
Expand Down Expand Up @@ -305,6 +306,7 @@ declare module "fastify" {
secretTag: TSecretTagServiceFactory;
secretValidationRule: TSecretValidationRuleServiceFactory;
secretImport: TSecretImportServiceFactory;
projectGrant: TProjectGrantServiceFactory;
projectBot: TProjectBotServiceFactory;
folder: TSecretFolderServiceFactory;
integration: TIntegrationServiceFactory;
Expand Down
8 changes: 8 additions & 0 deletions backend/src/@types/knex.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,9 @@ import {
TProjectEnvironments,
TProjectEnvironmentsInsert,
TProjectEnvironmentsUpdate,
TProjectFolderGrants,
TProjectFolderGrantsInsert,
TProjectFolderGrantsUpdate,
TProjectGateways,
TProjectGatewaysInsert,
TProjectGatewaysUpdate,
Expand Down Expand Up @@ -1179,6 +1182,11 @@ declare module "knex/types/tables" {
TSecretImportsInsert,
TSecretImportsUpdate
>;
[TableName.ProjectFolderGrant]: KnexOriginal.CompositeTableType<
TProjectFolderGrants,
TProjectFolderGrantsInsert,
TProjectFolderGrantsUpdate
>;
[TableName.Integration]: KnexOriginal.CompositeTableType<TIntegrations, TIntegrationsInsert, TIntegrationsUpdate>;
[TableName.Webhook]: KnexOriginal.CompositeTableType<TWebhooks, TWebhooksInsert, TWebhooksUpdate>;
[TableName.ServiceToken]: KnexOriginal.CompositeTableType<
Expand Down
31 changes: 31 additions & 0 deletions backend/src/db/migrations/20260624195421_project-folder-grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Knex } from "knex";

import { TableName } from "@app/db/schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";

export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.ProjectFolderGrant))) {
await knex.schema.createTable(TableName.ProjectFolderGrant, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("sourceProjectId").notNullable();
t.foreign("sourceProjectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("sourceFolderId").notNullable();
t.foreign("sourceFolderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.string("targetProjectId").notNullable();
t.foreign("targetProjectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.timestamps(true, true, true);

t.index(["sourceProjectId"]);
t.index(["sourceFolderId"]);
t.index(["targetProjectId"]);
t.unique(["sourceProjectId", "sourceFolderId", "targetProjectId"]);
});

await createOnUpdateTrigger(knex, TableName.ProjectFolderGrant);
}
}

export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.ProjectFolderGrant);
await knex.schema.dropTableIfExists(TableName.ProjectFolderGrant);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn(TableName.Organization, "allowCrossProjectSecretSharing");
if (!hasColumn) {
await knex.schema.table(TableName.Organization, (table) => {
table.boolean("allowCrossProjectSecretSharing").notNullable().defaultTo(false);
});
}
}

export async function down(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn(TableName.Organization, "allowCrossProjectSecretSharing");
if (hasColumn) {
await knex.schema.table(TableName.Organization, (table) => {
table.dropColumn("allowCrossProjectSecretSharing");
});
}
}
1 change: 1 addition & 0 deletions backend/src/db/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export * from "./pki-syncs";
export * from "./project-access-requests";
export * from "./project-bots";
export * from "./project-environments";
export * from "./project-folder-grants";
export * from "./project-gateways";
export * from "./project-keys";
export * from "./project-memberships";
Expand Down
1 change: 1 addition & 0 deletions backend/src/db/schemas/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export enum TableName {
SecretFolder = "secret_folders",
SecretFolderVersion = "secret_folder_versions",
SecretImport = "secret_imports",
ProjectFolderGrant = "project_folder_grants",
Snapshot = "secret_snapshots",
SnapshotSecret = "secret_snapshot_secrets",
SnapshotFolder = "secret_snapshot_folders",
Expand Down
1 change: 1 addition & 0 deletions backend/src/db/schemas/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const OrganizationsSchema = z.object({
parentOrgId: z.string().uuid().nullable().optional(),
rootOrgId: z.string().uuid().nullable().optional(),
blockDuplicateSecretSyncDestinations: z.boolean().default(false),
allowCrossProjectSecretSharing: z.boolean().default(false),
secretShareBrandConfig: z.unknown().nullable().optional(),
defaultCertManagerProjectId: z.string().nullable().optional()
});
Expand Down
21 changes: 21 additions & 0 deletions backend/src/db/schemas/project-folder-grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.

import { z } from "zod";

import { TImmutableDBKeys } from "./models";

export const ProjectFolderGrantsSchema = z.object({
id: z.string().uuid(),
sourceProjectId: z.string(),
sourceFolderId: z.string().uuid(),
targetProjectId: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});

export type TProjectFolderGrants = z.infer<typeof ProjectFolderGrantsSchema>;
export type TProjectFolderGrantsInsert = Omit<z.input<typeof ProjectFolderGrantsSchema>, TImmutableDBKeys>;
export type TProjectFolderGrantsUpdate = Partial<Omit<z.input<typeof ProjectFolderGrantsSchema>, TImmutableDBKeys>>;
17 changes: 14 additions & 3 deletions backend/src/ee/routes/v1/project-role-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { z } from "zod";
import { AccessScope, ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns";
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
import { ProjectPermissionSub, ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
import { ApiDocsTags, PROJECT_ROLE } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { canUseCrossProjectSecretSharing } from "@app/services/project-grant/project-grant-fns";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";

export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
Expand Down Expand Up @@ -54,7 +55,12 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions));
const isCrossProjectEnabled = canUseCrossProjectSecretSharing(req.permission.orgId);
const permissions = isCrossProjectEnabled
? req.body.permissions
: req.body.permissions.filter((p) => p.subject !== ProjectPermissionSub.ProjectGrant);

const stringifiedPermissions = JSON.stringify(packRules(permissions));

const role = await server.services.role.createRole({
permission: req.permission,
Expand Down Expand Up @@ -143,7 +149,12 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined;
const isCrossProjectEnabled = canUseCrossProjectSecretSharing(req.permission.orgId);
const filteredPermissions = req.body.permissions
? req.body.permissions.filter((p) => isCrossProjectEnabled || p.subject !== ProjectPermissionSub.ProjectGrant)
: undefined;

const stringifiedPermissions = filteredPermissions ? JSON.stringify(packRules(filteredPermissions)) : undefined;
const role = await server.services.role.updateRole({
permission: req.permission,
scopeData: {
Expand Down
30 changes: 28 additions & 2 deletions backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,11 @@ export enum EventType {
CREATE_HONEY_TOKEN = "create-honey-token",
UPDATE_HONEY_TOKEN = "update-honey-token",
REVOKE_HONEY_TOKEN = "revoke-honey-token",
TRIGGER_HONEY_TOKEN = "trigger-honey-token"
TRIGGER_HONEY_TOKEN = "trigger-honey-token",

// Project Grants
CREATE_PROJECT_GRANT = "create-project-grant",
DELETE_PROJECT_GRANT = "delete-project-grant"
}

// Maps each actor type to the JSONB key that holds the actor's primary ID in actorMetadata.
Expand Down Expand Up @@ -7502,6 +7506,26 @@ interface TriggerHoneyTokenEvent {
};
}

interface CreateProjectGrantEvent {
type: EventType.CREATE_PROJECT_GRANT;
metadata: {
grantId: string;
sourceProjectId: string;
targetProjectId: string;
environment: string;
secretPath: string;
};
}

interface DeleteProjectGrantEvent {
type: EventType.DELETE_PROJECT_GRANT;
metadata: {
grantId: string;
sourceProjectId: string;
targetProjectId: string;
};
}

export type Event =
| CreateSubOrganizationEvent
| UpdateSubOrganizationEvent
Expand Down Expand Up @@ -8179,4 +8203,6 @@ export type Event =
| RemoveIdentityFromGroupEvent
| AddGroupToProjectEvent
| UpdateGroupProjectMembershipEvent
| RemoveGroupFromProjectEvent;
| RemoveGroupFromProjectEvent
| CreateProjectGrantEvent
| DeleteProjectGrantEvent;
6 changes: 6 additions & 0 deletions backend/src/ee/services/permission/default-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionProjectGrantActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretApprovalRequestActions,
ProjectPermissionSecretEventActions,
Expand Down Expand Up @@ -496,6 +497,11 @@ const buildAdminPermissionRules = () => {

can([ProjectPermissionSecretApprovalRequestActions.Read], ProjectPermissionSub.SecretApprovalRequest);

can(
[ProjectPermissionProjectGrantActions.CreateGrant, ProjectPermissionProjectGrantActions.RevokeGrant],
ProjectPermissionSub.ProjectGrant
);

can([ProjectPermissionInsightsActions.Read], ProjectPermissionSub.Insights);

return rules;
Expand Down
44 changes: 43 additions & 1 deletion backend/src/ee/services/permission/project-permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ export enum ProjectPermissionApprovalRequestGrantActions {
Revoke = "revoke"
}

export enum ProjectPermissionProjectGrantActions {
CreateGrant = "create-grant",
RevokeGrant = "revoke-grant"
}

export enum ProjectPermissionSecretApprovalRequestActions {
Read = "read"
}
Expand Down Expand Up @@ -399,6 +404,7 @@ export enum ProjectPermissionSub {
Application = "certificate-application",
ApprovalRequests = "approval-requests",
ApprovalRequestGrants = "approval-request-grants",
ProjectGrant = "project-grant",
McpEndpoints = "mcp-endpoints",
McpServers = "mcp-servers",
McpActivityLogs = "mcp-activity-logs",
Expand Down Expand Up @@ -576,6 +582,11 @@ export type HoneyTokenSubjectFields = {
secretPath: string;
};

export type ProjectGrantSubjectFields = {
environment: string;
secretPath: string;
};

export type PamAccountSubjectFields = {
resourceName?: string;
resourceType?: string;
Expand Down Expand Up @@ -776,7 +787,11 @@ export type ProjectPermissionSet =
| [ProjectPermissionApprovalRequestActions, ProjectPermissionSub.ApprovalRequests]
| [ProjectPermissionApprovalRequestGrantActions, ProjectPermissionSub.ApprovalRequestGrants]
| [ProjectPermissionSecretApprovalRequestActions, ProjectPermissionSub.SecretApprovalRequest]
| [ProjectPermissionInsightsActions, ProjectPermissionSub.Insights];
| [ProjectPermissionInsightsActions, ProjectPermissionSub.Insights]
| [
ProjectPermissionProjectGrantActions,
ProjectPermissionSub.ProjectGrant | (ForcedSubject<ProjectPermissionSub.ProjectGrant> & ProjectGrantSubjectFields)
];

const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
Expand Down Expand Up @@ -974,6 +989,23 @@ const HoneyTokenConditionSchema = z
})
.partial();

const ProjectGrantConditionSchema = z
.object({
environment: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA
})
.partial();

const CommitsConditionSchema = z
.object({
environment: z.union([
Expand Down Expand Up @@ -1961,6 +1993,16 @@ const GeneralPermissionSchema = [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretApprovalRequestActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.ProjectGrant).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionProjectGrantActions).describe(
"Describe what action an entity can take."
),
conditions: ProjectGrantConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
})
];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SecretType, TSecrets, TSecretsV2 } from "@app/db/schemas";

Check warning on line 1 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Run autofix to sort these imports!
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
Expand Down Expand Up @@ -26,6 +26,7 @@
import { ReservedFolders } from "@app/services/secret-folder/secret-folder-types";
import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
import { fnSecretsFromImports, fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns";
import { TProjectGrantDALFactory } from "@app/services/project-grant/project-grant-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { getAllSecretReferences } from "@app/services/secret-v2-bridge/secret-reference-fns";
import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
Expand Down Expand Up @@ -90,6 +91,7 @@
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
projectGrantDAL: Pick<TProjectGrantDALFactory, "find">;
};

export type TSecretReplicationServiceFactory = ReturnType<typeof secretReplicationServiceFactory>;
Expand Down Expand Up @@ -137,6 +139,7 @@
secretV2BridgeDAL,
kmsService,
folderCommitService,
projectGrantDAL,
resourceMetadataDAL
}: TSecretReplicationServiceFactoryDep) => {
const $getReplicatedSecrets = (
Expand Down Expand Up @@ -229,21 +232,21 @@
const nonReplicatedDestinationImports = destinationSecretImports.filter(({ isReplication }) => !isReplication);
if (nonReplicatedDestinationImports.length) {
// keep calling sync secret for all the imports made
const importedFolderIds = unique(nonReplicatedDestinationImports, (i) => i.folderId).map(

Check failure on line 235 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .folderId on an `any` value

Check failure on line 235 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe return of an `any` typed value
({ folderId }) => folderId

Check failure on line 236 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe return of an `any` typed value
);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);

Check failure on line 238 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any[]` assigned to a parameter of type `string[]`
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
await Promise.all(
nonReplicatedDestinationImports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))

Check failure on line 242 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Computed name [folderId] resolves to an any value
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue?.[
uniqueSecretQueueKey(
foldersGroupedById[folderId][0]?.environmentSlug as string,

Check failure on line 248 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Computed name [folderId] resolves to an any value
foldersGroupedById[folderId][0]?.path as string

Check failure on line 249 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Computed name [folderId] resolves to an any value
)
]
)
Expand All @@ -251,9 +254,9 @@
secretQueueService.replicateSecrets({
projectId,
orgId,
secretPath: foldersGroupedById[folderId][0]?.path as string,

Check failure on line 257 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Computed name [folderId] resolves to an any value
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,

Check failure on line 258 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Computed name [folderId] resolves to an any value
environmentName: foldersGroupedById[folderId][0]?.environmentName as string,

Check failure on line 259 in backend/src/ee/services/secret-replication/secret-replication-service.ts

View workflow job for this annotation

GitHub Actions / Lint

Computed name [folderId] resolves to an any value
actorId,
actor,
_depth: depth + 1,
Expand Down Expand Up @@ -288,7 +291,16 @@
secretImportDAL,
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""),
viewSecretValue: true,
hasSecretAccess: () => true
hasSecretAccess: () => true,
projectId,
projectGrantDAL,
getProjectDecryptor: async (sourceProjectId: string) => {
const { decryptor: sourceDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: sourceProjectId
});
return (value) => (value ? sourceDecryptor({ cipherTextBlob: value }).toString() : "");
}
});
// secrets that gets replicated across imports
const sourceDecryptedLocalSecrets = sourceLocalSecrets.map((el) => ({
Expand Down
Loading
Loading