Skip to content

Commit d33cf6b

Browse files
authored
Introduce experimental permission validation based on ToDo resource (#186)
1 parent 676b6a0 commit d33cf6b

File tree

8 files changed

+189
-27
lines changed

8 files changed

+189
-27
lines changed

.github/workflows/tests.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ jobs:
1616
with:
1717
node-version: ${{ matrix.node-version }}
1818

19-
- name: EditorConfig Lint
20-
uses: docker://mstruebing/editorconfig-checker:2.0.3
21-
2219
- name: Install
2320
run: yarn install --frozen-lockfile && yarn run generate
2421

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ GITLAB_AUTH_TOKEN="<token>" yarn run start
102102
| `HTTP_SERVER_ENABLE` | `false` | It'll enable experimental API and dashboard support |
103103
| `HTTP_SERVER_PORT` | `4000` | It'll use different http server port |
104104
| `WEB_HOOK_TOKEN` | `` | It'll enable experimental web hook support |
105+
| `PERMISSION_VALIDATION` | `false` | It'll enable experimental permission validation |
105106

106107
## Development
107108

server/src/Config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const defaultConfig = {
1616
WEB_HOOK_TOKEN: '',
1717
DRY_RUN: false,
1818
HTTP_PROXY: '',
19+
ENABLE_PERMISSION_VALIDATION: false,
1920
};
2021

2122
export const getConfig = (): Config => ({
@@ -66,6 +67,10 @@ export const getConfig = (): Config => ({
6667
WEB_HOOK_TOKEN: env.get('WEB_HOOK_TOKEN').default(defaultConfig.WEB_HOOK_TOKEN).asString(),
6768
DRY_RUN: env.get('DRY_RUN').default(`${defaultConfig.DRY_RUN}`).asBoolStrict(),
6869
HTTP_PROXY: env.get('HTTP_PROXY').default('').asString(),
70+
ENABLE_PERMISSION_VALIDATION: env
71+
.get('ENABLE_PERMISSION_VALIDATION')
72+
.default(`${defaultConfig.ENABLE_PERMISSION_VALIDATION}`)
73+
.asBoolStrict(),
6974
});
7075

7176
export type Config = typeof defaultConfig;

server/src/GitlabApi.ts

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fetch, { FetchError, RequestInit, Response } from 'node-fetch';
22
import queryString, { ParsedUrlQueryInput } from 'querystring';
33
import { sleep } from './Utils';
44
import { HttpsProxyAgent } from 'https-proxy-agent';
5+
import * as console from 'console';
56

67
export interface User {
78
id: number;
@@ -40,13 +41,55 @@ interface MergeRequestAssignee {
4041
id: number;
4142
}
4243

44+
export interface ProtectedBranch {
45+
id: number;
46+
name: string;
47+
merge_access_levels: (
48+
| {
49+
access_level: number;
50+
user_id: null;
51+
group_id: null;
52+
}
53+
| {
54+
access_level: null;
55+
user_id: number;
56+
group_id: null;
57+
}
58+
| {
59+
access_level: null;
60+
user_id: null;
61+
group_id: number;
62+
}
63+
)[];
64+
}
65+
66+
interface Author {
67+
id: number;
68+
username: string;
69+
name: string;
70+
web_url: string;
71+
state: 'active' | 'awaiting';
72+
}
73+
74+
export interface Member extends Author {
75+
access_level: number;
76+
expires_at: string | null;
77+
}
78+
79+
export interface ToDo {
80+
id: number;
81+
project: {
82+
id: number;
83+
};
84+
author: Author;
85+
target: MergeRequest;
86+
}
87+
4388
export interface MergeRequest {
4489
id: number;
4590
iid: number;
4691
title: string;
47-
author: {
48-
id: number;
49-
};
92+
author: Author;
5093
assignee: MergeRequestAssignee | null;
5194
assignees: MergeRequestAssignee[];
5295
project_id: number;
@@ -159,6 +202,37 @@ export class GitlabApi {
159202
return this.sendRequestWithSingleResponse(`/api/v4/users/${userId}`, RequestMethod.Get);
160203
}
161204

205+
public async getProtectedBranch(
206+
projectId: number,
207+
name: string,
208+
): Promise<ProtectedBranch | null> {
209+
return this.sendRequestWithSingleResponse(
210+
`/api/v4/projects/${projectId}/protected_branches/${encodeURIComponent(name)}`,
211+
RequestMethod.Get,
212+
);
213+
}
214+
215+
public async getMember(projectId: number, userId: number): Promise<Member | null> {
216+
return this.sendRequestWithSingleResponse(
217+
`/api/v4/projects/${projectId}/members/${userId}`,
218+
RequestMethod.Get,
219+
);
220+
}
221+
222+
public async getMergeRequestTodos(): Promise<ToDo[]> {
223+
return this.sendRequestWithMultiResponse(
224+
`/api/v4/todos?type=MergeRequest&action=assigned&state=pending`,
225+
RequestMethod.Get,
226+
);
227+
}
228+
229+
public async markTodoAsDone(todoId: number): Promise<void> {
230+
return this.sendRequestWithSingleResponse(
231+
`/api/v4/todos/${todoId}/mark_as_done`,
232+
RequestMethod.Post,
233+
);
234+
}
235+
162236
public async getAssignedOpenedMergeRequests(): Promise<MergeRequest[]> {
163237
return this.sendRequestWithMultiResponse(
164238
`/api/v4/merge_requests?scope=assigned_to_me&state=opened`,
@@ -190,16 +264,6 @@ export class GitlabApi {
190264
);
191265
}
192266

193-
public async getMergeRequestPipelines(
194-
projectId: number,
195-
mergeRequestIid: number,
196-
): Promise<MergeRequestPipeline[]> {
197-
return this.sendRequestWithMultiResponse(
198-
`/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}/pipelines`,
199-
RequestMethod.Get,
200-
);
201-
}
202-
203267
public async getPipelineJobs(projectId: number, pipelineId: number): Promise<PipelineJob[]> {
204268
return this.sendRequestWithMultiResponse(
205269
`/api/v4/projects/${projectId}/pipelines/${pipelineId}/jobs?per_page=100`,
@@ -287,6 +351,10 @@ export class GitlabApi {
287351
body?: ParsedUrlQueryInput,
288352
): Promise<any> {
289353
const response = await this.sendRawRequest(url, method, body);
354+
if (response.status === 404) {
355+
return null;
356+
}
357+
290358
await this.validateResponseStatus(response);
291359

292360
const data = await response.json();

server/src/MergeRequestAcceptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ const incompletePipelineStatuses = [
186186
];
187187

188188
const containsLabel = (labels: string[], label: BotLabels) => labels.includes(label);
189-
const containsAssignedUser = (mergeRequest: MergeRequest, user: User) => {
189+
export const containsAssignedUser = (mergeRequest: MergeRequest, user: User) => {
190190
const userIds = mergeRequest.assignees.map((assignee) => assignee.id);
191191
return userIds.includes(user.id);
192192
};

server/src/MergeRequestCheckerLoop.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GitlabApi, MergeRequest, User } from './GitlabApi';
1+
import { GitlabApi, MergeRequest, ToDo, User } from './GitlabApi';
22
import { prepareMergeRequestForMerge } from './MergeRequestReceiver';
33
import { Config } from './Config';
44
import { Worker } from './Worker';
@@ -69,17 +69,26 @@ export class MergeRequestCheckerLoop {
6969
}
7070

7171
private async task() {
72+
if (this.config.ENABLE_PERMISSION_VALIDATION) {
73+
console.log('[loop] Checking new todos');
74+
const mergeRequestTodos = await this.gitlabApi.getMergeRequestTodos();
75+
const possibleToAcceptMergeRequests = mergeRequestTodos.map((mergeRequestTodo: ToDo) =>
76+
prepareMergeRequestForMerge(this.gitlabApi, this.user, this.worker, this.config, {
77+
mergeRequestTodo,
78+
}),
79+
);
80+
81+
await Promise.all(possibleToAcceptMergeRequests);
82+
return;
83+
}
84+
7285
console.log('[loop] Checking assigned merge requests');
7386
const assignedMergeRequests = await this.gitlabApi.getAssignedOpenedMergeRequests();
7487
const possibleToAcceptMergeRequests = assignedMergeRequests.map(
7588
(mergeRequest: MergeRequest) =>
76-
prepareMergeRequestForMerge(
77-
this.gitlabApi,
78-
this.user,
79-
this.worker,
80-
this.config,
89+
prepareMergeRequestForMerge(this.gitlabApi, this.user, this.worker, this.config, {
8190
mergeRequest,
82-
),
91+
}),
8392
);
8493

8594
await Promise.all(possibleToAcceptMergeRequests);

server/src/MergeRequestReceiver.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { DetailedMergeStatus, GitlabApi, MergeRequest, User } from './GitlabApi';
1+
import { DetailedMergeStatus, GitlabApi, MergeRequest, ToDo, User } from './GitlabApi';
22
import { assignToAuthorAndResetLabels } from './AssignToAuthor';
33
import { sendNote } from './SendNote';
44
import {
55
acceptMergeRequest,
66
AcceptMergeRequestResultKind,
77
BotLabels,
8+
containsAssignedUser,
89
runAcceptingMergeRequest,
910
} from './MergeRequestAcceptor';
1011
import { resolveMergeRequestResult } from './MergeRequestResultResolver';
@@ -18,8 +19,28 @@ export const prepareMergeRequestForMerge = async (
1819
user: User,
1920
worker: Worker,
2021
config: Config,
21-
mergeRequest: MergeRequest,
22+
mergeRequestData:
23+
| {
24+
mergeRequestTodo: ToDo;
25+
}
26+
| {
27+
mergeRequest: MergeRequest;
28+
},
2229
) => {
30+
const { mergeRequest, author } = (() => {
31+
if ('mergeRequestTodo' in mergeRequestData) {
32+
return {
33+
mergeRequest: mergeRequestData.mergeRequestTodo.target,
34+
author: mergeRequestData.mergeRequestTodo.author,
35+
};
36+
}
37+
38+
return {
39+
mergeRequest: mergeRequestData.mergeRequest,
40+
author: null,
41+
};
42+
})();
43+
2344
const jobId = `accept-merge-${mergeRequest.id}`;
2445
const jobPriority = mergeRequest.labels.includes(config.HIGH_PRIORITY_LABEL)
2546
? JobPriority.HIGH
@@ -50,6 +71,64 @@ export const prepareMergeRequestForMerge = async (
5071
return;
5172
}
5273

74+
if (!containsAssignedUser(mergeRequest, user)) {
75+
if ('mergeRequestTodo' in mergeRequestData) {
76+
await gitlabApi.markTodoAsDone(mergeRequestData.mergeRequestTodo.id);
77+
}
78+
79+
return;
80+
}
81+
82+
// Validate permissions
83+
if (author !== null) {
84+
const protectedBranch = await gitlabApi.getProtectedBranch(
85+
mergeRequest.target_project_id,
86+
mergeRequest.target_branch,
87+
);
88+
if (protectedBranch !== null) {
89+
const member = await gitlabApi.getMember(mergeRequest.target_project_id, author.id);
90+
if (member === null) {
91+
await Promise.all([
92+
assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user),
93+
sendNote(
94+
gitlabApi,
95+
mergeRequest,
96+
`I can't merge it because the merge request was made by ${author.username} who is unauthorized for this instruction.`,
97+
),
98+
]);
99+
100+
return;
101+
}
102+
103+
const hasAccessLevel = protectedBranch.merge_access_levels.find((mergeAccessLevel) => {
104+
if (mergeAccessLevel.user_id !== null && member.id === mergeAccessLevel.user_id) {
105+
return true;
106+
}
107+
108+
if (
109+
mergeAccessLevel.access_level !== null &&
110+
member.access_level >= mergeAccessLevel.access_level
111+
) {
112+
return true;
113+
}
114+
115+
return false;
116+
});
117+
if (!hasAccessLevel) {
118+
await Promise.all([
119+
assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user),
120+
sendNote(
121+
gitlabApi,
122+
mergeRequest,
123+
`I can't merge it because the merge request was made by ${author.username} who doesn't pass the protection of the target branch.`,
124+
),
125+
]);
126+
127+
return;
128+
}
129+
}
130+
}
131+
53132
if (mergeRequest.detailed_merge_status === DetailedMergeStatus.DraftStatus) {
54133
console.log(`[loop][MR][${mergeRequest.iid}] Merge request is a draft, assigning back`);
55134

@@ -155,6 +234,9 @@ export const prepareMergeRequestForMerge = async (
155234
}
156235

157236
console.log(`Finishing job: ${JSON.stringify(result)}`);
237+
if ('mergeRequestTodo' in mergeRequestData) {
238+
await gitlabApi.markTodoAsDone(mergeRequestData.mergeRequestTodo.id);
239+
}
158240
success();
159241
await resolveMergeRequestResult(gitlabApi, result);
160242
},

server/src/WebHookServer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const processMergeRequestHook = async (
6262
data.project.id,
6363
data.object_attributes.iid,
6464
);
65-
await prepareMergeRequestForMerge(gitlabApi, user, worker, config, mergeRequest);
65+
await prepareMergeRequestForMerge(gitlabApi, user, worker, config, { mergeRequest });
6666
return;
6767
}
6868

0 commit comments

Comments
 (0)