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.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   * Annotated service object for managing metadata of projects.
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       * POST /metadata/{projectName}/members
77       *
78       * <p>Adds a member to the specified {@code projectName}.
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       * PATCH /metadata/{projectName}/members/{memberId}
91       *
92       * <p>Updates the {@link ProjectRole} of the specified {@code memberId} in the specified
93       * {@code projectName}.
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      * DELETE /metadata/{projectName}/members/{memberId}
111      *
112      * <p>Removes the specified {@code memberId} from the specified {@code projectName}.
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      * POST /metadata/{projectName}/tokens
126      *
127      * <p>Adds a {@link Token} to the specified {@code projectName}.
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      * PATCH /metadata/{projectName}/tokens/{appId}
140      *
141      * <p>Updates the {@link ProjectRole} of the {@link Token} of the specified {@code appId}
142      * in the specified {@code projectName}.
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      * DELETE /metadata/{projectName}/tokens/{appId}
159      *
160      * <p>Removes the {@link Token} of the specified {@code appId} from the specified {@code projectName}.
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      * POST /metadata/{projectName}/repos/{repoName}/roles/projects
173      *
174      * <p>Updates member and guest's {@link RepositoryRole}s of the specified {@code repoName} in the specified
175      * {@code projectName}. The body of the request will be:
176      * <pre>{@code
177      * {
178      *   "member": "WRITE",
179      *   "guest": "READ"
180      * }
181      * }</pre>
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             // TODO(ikhoon): Move this validation to the constructor of ProjectRoles once GUEST WRITE role is
193             //               migrated to GUEST READ.
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      * POST /metadata/{projectName}/repos/{repoName}/roles/users
205      *
206      * <p>Adds the {@link RepositoryRole} of the specific users to the specified {@code repoName} in the
207      * specified {@code projectName}.
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      * DELETE /metadata/{projectName}/repos/{repoName}/roles/users/{memberId}
223      *
224      * <p>Removes {@link RepositoryRole} of the specified {@code memberId} from the specified {@code repoName}
225      * in the specified {@code projectName}.
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      * POST /metadata/{projectName}/repos/{repoName}/roles/tokens
241      *
242      * <p>Adds the {@link RepositoryRole} for a token to the specified {@code repoName} in the specified
243      * {@code projectName}.
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      * DELETE /metadata/{projectName}/repos/{repoName}/roles/tokens/{appId}
258      *
259      * <p>Removes the {@link RepositoryRole} of the specified {@code appId} from the specified {@code repoName}
260      * in the specified {@code projectName}.
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             // TODO(hyangtack) Remove this after https://github.com/line/armeria/issues/756 is resolved.
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 }