mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
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:
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+20
-12
@@ -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;
|
||||
|
||||
+18
@@ -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',
|
||||
];
|
||||
@@ -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), {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+206
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
-21
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
@@ -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(),
|
||||
|
||||
+104
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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,
|
||||
|
||||
+73
-20
@@ -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(),
|
||||
|
||||
+58
-9
@@ -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[]> {
|
||||
|
||||
+5
-3
@@ -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')
|
||||
|
||||
+4
-11
@@ -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',
|
||||
|
||||
+28
-3
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
)
|
||||
|
||||
+102
-2
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
+59
-33
@@ -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
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user