1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 package com.linecorp.centraldogma.server.internal.api;
18
19 import static com.google.common.base.Preconditions.checkArgument;
20 import static java.util.Objects.requireNonNull;
21
22 import java.io.UnsupportedEncodingException;
23 import java.net.URLDecoder;
24 import java.nio.charset.StandardCharsets;
25 import java.util.List;
26 import java.util.concurrent.CompletableFuture;
27 import java.util.function.Function;
28
29 import com.fasterxml.jackson.annotation.JsonCreator;
30 import com.fasterxml.jackson.annotation.JsonProperty;
31 import com.fasterxml.jackson.core.JsonProcessingException;
32 import com.fasterxml.jackson.databind.JsonNode;
33 import com.google.common.annotations.VisibleForTesting;
34 import com.google.common.base.MoreObjects;
35
36 import com.linecorp.armeria.common.util.Exceptions;
37 import com.linecorp.armeria.server.annotation.Consumes;
38 import com.linecorp.armeria.server.annotation.Delete;
39 import com.linecorp.armeria.server.annotation.Param;
40 import com.linecorp.armeria.server.annotation.Patch;
41 import com.linecorp.armeria.server.annotation.Post;
42 import com.linecorp.armeria.server.annotation.ProducesJson;
43 import com.linecorp.centraldogma.common.Author;
44 import com.linecorp.centraldogma.common.ProjectRole;
45 import com.linecorp.centraldogma.common.RepositoryRole;
46 import com.linecorp.centraldogma.common.Revision;
47 import com.linecorp.centraldogma.common.jsonpatch.JsonPatchOperation;
48 import com.linecorp.centraldogma.common.jsonpatch.ReplaceOperation;
49 import com.linecorp.centraldogma.internal.Jackson;
50 import com.linecorp.centraldogma.internal.jsonpatch.JsonPatch;
51 import com.linecorp.centraldogma.server.command.CommandExecutor;
52 import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRole;
53 import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRole;
54 import com.linecorp.centraldogma.server.metadata.MetadataService;
55 import com.linecorp.centraldogma.server.metadata.ProjectRoles;
56 import com.linecorp.centraldogma.server.metadata.Token;
57 import com.linecorp.centraldogma.server.metadata.User;
58
59
60
61
62 @ProducesJson
63 public class MetadataApiService extends AbstractService {
64
65 private final MetadataService mds;
66 private final Function<String, String> loginNameNormalizer;
67
68 public MetadataApiService(CommandExecutor executor, MetadataService mds,
69 Function<String, String> loginNameNormalizer) {
70 super(executor);
71 this.mds = requireNonNull(mds, "mds");
72 this.loginNameNormalizer = requireNonNull(loginNameNormalizer, "loginNameNormalizer");
73 }
74
75
76
77
78
79
80 @RequiresProjectRole(ProjectRole.OWNER)
81 @Post("/metadata/{projectName}/members")
82 public CompletableFuture<Revision> addMember(@Param String projectName,
83 IdAndProjectRole request,
84 Author author) {
85 final User member = new User(loginNameNormalizer.apply(request.id()));
86 return mds.addMember(author, projectName, member, request.role());
87 }
88
89
90
91
92
93
94
95 @RequiresProjectRole(ProjectRole.OWNER)
96 @Patch("/metadata/{projectName}/members/{memberId}")
97 @Consumes("application/json-patch+json")
98 public CompletableFuture<Revision> updateMember(@Param String projectName,
99 @Param String memberId,
100 JsonPatch jsonPatch,
101 Author author) {
102 final ReplaceOperation operation = ensureSingleReplaceOperation(jsonPatch, "/role");
103 final ProjectRole role = ProjectRole.of(operation.value());
104 final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
105 return mds.getMember(projectName, member)
106 .thenCompose(unused -> mds.updateMemberRole(author, projectName, member, role));
107 }
108
109
110
111
112
113
114 @RequiresProjectRole(ProjectRole.OWNER)
115 @Delete("/metadata/{projectName}/members/{memberId}")
116 public CompletableFuture<Revision> removeMember(@Param String projectName,
117 @Param String memberId,
118 Author author) {
119 final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
120 return mds.getMember(projectName, member)
121 .thenCompose(unused -> mds.removeMember(author, projectName, member));
122 }
123
124
125
126
127
128
129 @RequiresProjectRole(ProjectRole.OWNER)
130 @Post("/metadata/{projectName}/tokens")
131 public CompletableFuture<Revision> addToken(@Param String projectName,
132 IdAndProjectRole request,
133 Author author) {
134 final Token token = mds.findTokenByAppId(request.id());
135 return mds.addToken(author, projectName, token.appId(), request.role());
136 }
137
138
139
140
141
142
143
144 @RequiresProjectRole(ProjectRole.OWNER)
145 @Patch("/metadata/{projectName}/tokens/{appId}")
146 @Consumes("application/json-patch+json")
147 public CompletableFuture<Revision> updateTokenRole(@Param String projectName,
148 @Param String appId,
149 JsonPatch jsonPatch,
150 Author author) {
151 final ReplaceOperation operation = ensureSingleReplaceOperation(jsonPatch, "/role");
152 final ProjectRole role = ProjectRole.of(operation.value());
153 final Token token = mds.findTokenByAppId(appId);
154 return mds.updateTokenRole(author, projectName, token, role);
155 }
156
157
158
159
160
161
162 @RequiresProjectRole(ProjectRole.OWNER)
163 @Delete("/metadata/{projectName}/tokens/{appId}")
164 public CompletableFuture<Revision> removeToken(@Param String projectName,
165 @Param String appId,
166 Author author) {
167 final Token token = mds.findTokenByAppId(appId);
168 return mds.removeToken(author, projectName, token.appId());
169 }
170
171
172
173
174
175
176
177
178
179
180
181
182
183 @RequiresRepositoryRole(RepositoryRole.ADMIN)
184 @Post("/metadata/{projectName}/repos/{repoName}/roles/projects")
185 public CompletableFuture<Revision> updateRepositoryProjectRoles(
186 @Param String projectName,
187 @Param String repoName,
188 JsonNode payload,
189 Author author) throws JsonProcessingException {
190 final JsonNode guest = payload.get("guest");
191 if (guest.isTextual()) {
192
193
194 final String role = guest.asText();
195 if ("WRITE".equals(role)) {
196 throw new IllegalArgumentException("WRITE is not allowed for GUEST");
197 }
198 }
199 final ProjectRoles projectRoles = Jackson.treeToValue(payload, ProjectRoles.class);
200 return mds.updateRepositoryProjectRoles(author, projectName, repoName, projectRoles);
201 }
202
203
204
205
206
207
208
209 @RequiresRepositoryRole(RepositoryRole.ADMIN)
210 @Post("/metadata/{projectName}/repos/{repoName}/roles/users")
211 public CompletableFuture<Revision> addUserRepositoryRole(
212 @Param String projectName,
213 @Param String repoName,
214 IdAndRepositoryRole idAndRepositoryRole,
215 Author author) {
216 final User member = new User(loginNameNormalizer.apply(idAndRepositoryRole.id()));
217 return mds.addUserRepositoryRole(author, projectName, repoName,
218 member, idAndRepositoryRole.role());
219 }
220
221
222
223
224
225
226
227 @RequiresRepositoryRole(RepositoryRole.ADMIN)
228 @Delete("/metadata/{projectName}/repos/{repoName}/roles/users/{memberId}")
229 public CompletableFuture<Revision> removeUserRepositoryRole(@Param String projectName,
230 @Param String repoName,
231 @Param String memberId,
232 Author author) {
233 final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
234 return mds.findRepositoryRole(projectName, repoName, member)
235 .thenCompose(unused -> mds.removeUserRepositoryRole(author, projectName,
236 repoName, member));
237 }
238
239
240
241
242
243
244
245 @RequiresRepositoryRole(RepositoryRole.ADMIN)
246 @Post("/metadata/{projectName}/repos/{repoName}/roles/tokens")
247 public CompletableFuture<Revision> addTokenRepositoryRole(
248 @Param String projectName,
249 @Param String repoName,
250 IdAndRepositoryRole tokenAndRepositoryRole,
251 Author author) {
252 return mds.addTokenRepositoryRole(author, projectName, repoName,
253 tokenAndRepositoryRole.id(), tokenAndRepositoryRole.role());
254 }
255
256
257
258
259
260
261
262 @RequiresRepositoryRole(RepositoryRole.ADMIN)
263 @Delete("/metadata/{projectName}/repos/{repoName}/roles/tokens/{appId}")
264 public CompletableFuture<Revision> removeTokenRepositoryRole(@Param String projectName,
265 @Param String repoName,
266 @Param String appId,
267 Author author) {
268 final Token token = mds.findTokenByAppId(appId);
269 return mds.removeTokenRepositoryRole(author, projectName, repoName, appId);
270 }
271
272 private static ReplaceOperation ensureSingleReplaceOperation(JsonPatch patch, String expectedPath) {
273 final List<JsonPatchOperation> operations = patch.operations();
274 checkArgument(operations.size() == 1,
275 "Should be a single JSON patch operation in the list: " + operations.size());
276
277 final JsonPatchOperation operation = patch.operations().get(0);
278 checkArgument(operation instanceof ReplaceOperation,
279 "Should be a replace operation: " + operation);
280
281 checkArgument(expectedPath.equals(operation.path().toString()),
282 "Invalid path value: " + operation.path());
283
284 return (ReplaceOperation) operation;
285 }
286
287 private static String urlDecode(String input) {
288 try {
289
290 return URLDecoder.decode(input, StandardCharsets.UTF_8.name());
291 } catch (UnsupportedEncodingException e) {
292 return Exceptions.throwUnsafely(e);
293 }
294 }
295
296 public static final class IdAndProjectRole {
297
298 private final String id;
299 private final ProjectRole role;
300
301 @VisibleForTesting
302 @JsonCreator
303 public IdAndProjectRole(@JsonProperty("id") String id,
304 @JsonProperty("role") ProjectRole role) {
305 this.id = requireNonNull(id, "id");
306 requireNonNull(role, "role");
307 checkArgument(role == ProjectRole.OWNER || role == ProjectRole.MEMBER,
308 "Invalid role: " + role +
309 " (expected: '" + ProjectRole.OWNER + "' or '" + ProjectRole.MEMBER + "')");
310 this.role = role;
311 }
312
313 @JsonProperty
314 public String id() {
315 return id;
316 }
317
318 @JsonProperty
319 public ProjectRole role() {
320 return role;
321 }
322
323 @Override
324 public String toString() {
325 return MoreObjects.toStringHelper(this)
326 .add("id", id())
327 .add("role", role())
328 .toString();
329 }
330 }
331
332 public static final class IdAndRepositoryRole {
333
334 private final String id;
335 private final RepositoryRole role;
336
337 @VisibleForTesting
338 @JsonCreator
339 public IdAndRepositoryRole(@JsonProperty("id") String id,
340 @JsonProperty("role") RepositoryRole role) {
341 this.id = requireNonNull(id, "id");
342 this.role = requireNonNull(role, "role");
343 }
344
345 @JsonProperty
346 public String id() {
347 return id;
348 }
349
350 @JsonProperty
351 public RepositoryRole role() {
352 return role;
353 }
354
355 @Override
356 public String toString() {
357 return MoreObjects.toStringHelper(this)
358 .add("id", id())
359 .add("role", role())
360 .toString();
361 }
362 }
363 }