feat: External secrets access based on system roles (no-changelog) (#26646)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
This commit is contained in:
Irénée
2026-03-16 09:43:06 +00:00
committed by GitHub
parent 15f533dc0b
commit af0ac3ff3a
27 changed files with 858 additions and 126 deletions
+2
View File
@@ -206,6 +206,8 @@ export type {
SecretsProviderType,
SecretsProviderState,
SecretsProviderConnectionTestState,
SecretsProviderAccessRole,
ConnectionProjectSummary,
SecretProviderConnectionListItem,
SecretProviderConnection,
SecretProviderTypeResponse,
@@ -38,14 +38,22 @@ export type SecretsProviderConnectionTestState = z.infer<
//#region SHARED / NESTED TYPES
// ============================
export const secretsProviderAccessRoleSchema = z.enum([
'secretsProviderConnection:owner',
'secretsProviderConnection:user',
]);
export type SecretsProviderAccessRole = z.infer<typeof secretsProviderAccessRoleSchema>;
/**
* Owner of a secret provider connection
* Re-uses project schemas defined in project.schema.ts
*/
const projectSummarySchema = z.object({
const connectionProjectSummarySchema = z.object({
id: z.string(),
name: z.string(),
role: secretsProviderAccessRoleSchema.optional(),
});
export type ConnectionProjectSummary = z.infer<typeof connectionProjectSummarySchema>;
/**
* Secret with its name and optional credentials count
@@ -71,10 +79,11 @@ export const secretProviderConnectionSchema = z.object({
type: secretsProviderTypeSchema,
state: secretsProviderStateSchema,
isEnabled: z.boolean(),
projects: z.array(projectSummarySchema),
projects: z.array(connectionProjectSummarySchema),
settings: z.object({}).catchall(z.any()) satisfies z.ZodType<IDataObject>,
secretsCount: z.number(),
secrets: z.array(secretSummarySchema).optional(),
scopes: z.array(z.string()).optional(),
createdAt: z.string(),
updatedAt: z.string(),
});
+1 -1
View File
@@ -45,7 +45,7 @@ export class Role extends WithTimestamps {
/**
* Type of the role, e.g., global, project, or workflow.
*/
roleType: 'global' | 'project' | 'workflow' | 'credential';
roleType: 'global' | 'project' | 'workflow' | 'credential' | 'secretsProviderConnection';
@OneToMany('ProjectRelation', 'role')
projectRelations: ProjectRelation[];
@@ -1,7 +1,8 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectSecretsProviderAccess } from '../entities';
import type { SecretsProviderAccessRole } from '../entities';
@Service()
export class ProjectSecretsProviderAccessRepository extends Repository<ProjectSecretsProviderAccess> {
@@ -28,20 +29,27 @@ export class ProjectSecretsProviderAccessRepository extends Repository<ProjectSe
await this.delete({ secretsProviderConnectionId });
}
async setProjectAccess(secretsProviderConnectionId: number, projectIds: string[]): Promise<void> {
// Given we're deleting / re-adding we should probably do it in a single operation
async updateProjectAccess(
secretsProviderConnectionId: number,
projectIdsToRemove: string[],
entriesToAdd: Array<{
projectId: string;
role: SecretsProviderAccessRole;
}>,
): Promise<void> {
await this.manager.transaction(async (tx) => {
await tx.delete(ProjectSecretsProviderAccess, { secretsProviderConnectionId });
if (projectIdsToRemove.length > 0) {
await tx.delete(ProjectSecretsProviderAccess, {
secretsProviderConnectionId,
projectId: In(projectIdsToRemove),
});
}
if (projectIds.length > 0) {
const entries = projectIds.map((projectId) =>
this.create({
secretsProviderConnectionId,
projectId,
}),
if (entriesToAdd.length > 0) {
await tx.insert(
ProjectSecretsProviderAccess,
entriesToAdd.map((e) => this.create({ ...e, secretsProviderConnectionId })),
);
await tx.insert(ProjectSecretsProviderAccess, entries);
}
});
}
@@ -10,6 +10,7 @@ import {
CREDENTIALS_SHARING_SCOPE_MAP,
GLOBAL_SCOPE_MAP,
PROJECT_SCOPE_MAP,
SECRETS_PROVIDER_CONNECTION_SHARING_SCOPE_MAP,
WORKFLOW_SHARING_SCOPE_MAP,
} from './role-maps.ee';
import type { AllRolesMap, AllRoleTypes, Scope } from '../types.ee';
@@ -29,6 +30,8 @@ const ROLE_NAMES: Record<AllRoleTypes, string> = {
'credential:owner': 'Credential Owner',
'workflow:owner': 'Workflow Owner',
'workflow:editor': 'Workflow Editor',
'secretsProviderConnection:owner': 'Secrets Provider Connection Owner',
'secretsProviderConnection:user': 'Secrets Provider Connection User',
};
const ROLE_DESCRIPTIONS: Record<AllRoleTypes, string> = {
@@ -47,11 +50,14 @@ const ROLE_DESCRIPTIONS: Record<AllRoleTypes, string> = {
'credential:owner': 'Credential Owner',
'workflow:owner': 'Workflow Owner',
'workflow:editor': 'Workflow Editor',
'secretsProviderConnection:owner':
'Full control of secrets provider connection settings and secrets',
'secretsProviderConnection:user': 'Read-only access to use secrets from the connection',
};
const mapToRoleObject = <T extends keyof typeof ROLE_NAMES>(
roles: Record<T, Scope[]>,
roleType: 'global' | 'project' | 'credential' | 'workflow',
roleType: 'global' | 'project' | 'credential' | 'workflow' | 'secretsProviderConnection',
) =>
(Object.keys(roles) as T[]).map((role) => ({
slug: role,
@@ -68,6 +74,10 @@ export const ALL_ROLES: AllRolesMap = Object.freeze({
project: mapToRoleObject(PROJECT_SCOPE_MAP, 'project'),
credential: mapToRoleObject(CREDENTIALS_SHARING_SCOPE_MAP, 'credential'),
workflow: mapToRoleObject(WORKFLOW_SHARING_SCOPE_MAP, 'workflow'),
secretsProviderConnection: mapToRoleObject(
SECRETS_PROVIDER_CONNECTION_SHARING_SCOPE_MAP,
'secretsProviderConnection',
),
});
export const isBuiltInRole = (role: string): role is AllRoleTypes => {
@@ -4,6 +4,7 @@ import type {
GlobalRole,
ProjectRole,
Scope,
SecretsProviderConnectionSharingRole,
WorkflowSharingRole,
} from '../types.ee';
import {
@@ -27,6 +28,10 @@ import {
WORKFLOW_SHARING_OWNER_SCOPES,
WORKFLOW_SHARING_EDITOR_SCOPES,
} from './scopes/workflow-sharing-scopes.ee';
import {
SECRETS_PROVIDER_CONNECTION_SHARING_OWNER_SCOPES,
SECRETS_PROVIDER_CONNECTION_SHARING_USER_SCOPES,
} from './scopes/secrets-provider-connection-sharing-scopes.ee';
export const GLOBAL_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': GLOBAL_OWNER_SCOPES,
@@ -53,9 +58,18 @@ export const WORKFLOW_SHARING_SCOPE_MAP: Record<WorkflowSharingRole, Scope[]> =
'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES,
};
export const SECRETS_PROVIDER_CONNECTION_SHARING_SCOPE_MAP: Record<
SecretsProviderConnectionSharingRole,
Scope[]
> = {
'secretsProviderConnection:owner': SECRETS_PROVIDER_CONNECTION_SHARING_OWNER_SCOPES,
'secretsProviderConnection:user': SECRETS_PROVIDER_CONNECTION_SHARING_USER_SCOPES,
};
export const ALL_ROLE_MAPS = {
global: GLOBAL_SCOPE_MAP,
project: PROJECT_SCOPE_MAP,
credential: CREDENTIALS_SHARING_SCOPE_MAP,
workflow: WORKFLOW_SHARING_SCOPE_MAP,
secretsProviderConnection: SECRETS_PROVIDER_CONNECTION_SHARING_SCOPE_MAP,
} as const;
@@ -0,0 +1,18 @@
import type { Scope } from '../../types.ee';
// Owner can edit connection settings AND use secrets
export const SECRETS_PROVIDER_CONNECTION_SHARING_OWNER_SCOPES: Scope[] = [
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
'externalSecretsProvider:list',
'externalSecretsProvider:sync',
'externalSecret:list',
];
// User can only read connection info and use secrets
export const SECRETS_PROVIDER_CONNECTION_SHARING_USER_SCOPES: Scope[] = [
'externalSecretsProvider:read',
'externalSecretsProvider:list',
'externalSecret:list',
];
+12 -1
View File
@@ -2,7 +2,13 @@ import { z } from 'zod';
import { ALL_SCOPES } from './scope-information';
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
export const roleNamespaceSchema = z.enum([
'global',
'project',
'credential',
'workflow',
'secretsProviderConnection',
]);
export const globalRoleSchema = z.enum([
'global:owner',
@@ -57,6 +63,11 @@ export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credenti
export const workflowSharingRoleSchema = z.enum(['workflow:owner', 'workflow:editor']);
export const secretsProviderConnectionSharingRoleSchema = z.enum([
'secretsProviderConnection:owner',
'secretsProviderConnection:user',
]);
const ALL_SCOPES_LOOKUP_SET = new Set(ALL_SCOPES as string[]);
export const scopeSchema = z.string().refine((val) => ALL_SCOPES_LOOKUP_SET.has(val), {
+11 -1
View File
@@ -10,6 +10,7 @@ import type {
roleNamespaceSchema,
teamRoleSchema,
workflowSharingRoleSchema,
secretsProviderConnectionSharingRoleSchema,
assignableProjectRoleSchema,
} from './schemas.ee';
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
@@ -59,6 +60,9 @@ export type GlobalRole = z.infer<typeof globalRoleSchema>;
export type AssignableGlobalRole = z.infer<typeof assignableGlobalRoleSchema>;
export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>;
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
export type SecretsProviderConnectionSharingRole = z.infer<
typeof secretsProviderConnectionSharingRoleSchema
>;
export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
export type ProjectRole = z.infer<typeof systemProjectRoleSchema>;
export type AssignableProjectRole = z.infer<typeof assignableProjectRoleSchema>;
@@ -76,13 +80,19 @@ export function isAssignableProjectRoleSlug(slug: string): slug is AssignablePro
}
/** Union of all possible role types in the system */
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
export type AllRoleTypes =
| GlobalRole
| ProjectRole
| WorkflowSharingRole
| CredentialSharingRole
| SecretsProviderConnectionSharingRole;
export type AllRolesMap = {
global: Role[];
project: Role[];
credential: Role[];
workflow: Role[];
secretsProviderConnection: Role[];
};
export type DbScope = {
@@ -0,0 +1,206 @@
import type {
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
User,
} from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { Scope } from '@n8n/permissions';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ProjectService } from '@/services/project.service.ee';
import type { RoleService } from '@/services/role.service';
import { SecretsProviderAccessCheckService } from '../secret-provider-access-check.service.ee';
describe('SecretsProviderAccessCheckService', () => {
const connectionRepository = mock<SecretsProviderConnectionRepository>();
const projectAccessRepository = mock<ProjectSecretsProviderAccessRepository>();
const roleService = mock<RoleService>();
const projectService = mock<ProjectService>();
const service = new SecretsProviderAccessCheckService(
connectionRepository,
projectAccessRepository,
roleService,
projectService,
);
const user = {
id: 'user-1',
role: { slug: 'global:member', scopes: [] },
} as unknown as User;
const adminUser = {
id: 'admin-1',
role: {
slug: 'global:admin',
scopes: [
{ slug: 'externalSecretsProvider:create' },
{ slug: 'externalSecretsProvider:read' },
{ slug: 'externalSecretsProvider:update' },
{ slug: 'externalSecretsProvider:delete' },
{ slug: 'externalSecretsProvider:list' },
],
},
} as unknown as User;
const providerKey = 'my-vault';
const projectId = 'project-1';
beforeEach(() => {
jest.clearAllMocks();
});
describe('assertConnectionAccess', () => {
it('should throw NotFoundError when no access record exists', async () => {
projectAccessRepository.findOne.mockResolvedValue(null);
await expect(
service.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:update',
user,
}),
).rejects.toThrow(NotFoundError);
});
it('should pass when user has the global scope and access record exists', async () => {
projectAccessRepository.findOne.mockResolvedValue({
role: 'secretsProviderConnection:user',
} as never);
await expect(
service.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:update',
user: adminUser,
}),
).resolves.toBeUndefined();
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
});
it('should throw NotFoundError for global connections even with global scope', async () => {
// assertConnectionAccess requires a project access record —
// global connections must be handled at the controller level
projectAccessRepository.findOne.mockResolvedValue(null);
await expect(
service.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:update',
user: adminUser,
}),
).rejects.toThrow(NotFoundError);
});
it('should pass when the access role has the required scope', async () => {
projectAccessRepository.findOne.mockResolvedValue({
role: 'secretsProviderConnection:owner',
} as never);
roleService.rolesWithScope.mockResolvedValue([
'secretsProviderConnection:owner',
'secretsProviderConnection:editor',
]);
await expect(
service.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:update',
user,
}),
).resolves.toBeUndefined();
expect(roleService.rolesWithScope).toHaveBeenCalledWith('secretsProviderConnection', [
'externalSecretsProvider:update',
]);
});
it('should throw ForbiddenError when access role lacks the required scope', async () => {
projectAccessRepository.findOne.mockResolvedValue({
role: 'secretsProviderConnection:user',
} as never);
roleService.rolesWithScope.mockResolvedValue(['secretsProviderConnection:owner']);
await expect(
service.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:delete',
user,
}),
).rejects.toThrow(ForbiddenError);
});
});
describe('getConnectionScopesForProject', () => {
it('should combine global, project, and sharing scopes', async () => {
projectAccessRepository.findOne.mockResolvedValue({
role: 'secretsProviderConnection:owner',
} as never);
projectService.getProjectRelationsForUser.mockResolvedValue([
{
projectId,
role: {
scopes: [
{ slug: 'externalSecretsProvider:list' },
{ slug: 'externalSecretsProvider:read' },
],
},
},
] as never);
roleService.getRole.mockResolvedValue({
scopes: [
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
] as Scope[],
} as never);
const scopes = await service.getConnectionScopesForProject(user, providerKey, projectId);
expect(roleService.getRole).toHaveBeenCalledWith('secretsProviderConnection:owner');
expect(Array.isArray(scopes)).toBe(true);
});
it('should default to user role when no access record exists', async () => {
projectAccessRepository.findOne.mockResolvedValue(null);
projectService.getProjectRelationsForUser.mockResolvedValue([]);
roleService.getRole.mockResolvedValue({
scopes: ['externalSecretsProvider:read'] as Scope[],
} as never);
await service.getConnectionScopesForProject(user, providerKey, projectId);
expect(roleService.getRole).toHaveBeenCalledWith('secretsProviderConnection:user');
});
it('should return empty project scopes when user has no project relation', async () => {
projectAccessRepository.findOne.mockResolvedValue({
role: 'secretsProviderConnection:owner',
} as never);
projectService.getProjectRelationsForUser.mockResolvedValue([
{ projectId: 'other-project', role: { scopes: [{ slug: 'some:scope' }] } },
] as never);
roleService.getRole.mockResolvedValue({
scopes: ['externalSecretsProvider:read'] as Scope[],
} as never);
const scopes = await service.getConnectionScopesForProject(user, providerKey, projectId);
expect(Array.isArray(scopes)).toBe(true);
});
});
});
@@ -13,7 +13,6 @@ import type { ExternalSecretsProviderRegistry } from '@/modules/external-secrets
import type { RedactionService } from '@/modules/external-secrets.ee/redaction.service.ee';
import { SecretsProvidersConnectionsService } from '@/modules/external-secrets.ee/secrets-providers-connections.service.ee';
import type { SecretsProvider } from '@/modules/external-secrets.ee/types';
describe('SecretsProvidersConnectionsService', () => {
const mockRepository = mock<SecretsProviderConnectionRepository>();
const mockProjectAccessRepository = mock<ProjectSecretsProviderAccessRepository>();
@@ -358,17 +357,29 @@ describe('SecretsProvidersConnectionsService', () => {
projectIds: [],
},
'user-123',
'secretsProviderConnection:user',
);
expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws');
});
it('should sync provider connection after updateConnection', async () => {
it('should sync provider connection after updateGlobalConnection', async () => {
mockRepository.findOne
.mockResolvedValueOnce(savedConnection)
.mockResolvedValueOnce(savedConnection);
mockProjectAccessRepository.findByConnectionId.mockResolvedValueOnce([]);
await service.updateGlobalConnection('my-aws', { projectIds: ['p1'] }, 'user-123');
expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws');
});
it('should sync provider connection after updateProjectConnection', async () => {
mockRepository.findOne
.mockResolvedValueOnce(savedConnection)
.mockResolvedValueOnce(savedConnection);
await service.updateConnection('my-aws', { projectIds: ['p1'] }, 'user-123');
await service.updateProjectConnection('my-aws', { isEnabled: false }, 'user-123');
expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws');
});
@@ -418,6 +429,7 @@ describe('SecretsProvidersConnectionsService', () => {
projectIds: ['p1', 'p2'],
},
'user-123',
'secretsProviderConnection:user',
);
expect(mockEventService.emit).toHaveBeenCalledWith('external-secrets-connection-created', {
@@ -432,8 +444,9 @@ describe('SecretsProvidersConnectionsService', () => {
mockRepository.findOne
.mockResolvedValueOnce(connectionWithProjects)
.mockResolvedValueOnce(connectionWithProjects);
mockProjectAccessRepository.findByConnectionId.mockResolvedValueOnce([]);
await service.updateConnection('my-aws', { projectIds: ['p1'] }, 'user-123');
await service.updateGlobalConnection('my-aws', { projectIds: ['p1'] }, 'user-123');
expect(mockEventService.emit).toHaveBeenCalledWith('external-secrets-connection-updated', {
userId: 'user-123',
@@ -640,7 +653,7 @@ describe('SecretsProvidersConnectionsService', () => {
});
});
describe('CRUD operations reload providers', () => {
describe('role assignment on project access', () => {
const savedConnection = {
id: 1,
providerKey: 'my-aws',
@@ -652,7 +665,7 @@ describe('SecretsProvidersConnectionsService', () => {
updatedAt: new Date('2024-01-02'),
} as unknown as SecretsProviderConnection;
it('should sync provider connection after createConnection', async () => {
it('should pass the provided role when creating project access entries', async () => {
mockRepository.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce(savedConnection);
mockRepository.create.mockReturnValue(savedConnection);
mockRepository.save.mockResolvedValue(savedConnection);
@@ -662,31 +675,84 @@ describe('SecretsProvidersConnectionsService', () => {
providerKey: 'my-aws',
type: 'awsSecretsManager',
settings: { apiKey: 'secret' },
projectIds: [],
projectIds: ['p1'],
},
'test-user',
'user-123',
'secretsProviderConnection:owner',
);
expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws');
expect(mockProjectAccessRepository.create).toHaveBeenCalledWith({
secretsProviderConnectionId: 1,
projectId: 'p1',
role: 'secretsProviderConnection:owner',
});
});
it('should sync provider connection after updateConnection', async () => {
it('should assign user role to newly added projects via updateGlobalConnection', async () => {
mockRepository.findOne
.mockResolvedValueOnce(savedConnection)
.mockResolvedValueOnce(savedConnection);
mockProjectAccessRepository.findByConnectionId.mockResolvedValueOnce([]);
await service.updateGlobalConnection('my-aws', { projectIds: ['p1'] }, 'user-123');
expect(mockProjectAccessRepository.updateProjectAccess).toHaveBeenCalledWith(
1,
[],
[
{
projectId: 'p1',
role: 'secretsProviderConnection:user',
},
],
);
});
it('should preserve existing project roles when updating via updateGlobalConnection', async () => {
mockRepository.findOne
.mockResolvedValueOnce(savedConnection)
.mockResolvedValueOnce(savedConnection);
mockProjectAccessRepository.findByConnectionId.mockResolvedValueOnce([
{ projectId: 'p1', role: 'secretsProviderConnection:owner' },
] as any);
await service.updateGlobalConnection('my-aws', { projectIds: ['p1', 'p2'] }, 'user-123');
expect(mockProjectAccessRepository.updateProjectAccess).toHaveBeenCalledWith(
1,
[],
[
{
projectId: 'p2',
role: 'secretsProviderConnection:user',
},
],
);
});
it('should remove projects no longer in the list via updateGlobalConnection', async () => {
mockRepository.findOne
.mockResolvedValueOnce(savedConnection)
.mockResolvedValueOnce(savedConnection);
mockProjectAccessRepository.findByConnectionId.mockResolvedValueOnce([
{ projectId: 'p1', role: 'secretsProviderConnection:user' },
{ projectId: 'p2', role: 'secretsProviderConnection:owner' },
] as any);
await service.updateGlobalConnection('my-aws', { projectIds: ['p1'] }, 'user-123');
expect(mockProjectAccessRepository.updateProjectAccess).toHaveBeenCalledWith(1, ['p2'], []);
});
it('should not touch project access via updateProjectConnection', async () => {
mockRepository.findOne
.mockResolvedValueOnce(savedConnection)
.mockResolvedValueOnce(savedConnection);
await service.updateConnection('my-aws', { projectIds: ['p1'] }, 'test-user');
await service.updateProjectConnection('my-aws', { isEnabled: false }, 'user-123');
expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws');
});
it('should sync provider connection after deleteConnection', async () => {
mockRepository.findOne.mockResolvedValueOnce(savedConnection);
mockRepository.remove.mockResolvedValue(savedConnection);
await service.deleteConnection('my-aws', 'test-user');
expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws');
expect(mockProjectAccessRepository.updateProjectAccess).not.toHaveBeenCalled();
expect(mockProjectAccessRepository.findByConnectionId).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { UpdateExternalSecretsSettingsDto } from '@n8n/api-types';
import { ModuleRegistry, Logger } from '@n8n/backend-common';
import { Body, GlobalScope, Middleware, Post, RestController } from '@n8n/decorators';
import type { NextFunction, Request, Response } from 'express';
@@ -13,6 +14,8 @@ export class ExternalSecretsSettingsController {
constructor(
private readonly config: ExternalSecretsConfig,
private readonly settingsService: ExternalSecretsSettingsService,
private readonly moduleRegistry: ModuleRegistry,
private readonly logger: Logger,
) {}
@Middleware()
@@ -35,6 +38,13 @@ export class ExternalSecretsSettingsController {
@Body body: UpdateExternalSecretsSettingsDto,
) {
await this.settingsService.setSystemRolesEnabled(body.systemRolesEnabled);
try {
await this.moduleRegistry.refreshModuleSettings('external-secrets');
} catch (error) {
this.logger.warn('Failed to sync external secrets settings to module registry', {
cause: error instanceof Error ? error.message : String(error),
});
}
return {
systemRolesEnabled: await this.settingsService.isSystemRolesEnabled(),
@@ -1,11 +1,113 @@
import { SecretsProviderConnectionRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import {
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@Service()
export class SecretsProviderAccessCheckService {
constructor(private readonly connectionRepository: SecretsProviderConnectionRepository) {}
constructor(
private readonly connectionRepository: SecretsProviderConnectionRepository,
private readonly projectAccessRepository: ProjectSecretsProviderAccessRepository,
private readonly roleService: RoleService,
private readonly projectService: ProjectService,
) {}
async isProviderAvailableInProject(providerKey: string, projectId: string): Promise<boolean> {
return await this.connectionRepository.isProviderAvailableInProject(providerKey, projectId);
}
/**
* Asserts that the project's sharing role on a connection grants the required scope.
* Throws ForbiddenError if the project's access role does not have the required scope.
*
* Users with the global scope bypass the project role check.
*/
async assertConnectionAccess({
providerKey,
projectId,
requiredScope,
user,
}: {
providerKey: string;
projectId: string;
requiredScope: Scope;
user: User;
}): Promise<void> {
const access = await this.projectAccessRepository.findOne({
where: {
secretsProviderConnection: { providerKey },
projectId,
},
});
if (!access) {
throw new NotFoundError(
`Connection with key "${providerKey}" not found in project "${projectId}"`,
);
}
if (hasGlobalScope(user, requiredScope)) {
return;
}
const validRoles = await this.roleService.rolesWithScope('secretsProviderConnection', [
requiredScope,
]);
if (!validRoles.includes(access.role)) {
throw new ForbiddenError(
'Project does not have the required access level for this connection',
);
}
}
/**
* Computes the effective scopes for a user on a connection within a project context.
* Combines global scopes, project-level scopes, and the connection's sharing role scopes.
*/
async getConnectionScopesForProject(
user: User,
providerKey: string,
projectId: string,
): Promise<Scope[]> {
const globalScopes = getAuthPrincipalScopes(user, [
'externalSecretsProvider',
'externalSecret',
]);
const access = await this.projectAccessRepository.findOne({
where: {
secretsProviderConnection: { providerKey },
projectId,
},
});
// For global connections (no project access entry), treat as read-only (user role)
const sharingRoleSlug = access?.role ?? 'secretsProviderConnection:user';
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
const projectRelation = userProjectRelations.find((pr) => pr.projectId === projectId);
const projectScopes: Scope[] = projectRelation
? projectRelation.role.scopes.map((s) => s.slug)
: [];
const sharingRole = await this.roleService.getRole(sharingRoleSlug);
const sharingScopes = sharingRole.scopes as Scope[];
const mergedScopes = combineScopes(
{ global: globalScopes, project: projectScopes },
{ sharing: sharingScopes },
);
return [...mergedScopes].sort();
}
}
@@ -87,7 +87,14 @@ export class SecretProvidersConnectionsController {
providerKey: body.providerKey,
type: body.type,
});
const savedConnection = await this.connectionsService.createConnection(body, req.user.id);
const savedConnection = await this.connectionsService.createConnection(
body,
req.user.id,
// For connections created at the instance level,
// shared with projects will be able to use the connection secrets
// but they do not own the connection and can't modify it
'secretsProviderConnection:user',
);
return this.connectionsService.toPublicConnection(savedConnection);
}
@@ -100,7 +107,7 @@ export class SecretProvidersConnectionsController {
@Body body: UpdateSecretsProviderConnectionDto,
): Promise<SecretProviderConnection> {
this.logger.debug('Updating connection', { providerKey });
const connection = await this.connectionsService.updateConnection(
const connection = await this.connectionsService.updateGlobalConnection(
providerKey,
body,
req.user.id,
@@ -1,16 +1,16 @@
import {
type CreateSecretsProviderConnectionDto,
type ReloadSecretProviderConnectionResponse,
type TestSecretProviderConnectionResponse,
reloadSecretProviderConnectionResponseSchema,
type SecretCompletionsResponse,
type SecretProviderConnection,
type SecretProviderConnectionListItem,
type SecretsProviderType,
type TestSecretProviderConnectionResponse,
testSecretProviderConnectionResponseSchema,
reloadSecretProviderConnectionResponseSchema,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { SecretsProviderConnection } from '@n8n/db';
import type { SecretsProviderAccessRole, SecretsProviderConnection } from '@n8n/db';
import {
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
@@ -47,6 +47,7 @@ export class SecretsProvidersConnectionsService {
async createConnection(
proposedConnection: CreateSecretsProviderConnectionDto,
userId: string,
projectRole: SecretsProviderAccessRole,
): Promise<SecretsProviderConnection> {
const existing = await this.repository.findOne({
where: { providerKey: proposedConnection.providerKey },
@@ -72,6 +73,7 @@ export class SecretsProvidersConnectionsService {
this.projectAccessRepository.create({
secretsProviderConnectionId: savedConnection.id,
projectId,
role: projectRole,
}),
);
await this.projectAccessRepository.save(entries);
@@ -94,7 +96,23 @@ export class SecretsProvidersConnectionsService {
return result;
}
async updateConnection(
async updateProjectConnection(
providerKey: string,
updates: {
type?: string;
settings?: IDataObject;
isEnabled?: boolean;
},
userId: string,
): Promise<SecretsProviderConnection> {
const connection = await this.findConnectionOrFail(providerKey);
this.applyConnectionUpdates(connection, updates);
await this.repository.save(connection);
return await this.syncAndEmitUpdate(providerKey, userId);
}
async updateGlobalConnection(
providerKey: string,
updates: {
type?: string;
@@ -104,11 +122,43 @@ export class SecretsProvidersConnectionsService {
},
userId: string,
): Promise<SecretsProviderConnection> {
const connection = await this.repository.findOne({ where: { providerKey } });
const connection = await this.findConnectionOrFail(providerKey);
this.applyConnectionUpdates(connection, updates);
await this.repository.save(connection);
if (!connection) {
throw new NotFoundError(`Connection with key "${providerKey}" not found`);
if (updates.projectIds !== undefined) {
const existing = await this.projectAccessRepository.findByConnectionId(connection.id);
const existingProjectIds = new Set(existing.map((e) => e.projectId));
const desiredProjectIds = new Set(updates.projectIds);
// Remove access for projects no longer in the list
const projectIdsToRemove = existing
.filter((e) => !desiredProjectIds.has(e.projectId))
.map((e) => e.projectId);
// Add access for newly added projects with user role
// Existing projects keep their current role (e.g. owner)
const entriesToAdd = updates.projectIds
.filter((id) => !existingProjectIds.has(id))
.map((projectId) => ({
projectId,
role: 'secretsProviderConnection:user' as const,
}));
await this.projectAccessRepository.updateProjectAccess(
connection.id,
projectIdsToRemove,
entriesToAdd,
);
}
return await this.syncAndEmitUpdate(providerKey, userId);
}
private applyConnectionUpdates(
connection: SecretsProviderConnection,
updates: { type?: string; settings?: IDataObject; isEnabled?: boolean },
): void {
if (updates.type !== undefined) {
connection.type = updates.type;
if (!updates.settings) {
@@ -118,7 +168,6 @@ export class SecretsProvidersConnectionsService {
}
}
if (updates.settings !== undefined) {
// Unredact incoming settings before encrypting
const savedSettings = this.decryptConnectionSettings(connection.encryptedSettings);
const unredactedSettings = this.redactionService.unredact(updates.settings, savedSettings);
connection.encryptedSettings = this.encryptConnectionSettings(unredactedSettings);
@@ -126,13 +175,12 @@ export class SecretsProvidersConnectionsService {
if (updates.isEnabled !== undefined) {
connection.isEnabled = updates.isEnabled;
}
}
await this.repository.save(connection);
if (updates.projectIds !== undefined) {
await this.projectAccessRepository.setProjectAccess(connection.id, updates.projectIds);
}
private async syncAndEmitUpdate(
providerKey: string,
userId: string,
): Promise<SecretsProviderConnection> {
await this.externalSecretsManager.syncProviderConnection(providerKey);
const result = (await this.repository.findOne({
@@ -150,12 +198,7 @@ export class SecretsProvidersConnectionsService {
}
async deleteConnection(providerKey: string, userId: string): Promise<SecretsProviderConnection> {
const connection = await this.repository.findOne({ where: { providerKey } });
if (!connection) {
throw new NotFoundError(`Connection with key "${providerKey}" not found`);
}
const connection = await this.findConnectionOrFail(providerKey);
const projectInfo = this.extractProjectInfo(connection);
await this.projectAccessRepository.deleteByConnectionId(connection.id);
@@ -173,6 +216,14 @@ export class SecretsProvidersConnectionsService {
return connection;
}
private async findConnectionOrFail(providerKey: string): Promise<SecretsProviderConnection> {
const connection = await this.repository.findOne({ where: { providerKey } });
if (!connection) {
throw new NotFoundError(`Connection with key "${providerKey}" not found`);
}
return connection;
}
async getConnection(providerKey: string): Promise<SecretsProviderConnection> {
const connection = await this.repository.findOne({ where: { providerKey } });
@@ -234,6 +285,7 @@ export class SecretsProvidersConnectionsService {
projects: connection.projectAccess.map((access) => ({
id: access.project.id,
name: access.project.name,
role: access.role,
})),
createdAt: connection.createdAt.toISOString(),
updatedAt: connection.updatedAt.toISOString(),
@@ -260,6 +312,7 @@ export class SecretsProvidersConnectionsService {
projects: connection.projectAccess.map((access) => ({
id: access.project.id,
name: access.project.name,
role: access.role,
})),
settings: redactedSettings,
createdAt: connection.createdAt.toISOString(),
@@ -24,6 +24,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { sendErrorResponse } from '@/response-helper';
import { ExternalSecretsConfig } from './external-secrets.config';
import { SecretsProviderAccessCheckService } from './secret-provider-access-check.service.ee';
import { SecretsProvidersConnectionsService } from './secrets-providers-connections.service.ee';
@RestController('/secret-providers/projects')
@@ -32,6 +33,7 @@ export class SecretProvidersProjectController {
private readonly config: ExternalSecretsConfig,
private readonly logger: Logger,
private readonly connectionsService: SecretsProvidersConnectionsService,
private readonly accessCheckService: SecretsProviderAccessCheckService,
) {
this.logger = this.logger.scoped('external-secrets');
}
@@ -61,14 +63,24 @@ export class SecretProvidersProjectController {
projectId,
providerKey: body.providerKey,
});
const savedConnection = await this.connectionsService.createConnection(
{
...body,
projectIds: [projectId],
},
req.user.id,
// When creating a connection for a project, the project owns the connection
'secretsProviderConnection:owner',
);
return this.connectionsService.toPublicConnection(savedConnection);
const connection = this.connectionsService.toPublicConnection(savedConnection);
const scopes = await this.accessCheckService.getConnectionScopesForProject(
req.user,
body.providerKey,
projectId,
);
return { ...connection, scopes };
}
@Get('/:projectId/connections')
@@ -86,17 +98,25 @@ export class SecretProvidersProjectController {
@Get('/:projectId/connections/:providerKey')
@ProjectScope('externalSecretsProvider:read')
async getConnection(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
_res: Response,
@Param('projectId') projectId: string,
@Param('providerKey') providerKey: string,
): Promise<SecretProviderConnection> {
this.logger.debug('Getting connection for project', { projectId, providerKey });
const connection = await this.connectionsService.getConnectionAccessibleFromProject(
const connectionEntity = await this.connectionsService.getConnectionAccessibleFromProject(
providerKey,
projectId,
);
return this.connectionsService.toPublicConnection(connection);
const connection = this.connectionsService.toPublicConnection(connectionEntity);
const scopes = await this.accessCheckService.getConnectionScopesForProject(
req.user,
providerKey,
projectId,
);
return { ...connection, scopes };
}
@Patch('/:projectId/connections/:providerKey')
@@ -109,25 +129,47 @@ export class SecretProvidersProjectController {
@Body body: UpdateSecretsProviderConnectionDto,
): Promise<SecretProviderConnection> {
this.logger.debug('Updating connection for project', { projectId, providerKey });
await this.connectionsService.getConnectionForProject(providerKey, projectId);
await this.accessCheckService.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:update',
user: req.user,
});
const { projectIds: _, ...updates } = body;
const connection = await this.connectionsService.updateConnection(
const updated = await this.connectionsService.updateProjectConnection(
providerKey,
updates,
req.user.id,
);
return this.connectionsService.toPublicConnection(connection);
const connection = this.connectionsService.toPublicConnection(updated);
const scopes = await this.accessCheckService.getConnectionScopesForProject(
req.user,
providerKey,
projectId,
);
return { ...connection, scopes };
}
@Delete('/:projectId/connections/:providerKey')
@ProjectScope('externalSecretsProvider:delete')
async deleteConnection(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Param('providerKey') providerKey: string,
) {
this.logger.debug('Deleting connection for project', { projectId, providerKey });
await this.accessCheckService.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:delete',
user: req.user,
});
await this.connectionsService.deleteConnectionForProject(providerKey, projectId);
res.status(204).send();
}
@@ -141,7 +183,14 @@ export class SecretProvidersProjectController {
@Param('providerKey') providerKey: string,
): Promise<TestSecretProviderConnectionResponse> {
this.logger.debug('Testing connection for project', { projectId, providerKey });
await this.connectionsService.getConnectionAccessibleFromProject(providerKey, projectId);
await this.accessCheckService.assertConnectionAccess({
providerKey,
projectId,
requiredScope: 'externalSecretsProvider:update',
user: req.user,
});
return await this.connectionsService.testConnection(providerKey, req.user.id);
}
}
@@ -24,6 +24,9 @@ interface RoleScopeMap {
workflow?: {
[roleSlug: string]: RoleInfo;
};
secretsProviderConnection?: {
[roleSlug: string]: RoleInfo;
};
}
@Service()
@@ -63,7 +66,7 @@ export class RoleCacheService {
* Get roles with all specified scopes (with caching)
*/
async getRolesWithAllScopes(
namespace: 'global' | 'project' | 'credential' | 'workflow',
namespace: 'global' | 'project' | 'credential' | 'workflow' | 'secretsProviderConnection',
requiredScopes: Scope[],
em?: EntityManager,
): Promise<string[]> {
@@ -177,8 +177,8 @@ describe('Secret Providers Connections API', () => {
expect(response.body.data.projects).toHaveLength(2);
expect(response.body.data.projects).toEqual(
expect.arrayContaining([
{ id: teamProject1.id, name: 'Engineering' },
{ id: teamProject2.id, name: 'Marketing' },
{ id: teamProject1.id, name: 'Engineering', role: 'secretsProviderConnection:user' },
{ id: teamProject2.id, name: 'Marketing', role: 'secretsProviderConnection:user' },
]),
);
@@ -293,7 +293,9 @@ describe('Secret Providers Connections API', () => {
.send({ projectIds: [teamProject2.id] })
.expect(200);
expect(response.body.data.projects).toEqual([{ id: teamProject2.id, name: 'Marketing' }]);
expect(response.body.data.projects).toEqual([
{ id: teamProject2.id, name: 'Marketing', role: 'secretsProviderConnection:user' },
]);
const getResponse = await ownerAgent
.get('/secret-providers/connections/updateProjectsTest')
@@ -514,7 +514,7 @@ describe('Secret Providers Project API', () => {
.expect(404);
});
test('should return 404 for a global connection (cannot update global from project context)', async () => {
test('should return 404 for a global connection from project context', async () => {
await createProviderConnection('global-update', []);
await ownerAgent
@@ -675,19 +675,12 @@ describe('Secret Providers Project API', () => {
expect(response.body.data).toMatchObject({ success: true });
});
test('should test a global connection accessible from the project', async () => {
test('should return 404 for a global connection (cannot test global from project context)', async () => {
await createProviderConnection('global-test-conn', []);
const { ExternalSecretsManager } = await import(
'@/modules/external-secrets.ee/external-secrets-manager.ee'
);
await Container.get(ExternalSecretsManager).reloadAllProviders();
const response = await ownerAgent
await ownerAgent
.post(`/secret-providers/projects/${teamProject1.id}/connections/global-test-conn/test`)
.expect(200);
expect(response.body.data).toMatchObject({ success: true });
.expect(404);
});
test('should return 404 for a connection belonging to another project', async () => {
@@ -33,6 +33,7 @@ const ALL_ROLES_SET = ALL_ROLES.global.concat(
ALL_ROLES.project,
ALL_ROLES.credential,
ALL_ROLES.workflow,
ALL_ROLES.secretsProviderConnection,
);
beforeAll(async () => {
@@ -15,6 +15,7 @@ describe('roles store', () => {
global: [],
credential: [],
workflow: [],
secretsProviderConnection: [],
project: [
{
displayName: 'Project Admin',
@@ -25,6 +25,7 @@ export const useRolesStore = defineStore('roles', () => {
project: [],
credential: [],
workflow: [],
secretsProviderConnection: [],
});
const projectRoleOrder = ref<string[]>([
'project:viewer',
@@ -5,12 +5,14 @@ import { createTestingPinia } from '@pinia/testing';
import SecretsProviderConnectionModal from './SecretsProviderConnectionModal.ee.vue';
import { SECRETS_PROVIDER_CONNECTION_MODAL_KEY } from '@/app/constants';
import { STORES } from '@n8n/stores';
import type { SecretProviderTypeResponse } from '@n8n/api-types';
import type {
SecretProviderTypeResponse,
ConnectionProjectSummary,
SecretProviderConnection,
} from '@n8n/api-types';
import { vi } from 'vitest';
import { nextTick } from 'vue';
import type { SecretProviderConnection } from '@n8n/api-types';
import { createProjectListItem } from '@/features/collaboration/projects/__tests__/utils';
import type { ConnectionProjectSummary } from '../composables/useConnectionModal.ee';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import orderBy from 'lodash/orderBy';
@@ -603,4 +605,27 @@ describe('SecretsProviderConnectionModal', () => {
expect(notice).toBeInTheDocument();
});
});
describe('read-only mode for insufficient role', () => {
it('should show read-only notice when connection is not global but user lacks update permission', async () => {
mockConnectionModal.isReadOnly.value = true;
mockConnectionModal.isSharedGlobally.value = false;
const { container } = renderComponent({
props: {
modalName: SECRETS_PROVIDER_CONNECTION_MODAL_KEY,
data: {
providerKey: 'test-123',
providerTypes: mockProviderTypes,
},
},
});
await nextTick();
const notice = container.querySelector('[data-test-id="secrets-provider-read-only-notice"]');
expect(notice).toBeInTheDocument();
expect(notice?.textContent).toContain('Contact your instance admin');
});
});
});
@@ -328,7 +328,7 @@ onMounted(async () => {
data-test-id="secrets-provider-read-only-notice"
:content="
i18n.baseText(
modal.canShareGlobally.value
modal.isSharedGlobally.value && modal.canShareGlobally.value
? 'settings.secretsProviderConnections.modal.readOnly.notice.admin'
: 'settings.secretsProviderConnections.modal.readOnly.notice.noPermission',
)
@@ -13,6 +13,7 @@ const mockConnection = {
createConnection: vi.fn(),
updateConnection: vi.fn(),
testConnection: vi.fn(),
setConnectionState: vi.fn(),
isLoading: ref(false),
connectionState: ref('initializing'),
};
@@ -184,6 +185,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
@@ -249,6 +251,7 @@ describe('useConnectionModal', () => {
id: 'infisical-id',
name: 'infisical-connection',
type: 'infisical',
state: 'connected',
settings: {},
});
@@ -273,6 +276,7 @@ describe('useConnectionModal', () => {
id: 'test-id',
name: 'testConnection',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
@@ -304,6 +308,7 @@ describe('useConnectionModal', () => {
id: 'test-id',
name: 'testConnection',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
projects: [],
});
@@ -343,6 +348,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
@@ -380,6 +386,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
@@ -390,7 +397,7 @@ describe('useConnectionModal', () => {
expect(modal.canSave.value).toBe(true);
});
it('should allow update with project-scoped permission', async () => {
it('should not allow update with only project-scoped permission in non-scoped mode', async () => {
mockHasScope.mockReturnValue(false);
mockProjectsStore.myProjects = [
@@ -411,6 +418,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
projects: [{ id: 'project-1', name: 'Project 1' }],
});
@@ -419,7 +427,9 @@ describe('useConnectionModal', () => {
await modal.loadConnection();
modal.updateSettings('region', 'us-west-2');
expect(modal.canSave.value).toBe(true);
// In non-scoped mode, only global scope grants update permission
// since the global API controller requires @GlobalScope
expect(modal.canSave.value).toBe(false);
});
it('should not allow update without any permission', async () => {
@@ -443,6 +453,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
projects: [{ id: 'project-1', name: 'Project 1' }],
});
@@ -485,6 +496,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
projects: [{ id: 'project-1', name: 'Project 1' }],
});
@@ -526,6 +538,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
projects: [
{ id: 'project-1', name: 'Project 1' },
@@ -542,6 +555,43 @@ describe('useConnectionModal', () => {
});
});
describe('connection test on load', () => {
it('should test connection on load when user has update permission', async () => {
const options = { ...defaultOptions, providerKey: ref('testKey') };
mockConnection.getConnection.mockResolvedValue({
id: 'test-id',
name: 'testConnection',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
const modal = useConnectionModal(options);
await modal.loadConnection();
expect(mockConnection.testConnection).toHaveBeenCalledWith('testKey');
});
it('should skip test and use GET state when user lacks update permission', async () => {
mockHasScope.mockReturnValue(false);
const options = { ...defaultOptions, providerKey: ref('testKey') };
mockConnection.getConnection.mockResolvedValue({
id: 'test-id',
name: 'testConnection',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
const modal = useConnectionModal(options);
await modal.loadConnection();
expect(mockConnection.testConnection).not.toHaveBeenCalled();
expect(mockConnection.setConnectionState).toHaveBeenCalledWith('connected');
});
});
describe('error handling', () => {
it('should handle error when loading connection', async () => {
const error = new Error('Failed to load');
@@ -654,6 +704,54 @@ describe('useConnectionModal', () => {
});
});
describe('project-scoped creation', () => {
it('should transition to editable edit mode after creating a connection', async () => {
mockHasScope.mockReturnValue(false);
const projectId = 'project-1';
mockProjectsStore.myProjects = [
{
id: projectId,
name: 'Project 1',
type: 'team',
createdAt: '',
updatedAt: '',
icon: null,
role: 'owner',
scopes: ['externalSecretsProvider:create', 'externalSecretsProvider:update'],
},
];
mockConnection.createConnection.mockResolvedValue({
id: 'new-id',
name: 'newVault',
type: 'awsSecretsManager',
settings: { region: 'us-east-1' },
secretsCount: 0,
scopes: [
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
],
projects: [{ id: projectId, name: 'Project 1', role: 'secretsProviderConnection:owner' }],
});
const modal = useConnectionModal({
...defaultOptions,
projectId,
});
modal.selectProviderType('awsSecretsManager');
modal.connectionName.value = 'newVault';
await modal.saveConnection();
expect(modal.isEditMode.value).toBe(true);
expect(modal.isReadOnly.value).toBe(false);
expect(modal.canUpdate.value).toBe(true);
});
});
describe('scope management', () => {
it('should limit project IDs to one when setting scope', () => {
const { setScopeState, projectIds } = useConnectionModal(defaultOptions);
@@ -668,6 +766,7 @@ describe('useConnectionModal', () => {
id: 'test-id',
name: 'testConnection',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
projects: [{ id: 'project-1', name: 'Project 1' }],
});
@@ -686,6 +785,7 @@ describe('useConnectionModal', () => {
id: 'existing-id',
name: 'existingKey',
type: 'awsSecretsManager',
state: 'connected',
settings: { region: 'us-east-1' },
});
@@ -1,19 +1,18 @@
import { computed, ref, watch, type Ref, type ComponentPublicInstance } from 'vue';
import type { IUpdateInformation } from '@/Interface';
import type { SecretProviderTypeResponse } from '@n8n/api-types';
import type { SecretProviderTypeResponse, ConnectionProjectSummary } from '@n8n/api-types';
import type { INodeProperties } from 'n8n-workflow';
import { useSecretsProviderConnection } from './useSecretsProviderConnection.ee';
import { useRBACStore } from '@/app/stores/rbac.store';
import { useToast } from '@/app/composables/useToast';
import { i18n } from '@n8n/i18n';
import type { Scope } from '@n8n/permissions';
import { getResourcePermissions } from '@n8n/permissions';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import { isComponentPublicInstance } from '@/app/utils/typeGuards';
import { useSettingsStore } from '@/app/stores/settings.store';
export type ConnectionProjectSummary = { id: string; name: string };
const CONNECTION_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9]*$/;
interface UseConnectionModalOptions {
providerTypes: Ref<SecretProviderTypeResponse[]>;
@@ -115,39 +114,46 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
return project?.scopes?.includes(scope) ?? false;
};
// Scoped mode and connection-level permissions
const isScopedMode = computed(() => !!options.projectId);
const connectionScopes = ref<Scope[]>([]);
const connectionPermissions = computed(() => {
return getResourcePermissions(connectionScopes.value).externalSecretsProvider;
});
// Whether the current project has the owner role on this connection.
// Only owner-role connections should be editable from the project settings page.
const isProjectOwned = computed(() => {
if (!options.projectId) return false;
const projectAccess = connectionProjects.value.find((p) => p.id === options.projectId);
return projectAccess?.role === 'secretsProviderConnection:owner';
});
// Permission checks
const canCreateProjectScoped = computed(() => {
if (!options.projectId) return false;
return hasProjectScope(options.projectId, 'externalSecretsProvider:create');
});
const canUpdateProjectScoped = computed(() => {
// Can update if user has update permission within the scope of the original project
// This allows removing project from scope
if (originalProjectIds.value.length === 0) return false;
return originalProjectIds.value.every((id) =>
hasProjectScope(id, 'externalSecretsProvider:update'),
);
});
const canCreate = computed(
() => rbacStore.hasScope('externalSecretsProvider:create') || canCreateProjectScoped.value,
);
const canUpdate = computed(() => {
return rbacStore.hasScope('externalSecretsProvider:update') || canUpdateProjectScoped.value;
});
// In project-scoped mode, only allow updates for connections the project owns
if (isScopedMode.value) {
return connectionPermissions.value.update && isProjectOwned.value;
}
const canDeleteProjectScoped = computed(() => {
if (originalProjectIds.value.length === 0) return false;
return originalProjectIds.value.every((id) =>
hasProjectScope(id, 'externalSecretsProvider:delete'),
);
return rbacStore.hasScope('externalSecretsProvider:update');
});
const canDelete = computed(() => {
return rbacStore.hasScope('externalSecretsProvider:delete') || canDeleteProjectScoped.value;
// In project-scoped mode, only allow deletes for connections the project owns
if (isScopedMode.value) {
return connectionPermissions.value.delete && isProjectOwned.value;
}
return rbacStore.hasScope('externalSecretsProvider:delete');
});
const canShareGlobally = computed(() => {
@@ -155,7 +161,6 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
return rbacStore.hasScope('externalSecretsProvider:update');
});
// Computed - State
const isEditMode = computed(() => !!providerKey.value);
const providerTypeOptions = computed(() => {
@@ -320,9 +325,8 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
if (!providerKey.value) return;
try {
const { name, type, settings, projects, secretsCount } = await connection.getConnection(
providerKey.value,
);
const { name, type, state, settings, projects, secretsCount, scopes } =
await connection.getConnection(providerKey.value);
connectionName.value = name;
originalConnectionName.value = name;
@@ -337,10 +341,19 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
originalProjectIds.value = [...projectIds.value];
originalIsSharedGlobally.value = isSharedGlobally.value;
if (scopes) {
connectionScopes.value = scopes as Scope[];
}
selectedProviderType.value = providerTypes.value.find(
(providerType) => providerType.type === type,
);
await connection.testConnection(providerKey.value);
connection.setConnectionState(state);
if (canUpdate.value) {
await connection.testConnection(providerKey.value);
}
} catch (error) {
toast.showError(error, i18n.baseText('generic.error'), error?.response?.data?.data.error);
}
@@ -362,12 +375,20 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
projectIds: scopeProjectIds,
};
const { secretsCount } = await connection.createConnection(connectionData);
const { secretsCount, scopes, projects } = await connection.createConnection(connectionData);
// Transition to edit mode after successful creation
providerKey.value = connectionName.value.trim();
providerSecretsCount.value = secretsCount;
if (scopes) {
connectionScopes.value = scopes as Scope[];
}
if (projects) {
connectionProjects.value = projects;
}
// Update saved state
originalSettings.value = { ...connectionSettings.value };
originalConnectionName.value = connectionName.value.trim();
@@ -397,13 +418,20 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
const hasSettingsChanges = settingsUpdated.value;
const { secretsCount, projects } = await connection.updateConnection(
const { secretsCount, projects, scopes, state } = await connection.updateConnection(
providerKey.value,
updateData,
);
providerSecretsCount.value = secretsCount;
if (scopes) {
connectionScopes.value = scopes as Scope[];
}
if (state) {
connection.setConnectionState(state);
}
// Update saved state
originalSettings.value = { ...connectionSettings.value };
originalConnectionName.value = connectionName.value.trim();
@@ -491,11 +519,9 @@ export function useConnectionModal(options: UseConnectionModalOptions) {
}
}
const isScopedMode = computed(() => !!options.projectId);
const isReadOnly = computed(
() => isScopedMode.value && isSharedGlobally.value && isEditMode.value,
);
const isReadOnly = computed(() => {
return isScopedMode.value && isEditMode.value && !canUpdate.value;
});
return {
// State refs
@@ -31,6 +31,10 @@ export function useSecretsProviderConnection(projectId?: string) {
const isLoading = ref(false);
const isTesting = ref(false);
function setConnectionState(state: SecretProviderConnection['state']) {
connectionState.value = state;
}
// API operations
async function testConnection(providerKey: string): Promise<SecretProviderConnection['state']> {
isTesting.value = true;
@@ -131,6 +135,7 @@ export function useSecretsProviderConnection(projectId?: string) {
isTesting,
// Methods
setConnectionState,
getConnection,
createConnection,
updateConnection,