feat(core): Scope getStatus for environments for project admin role (#15404)

This commit is contained in:
Andreas Fitzek
2025-05-22 13:49:54 +02:00
committed by GitHub
parent 8152f8c6a7
commit f9f9597bbd
11 changed files with 2275 additions and 66 deletions

View File

@@ -3,9 +3,15 @@ import type {
PushWorkFolderRequestDto,
SourceControlledFile,
} from '@n8n/api-types';
import type { Variables, TagEntity, User } from '@n8n/db';
import { FolderRepository, TagRepository } from '@n8n/db';
import {
type Variables,
type TagEntity,
FolderRepository,
TagRepository,
type User,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { writeFileSync } from 'fs';
import { Logger } from 'n8n-core';
import { UnexpectedError, UserError } from 'n8n-workflow';
@@ -13,6 +19,7 @@ import path from 'path';
import type { PushResult } from 'simple-git';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import {
@@ -38,6 +45,7 @@ import { SourceControlPreferencesService } from './source-control-preferences.se
import type { ExportableCredential } from './types/exportable-credential';
import type { ExportableFolder } from './types/exportable-folders';
import type { ImportResult } from './types/import-result';
import { SourceControlContext } from './types/source-control-context';
import type { SourceControlGetStatus } from './types/source-control-get-status';
import type { SourceControlPreferences } from './types/source-control-preferences';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
@@ -481,6 +489,13 @@ export class SourceControlService {
async getStatus(user: User, options: SourceControlGetStatus) {
await this.sanityCheck();
const context = new SourceControlContext(user);
if (options.direction === 'pull' && !hasGlobalScope(user, 'sourceControl:pull')) {
// A pull is only allowed by global admins or owners
return new ForbiddenError('You do not have permission to pull from source control');
}
const sourceControlledFiles: SourceControlledFile[] = [];
// fetch and reset hard first
@@ -492,10 +507,10 @@ export class SourceControlService {
wfMissingInLocal,
wfMissingInRemote,
wfModifiedInEither,
} = await this.getStatusWorkflows(options, sourceControlledFiles);
} = await this.getStatusWorkflows(options, context, sourceControlledFiles);
const { credMissingInLocal, credMissingInRemote, credModifiedInEither } =
await this.getStatusCredentials(options, sourceControlledFiles);
await this.getStatusCredentials(options, context, sourceControlledFiles);
const { varMissingInLocal, varMissingInRemote, varModifiedInEither } =
await this.getStatusVariables(options, sourceControlledFiles);
@@ -506,10 +521,10 @@ export class SourceControlService {
tagsModifiedInEither,
mappingsMissingInLocal,
mappingsMissingInRemote,
} = await this.getStatusTagsMappings(options, sourceControlledFiles);
} = await this.getStatusTagsMappings(options, context, sourceControlledFiles);
const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } =
await this.getStatusFoldersMapping(options, sourceControlledFiles);
await this.getStatusFoldersMapping(options, context, sourceControlledFiles);
// #region Tracking Information
if (options.direction === 'push') {
@@ -555,14 +570,34 @@ export class SourceControlService {
private async getStatusWorkflows(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const wfRemoteVersionIds = await this.sourceControlImportService.getRemoteVersionIdsFromFiles();
const wfLocalVersionIds = await this.sourceControlImportService.getLocalVersionIdsFromDb();
// TODO: We need to check the case where it exists in the DB (out of scope) but is in GIT
const wfRemoteVersionIds =
await this.sourceControlImportService.getRemoteVersionIdsFromFiles(context);
const wfLocalVersionIds =
await this.sourceControlImportService.getLocalVersionIdsFromDb(context);
const wfMissingInLocal = wfRemoteVersionIds.filter(
(remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1,
);
let outOfScopeWF: SourceControlWorkflowVersionId[] = [];
if (!context.hasAccessToAllProjects()) {
// we need to query for all wf in the DB to hide possible deletions,
// when a wf went out of scope locally
outOfScopeWF = await this.sourceControlImportService.getAllLocalVersionIdsFromDb();
outOfScopeWF = outOfScopeWF.filter(
(wf) => !wfLocalVersionIds.some((local) => local.id === wf.id),
);
}
const wfMissingInLocal = wfRemoteVersionIds
.filter((remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1)
.filter(
// If we have out of scope workflows, these are workflows, that are not
// visible locally, but exists locally but are available in remote
// we skip them and hide them from deletion from the user.
(remote) => !outOfScopeWF.some((outOfScope) => outOfScope.id === remote.id),
);
const wfMissingInRemote = wfLocalVersionIds.filter(
(local) => wfRemoteVersionIds.findIndex((remote) => remote.id === local.id) === -1,
@@ -654,10 +689,12 @@ export class SourceControlService {
private async getStatusCredentials(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const credRemoteIds = await this.sourceControlImportService.getRemoteCredentialsFromFiles();
const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb();
const credRemoteIds =
await this.sourceControlImportService.getRemoteCredentialsFromFiles(context);
const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb(context);
const credMissingInLocal = credRemoteIds.filter(
(remote) => credLocalIds.findIndex((local) => local.id === remote.id) === -1,
@@ -807,6 +844,7 @@ export class SourceControlService {
private async getStatusTagsMappings(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const lastUpdatedTag = await this.tagRepository.find({
@@ -816,8 +854,9 @@ export class SourceControlService {
});
const tagMappingsRemote =
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile();
const tagMappingsLocal = await this.sourceControlImportService.getLocalTagsAndMappingsFromDb();
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(context);
const tagMappingsLocal =
await this.sourceControlImportService.getLocalTagsAndMappingsFromDb(context);
const tagsMissingInLocal = tagMappingsRemote.tags.filter(
(remote) => tagMappingsLocal.tags.findIndex((local) => local.id === remote.id) === -1,
@@ -901,6 +940,7 @@ export class SourceControlService {
private async getStatusFoldersMapping(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const lastUpdatedFolder = await this.folderRepository.find({
@@ -910,9 +950,9 @@ export class SourceControlService {
});
const foldersMappingsRemote =
await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile();
await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile(context);
const foldersMappingsLocal =
await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb();
await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb(context);
const foldersMissingInLocal = foldersMappingsRemote.folders.filter(
(remote) => foldersMappingsLocal.folders.findIndex((local) => local.id === remote.id) === -1,