1   /*
2    * Copyright 2019 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.metadata;
18  
19  import static com.google.common.base.Preconditions.checkArgument;
20  import static com.linecorp.centraldogma.internal.jsonpatch.JsonPatchOperation.asJsonArray;
21  import static com.linecorp.centraldogma.internal.jsonpatch.JsonPatchUtil.encodeSegment;
22  import static com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager.listProjectsWithoutDogma;
23  import static com.linecorp.centraldogma.server.internal.storage.project.ProjectInitializer.INTERNAL_PROJECT_DOGMA;
24  import static com.linecorp.centraldogma.server.metadata.RepositorySupport.convertWithJackson;
25  import static com.linecorp.centraldogma.server.metadata.Tokens.SECRET_PREFIX;
26  import static com.linecorp.centraldogma.server.metadata.Tokens.validateSecret;
27  import static java.util.Objects.requireNonNull;
28  
29  import java.util.Collection;
30  import java.util.Map;
31  import java.util.Set;
32  import java.util.UUID;
33  import java.util.concurrent.CompletableFuture;
34  import java.util.concurrent.ConcurrentHashMap;
35  
36  import org.slf4j.Logger;
37  import org.slf4j.LoggerFactory;
38  
39  import com.fasterxml.jackson.core.JsonPointer;
40  import com.fasterxml.jackson.databind.JsonNode;
41  import com.google.common.collect.ImmutableList;
42  import com.spotify.futures.CompletableFutures;
43  
44  import com.linecorp.armeria.common.util.Exceptions;
45  import com.linecorp.centraldogma.common.Author;
46  import com.linecorp.centraldogma.common.Change;
47  import com.linecorp.centraldogma.common.ChangeConflictException;
48  import com.linecorp.centraldogma.common.RepositoryExistsException;
49  import com.linecorp.centraldogma.common.Revision;
50  import com.linecorp.centraldogma.internal.Jackson;
51  import com.linecorp.centraldogma.internal.jsonpatch.AddOperation;
52  import com.linecorp.centraldogma.internal.jsonpatch.JsonPatchOperation;
53  import com.linecorp.centraldogma.internal.jsonpatch.RemoveIfExistsOperation;
54  import com.linecorp.centraldogma.internal.jsonpatch.RemoveOperation;
55  import com.linecorp.centraldogma.internal.jsonpatch.ReplaceOperation;
56  import com.linecorp.centraldogma.internal.jsonpatch.TestAbsenceOperation;
57  import com.linecorp.centraldogma.server.QuotaConfig;
58  import com.linecorp.centraldogma.server.command.CommandExecutor;
59  import com.linecorp.centraldogma.server.storage.project.Project;
60  import com.linecorp.centraldogma.server.storage.project.ProjectManager;
61  
62  /**
63   * A service class for metadata management.
64   */
65  public class MetadataService {
66  
67      private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
68  
69      /**
70       * A path of metadata file.
71       */
72      public static final String METADATA_JSON = "/metadata.json";
73  
74      /**
75       * A path of token list file.
76       */
77      public static final String TOKEN_JSON = "/tokens.json";
78  
79      /**
80       * A {@link JsonPointer} of project removal information.
81       */
82      private static final JsonPointer PROJECT_REMOVAL = JsonPointer.compile("/removal");
83  
84      private final ProjectManager projectManager;
85      private final RepositorySupport<ProjectMetadata> metadataRepo;
86      private final RepositorySupport<Tokens> tokenRepo;
87      private final CommandExecutor executor;
88  
89      private final Map<String, CompletableFuture<Revision>> reposInAddingMetadata = new ConcurrentHashMap<>();
90  
91      /**
92       * Creates a new instance.
93       */
94      public MetadataService(ProjectManager projectManager, CommandExecutor executor) {
95          this.projectManager = requireNonNull(projectManager, "projectManager");
96          this.executor = requireNonNull(executor, "executor");
97          metadataRepo = new RepositorySupport<>(projectManager, executor,
98                                                 entry -> convertWithJackson(entry, ProjectMetadata.class));
99          tokenRepo = new RepositorySupport<>(projectManager, executor,
100                                             entry -> convertWithJackson(entry, Tokens.class));
101     }
102 
103     /**
104      * Returns a {@link ProjectMetadata} whose name equals to the specified {@code projectName}.
105      */
106     public CompletableFuture<ProjectMetadata> getProject(String projectName) {
107         requireNonNull(projectName, "projectName");
108         return fetchMetadata(projectName).thenApply(HolderWithRevision::object);
109     }
110 
111     private CompletableFuture<HolderWithRevision<ProjectMetadata>> fetchMetadata(String projectName) {
112         return fetchMetadata0(projectName).thenCompose(holder -> {
113             final Set<String> repos = projectManager.get(projectName).repos().list().keySet();
114             final Set<String> reposWithMetadata = holder.object().repos().keySet();
115 
116             // Make sure all repositories have metadata. If not, create missing metadata.
117             // A repository can have missing metadata when a dev forgot to call `addRepo()`
118             // after creating a new repository.
119             final ImmutableList.Builder<CompletableFuture<Revision>> builder = ImmutableList.builder();
120             for (String repo : repos) {
121                 if (reposWithMetadata.contains(repo) ||
122                     repo.equals(Project.REPO_DOGMA)) {
123                     continue;
124                 }
125 
126                 final String projectAndRepositoryName = projectName + '/' + repo;
127                 final CompletableFuture<Revision> future = new CompletableFuture<>();
128                 final CompletableFuture<Revision> futureInMap =
129                         reposInAddingMetadata.computeIfAbsent(projectAndRepositoryName, key -> future);
130                 if (futureInMap != future) { // The metadata is already in adding.
131                     builder.add(futureInMap);
132                     continue;
133                 }
134 
135                 logger.warn("Adding missing repository metadata: {}/{}", projectName, repo);
136                 final Author author = projectManager.get(projectName).repos().get(repo).author();
137                 final CompletableFuture<Revision> addRepoFuture = addRepo(author, projectName, repo);
138                 addRepoFuture.handle((revision, cause) -> {
139                     if (cause != null) {
140                         future.completeExceptionally(cause);
141                     } else {
142                         future.complete(revision);
143                     }
144                     reposInAddingMetadata.remove(projectAndRepositoryName);
145                     return null;
146                 });
147                 builder.add(future);
148             }
149 
150             final ImmutableList<CompletableFuture<Revision>> futures = builder.build();
151             if (futures.isEmpty()) {
152                 // All repositories have metadata.
153                 return CompletableFuture.completedFuture(holder);
154             }
155 
156             // Some repository did not have metadata and thus will add the missing ones.
157             return CompletableFutures.successfulAsList(futures, cause -> {
158                 final Throwable peeled = Exceptions.peel(cause);
159                 // The metadata of the repository is added by another worker, so we can ignore the exception.
160                 if (peeled instanceof RepositoryExistsException) {
161                     return null;
162                 }
163                 return Exceptions.throwUnsafely(cause);
164             }).thenCompose(unused -> {
165                 logger.info("Fetching {}/{}{} again",
166                             projectName, Project.REPO_DOGMA, METADATA_JSON);
167                 return fetchMetadata0(projectName);
168             });
169         });
170     }
171 
172     private CompletableFuture<HolderWithRevision<ProjectMetadata>> fetchMetadata0(String projectName) {
173         return metadataRepo.fetch(projectName, Project.REPO_DOGMA, METADATA_JSON);
174     }
175 
176     /**
177      * Removes a {@link ProjectMetadata} whose name equals to the specified {@code projectName}.
178      */
179     public CompletableFuture<Revision> removeProject(Author author, String projectName) {
180         requireNonNull(author, "author");
181         requireNonNull(projectName, "projectName");
182 
183         final Change<JsonNode> change = Change.ofJsonPatch(
184                 METADATA_JSON,
185                 asJsonArray(new TestAbsenceOperation(PROJECT_REMOVAL),
186                             new AddOperation(PROJECT_REMOVAL,
187                                              Jackson.valueToTree(UserAndTimestamp.of(author)))));
188         return metadataRepo.push(projectName, Project.REPO_DOGMA, author,
189                                  "Remove the project: " + projectName, change);
190     }
191 
192     /**
193      * Restores a {@link ProjectMetadata} whose name equals to the specified {@code projectName}.
194      */
195     public CompletableFuture<Revision> restoreProject(Author author, String projectName) {
196         requireNonNull(author, "author");
197         requireNonNull(projectName, "projectName");
198 
199         final Change<JsonNode> change =
200                 Change.ofJsonPatch(METADATA_JSON, new RemoveOperation(PROJECT_REMOVAL).toJsonNode());
201         return metadataRepo.push(projectName, Project.REPO_DOGMA, author,
202                                  "Restore the project: " + projectName, change);
203     }
204 
205     /**
206      * Returns a {@link Member} if the specified {@link User} is a member of the specified {@code projectName}.
207      */
208     public CompletableFuture<Member> getMember(String projectName, User user) {
209         requireNonNull(projectName, "projectName");
210         requireNonNull(user, "user");
211 
212         return getProject(projectName).thenApply(
213                 project -> project.memberOrDefault(user.id(), null));
214     }
215 
216     /**
217      * Adds the specified {@code member} to the {@link ProjectMetadata} of the specified {@code projectName}
218      * with the specified {@code projectRole}.
219      */
220     public CompletableFuture<Revision> addMember(Author author, String projectName,
221                                                  User member, ProjectRole projectRole) {
222         requireNonNull(author, "author");
223         requireNonNull(projectName, "projectName");
224         requireNonNull(member, "member");
225         requireNonNull(projectRole, "projectRole");
226 
227         final Member newMember = new Member(member, projectRole, UserAndTimestamp.of(author));
228         final JsonPointer path = JsonPointer.compile("/members" + encodeSegment(newMember.id()));
229         final Change<JsonNode> change =
230                 Change.ofJsonPatch(METADATA_JSON,
231                                    asJsonArray(new TestAbsenceOperation(path),
232                                                new AddOperation(path, Jackson.valueToTree(newMember))));
233         final String commitSummary =
234                 "Add a member '" + newMember.id() + "' to the project '" + projectName + '\'';
235         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
236     }
237 
238     /**
239      * Removes the specified {@code member} from the {@link ProjectMetadata} in the specified
240      * {@code projectName}. It also removes permission of the specified {@code member} from every
241      * {@link RepositoryMetadata}.
242      */
243     public CompletableFuture<Revision> removeMember(Author author, String projectName, User member) {
244         requireNonNull(author, "author");
245         requireNonNull(projectName, "projectName");
246         requireNonNull(member, "member");
247 
248         final String commitSummary =
249                 "Remove the member '" + member.id() + "' from the project '" + projectName + '\'';
250         return metadataRepo.push(
251                 projectName, Project.REPO_DOGMA, author, commitSummary,
252                 () -> fetchMetadata(projectName).thenApply(
253                         metadataWithRevision -> {
254                             final ImmutableList.Builder<JsonPatchOperation> patches = ImmutableList.builder();
255                             metadataWithRevision
256                                     .object().repos().values()
257                                     .stream().filter(r -> r.perUserPermissions().containsKey(member.id()))
258                                     .forEach(r -> patches.add(new RemoveOperation(
259                                             perUserPermissionPointer(r.name(), member.id()))));
260                             patches.add(new RemoveOperation(JsonPointer.compile("/members" +
261                                                                                 encodeSegment(member.id()))));
262                             final Change<JsonNode> change =
263                                     Change.ofJsonPatch(METADATA_JSON, Jackson.valueToTree(patches.build()));
264                             return HolderWithRevision.of(change, metadataWithRevision.revision());
265                         })
266         );
267     }
268 
269     /**
270      * Updates a {@link ProjectRole} for the specified {@code member} in the specified {@code projectName}.
271      */
272     public CompletableFuture<Revision> updateMemberRole(Author author, String projectName,
273                                                         User member, ProjectRole projectRole) {
274         requireNonNull(author, "author");
275         requireNonNull(projectName, "projectName");
276         requireNonNull(member, "member");
277         requireNonNull(projectRole, "projectRole");
278 
279         final Change<JsonNode> change = Change.ofJsonPatch(
280                 METADATA_JSON,
281                 new ReplaceOperation(JsonPointer.compile("/members" + encodeSegment(member.id()) + "/role"),
282                                      Jackson.valueToTree(projectRole)).toJsonNode());
283         final String commitSummary = "Updates the role of the member '" + member.id() +
284                                      "' as '" + projectRole + "' for the project '" + projectName + '\'';
285         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
286     }
287 
288     /**
289      * Returns a {@link RepositoryMetadata} of the specified {@code repoName} in the specified
290      * {@code projectName}.
291      */
292     public CompletableFuture<RepositoryMetadata> getRepo(String projectName, String repoName) {
293         requireNonNull(projectName, "projectName");
294         requireNonNull(repoName, "repoName");
295 
296         return getProject(projectName).thenApply(project -> project.repo(repoName));
297     }
298 
299     /**
300      * Adds a {@link RepositoryMetadata} of the specified {@code repoName} to the specified {@code projectName}
301      * with a default {@link PerRolePermissions}.
302      */
303     public CompletableFuture<Revision> addRepo(Author author, String projectName, String repoName) {
304         return addRepo(author, projectName, repoName, PerRolePermissions.ofDefault());
305     }
306 
307     /**
308      * Adds a {@link RepositoryMetadata} of the specified {@code repoName} to the specified {@code projectName}
309      * with the specified {@link PerRolePermissions}.
310      */
311     public CompletableFuture<Revision> addRepo(Author author, String projectName, String repoName,
312                                                PerRolePermissions permission) {
313         requireNonNull(author, "author");
314         requireNonNull(projectName, "projectName");
315         requireNonNull(repoName, "repoName");
316         requireNonNull(permission, "permission");
317 
318         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName));
319         final RepositoryMetadata newRepositoryMetadata = new RepositoryMetadata(repoName,
320                                                                                 UserAndTimestamp.of(author),
321                                                                                 permission);
322         final Change<JsonNode> change =
323                 Change.ofJsonPatch(METADATA_JSON,
324                                    asJsonArray(new TestAbsenceOperation(path),
325                                                new AddOperation(path,
326                                                                 Jackson.valueToTree(newRepositoryMetadata))));
327         final String commitSummary =
328                 "Add a repo '" + newRepositoryMetadata.id() + "' to the project '" + projectName + '\'';
329         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change)
330                            .handle((revision, cause) -> {
331                                if (cause != null) {
332                                    if (Exceptions.peel(cause) instanceof ChangeConflictException) {
333                                        throw new RepositoryExistsException(repoName);
334                                    } else {
335                                        return Exceptions.throwUnsafely(cause);
336                                    }
337                                }
338                                return revision;
339                            });
340     }
341 
342     /**
343      * Removes a {@link RepositoryMetadata} of the specified {@code repoName} from the specified
344      * {@code projectName}.
345      */
346     public CompletableFuture<Revision> removeRepo(Author author, String projectName, String repoName) {
347         requireNonNull(author, "author");
348         requireNonNull(projectName, "projectName");
349         requireNonNull(repoName, "repoName");
350 
351         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName) + "/removal");
352         final Change<JsonNode> change =
353                 Change.ofJsonPatch(METADATA_JSON,
354                                    asJsonArray(new TestAbsenceOperation(path),
355                                                new AddOperation(path, Jackson.valueToTree(
356                                                        UserAndTimestamp.of(author)))));
357         final String commitSummary =
358                 "Remove the repo '" + repoName + "' from the project '" + projectName + '\'';
359         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
360     }
361 
362     /**
363      * Purges a {@link RepositoryMetadata} of the specified {@code repoName} from the specified
364      * {@code projectName}.
365      */
366     public CompletableFuture<Revision> purgeRepo(Author author, String projectName, String repoName) {
367         requireNonNull(author, "author");
368         requireNonNull(projectName, "projectName");
369         requireNonNull(repoName, "repoName");
370 
371         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName));
372         final Change<JsonNode> change = Change.ofJsonPatch(METADATA_JSON,
373                                                            new RemoveOperation(path).toJsonNode());
374         final String commitSummary =
375                 "Purge the repo '" + repoName + "' from the project '" + projectName + '\'';
376         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
377     }
378 
379     /**
380      * Restores a {@link RepositoryMetadata} of the specified {@code repoName} in the specified
381      * {@code projectName}.
382      */
383     public CompletableFuture<Revision> restoreRepo(Author author, String projectName, String repoName) {
384         requireNonNull(author, "author");
385         requireNonNull(projectName, "projectName");
386         requireNonNull(repoName, "repoName");
387 
388         final Change<JsonNode> change =
389                 Change.ofJsonPatch(METADATA_JSON,
390                                    new RemoveOperation(JsonPointer.compile(
391                                            "/repos" + encodeSegment(repoName) + "/removal")).toJsonNode());
392         final String commitSummary =
393                 "Restore the repo '" + repoName + "' from the project '" + projectName + '\'';
394         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
395     }
396 
397     /**
398      * Updates a {@link PerRolePermissions} of the specified {@code repoName} in the specified
399      * {@code projectName}.
400      */
401     public CompletableFuture<Revision> updatePerRolePermissions(Author author,
402                                                                 String projectName, String repoName,
403                                                                 PerRolePermissions perRolePermissions) {
404         requireNonNull(author, "author");
405         requireNonNull(projectName, "projectName");
406         requireNonNull(repoName, "repoName");
407         requireNonNull(perRolePermissions, "perRolePermissions");
408 
409         if (Project.REPO_DOGMA.equals(repoName)) {
410             throw new UnsupportedOperationException(
411                     "Can't update the per role permission for internal repository: " + repoName);
412         }
413 
414         final Set<Permission> anonymous = perRolePermissions.anonymous();
415         if (Project.REPO_META.equals(repoName)) {
416             final Set<Permission> guest = perRolePermissions.guest();
417             if (!guest.isEmpty() || !anonymous.isEmpty()) {
418                 throw new UnsupportedOperationException(
419                         "Can't give a permission to guest or anonymous for internal repository: " + repoName);
420             }
421         }
422         if (anonymous.contains(Permission.WRITE)) {
423             throw new IllegalArgumentException("Anonymous users cannot have write permission.");
424         }
425 
426         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName) +
427                                                      "/perRolePermissions");
428         final Change<JsonNode> change =
429                 Change.ofJsonPatch(METADATA_JSON,
430                                    new ReplaceOperation(path, Jackson.valueToTree(perRolePermissions))
431                                            .toJsonNode());
432         final String commitSummary =
433                 "Update the role permission of the '" + repoName + "' in the project '" + projectName + '\'';
434         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
435     }
436 
437     /**
438      * Adds the specified {@link Token} to the specified {@code projectName}.
439      */
440     public CompletableFuture<Revision> addToken(Author author, String projectName,
441                                                 Token token, ProjectRole role) {
442         return addToken(author, projectName, requireNonNull(token, "token").appId(), role);
443     }
444 
445     /**
446      * Adds a {@link Token} of the specified {@code appId} to the specified {@code projectName}.
447      */
448     public CompletableFuture<Revision> addToken(Author author, String projectName,
449                                                 String appId, ProjectRole role) {
450         requireNonNull(author, "author");
451         requireNonNull(projectName, "projectName");
452         requireNonNull(appId, "appId");
453         requireNonNull(role, "role");
454 
455         return getTokens().thenCompose(tokens -> {
456             final Token token = tokens.appIds().get(appId);
457             checkArgument(token != null, "Token not found: " + appId);
458 
459             final TokenRegistration registration = new TokenRegistration(appId, role,
460                                                                          UserAndTimestamp.of(author));
461             final JsonPointer path = JsonPointer.compile("/tokens" + encodeSegment(registration.id()));
462             final Change<JsonNode> change =
463                     Change.ofJsonPatch(METADATA_JSON,
464                                        asJsonArray(new TestAbsenceOperation(path),
465                                                    new AddOperation(path, Jackson.valueToTree(registration))));
466             final String commitSummary = "Add a token '" + registration.id() +
467                                          "' to the project '" + projectName + "' with a role '" + role + '\'';
468             return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
469         });
470     }
471 
472     /**
473      * Removes the specified {@link Token} from the specified {@code projectName}. It also removes
474      * every token permission belonging to the {@link Token} from every {@link RepositoryMetadata}.
475      */
476     public CompletableFuture<Revision> removeToken(Author author, String projectName, Token token) {
477         return removeToken(author, projectName, requireNonNull(token, "token").appId());
478     }
479 
480     /**
481      * Removes the {@link Token} of the specified {@code appId} from the specified {@code projectName}.
482      * It also removes every token permission belonging to {@link Token} from every {@link RepositoryMetadata}.
483      */
484     public CompletableFuture<Revision> removeToken(Author author, String projectName, String appId) {
485         requireNonNull(author, "author");
486         requireNonNull(projectName, "projectName");
487         requireNonNull(appId, "appId");
488 
489         return removeToken(projectName, author, appId, false);
490     }
491 
492     private CompletableFuture<Revision> removeToken(String projectName, Author author, String appId,
493                                                     boolean quiet) {
494         final String commitSummary = "Remove the token '" + appId + "' from the project '" + projectName + '\'';
495         return metadataRepo.push(
496                 projectName, Project.REPO_DOGMA, author, commitSummary,
497                 () -> fetchMetadata(projectName).thenApply(metadataWithRevision -> {
498                     final ImmutableList.Builder<JsonPatchOperation> patches = ImmutableList.builder();
499                     final ProjectMetadata metadata = metadataWithRevision.object();
500                     metadata.repos().values()
501                             .stream().filter(repo -> repo.perTokenPermissions().containsKey(appId))
502                             .forEach(r -> patches.add(
503                                     new RemoveOperation(perTokenPermissionPointer(r.name(), appId))));
504                     if (quiet) {
505                         patches.add(new RemoveIfExistsOperation(JsonPointer.compile("/tokens" +
506                                                                                     encodeSegment(appId))));
507                     } else {
508                         patches.add(new RemoveOperation(JsonPointer.compile("/tokens" +
509                                                                             encodeSegment(appId))));
510                     }
511                     final Change<JsonNode> change =
512                             Change.ofJsonPatch(METADATA_JSON, Jackson.valueToTree(patches.build()));
513                     return HolderWithRevision.of(change, metadataWithRevision.revision());
514                 })
515         );
516     }
517 
518     /**
519      * Updates a {@link ProjectRole} for the {@link Token} of the specified {@code appId}.
520      */
521     public CompletableFuture<Revision> updateTokenRole(Author author, String projectName,
522                                                        Token token, ProjectRole role) {
523         requireNonNull(author, "author");
524         requireNonNull(projectName, "projectName");
525         requireNonNull(token, "token");
526         requireNonNull(role, "role");
527 
528         final TokenRegistration registration = new TokenRegistration(token.appId(), role,
529                                                                      UserAndTimestamp.of(author));
530         final JsonPointer path = JsonPointer.compile("/tokens" + encodeSegment(registration.id()));
531         final Change<JsonNode> change =
532                 Change.ofJsonPatch(METADATA_JSON,
533                                    new ReplaceOperation(path, Jackson.valueToTree(registration))
534                                            .toJsonNode());
535         final String commitSummary = "Update the role of a token '" + token.appId() +
536                                      "' as '" + role + "' for the project '" + projectName + '\'';
537         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
538     }
539 
540     /**
541      * Adds {@link Permission}s for the specified {@code member} to the specified {@code repoName}
542      * in the specified {@code projectName}.
543      */
544     public CompletableFuture<Revision> addPerUserPermission(Author author, String projectName,
545                                                             String repoName, User member,
546                                                             Collection<Permission> permission) {
547         requireNonNull(author, "author");
548         requireNonNull(projectName, "projectName");
549         requireNonNull(repoName, "repoName");
550         requireNonNull(member, "member");
551         requireNonNull(permission, "permission");
552 
553         return getProject(projectName).thenCompose(project -> {
554             ensureProjectMember(project, member);
555             final String commitSummary = "Add permission of '" + member.id() +
556                                          "' as '" + permission + "' to the project '" + projectName + '\n';
557             return addPermissionAtPointer(author, projectName, perUserPermissionPointer(repoName, member.id()),
558                                           permission, commitSummary);
559         });
560     }
561 
562     /**
563      * Removes {@link Permission}s for the specified {@code member} from the specified {@code repoName}
564      * in the specified {@code projectName}.
565      */
566     public CompletableFuture<Revision> removePerUserPermission(Author author, String projectName,
567                                                                String repoName, User member) {
568         requireNonNull(author, "author");
569         requireNonNull(projectName, "projectName");
570         requireNonNull(repoName, "repoName");
571         requireNonNull(member, "member");
572 
573         final String memberId = member.id();
574         return removePermissionAtPointer(author, projectName,
575                                          perUserPermissionPointer(repoName, memberId),
576                                          "Remove permission of the '" + memberId +
577                                          "' from the project '" + projectName + '\'');
578     }
579 
580     /**
581      * Updates {@link Permission}s for the specified {@code member} of the specified {@code repoName}
582      * in the specified {@code projectName}.
583      */
584     public CompletableFuture<Revision> updatePerUserPermission(Author author, String projectName,
585                                                                String repoName, User member,
586                                                                Collection<Permission> permission) {
587         requireNonNull(author, "author");
588         requireNonNull(projectName, "projectName");
589         requireNonNull(repoName, "repoName");
590         requireNonNull(member, "member");
591         requireNonNull(permission, "permission");
592 
593         final String memberId = member.id();
594         return replacePermissionAtPointer(author, projectName,
595                                           perUserPermissionPointer(repoName, memberId), permission,
596                                           "Update permission of the '" + memberId +
597                                           "' as '" + permission + "' for the project '" + projectName + '\'');
598     }
599 
600     /**
601      * Adds {@link Permission}s for the {@link Token} of the specified {@code appId} to the specified
602      * {@code repoName} in the specified {@code projectName}.
603      */
604     public CompletableFuture<Revision> addPerTokenPermission(Author author, String projectName,
605                                                              String repoName, String appId,
606                                                              Collection<Permission> permission) {
607         requireNonNull(author, "author");
608         requireNonNull(projectName, "projectName");
609         requireNonNull(repoName, "repoName");
610         requireNonNull(appId, "appId");
611         requireNonNull(permission, "permission");
612 
613         return getProject(projectName).thenCompose(project -> {
614             ensureProjectToken(project, appId);
615             return addPermissionAtPointer(author, projectName,
616                                           perTokenPermissionPointer(repoName, appId), permission,
617                                           "Add permission of the token '" + appId +
618                                           "' as '" + permission + "' to the project '" + projectName + '\'');
619         });
620     }
621 
622     /**
623      * Removes {@link Permission}s for the {@link Token} of the specified {@code appId} from the specified
624      * {@code repoName} in the specified {@code projectName}.
625      */
626     public CompletableFuture<Revision> removePerTokenPermission(Author author, String projectName,
627                                                                 String repoName, String appId) {
628         requireNonNull(author, "author");
629         requireNonNull(projectName, "projectName");
630         requireNonNull(repoName, "repoName");
631         requireNonNull(appId, "appId");
632 
633         return removePermissionAtPointer(author, projectName,
634                                          perTokenPermissionPointer(repoName, appId),
635                                          "Remove permission of the token '" + appId +
636                                          "' from the project '" + projectName + '\'');
637     }
638 
639     /**
640      * Updates {@link Permission}s for the {@link Token} of the specified {@code appId} of the specified
641      * {@code repoName} in the specified {@code projectName}.
642      */
643     public CompletableFuture<Revision> updatePerTokenPermission(Author author, String projectName,
644                                                                 String repoName, String appId,
645                                                                 Collection<Permission> permission) {
646         requireNonNull(author, "author");
647         requireNonNull(projectName, "projectName");
648         requireNonNull(repoName, "repoName");
649         requireNonNull(appId, "appId");
650         requireNonNull(permission, "permission");
651 
652         return replacePermissionAtPointer(author, projectName,
653                                           perTokenPermissionPointer(repoName, appId), permission,
654                                           "Update permission of the token '" + appId +
655                                           "' as '" + permission + "' for the project '" + projectName + '\'');
656     }
657 
658     /**
659      * Updates the {@linkplain QuotaConfig write quota} for the specified {@code repoName}
660      * in the specified {@code projectName}.
661      */
662     public CompletableFuture<Revision> updateWriteQuota(
663             Author author, String projectName, String repoName, QuotaConfig writeQuota) {
664         requireNonNull(author, "author");
665         requireNonNull(projectName, "projectName");
666         requireNonNull(repoName, "repoName");
667         requireNonNull(writeQuota, "writeQuota");
668 
669         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName) + "/writeQuota");
670         final Change<JsonNode> change =
671                 Change.ofJsonPatch(METADATA_JSON,
672                                    new AddOperation(path, Jackson.valueToTree(writeQuota)).toJsonNode());
673         final String commitSummary = "Update a write quota for the repository '" + repoName + '\'';
674         executor.setWriteQuota(projectName, repoName, writeQuota);
675         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
676     }
677 
678     /**
679      * Adds {@link Permission}s to the specified {@code path}.
680      */
681     private CompletableFuture<Revision> addPermissionAtPointer(Author author,
682                                                                String projectName, JsonPointer path,
683                                                                Collection<Permission> permission,
684                                                                String commitSummary) {
685         final Change<JsonNode> change =
686                 Change.ofJsonPatch(METADATA_JSON,
687                                    asJsonArray(new TestAbsenceOperation(path),
688                                                new AddOperation(path, Jackson.valueToTree(permission))));
689         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
690     }
691 
692     /**
693      * Removes {@link Permission}s from the specified {@code path}.
694      */
695     private CompletableFuture<Revision> removePermissionAtPointer(Author author, String projectName,
696                                                                   JsonPointer path, String commitSummary) {
697         final Change<JsonNode> change = Change.ofJsonPatch(METADATA_JSON,
698                                                            new RemoveOperation(path).toJsonNode());
699         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
700     }
701 
702     /**
703      * Replaces {@link Permission}s of the specified {@code path} with the specified {@code permission}.
704      */
705     private CompletableFuture<Revision> replacePermissionAtPointer(Author author,
706                                                                    String projectName, JsonPointer path,
707                                                                    Collection<Permission> permission,
708                                                                    String commitSummary) {
709         final Change<JsonNode> change =
710                 Change.ofJsonPatch(METADATA_JSON,
711                                    new ReplaceOperation(path, Jackson.valueToTree(permission)).toJsonNode());
712         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
713     }
714 
715     /**
716      * Finds {@link Permission}s which belong to the specified {@link User} or {@link UserWithToken}
717      * from the specified {@code repoName} in the specified {@code projectName}.
718      */
719     public CompletableFuture<Collection<Permission>> findPermissions(String projectName, String repoName,
720                                                                      User user) {
721         requireNonNull(user, "user");
722         if (user.isAdmin()) {
723             return CompletableFuture.completedFuture(PerRolePermissions.ALL_PERMISSION);
724         }
725         if (user instanceof UserWithToken) {
726             return findPermissions(projectName, repoName, ((UserWithToken) user).token().appId());
727         } else {
728             return findPermissions0(projectName, repoName, user);
729         }
730     }
731 
732     /**
733      * Finds {@link Permission}s which belong to the specified {@code appId} from the specified
734      * {@code repoName} in the specified {@code projectName}.
735      */
736     public CompletableFuture<Collection<Permission>> findPermissions(String projectName, String repoName,
737                                                                      String appId) {
738         requireNonNull(projectName, "projectName");
739         requireNonNull(repoName, "repoName");
740         requireNonNull(appId, "appId");
741 
742         return getProject(projectName).thenApply(metadata -> {
743             final RepositoryMetadata repositoryMetadata = metadata.repo(repoName);
744             final TokenRegistration registration = metadata.tokens().getOrDefault(appId, null);
745 
746             // If the token is guest.
747             if (registration == null) {
748                 return repositoryMetadata.perRolePermissions().guest();
749             }
750             final Collection<Permission> p = repositoryMetadata.perTokenPermissions().get(registration.id());
751             if (p != null) {
752                 return p;
753             }
754             return findPerRolePermissions(repositoryMetadata, registration.role());
755         });
756     }
757 
758     /**
759      * Finds {@link Permission}s which belong to the specified {@link User} from the specified
760      * {@code repoName} in the specified {@code projectName}.
761      */
762     private CompletableFuture<Collection<Permission>> findPermissions0(String projectName, String repoName,
763                                                                        User user) {
764         requireNonNull(projectName, "projectName");
765         requireNonNull(repoName, "repoName");
766         requireNonNull(user, "user");
767 
768         return getProject(projectName).thenApply(metadata -> {
769             final RepositoryMetadata repositoryMetadata = metadata.repo(repoName);
770             final Member member = metadata.memberOrDefault(user.id(), null);
771 
772             // If the member is guest or using anonymous token.
773             if (member == null) {
774                 return !user.isAnonymous() ? repositoryMetadata.perRolePermissions().guest()
775                                            : repositoryMetadata.perRolePermissions().anonymous();
776             }
777             final Collection<Permission> p = repositoryMetadata.perUserPermissions().get(member.id());
778             if (p != null) {
779                 return p;
780             }
781             return findPerRolePermissions(repositoryMetadata, member.role());
782         });
783     }
784 
785     private static Collection<Permission> findPerRolePermissions(RepositoryMetadata repositoryMetadata,
786                                                                  ProjectRole role) {
787         switch (role) {
788             case OWNER:
789                 return repositoryMetadata.perRolePermissions().owner();
790             case MEMBER:
791                 return repositoryMetadata.perRolePermissions().member();
792             case GUEST:
793                 return repositoryMetadata.perRolePermissions().guest();
794             default:
795                 return repositoryMetadata.perRolePermissions().anonymous();
796         }
797     }
798 
799     /**
800      * Finds a {@link ProjectRole} of the specified {@link User} in the specified {@code projectName}.
801      */
802     public CompletableFuture<ProjectRole> findRole(String projectName, User user) {
803         requireNonNull(projectName, "projectName");
804         requireNonNull(user, "user");
805 
806         if (user.isAdmin()) {
807             return CompletableFuture.completedFuture(ProjectRole.OWNER);
808         }
809         return getProject(projectName).thenApply(project -> {
810             if (user instanceof UserWithToken) {
811                 final TokenRegistration registration = project.tokens().getOrDefault(
812                         ((UserWithToken) user).token().id(), null);
813                 return registration != null ? registration.role() : ProjectRole.GUEST;
814             } else {
815                 final Member member = project.memberOrDefault(user.id(), null);
816                 return member != null ? member.role() : ProjectRole.GUEST;
817             }
818         });
819     }
820 
821     /**
822      * Returns a {@link Tokens}.
823      */
824     public CompletableFuture<Tokens> getTokens() {
825         return tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
826                         .thenApply(HolderWithRevision::object);
827     }
828 
829     /**
830      * Creates a new user-level {@link Token} with the specified {@code appId}. A secret for the {@code appId}
831      * will be automatically generated.
832      */
833     public CompletableFuture<Revision> createToken(Author author, String appId) {
834         return createToken(author, appId, false);
835     }
836 
837     /**
838      * Creates a new {@link Token} with the specified {@code appId}, {@code isAdmin} and an auto-generated
839      * secret.
840      */
841     public CompletableFuture<Revision> createToken(Author author, String appId, boolean isAdmin) {
842         return createToken(author, appId, SECRET_PREFIX + UUID.randomUUID(), isAdmin);
843     }
844 
845     /**
846      * Creates a new user-level {@link Token} with the specified {@code appId} and {@code secret}.
847      */
848     public CompletableFuture<Revision> createToken(Author author, String appId, String secret) {
849         return createToken(author, appId, secret, false);
850     }
851 
852     /**
853      * Creates a new {@link Token} with the specified {@code appId}, {@code secret} and {@code isAdmin}.
854      */
855     public CompletableFuture<Revision> createToken(Author author, String appId, String secret,
856                                                    boolean isAdmin) {
857         requireNonNull(author, "author");
858         requireNonNull(appId, "appId");
859         requireNonNull(secret, "secret");
860 
861         checkArgument(secret.startsWith(SECRET_PREFIX), "secret must start with: " + SECRET_PREFIX);
862 
863         final Token newToken = new Token(appId, secret, isAdmin, UserAndTimestamp.of(author));
864         final JsonPointer appIdPath = JsonPointer.compile("/appIds" + encodeSegment(newToken.id()));
865         final String newTokenSecret = newToken.secret();
866         assert newTokenSecret != null;
867         final JsonPointer secretPath = JsonPointer.compile("/secrets" + encodeSegment(newTokenSecret));
868         final Change<JsonNode> change =
869                 Change.ofJsonPatch(TOKEN_JSON,
870                                    asJsonArray(new TestAbsenceOperation(appIdPath),
871                                                new TestAbsenceOperation(secretPath),
872                                                new AddOperation(appIdPath, Jackson.valueToTree(newToken)),
873                                                new AddOperation(secretPath,
874                                                                 Jackson.valueToTree(newToken.id()))));
875         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,
876                               "Add a token: " + newToken.id(), change);
877     }
878 
879     /**
880      * Removes the {@link Token} of the specified {@code appId} completely from the system.
881      */
882     public CompletableFuture<Revision> destroyToken(Author author, String appId) {
883         requireNonNull(author, "author");
884         requireNonNull(appId, "appId");
885 
886         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,
887                               "Delete the token: " + appId,
888                               () -> tokenRepo
889                                       .fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
890                                       .thenApply(tokens -> {
891                                           final JsonPointer deletionPath =
892                                                   JsonPointer.compile("/appIds" + encodeSegment(appId) +
893                                                                       "/deletion");
894                                           final Change<?> change = Change.ofJsonPatch(
895                                                   TOKEN_JSON,
896                                                   asJsonArray(new TestAbsenceOperation(deletionPath),
897                                                               new AddOperation(deletionPath,
898                                                                                Jackson.valueToTree(
899                                                                                        UserAndTimestamp.of(
900                                                                                                author)))));
901                                           return HolderWithRevision.of(change, tokens.revision());
902                                       }));
903     }
904 
905     /**
906      * Purges the {@link Token} of the specified {@code appId} that was removed before.
907      *
908      * <p>Note that this is a blocking method that should not be invoked in an event loop.
909      */
910     public Revision purgeToken(Author author, String appId) {
911         requireNonNull(author, "author");
912         requireNonNull(appId, "appId");
913 
914         final Collection<Project> projects = listProjectsWithoutDogma(projectManager.list()).values();
915         // Remove the token from projects that only have the token.
916         for (Project project : projects) {
917             final ProjectMetadata projectMetadata = fetchMetadata(project.name()).join().object();
918             final boolean containsTargetTokenInTheProject =
919                     projectMetadata.tokens().values()
920                                    .stream()
921                                    .anyMatch(token -> token.appId().equals(appId));
922 
923             if (containsTargetTokenInTheProject) {
924                 removeToken(project.name(), author, appId, true).join();
925             }
926         }
927 
928         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, "Remove the token: " + appId,
929                               () -> tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
930                                              .thenApply(tokens -> {
931                                                  final Token token = tokens.object().get(appId);
932                                                  final JsonPointer appIdPath =
933                                                          JsonPointer.compile("/appIds" + encodeSegment(appId));
934                                                  final String secret = token.secret();
935                                                  assert secret != null;
936                                                  final JsonPointer secretPath =
937                                                          JsonPointer.compile(
938                                                                  "/secrets" + encodeSegment(secret));
939                                                  final Change<?> change = Change.ofJsonPatch(
940                                                          TOKEN_JSON,
941                                                          asJsonArray(new RemoveOperation(appIdPath),
942                                                                      new RemoveIfExistsOperation(secretPath)));
943                                                  return HolderWithRevision.of(change, tokens.revision());
944                                              })).join();
945     }
946 
947     /**
948      * Activates the {@link Token} of the specified {@code appId}.
949      */
950     public CompletableFuture<Revision> activateToken(Author author, String appId) {
951         requireNonNull(author, "author");
952         requireNonNull(appId, "appId");
953 
954         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,
955                               "Enable the token: " + appId,
956                               () -> tokenRepo
957                                       .fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
958                                       .thenApply(tokens -> {
959                                           final Token token = tokens.object().get(appId);
960                                           final JsonPointer removalPath =
961                                                   JsonPointer.compile("/appIds" + encodeSegment(appId) +
962                                                                       "/deactivation");
963                                           final String secret = token.secret();
964                                           assert secret != null;
965                                           final JsonPointer secretPath =
966                                                   JsonPointer.compile("/secrets" +
967                                                                       encodeSegment(secret));
968                                           final Change<JsonNode> change = Change.ofJsonPatch(
969                                                   TOKEN_JSON,
970                                                   asJsonArray(new RemoveOperation(removalPath),
971                                                               new AddOperation(secretPath,
972                                                                                Jackson.valueToTree(appId))));
973                                           return HolderWithRevision.of(change, tokens.revision());
974                                       })
975         );
976     }
977 
978     /**
979      * Deactivates the {@link Token} of the specified {@code appId}.
980      */
981     public CompletableFuture<Revision> deactivateToken(Author author, String appId) {
982         requireNonNull(author, "author");
983         requireNonNull(appId, "appId");
984 
985         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,
986                               "Disable the token: " + appId,
987                               () -> tokenRepo
988                                       .fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
989                                       .thenApply(tokens -> {
990                                           final Token token = tokens.object().get(appId);
991                                           final JsonPointer removalPath =
992                                                   JsonPointer.compile("/appIds" + encodeSegment(appId) +
993                                                                       "/deactivation");
994                                           final String secret = token.secret();
995                                           assert secret != null;
996                                           final JsonPointer secretPath =
997                                                   JsonPointer.compile("/secrets" +
998                                                                       encodeSegment(secret));
999                                           final Change<?> change = Change.ofJsonPatch(
1000                                                   TOKEN_JSON,
1001                                                   asJsonArray(new TestAbsenceOperation(removalPath),
1002                                                               new AddOperation(removalPath, Jackson.valueToTree(
1003                                                                       UserAndTimestamp.of(author))),
1004                                                               new RemoveOperation(secretPath)));
1005                                           return HolderWithRevision.of(change, tokens.revision());
1006                                       }));
1007     }
1008 
1009     /**
1010      * Returns a {@link Token} which has the specified {@code appId}.
1011      */
1012     public CompletableFuture<Token> findTokenByAppId(String appId) {
1013         requireNonNull(appId, "appId");
1014         return tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
1015                         .thenApply(tokens -> tokens.object().get(appId));
1016     }
1017 
1018     /**
1019      * Returns a {@link Token} which has the specified {@code secret}.
1020      */
1021     public CompletableFuture<Token> findTokenBySecret(String secret) {
1022         requireNonNull(secret, "secret");
1023         validateSecret(secret);
1024         return tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
1025                         .thenApply(tokens -> tokens.object().findBySecret(secret));
1026     }
1027 
1028     /**
1029      * Ensures that the specified {@code user} is a member of the specified {@code project}.
1030      */
1031     private static void ensureProjectMember(ProjectMetadata project, User user) {
1032         requireNonNull(project, "project");
1033         requireNonNull(user, "user");
1034 
1035         checkArgument(project.members().values().stream().anyMatch(member -> member.login().equals(user.id())),
1036                       user.id() + " is not a member of the project '" + project.name() + '\'');
1037     }
1038 
1039     /**
1040      * Ensures that the specified {@code appId} is a token of the specified {@code project}.
1041      */
1042     private static void ensureProjectToken(ProjectMetadata project, String appId) {
1043         requireNonNull(project, "project");
1044         requireNonNull(appId, "appId");
1045 
1046         checkArgument(project.tokens().containsKey(appId),
1047                       appId + " is not a token of the project '" + project.name() + '\'');
1048     }
1049 
1050     /**
1051      * Generates the path of {@link JsonPointer} of permission of the specified {@code memberId} in the
1052      * specified {@code repoName}.
1053      */
1054     private static JsonPointer perUserPermissionPointer(String repoName, String memberId) {
1055         return JsonPointer.compile("/repos" + encodeSegment(repoName) +
1056                                    "/perUserPermissions" + encodeSegment(memberId));
1057     }
1058 
1059     /**
1060      * Generates the path of {@link JsonPointer} of permission of the specified token {@code appId}
1061      * in the specified {@code repoName}.
1062      */
1063     private static JsonPointer perTokenPermissionPointer(String repoName, String appId) {
1064         return JsonPointer.compile("/repos" + encodeSegment(repoName) +
1065                                    "/perTokenPermissions" + encodeSegment(appId));
1066     }
1067 }