1   /*
2    * Copyright 2018 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
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.Collection;
26  import java.util.List;
27  import java.util.concurrent.CompletableFuture;
28  import java.util.function.Function;
29  
30  import com.fasterxml.jackson.annotation.JsonCreator;
31  import com.fasterxml.jackson.annotation.JsonProperty;
32  import com.fasterxml.jackson.core.type.TypeReference;
33  import com.google.common.base.MoreObjects;
34  
35  import com.linecorp.armeria.common.util.Exceptions;
36  import com.linecorp.armeria.server.annotation.Consumes;
37  import com.linecorp.armeria.server.annotation.Delete;
38  import com.linecorp.armeria.server.annotation.ExceptionHandler;
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.Revision;
45  import com.linecorp.centraldogma.internal.Jackson;
46  import com.linecorp.centraldogma.internal.jsonpatch.JsonPatch;
47  import com.linecorp.centraldogma.internal.jsonpatch.JsonPatchOperation;
48  import com.linecorp.centraldogma.internal.jsonpatch.ReplaceOperation;
49  import com.linecorp.centraldogma.server.QuotaConfig;
50  import com.linecorp.centraldogma.server.internal.api.auth.RequiresAdministrator;
51  import com.linecorp.centraldogma.server.internal.api.auth.RequiresRole;
52  import com.linecorp.centraldogma.server.metadata.MetadataService;
53  import com.linecorp.centraldogma.server.metadata.PerRolePermissions;
54  import com.linecorp.centraldogma.server.metadata.Permission;
55  import com.linecorp.centraldogma.server.metadata.ProjectRole;
56  import com.linecorp.centraldogma.server.metadata.Token;
57  import com.linecorp.centraldogma.server.metadata.User;
58  
59  /**
60   * Annotated service object for managing metadata of projects.
61   */
62  @ProducesJson
63  @RequiresRole(roles = ProjectRole.OWNER)
64  @ExceptionHandler(HttpApiExceptionHandler.class)
65  public class MetadataApiService {
66  
67      private static final TypeReference<Collection<Permission>> permissionsTypeRef =
68              new TypeReference<Collection<Permission>>() {};
69  
70      private final MetadataService mds;
71      private final Function<String, String> loginNameNormalizer;
72  
73      public MetadataApiService(MetadataService mds, Function<String, String> loginNameNormalizer) {
74          this.mds = requireNonNull(mds, "mds");
75          this.loginNameNormalizer = requireNonNull(loginNameNormalizer, "loginNameNormalizer");
76      }
77  
78      /**
79       * POST /metadata/{projectName}/members
80       *
81       * <p>Adds a member to the specified {@code projectName}.
82       */
83      @Post("/metadata/{projectName}/members")
84      public CompletableFuture<Revision> addMember(@Param String projectName,
85                                                   IdentifierWithRole request,
86                                                   Author author) {
87          final ProjectRole role = toProjectRole(request.role());
88          final User member = new User(loginNameNormalizer.apply(request.id()));
89          return mds.addMember(author, projectName, member, role);
90      }
91  
92      /**
93       * PATCH /metadata/{projectName}/members/{memberId}
94       *
95       * <p>Updates the {@link ProjectRole} of the specified {@code memberId} in the specified
96       * {@code projectName}.
97       */
98      @Patch("/metadata/{projectName}/members/{memberId}")
99      @Consumes("application/json-patch+json")
100     public CompletableFuture<Revision> updateMember(@Param String projectName,
101                                                     @Param String memberId,
102                                                     JsonPatch jsonPatch,
103                                                     Author author) {
104         final ReplaceOperation operation = ensureSingleReplaceOperation(jsonPatch, "/role");
105         final ProjectRole role = ProjectRole.of(operation.value());
106         final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
107         return mds.getMember(projectName, member)
108                   .thenCompose(unused -> mds.updateMemberRole(author, projectName, member, role));
109     }
110 
111     /**
112      * DELETE /metadata/{projectName}/members/{memberId}
113      *
114      * <p>Removes the specified {@code memberId} from the specified {@code projectName}.
115      */
116     @Delete("/metadata/{projectName}/members/{memberId}")
117     public CompletableFuture<Revision> removeMember(@Param String projectName,
118                                                     @Param String memberId,
119                                                     Author author) {
120         final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
121         return mds.getMember(projectName, member)
122                   .thenCompose(unused -> mds.removeMember(author, projectName, member));
123     }
124 
125     /**
126      * POST /metadata/{projectName}/tokens
127      *
128      * <p>Adds a {@link Token} to the specified {@code projectName}.
129      */
130     @Post("/metadata/{projectName}/tokens")
131     public CompletableFuture<Revision> addToken(@Param String projectName,
132                                                 IdentifierWithRole request,
133                                                 Author author) {
134         final ProjectRole role = toProjectRole(request.role());
135         return mds.findTokenByAppId(request.id())
136                   .thenCompose(token -> mds.addToken(author, projectName, token.appId(), role));
137     }
138 
139     /**
140      * PATCH /metadata/{projectName}/tokens/{appId}
141      *
142      * <p>Updates the {@link ProjectRole} of the {@link Token} of the specified {@code appId}
143      * in the specified {@code projectName}.
144      */
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         return mds.findTokenByAppId(appId)
154                   .thenCompose(token -> mds.updateTokenRole(author, projectName, token, role));
155     }
156 
157     /**
158      * DELETE /metadata/{projectName}/tokens/{appId}
159      *
160      * <p>Removes the {@link Token} of the specified {@code appId} from the specified {@code projectName}.
161      */
162     @Delete("/metadata/{projectName}/tokens/{appId}")
163     public CompletableFuture<Revision> removeToken(@Param String projectName,
164                                                    @Param String appId,
165                                                    Author author) {
166         return mds.findTokenByAppId(appId)
167                   .thenCompose(token -> mds.removeToken(author, projectName, token.appId()));
168     }
169 
170     /**
171      * POST /metadata/{projectName}/repos/{repoName}/perm/role
172      *
173      * <p>Updates the {@link PerRolePermissions} of the specified {@code repoName} in the specified
174      * {@code projectName}.
175      */
176     @Post("/metadata/{projectName}/repos/{repoName}/perm/role")
177     public CompletableFuture<Revision> updateRolePermission(@Param String projectName,
178                                                             @Param String repoName,
179                                                             PerRolePermissions newPermission,
180                                                             Author author) {
181         return mds.updatePerRolePermissions(author, projectName, repoName, newPermission);
182     }
183 
184     /**
185      * POST /metadata/{projectName}/repos/{repoName}/perm/users
186      *
187      * <p>Adds {@link Permission}s of the specific users to the specified {@code repoName} in the
188      * specified {@code projectName}.
189      */
190     @Post("/metadata/{projectName}/repos/{repoName}/perm/users")
191     public CompletableFuture<Revision> addSpecificUserPermission(
192             @Param String projectName,
193             @Param String repoName,
194             IdentifierWithPermissions memberWithPermissions,
195             Author author) {
196         final User member = new User(loginNameNormalizer.apply(memberWithPermissions.id()));
197         return mds.addPerUserPermission(author, projectName, repoName,
198                                         member, memberWithPermissions.permissions());
199     }
200 
201     /**
202      * PATCH /metadata/{projectName}/repos/{repoName}/perm/users/{memberId}
203      *
204      * <p>Updates {@link Permission}s for the specified {@code memberId} of the specified {@code repoName}
205      * in the specified {@code projectName}.
206      */
207     @Patch("/metadata/{projectName}/repos/{repoName}/perm/users/{memberId}")
208     @Consumes("application/json-patch+json")
209     public CompletableFuture<Revision> updateSpecificUserPermission(@Param String projectName,
210                                                                     @Param String repoName,
211                                                                     @Param String memberId,
212                                                                     JsonPatch jsonPatch,
213                                                                     Author author) {
214         final ReplaceOperation operation = ensureSingleReplaceOperation(jsonPatch, "/permissions");
215         final Collection<Permission> permissions = Jackson.convertValue(operation.value(), permissionsTypeRef);
216         final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
217         return mds.findPermissions(projectName, repoName, member)
218                   .thenCompose(unused -> mds.updatePerUserPermission(author,
219                                                                      projectName, repoName, member,
220                                                                      permissions));
221     }
222 
223     /**
224      * DELETE /metadata/{projectName}/repos/{repoName}/perm/users/{memberId}
225      *
226      * <p>Removes {@link Permission}s for the specified {@code memberId} from the specified {@code repoName}
227      * in the specified {@code projectName}.
228      */
229     @Delete("/metadata/{projectName}/repos/{repoName}/perm/users/{memberId}")
230     public CompletableFuture<Revision> removeSpecificUserPermission(@Param String projectName,
231                                                                     @Param String repoName,
232                                                                     @Param String memberId,
233                                                                     Author author) {
234         final User member = new User(loginNameNormalizer.apply(urlDecode(memberId)));
235         return mds.findPermissions(projectName, repoName, member)
236                   .thenCompose(unused -> mds.removePerUserPermission(author, projectName,
237                                                                      repoName, member));
238     }
239 
240     /**
241      * POST /metadata/{projectName}/repos/{repoName}/perm/tokens
242      *
243      * <p>Adds {@link Permission}s for a token to the specified {@code repoName} in the specified
244      * {@code projectName}.
245      */
246     @Post("/metadata/{projectName}/repos/{repoName}/perm/tokens")
247     public CompletableFuture<Revision> addSpecificTokenPermission(
248             @Param String projectName,
249             @Param String repoName,
250             IdentifierWithPermissions tokenWithPermissions,
251             Author author) {
252         return mds.addPerTokenPermission(author, projectName, repoName,
253                                          tokenWithPermissions.id(), tokenWithPermissions.permissions());
254     }
255 
256     /**
257      * PATCH /metadata/{projectName}/repos/{repoName}/perm/tokens/{appId}
258      *
259      * <p>Updates {@link Permission}s for the specified {@code appId} of the specified {@code repoName}
260      * in the specified {@code projectName}.
261      */
262     @Patch("/metadata/{projectName}/repos/{repoName}/perm/tokens/{appId}")
263     @Consumes("application/json-patch+json")
264     public CompletableFuture<Revision> updateSpecificTokenPermission(@Param String projectName,
265                                                                      @Param String repoName,
266                                                                      @Param String appId,
267                                                                      JsonPatch jsonPatch,
268                                                                      Author author) {
269         final ReplaceOperation operation = ensureSingleReplaceOperation(jsonPatch, "/permissions");
270         final Collection<Permission> permissions = Jackson.convertValue(operation.value(), permissionsTypeRef);
271         return mds.findTokenByAppId(appId)
272                   .thenCompose(token -> mds.updatePerTokenPermission(
273                           author, projectName, repoName, appId, permissions));
274     }
275 
276     /**
277      * DELETE /metadata/{projectName}/repos/{repoName}/perm/tokens/{appId}
278      *
279      * <p>Removes {@link Permission}s of the specified {@code appId} from the specified {@code repoName}
280      * in the specified {@code projectName}.
281      */
282     @Delete("/metadata/{projectName}/repos/{repoName}/perm/tokens/{appId}")
283     public CompletableFuture<Revision> removeSpecificTokenPermission(@Param String projectName,
284                                                                      @Param String repoName,
285                                                                      @Param String appId,
286                                                                      Author author) {
287         return mds.findTokenByAppId(appId)
288                   .thenCompose(token -> mds.removePerTokenPermission(author,
289                                                                      projectName, repoName, appId));
290     }
291 
292     /**
293      * PATCH /metadata/{projectName}/repos/{repoName}/quota/write
294      *
295      * <p>Updates the {@linkplain QuotaConfig write quota} for the specified {@code repoName}
296      * in the specified {@code projectName}.
297      */
298     @Patch("/metadata/{projectName}/repos/{repoName}/quota/write")
299     @Consumes("application/json-patch+json")
300     @RequiresAdministrator
301     public CompletableFuture<Revision> updateWriteQuota(@Param String projectName,
302                                                         @Param String repoName,
303                                                         QuotaConfig quota,
304                                                         Author author) {
305         return mds.updateWriteQuota(author, projectName, repoName, quota);
306     }
307 
308     private static ReplaceOperation ensureSingleReplaceOperation(JsonPatch patch, String expectedPath) {
309         final List<JsonPatchOperation> operations = patch.operations();
310         checkArgument(operations.size() == 1,
311                       "Should be a single JSON patch operation in the list: " + operations.size());
312 
313         final JsonPatchOperation operation = patch.operations().get(0);
314         checkArgument(operation instanceof ReplaceOperation,
315                       "Should be a replace operation: " + operation);
316 
317         checkArgument(expectedPath.equals(operation.path().toString()),
318                       "Invalid path value: " + operation.path());
319 
320         return (ReplaceOperation) operation;
321     }
322 
323     private static String urlDecode(String input) {
324         try {
325             // TODO(hyangtack) Remove this after https://github.com/line/armeria/issues/756 is resolved.
326             return URLDecoder.decode(input, StandardCharsets.UTF_8.name());
327         } catch (UnsupportedEncodingException e) {
328             return Exceptions.throwUnsafely(e);
329         }
330     }
331 
332     private static ProjectRole toProjectRole(String roleStr) {
333         final ProjectRole role = ProjectRole.valueOf(requireNonNull(roleStr, "roleStr"));
334         checkArgument(role == ProjectRole.OWNER || role == ProjectRole.MEMBER,
335                       "Invalid role: " + role +
336                       " (expected: '" + ProjectRole.OWNER + "' or '" + ProjectRole.MEMBER + "')");
337         return role;
338     }
339 
340     // TODO(hyangtack) Move these classes to the common module later when our java client accesses to
341     //                 the metadata.
342     static final class IdentifierWithRole {
343 
344         private final String id;
345         private final String role;
346 
347         @JsonCreator
348         IdentifierWithRole(@JsonProperty("id") String id,
349                            @JsonProperty("role") String role) {
350             this.id = requireNonNull(id, "id");
351             this.role = requireNonNull(role, "role");
352         }
353 
354         @JsonProperty
355         public String id() {
356             return id;
357         }
358 
359         @JsonProperty
360         public String role() {
361             return role;
362         }
363 
364         @Override
365         public String toString() {
366             return MoreObjects.toStringHelper(this)
367                               .add("id", id())
368                               .add("role", role())
369                               .toString();
370         }
371     }
372 
373     static final class IdentifierWithPermissions {
374 
375         private final String id;
376         private final Collection<Permission> permissions;
377 
378         @JsonCreator
379         IdentifierWithPermissions(@JsonProperty("id") String id,
380                                   @JsonProperty("permissions") Collection<Permission> permissions) {
381             this.id = requireNonNull(id, "id");
382             this.permissions = requireNonNull(permissions, "permissions");
383         }
384 
385         @JsonProperty
386         public String id() {
387             return id;
388         }
389 
390         @JsonProperty
391         public Collection<Permission> permissions() {
392             return permissions;
393         }
394 
395         @Override
396         public String toString() {
397             return MoreObjects.toStringHelper(this)
398                               .add("id", id())
399                               .add("permissions", permissions())
400                               .toString();
401         }
402     }
403 }