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.google.common.collect.ImmutableMap.toImmutableMap;
21  import static com.linecorp.centraldogma.common.jsonpatch.JsonPatchOperation.asJsonArray;
22  import static com.linecorp.centraldogma.internal.jsonpatch.JsonPatchUtil.encodeSegment;
23  import static com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager.listProjectsWithoutInternal;
24  import static com.linecorp.centraldogma.server.metadata.RepositoryMetadata.DEFAULT_PROJECT_ROLES;
25  import static com.linecorp.centraldogma.server.metadata.Tokens.SECRET_PREFIX;
26  import static com.linecorp.centraldogma.server.metadata.Tokens.validateSecret;
27  import static com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer.INTERNAL_PROJECT_DOGMA;
28  import static java.util.Objects.requireNonNull;
29  
30  import java.util.Collection;
31  import java.util.Map;
32  import java.util.Map.Entry;
33  import java.util.Set;
34  import java.util.UUID;
35  import java.util.concurrent.CompletableFuture;
36  import java.util.concurrent.ConcurrentHashMap;
37  
38  import org.slf4j.Logger;
39  import org.slf4j.LoggerFactory;
40  
41  import com.fasterxml.jackson.core.JsonPointer;
42  import com.fasterxml.jackson.databind.JsonNode;
43  import com.google.common.collect.ImmutableList;
44  import com.google.common.collect.ImmutableMap;
45  import com.google.common.collect.ImmutableMap.Builder;
46  import com.spotify.futures.CompletableFutures;
47  
48  import com.linecorp.armeria.common.annotation.Nullable;
49  import com.linecorp.armeria.common.util.Exceptions;
50  import com.linecorp.centraldogma.common.Author;
51  import com.linecorp.centraldogma.common.Change;
52  import com.linecorp.centraldogma.common.ChangeConflictException;
53  import com.linecorp.centraldogma.common.EntryNotFoundException;
54  import com.linecorp.centraldogma.common.ProjectRole;
55  import com.linecorp.centraldogma.common.RedundantChangeException;
56  import com.linecorp.centraldogma.common.RepositoryExistsException;
57  import com.linecorp.centraldogma.common.RepositoryRole;
58  import com.linecorp.centraldogma.common.RepositoryStatus;
59  import com.linecorp.centraldogma.common.Revision;
60  import com.linecorp.centraldogma.common.jsonpatch.JsonPatchOperation;
61  import com.linecorp.centraldogma.internal.Jackson;
62  import com.linecorp.centraldogma.server.command.CommandExecutor;
63  import com.linecorp.centraldogma.server.internal.metadata.ProjectMetadataTransformer;
64  import com.linecorp.centraldogma.server.management.ServerStatus;
65  import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer;
66  import com.linecorp.centraldogma.server.storage.project.Project;
67  import com.linecorp.centraldogma.server.storage.project.ProjectManager;
68  
69  /**
70   * A service class for metadata management.
71   */
72  public class MetadataService {
73  
74      private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
75  
76      /**
77       * A path of metadata file.
78       */
79      public static final String METADATA_JSON = "/metadata.json";
80  
81      /**
82       * A path of token list file.
83       */
84      public static final String TOKEN_JSON = "/tokens.json";
85  
86      /**
87       * A {@link JsonPointer} of project removal information.
88       */
89      private static final JsonPointer PROJECT_REMOVAL = JsonPointer.compile("/removal");
90  
91      private final ProjectManager projectManager;
92      private final RepositorySupport<ProjectMetadata> metadataRepo;
93      private final RepositorySupport<Tokens> tokenRepo;
94      private final InternalProjectInitializer projectInitializer;
95  
96      private final Map<String, CompletableFuture<Revision>> reposInAddingMetadata = new ConcurrentHashMap<>();
97  
98      /**
99       * Creates a new instance.
100      */
101     public MetadataService(ProjectManager projectManager, CommandExecutor executor,
102                            InternalProjectInitializer projectInitializer) {
103         this.projectManager = requireNonNull(projectManager, "projectManager");
104         this.projectInitializer = requireNonNull(projectInitializer, "projectInitializer");
105         metadataRepo = new RepositorySupport<>(projectManager, executor, ProjectMetadata.class);
106         tokenRepo = new RepositorySupport<>(projectManager, executor, Tokens.class);
107     }
108 
109     /**
110      * Returns a {@link ProjectMetadata} whose name equals to the specified {@code projectName}.
111      */
112     public CompletableFuture<ProjectMetadata> getProject(String projectName) {
113         requireNonNull(projectName, "projectName");
114         return getOrFetchMetadata(projectName);
115     }
116 
117     private CompletableFuture<ProjectMetadata> getOrFetchMetadata(String projectName) {
118         final ProjectMetadata metadata = getMetadata(projectName);
119         final Set<String> reposWithMetadata = metadata.repos().keySet();
120         final Set<String> repos = projectManager.get(projectName).repos().list().keySet();
121 
122         // Make sure all repositories have metadata. If not, create missing metadata.
123         // A repository can have missing metadata when a dev forgot to call `addRepo()`
124         // after creating a new repository.
125         final ImmutableList.Builder<CompletableFuture<Revision>> builder = ImmutableList.builder();
126         for (String repo : repos) {
127             if (reposWithMetadata.contains(repo) || Project.isInternalRepo(repo)) {
128                 continue;
129             }
130 
131             final String projectAndRepositoryName = projectName + '/' + repo;
132             final CompletableFuture<Revision> future = new CompletableFuture<>();
133             final CompletableFuture<Revision> futureInMap =
134                     reposInAddingMetadata.computeIfAbsent(projectAndRepositoryName, key -> future);
135             if (futureInMap != future) { // The metadata is already in adding.
136                 builder.add(futureInMap);
137                 continue;
138             }
139 
140             logger.warn("Adding missing repository metadata: {}/{}", projectName, repo);
141             final Author author = projectManager.get(projectName).repos().get(repo).author();
142             final CompletableFuture<Revision> addRepoFuture = addRepo(author, projectName, repo);
143             addRepoFuture.handle((revision, cause) -> {
144                 if (cause != null) {
145                     future.completeExceptionally(cause);
146                 } else {
147                     future.complete(revision);
148                 }
149                 reposInAddingMetadata.remove(projectAndRepositoryName);
150                 return null;
151             });
152             builder.add(future);
153         }
154 
155         final ImmutableList<CompletableFuture<Revision>> futures = builder.build();
156         if (futures.isEmpty()) {
157             // All repositories have metadata.
158             return CompletableFuture.completedFuture(metadata);
159         }
160 
161         // Some repository did not have metadata and thus will add the missing ones.
162         return CompletableFutures.successfulAsList(futures, cause -> {
163             final Throwable peeled = Exceptions.peel(cause);
164             // The metadata of the repository is added by another worker, so we can ignore the exception.
165             if (peeled instanceof RepositoryExistsException) {
166                 return null;
167             }
168             return Exceptions.throwUnsafely(cause);
169         }).thenCompose(unused -> {
170             logger.info("Fetching {}/{}{} again",
171                         projectName, Project.REPO_DOGMA, METADATA_JSON);
172             return fetchMetadata(projectName);
173         });
174     }
175 
176     private ProjectMetadata getMetadata(String projectName) {
177         final Project project = projectManager.get(projectName);
178         final ProjectMetadata metadata = project.metadata();
179         if (metadata == null) {
180             throw new EntryNotFoundException("project metadata not found: " + projectName);
181         }
182         return metadata;
183     }
184 
185     private CompletableFuture<ProjectMetadata> fetchMetadata(String projectName) {
186         return metadataRepo.fetch(projectName, Project.REPO_DOGMA, METADATA_JSON)
187                            .thenApply(HolderWithRevision::object);
188     }
189 
190     /**
191      * Removes a {@link ProjectMetadata} whose name equals to the specified {@code projectName}.
192      */
193     public CompletableFuture<Revision> removeProject(Author author, String projectName) {
194         requireNonNull(author, "author");
195         requireNonNull(projectName, "projectName");
196 
197         final Change<JsonNode> change = Change.ofJsonPatch(
198                 METADATA_JSON,
199                 asJsonArray(JsonPatchOperation.testAbsence(PROJECT_REMOVAL),
200                             JsonPatchOperation.add(PROJECT_REMOVAL,
201                                                    Jackson.valueToTree(UserAndTimestamp.of(author)))));
202         return metadataRepo.push(projectName, Project.REPO_DOGMA, author,
203                                  "Remove the project: " + projectName, change);
204     }
205 
206     /**
207      * Restores a {@link ProjectMetadata} whose name equals to the specified {@code projectName}.
208      */
209     public CompletableFuture<Revision> restoreProject(Author author, String projectName) {
210         requireNonNull(author, "author");
211         requireNonNull(projectName, "projectName");
212 
213         final Change<JsonNode> change =
214                 Change.ofJsonPatch(METADATA_JSON, JsonPatchOperation.remove(PROJECT_REMOVAL).toJsonNode());
215         return metadataRepo.push(projectName, Project.REPO_DOGMA, author,
216                                  "Restore the project: " + projectName, change);
217     }
218 
219     /**
220      * Returns a {@link Member} if the specified {@link User} is a member of the specified {@code projectName}.
221      */
222     public CompletableFuture<Member> getMember(String projectName, User user) {
223         requireNonNull(projectName, "projectName");
224         requireNonNull(user, "user");
225 
226         return getProject(projectName).thenApply(
227                 project -> project.memberOrDefault(user.id(), null));
228     }
229 
230     /**
231      * Adds the specified {@code member} to the {@link ProjectMetadata} of the specified {@code projectName}
232      * with the specified {@code projectRole}.
233      */
234     public CompletableFuture<Revision> addMember(Author author, String projectName,
235                                                  User member, ProjectRole projectRole) {
236         requireNonNull(author, "author");
237         requireNonNull(projectName, "projectName");
238         requireNonNull(member, "member");
239         requireNonNull(projectRole, "projectRole");
240 
241         final Member newMember = new Member(member, projectRole, UserAndTimestamp.of(author));
242         final JsonPointer path = JsonPointer.compile("/members" + encodeSegment(newMember.id()));
243         final Change<JsonNode> change =
244                 Change.ofJsonPatch(METADATA_JSON,
245                                    asJsonArray(JsonPatchOperation.testAbsence(path),
246                                                JsonPatchOperation.add(path, Jackson.valueToTree(newMember))));
247         final String commitSummary =
248                 "Add a member '" + newMember.id() + "' to the project '" + projectName + '\'';
249         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
250     }
251 
252     /**
253      * Removes the specified {@code user} from the {@link ProjectMetadata} in the specified
254      * {@code projectName}. It also removes the {@link RepositoryRole} of the specified {@code user} from every
255      * {@link RepositoryMetadata}.
256      */
257     public CompletableFuture<Revision> removeMember(Author author, String projectName, User user) {
258         requireNonNull(author, "author");
259         requireNonNull(projectName, "projectName");
260         requireNonNull(user, "user");
261 
262         final String memberId = user.id();
263         final String commitSummary =
264                 "Remove the member '" + memberId + "' from the project '" + projectName + '\'';
265 
266         final ProjectMetadataTransformer transformer =
267                 new ProjectMetadataTransformer((headRevision, projectMetadata) -> {
268                     projectMetadata.member(memberId); // Raises an exception if the member does not exist.
269                     final Map<String, Member> newMembers = removeFromMap(projectMetadata.members(), memberId);
270                     final ImmutableMap<String, RepositoryMetadata> newRepos =
271                             removeMemberFromRepositories(projectMetadata, memberId);
272                     return new ProjectMetadata(projectMetadata.name(),
273                                                newRepos,
274                                                newMembers,
275                                                projectMetadata.tokens(),
276                                                projectMetadata.creation(),
277                                                projectMetadata.removal());
278                 });
279         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
280     }
281 
282     private static ImmutableMap<String, RepositoryMetadata> removeMemberFromRepositories(
283             ProjectMetadata projectMetadata, String memberId) {
284         final ImmutableMap.Builder<String, RepositoryMetadata> reposBuilder =
285                 ImmutableMap.builderWithExpectedSize(projectMetadata.repos().size());
286         for (Entry<String, RepositoryMetadata> entry : projectMetadata.repos().entrySet()) {
287             final RepositoryMetadata repositoryMetadata = entry.getValue();
288             final Roles roles = repositoryMetadata.roles();
289             final Map<String, RepositoryRole> users = roles.users();
290             if (users.get(memberId) != null) {
291                 final ImmutableMap<String, RepositoryRole> newUsers = removeFromMap(users, memberId);
292                 final Roles newRoles = new Roles(roles.projectRoles(), newUsers, roles.tokens());
293                 reposBuilder.put(entry.getKey(),
294                                  new RepositoryMetadata(repositoryMetadata.name(),
295                                                         newRoles,
296                                                         repositoryMetadata.creation(),
297                                                         repositoryMetadata.removal(),
298                                                         repositoryMetadata.status()));
299             } else {
300                 reposBuilder.put(entry);
301             }
302         }
303         return reposBuilder.build();
304     }
305 
306     /**
307      * Updates a {@link ProjectRole} for the specified {@code member} in the specified {@code projectName}.
308      */
309     public CompletableFuture<Revision> updateMemberRole(Author author, String projectName,
310                                                         User member, ProjectRole projectRole) {
311         requireNonNull(author, "author");
312         requireNonNull(projectName, "projectName");
313         requireNonNull(member, "member");
314         requireNonNull(projectRole, "projectRole");
315 
316         final Change<JsonNode> change = Change.ofJsonPatch(
317                 METADATA_JSON,
318                 JsonPatchOperation.replace(
319                         JsonPointer.compile("/members" + encodeSegment(member.id()) + "/role"),
320                         Jackson.valueToTree(projectRole)).toJsonNode());
321         final String commitSummary = "Updates the role of the member '" + member.id() +
322                                      "' as '" + projectRole + "' for the project '" + projectName + '\'';
323         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
324     }
325 
326     /**
327      * Returns a {@link RepositoryMetadata} of the specified {@code repoName} in the specified
328      * {@code projectName}.
329      */
330     public CompletableFuture<RepositoryMetadata> getRepo(String projectName, String repoName) {
331         requireNonNull(projectName, "projectName");
332         requireNonNull(repoName, "repoName");
333 
334         return getProject(projectName).thenApply(project -> project.repo(repoName));
335     }
336 
337     /**
338      * Adds a {@link RepositoryMetadata} of the specified {@code repoName} to the specified {@code projectName}
339      * with a default {@link RepositoryRole}. The member will have the {@link RepositoryRole#WRITE} role and
340      * the guest won't have any role.
341      */
342     public CompletableFuture<Revision> addRepo(Author author, String projectName, String repoName) {
343         return addRepo(author, projectName, repoName, DEFAULT_PROJECT_ROLES);
344     }
345 
346     /**
347      * Adds a {@link RepositoryMetadata} of the specified {@code repoName} to the specified {@code projectName}
348      * with the specified {@link ProjectRoles}.
349      */
350     public CompletableFuture<Revision> addRepo(Author author, String projectName, String repoName,
351                                                ProjectRoles projectRoles) {
352         // TODO(minwoox): Prohibit adding internal repositories after migration is done.
353         requireNonNull(author, "author");
354         requireNonNull(projectName, "projectName");
355         requireNonNull(repoName, "repoName");
356 
357         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName));
358         final RepositoryMetadata newRepositoryMetadata =
359                 RepositoryMetadata.of(repoName, UserAndTimestamp.of(author), projectRoles);
360         final Change<JsonNode> change =
361                 Change.ofJsonPatch(METADATA_JSON,
362                                    asJsonArray(JsonPatchOperation.testAbsence(path),
363                                                JsonPatchOperation.add(
364                                                        path, Jackson.valueToTree(newRepositoryMetadata))));
365         final String commitSummary =
366                 "Add a repo '" + newRepositoryMetadata.id() + "' to the project '" + projectName + '\'';
367         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change)
368                            .handle((revision, cause) -> {
369                                if (cause != null) {
370                                    if (Exceptions.peel(cause) instanceof ChangeConflictException) {
371                                        throw new RepositoryExistsException(repoName);
372                                    } else {
373                                        return Exceptions.throwUnsafely(cause);
374                                    }
375                                }
376                                return revision;
377                            });
378     }
379 
380     /**
381      * Removes a {@link RepositoryMetadata} of the specified {@code repoName} from the specified
382      * {@code projectName}.
383      */
384     public CompletableFuture<Revision> removeRepo(Author author, String projectName, String repoName) {
385         requireNonNull(author, "author");
386         requireNonNull(projectName, "projectName");
387         requireNonNull(repoName, "repoName");
388 
389         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName) + "/removal");
390         final Change<JsonNode> change =
391                 Change.ofJsonPatch(METADATA_JSON,
392                                    asJsonArray(JsonPatchOperation.testAbsence(path),
393                                                JsonPatchOperation.add(path, Jackson.valueToTree(
394                                                        UserAndTimestamp.of(author)))));
395         final String commitSummary =
396                 "Remove the repo '" + repoName + "' from the project '" + projectName + '\'';
397         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
398     }
399 
400     /**
401      * Purges a {@link RepositoryMetadata} of the specified {@code repoName} from the specified
402      * {@code projectName}.
403      */
404     public CompletableFuture<Revision> purgeRepo(Author author, String projectName, String repoName) {
405         requireNonNull(author, "author");
406         requireNonNull(projectName, "projectName");
407         requireNonNull(repoName, "repoName");
408 
409         final JsonPointer path = JsonPointer.compile("/repos" + encodeSegment(repoName));
410         final Change<JsonNode> change = Change.ofJsonPatch(METADATA_JSON,
411                                                            JsonPatchOperation.remove(path).toJsonNode());
412         final String commitSummary =
413                 "Purge the repo '" + repoName + "' from the project '" + projectName + '\'';
414         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
415     }
416 
417     /**
418      * Restores a {@link RepositoryMetadata} of the specified {@code repoName} in the specified
419      * {@code projectName}.
420      */
421     public CompletableFuture<Revision> restoreRepo(Author author, String projectName, String repoName) {
422         requireNonNull(author, "author");
423         requireNonNull(projectName, "projectName");
424         requireNonNull(repoName, "repoName");
425 
426         final Change<JsonNode> change =
427                 Change.ofJsonPatch(METADATA_JSON,
428                                    JsonPatchOperation.remove(JsonPointer.compile(
429                                            "/repos" + encodeSegment(repoName) + "/removal")).toJsonNode());
430         final String commitSummary =
431                 "Restore the repo '" + repoName + "' from the project '" + projectName + '\'';
432         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
433     }
434 
435     /**
436      * Updates the member and guest {@link RepositoryRole} of the specified {@code repoName} in the specified
437      * {@code projectName}.
438      */
439     public CompletableFuture<Revision> updateRepositoryProjectRoles(Author author,
440                                                                     String projectName, String repoName,
441                                                                     ProjectRoles projectRoles) {
442         requireNonNull(author, "author");
443         requireNonNull(projectName, "projectName");
444         requireNonNull(repoName, "repoName");
445 
446         if (Project.isInternalRepo(repoName)) {
447             throw new UnsupportedOperationException(
448                     "Can't update role for internal repository: " + repoName);
449         }
450 
451         final String commitSummary =
452                 "Update the project roles of the '" + repoName + "' in the project '" + projectName + '\'';
453         final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
454                 repoName, (headRevision, repositoryMetadata) -> {
455             final Roles newRoles = new Roles(projectRoles, repositoryMetadata.roles().users(),
456                                              repositoryMetadata.roles().tokens());
457             return new RepositoryMetadata(repositoryMetadata.name(),
458                                           newRoles,
459                                           repositoryMetadata.creation(),
460                                           repositoryMetadata.removal(),
461                                           repositoryMetadata.status());
462         });
463         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
464     }
465 
466     /**
467      * Adds the specified {@link Token} to the specified {@code projectName}.
468      */
469     public CompletableFuture<Revision> addToken(Author author, String projectName,
470                                                 Token token, ProjectRole role) {
471         return addToken(author, projectName, requireNonNull(token, "token").appId(), role);
472     }
473 
474     /**
475      * Adds a {@link Token} of the specified {@code appId} to the specified {@code projectName}.
476      */
477     public CompletableFuture<Revision> addToken(Author author, String projectName,
478                                                 String appId, ProjectRole role) {
479         requireNonNull(author, "author");
480         requireNonNull(projectName, "projectName");
481         requireNonNull(appId, "appId");
482         requireNonNull(role, "role");
483 
484         getTokens().get(appId); // Will raise an exception if not found.
485         final TokenRegistration registration = new TokenRegistration(appId, role,
486                                                                      UserAndTimestamp.of(author));
487         final JsonPointer path = JsonPointer.compile("/tokens" + encodeSegment(registration.id()));
488         final Change<JsonNode> change =
489                 Change.ofJsonPatch(METADATA_JSON,
490                                        asJsonArray(JsonPatchOperation.testAbsence(path),
491                                                    JsonPatchOperation.add(path,
492                                                                           Jackson.valueToTree(registration))));
493         final String commitSummary = "Add a token '" + registration.id() +
494                                      "' to the project '" + projectName + "' with a role '" + role + '\'';
495         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
496     }
497 
498     /**
499      * Removes the specified {@link Token} from the specified {@code projectName}. It also removes
500      * every token repository role belonging to the {@link Token} from every {@link RepositoryMetadata}.
501      */
502     public CompletableFuture<Revision> removeToken(Author author, String projectName, Token token) {
503         return removeToken(author, projectName, requireNonNull(token, "token").appId());
504     }
505 
506     /**
507      * Removes the {@link Token} of the specified {@code appId} from the specified {@code projectName}.
508      * It also removes every token repository role belonging to {@link Token} from
509      * every {@link RepositoryMetadata}.
510      */
511     public CompletableFuture<Revision> removeToken(Author author, String projectName, String appId) {
512         requireNonNull(author, "author");
513         requireNonNull(projectName, "projectName");
514         requireNonNull(appId, "appId");
515 
516         return removeToken(projectName, author, appId, false);
517     }
518 
519     private CompletableFuture<Revision> removeToken(String projectName, Author author, String appId,
520                                                     boolean quiet) {
521         final String commitSummary = "Remove the token '" + appId + "' from the project '" + projectName + '\'';
522         final ProjectMetadataTransformer transformer =
523                 new ProjectMetadataTransformer((headRevision, projectMetadata) -> {
524                     final Map<String, TokenRegistration> tokens = projectMetadata.tokens();
525                     final Map<String, TokenRegistration> newTokens;
526                     if (tokens.get(appId) == null) {
527                         if (!quiet) {
528                             throw new TokenNotFoundException(
529                                     "failed to find the token " + appId + " in project " + projectName);
530                         }
531                         newTokens = tokens;
532                     } else {
533                         newTokens = tokens.entrySet()
534                                           .stream()
535                                           .filter(entry -> !entry.getKey().equals(appId))
536                                           .collect(toImmutableMap(Entry::getKey, Entry::getValue));
537                     }
538 
539                     final ImmutableMap<String, RepositoryMetadata> newRepos =
540                             removeTokenFromRepositories(appId, projectMetadata);
541                     return new ProjectMetadata(projectMetadata.name(),
542                                                newRepos,
543                                                projectMetadata.members(),
544                                                newTokens,
545                                                projectMetadata.creation(),
546                                                projectMetadata.removal());
547                 });
548         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
549     }
550 
551     private static ImmutableMap<String, RepositoryMetadata> removeTokenFromRepositories(
552             String appId, ProjectMetadata projectMetadata) {
553         final ImmutableMap.Builder<String, RepositoryMetadata> builder =
554                 ImmutableMap.builderWithExpectedSize(projectMetadata.repos().size());
555         for (Entry<String, RepositoryMetadata> entry : projectMetadata.repos().entrySet()) {
556             final RepositoryMetadata repositoryMetadata = entry.getValue();
557             final Roles roles = repositoryMetadata.roles();
558             if (roles.tokens().get(appId) != null) {
559                 final Map<String, RepositoryRole> newTokens = removeFromMap(roles.tokens(), appId);
560                 final Roles newRoles = new Roles(roles.projectRoles(), roles.users(), newTokens);
561                 builder.put(entry.getKey(), new RepositoryMetadata(repositoryMetadata.name(),
562                                                                    newRoles,
563                                                                    repositoryMetadata.creation(),
564                                                                    repositoryMetadata.removal(),
565                                                                    repositoryMetadata.status()));
566             } else {
567                 builder.put(entry);
568             }
569         }
570         return builder.build();
571     }
572 
573     /**
574      * Updates a {@link ProjectRole} for the {@link Token} of the specified {@code appId}.
575      */
576     public CompletableFuture<Revision> updateTokenRole(Author author, String projectName,
577                                                        Token token, ProjectRole role) {
578         requireNonNull(author, "author");
579         requireNonNull(projectName, "projectName");
580         requireNonNull(token, "token");
581         requireNonNull(role, "role");
582         final TokenRegistration registration = new TokenRegistration(token.appId(), role,
583                                                                      UserAndTimestamp.of(author));
584         final JsonPointer path = JsonPointer.compile("/tokens" + encodeSegment(registration.id()));
585         final Change<JsonNode> change =
586                 Change.ofJsonPatch(METADATA_JSON,
587                                    JsonPatchOperation.replace(
588                                            path, Jackson.valueToTree(registration)).toJsonNode());
589         final String commitSummary = "Update the role of a token '" + token.appId() +
590                                      "' as '" + role + "' for the project '" + projectName + '\'';
591         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, change);
592     }
593 
594     /**
595      * Adds the {@link RepositoryRole} for the specified {@code member} to the specified {@code repoName}
596      * in the specified {@code projectName}.
597      */
598     public CompletableFuture<Revision> addUserRepositoryRole(Author author, String projectName,
599                                                              String repoName, User member,
600                                                              RepositoryRole role) {
601         requireNonNull(author, "author");
602         requireNonNull(projectName, "projectName");
603         requireNonNull(repoName, "repoName");
604         requireNonNull(member, "member");
605         requireNonNull(role, "role");
606 
607         return getProject(projectName).thenCompose(project -> {
608             project.repo(repoName); // Raises an exception if the repository does not exist.
609             ensureProjectMember(project, member);
610             final String commitSummary = "Add repository role of '" + member.id() +
611                                          "' as '" + role + "' to '" + projectName + '/' + repoName + '\n';
612             final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
613                     repoName, (headRevision, repositoryMetadata) -> {
614                 final Roles roles = repositoryMetadata.roles();
615                 if (roles.users().get(member.id()) != null) {
616                     throw new ChangeConflictException(
617                             "the member " + member.id() + " is already added to '" +
618                             projectName + '/' + repoName + '\'');
619                 }
620 
621                 final Map<String, RepositoryRole> users = roles.users();
622                 final ImmutableMap<String, RepositoryRole> newUsers = addToMap(users, member.id(), role);
623                 final Roles newRoles = new Roles(roles.projectRoles(), newUsers, roles.tokens());
624                 return new RepositoryMetadata(repositoryMetadata.name(),
625                                               newRoles,
626                                               repositoryMetadata.creation(),
627                                               repositoryMetadata.removal(),
628                                               repositoryMetadata.status());
629             });
630             return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
631         });
632     }
633 
634     /**
635      * Removes the {@link RepositoryRole} for the specified {@code member} from the specified {@code repoName}
636      * in the specified {@code projectName}.
637      */
638     public CompletableFuture<Revision> removeUserRepositoryRole(Author author, String projectName,
639                                                                 String repoName, User member) {
640         requireNonNull(author, "author");
641         requireNonNull(projectName, "projectName");
642         requireNonNull(repoName, "repoName");
643         requireNonNull(member, "member");
644 
645         final String memberId = member.id();
646         final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
647                 repoName, (headRevision, repositoryMetadata) -> {
648             final Roles roles = repositoryMetadata.roles();
649             if (roles.users().get(memberId) == null) {
650                 throw new MemberNotFoundException(memberId, projectName, repoName);
651             }
652 
653             final Map<String, RepositoryRole> newUsers = removeFromMap(roles.users(), memberId);
654             final Roles newRoles = new Roles(roles.projectRoles(), newUsers, roles.tokens());
655             return new RepositoryMetadata(repositoryMetadata.name(),
656                                           newRoles,
657                                           repositoryMetadata.creation(),
658                                           repositoryMetadata.removal(),
659                                           repositoryMetadata.status());
660         });
661         final String commitSummary = "Remove repository role of the '" + memberId +
662                                      "' from '" + projectName + '/' + repoName + '\'';
663         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
664     }
665 
666     /**
667      * Updates the {@link RepositoryRole} for the specified {@code member} of the specified {@code repoName}
668      * in the specified {@code projectName}.
669      */
670     public CompletableFuture<Revision> updateUserRepositoryRole(Author author, String projectName,
671                                                                 String repoName, User member,
672                                                                 RepositoryRole role) {
673         requireNonNull(author, "author");
674         requireNonNull(projectName, "projectName");
675         requireNonNull(repoName, "repoName");
676         requireNonNull(member, "member");
677         requireNonNull(role, "role");
678 
679         final String memberId = member.id();
680         final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
681                 repoName, (headRevision, repositoryMetadata) -> {
682             final Roles roles = repositoryMetadata.roles();
683             final RepositoryRole oldRepositoryRole = roles.users().get(memberId);
684             if (oldRepositoryRole == null) {
685                 throw new MemberNotFoundException(memberId, projectName, repoName);
686             }
687 
688             if (oldRepositoryRole == role) {
689                 throw new RedundantChangeException(
690                         headRevision,
691                         "the repository role of " + memberId + " in '" + projectName + '/' + repoName +
692                         "' isn't changed.");
693             }
694 
695             final Map<String, RepositoryRole> newUsers = updateMap(roles.users(), memberId, role);
696             final Roles newRoles = new Roles(roles.projectRoles(), newUsers, roles.tokens());
697             return new RepositoryMetadata(repositoryMetadata.name(),
698                                           newRoles,
699                                           repositoryMetadata.creation(),
700                                           repositoryMetadata.removal(),
701                                           repositoryMetadata.status());
702         });
703         final String commitSummary = "Update repository role of the '" + memberId + "' as '" + role +
704                                      "' for '" + projectName + '/' + repoName + '\'';
705         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
706     }
707 
708     /**
709      * Adds the {@link RepositoryRole} for the {@link Token} of the specified {@code appId} to the specified
710      * {@code repoName} in the specified {@code projectName}.
711      */
712     public CompletableFuture<Revision> addTokenRepositoryRole(Author author, String projectName,
713                                                               String repoName, String appId,
714                                                               RepositoryRole role) {
715         requireNonNull(author, "author");
716         requireNonNull(projectName, "projectName");
717         requireNonNull(repoName, "repoName");
718         requireNonNull(appId, "appId");
719         requireNonNull(role, "role");
720 
721         return getProject(projectName).thenCompose(project -> {
722             project.repo(repoName); // Raises an exception if the repository does not exist.
723             ensureProjectToken(project, appId);
724             final String commitSummary = "Add repository role of the token '" + appId + "' as '" + role +
725                                          "' to '" + projectName + '/' + repoName + "'\n";
726             final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
727                     repoName, (headRevision, repositoryMetadata) -> {
728                 final Roles roles = repositoryMetadata.roles();
729                 if (roles.tokens().get(appId) != null) {
730                     throw new ChangeConflictException(
731                             "the token " + appId + " is already added to '" +
732                             projectName + '/' + repoName + '\'');
733                 }
734 
735                 final Map<String, RepositoryRole> newTokens = addToMap(roles.tokens(), appId, role);
736                 final Roles newRoles = new Roles(roles.projectRoles(), roles.users(), newTokens);
737                 return new RepositoryMetadata(repositoryMetadata.name(),
738                                               newRoles,
739                                               repositoryMetadata.creation(),
740                                               repositoryMetadata.removal(),
741                                               repositoryMetadata.status());
742             });
743             return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
744         });
745     }
746 
747     /**
748      * Removes the {@link RepositoryRole} for the {@link Token} of the specified {@code appId} of the specified
749      * {@code repoName} in the specified {@code projectName}.
750      */
751     public CompletableFuture<Revision> removeTokenRepositoryRole(Author author, String projectName,
752                                                                  String repoName, String appId) {
753         requireNonNull(author, "author");
754         requireNonNull(projectName, "projectName");
755         requireNonNull(repoName, "repoName");
756         requireNonNull(appId, "appId");
757 
758         final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
759                 repoName, (headRevision, repositoryMetadata) -> {
760             final Roles roles = repositoryMetadata.roles();
761             if (roles.tokens().get(appId) == null) {
762                 throw new ChangeConflictException(
763                         "the token " + appId + " doesn't exist at '" +
764                         projectName + '/' + repoName + '\'');
765             }
766 
767             final Map<String, RepositoryRole> newTokens = removeFromMap(roles.tokens(), appId);
768             final Roles newRoles = new Roles(roles.projectRoles(), roles.users(), newTokens);
769             return new RepositoryMetadata(repositoryMetadata.name(),
770                                           newRoles,
771                                           repositoryMetadata.creation(),
772                                           repositoryMetadata.removal(),
773                                           repositoryMetadata.status());
774         });
775         final String commitSummary = "Remove repository role of the token '" + appId +
776                                      "' from '" + projectName + '/' + repoName + '\'';
777         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
778     }
779 
780     /**
781      * Updates the {@link RepositoryRole} for the {@link Token} of the specified {@code appId} of the specified
782      * {@code repoName} in the specified {@code projectName}.
783      */
784     public CompletableFuture<Revision> updateTokenRepositoryRole(Author author, String projectName,
785                                                                  String repoName, String appId,
786                                                                  RepositoryRole role) {
787         requireNonNull(author, "author");
788         requireNonNull(projectName, "projectName");
789         requireNonNull(repoName, "repoName");
790         requireNonNull(appId, "appId");
791         requireNonNull(role, "role");
792 
793         final RepositoryMetadataTransformer transformer = new RepositoryMetadataTransformer(
794                 repoName, (headRevision, repositoryMetadata) -> {
795             final Roles roles = repositoryMetadata.roles();
796             final RepositoryRole oldRepositoryRole = roles.tokens().get(appId);
797             if (oldRepositoryRole == null) {
798                 throw new TokenNotFoundException(
799                         "the token " + appId + " doesn't exist at '" +
800                         projectName + '/' + repoName + '\'');
801             }
802 
803             if (oldRepositoryRole == role) {
804                 throw new RedundantChangeException(
805                         headRevision,
806                         "the permission of " + appId + " in '" + projectName + '/' + repoName +
807                         "' isn't changed.");
808             }
809 
810             final Map<String, RepositoryRole> newTokens = updateMap(roles.tokens(), appId, role);
811             final Roles newRoles = new Roles(roles.projectRoles(), roles.users(), newTokens);
812             return new RepositoryMetadata(repositoryMetadata.name(),
813                                           newRoles,
814                                           repositoryMetadata.creation(),
815                                           repositoryMetadata.removal(),
816                                           repositoryMetadata.status());
817         });
818         final String commitSummary = "Update repository role of the token '" + appId +
819                                      "' for '" + projectName + '/' + repoName + '\'';
820         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer);
821     }
822 
823     /**
824      * Finds {@link RepositoryRole} of the specified {@link User} or {@link UserWithToken}
825      * from the specified {@code repoName} in the specified {@code projectName}. If the {@link User}
826      * is not found, it will return {@code null}.
827      */
828     public CompletableFuture<RepositoryRole> findRepositoryRole(String projectName, String repoName,
829                                                                 User user) {
830         requireNonNull(projectName, "projectName");
831         requireNonNull(repoName, "repoName");
832         requireNonNull(user, "user");
833         if (user.isSystemAdmin()) {
834             return CompletableFuture.completedFuture(RepositoryRole.ADMIN);
835         }
836         if (user instanceof UserWithToken) {
837             return findRepositoryRole(projectName, repoName, ((UserWithToken) user).token());
838         }
839         return findRepositoryRole0(projectName, repoName, user);
840     }
841 
842     /**
843      * Finds {@link RepositoryRole} of the specified {@link Token} from the specified
844      * {@code repoName} in the specified {@code projectName}. If the {@code appId} is not found,
845      * it will return {@code null}.
846      */
847     public CompletableFuture<RepositoryRole> findRepositoryRole(String projectName, String repoName,
848                                                                 Token token) {
849         requireNonNull(projectName, "projectName");
850         requireNonNull(repoName, "repoName");
851         requireNonNull(token, "token");
852 
853         return getProject(projectName).thenApply(metadata -> {
854             final RepositoryMetadata repositoryMetadata = metadata.repo(repoName);
855             final Roles roles = repositoryMetadata.roles();
856             final String appId = token.appId();
857             final RepositoryRole tokenRepositoryRole = roles.tokens().get(appId);
858 
859             final TokenRegistration projectTokenRegistration = metadata.tokens().get(appId);
860             final ProjectRole projectRole;
861             if (projectTokenRegistration != null) {
862                 projectRole = projectTokenRegistration.role();
863             } else {
864                 // System admin tokens were checked before this method.
865                 assert !token.isSystemAdmin();
866                 if (token.allowGuestAccess()) {
867                     projectRole = ProjectRole.GUEST;
868                 } else {
869                     // The token is not allowed with the GUEST permission.
870                     return null;
871                 }
872             }
873             return repositoryRole(roles, tokenRepositoryRole, projectRole);
874         });
875     }
876 
877     private CompletableFuture<RepositoryRole> findRepositoryRole0(String projectName, String repoName,
878                                                                   User user) {
879         requireNonNull(projectName, "projectName");
880         requireNonNull(repoName, "repoName");
881         requireNonNull(user, "user");
882 
883         return getProject(projectName).thenApply(metadata -> {
884             final RepositoryMetadata repositoryMetadata = metadata.repo(repoName);
885             final Roles roles = repositoryMetadata.roles();
886             final RepositoryRole userRepositoryRole = roles.users().get(user.id());
887 
888             final Member projectUser = metadata.memberOrDefault(user.id(), null);
889             final ProjectRole projectRole = projectUser != null ? projectUser.role() : ProjectRole.GUEST;
890             return repositoryRole(roles, userRepositoryRole, projectRole);
891         });
892     }
893 
894     @Nullable
895     private static RepositoryRole repositoryRole(Roles roles, @Nullable RepositoryRole repositoryRole,
896                                                  ProjectRole projectRole) {
897         if (projectRole == ProjectRole.OWNER) {
898             return RepositoryRole.ADMIN;
899         }
900 
901         final RepositoryRole memberOrGuestRole;
902         if (projectRole == ProjectRole.MEMBER) {
903             memberOrGuestRole = roles.projectRoles().member();
904         } else {
905             assert projectRole == ProjectRole.GUEST;
906             memberOrGuestRole = roles.projectRoles().guest();
907         }
908 
909         if (repositoryRole == RepositoryRole.ADMIN || memberOrGuestRole == RepositoryRole.ADMIN) {
910             return RepositoryRole.ADMIN;
911         }
912 
913         if (repositoryRole == RepositoryRole.WRITE || memberOrGuestRole == RepositoryRole.WRITE) {
914             return RepositoryRole.WRITE;
915         }
916 
917         if (repositoryRole == RepositoryRole.READ || memberOrGuestRole == RepositoryRole.READ) {
918             return RepositoryRole.READ;
919         }
920 
921         return null;
922     }
923 
924     /**
925      * Finds a {@link ProjectRole} of the specified {@link User} in the specified {@code projectName}.
926      */
927     public CompletableFuture<ProjectRole> findProjectRole(String projectName, User user) {
928         requireNonNull(projectName, "projectName");
929         requireNonNull(user, "user");
930 
931         if (user.isSystemAdmin()) {
932             return CompletableFuture.completedFuture(ProjectRole.OWNER);
933         }
934         return getProject(projectName).thenApply(project -> {
935             if (user instanceof UserWithToken) {
936                 final TokenRegistration registration = project.tokens().getOrDefault(
937                         ((UserWithToken) user).token().id(), null);
938                 return registration != null ? registration.role() : ProjectRole.GUEST;
939             } else {
940                 final Member member = project.memberOrDefault(user.id(), null);
941                 return member != null ? member.role() : ProjectRole.GUEST;
942             }
943         });
944     }
945 
946     /**
947      * Fetches the {@link Tokens} from the repository.
948      */
949     public CompletableFuture<Tokens> fetchTokens() {
950         return tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON)
951                         .thenApply(HolderWithRevision::object);
952     }
953 
954     /**
955      * Returns a {@link Tokens}.
956      */
957     public Tokens getTokens() {
958         return projectInitializer.tokens();
959     }
960 
961     /**
962      * Creates a new user-level {@link Token} with the specified {@code appId}. A secret for the {@code appId}
963      * will be automatically generated.
964      */
965     public CompletableFuture<Revision> createToken(Author author, String appId) {
966         return createToken(author, appId, false);
967     }
968 
969     /**
970      * Creates a new {@link Token} with the specified {@code appId}, {@code isSystemAdmin} and an auto-generated
971      * secret.
972      */
973     public CompletableFuture<Revision> createToken(Author author, String appId, boolean isSystemAdmin) {
974         return createToken(author, appId, SECRET_PREFIX + UUID.randomUUID(), isSystemAdmin);
975     }
976 
977     /**
978      * Creates a new user-level {@link Token} with the specified {@code appId} and {@code secret}.
979      */
980     public CompletableFuture<Revision> createToken(Author author, String appId, String secret) {
981         return createToken(author, appId, secret, false);
982     }
983 
984     /**
985      * Creates a new {@link Token} with the specified {@code appId}, {@code secret} and {@code isSystemAdmin}.
986      */
987     public CompletableFuture<Revision> createToken(Author author, String appId, String secret,
988                                                    boolean isSystemAdmin) {
989         requireNonNull(author, "author");
990         requireNonNull(appId, "appId");
991         requireNonNull(secret, "secret");
992 
993         checkArgument(secret.startsWith(SECRET_PREFIX), "secret must start with: " + SECRET_PREFIX);
994 
995         // Does not allow guest access for normal tokens.
996         final boolean allowGuestAccess = isSystemAdmin;
997         final Token newToken = new Token(appId, secret, isSystemAdmin, allowGuestAccess,
998                                          UserAndTimestamp.of(author));
999         final JsonPointer appIdPath = JsonPointer.compile("/appIds" + encodeSegment(newToken.id()));
1000         final String newTokenSecret = newToken.secret();
1001         assert newTokenSecret != null;
1002         final JsonPointer secretPath = JsonPointer.compile("/secrets" + encodeSegment(newTokenSecret));
1003         final Change<JsonNode> change =
1004                 Change.ofJsonPatch(TOKEN_JSON,
1005                                    asJsonArray(JsonPatchOperation.testAbsence(appIdPath),
1006                                                JsonPatchOperation.testAbsence(secretPath),
1007                                                JsonPatchOperation.add(appIdPath, Jackson.valueToTree(newToken)),
1008                                                JsonPatchOperation.add(secretPath,
1009                                                                       Jackson.valueToTree(newToken.id()))));
1010         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,
1011                               "Add a token: " + newToken.id(), change);
1012     }
1013 
1014     /**
1015      * Removes the {@link Token} of the specified {@code appId} completely from the system.
1016      */
1017     public CompletableFuture<Revision> destroyToken(Author author, String appId) {
1018         requireNonNull(author, "author");
1019         requireNonNull(appId, "appId");
1020 
1021         final String commitSummary = "Destroy the token: " + appId;
1022         final UserAndTimestamp userAndTimestamp = UserAndTimestamp.of(author);
1023 
1024         final TokensTransformer transformer = new TokensTransformer((headRevision, tokens) -> {
1025             final Token token = tokens.get(appId); // Raise an exception if not found.
1026             if (token.deletion() != null) {
1027                 throw new ChangeConflictException("The token is already destroyed: " + appId);
1028             }
1029 
1030             final String secret = token.secret();
1031             assert secret != null;
1032             final Token newToken = new Token(token.appId(), secret, token.isSystemAdmin(),
1033                                              token.isSystemAdmin(), token.allowGuestAccess(),
1034                                              token.creation(), token.deactivation(), userAndTimestamp);
1035             return new Tokens(updateMap(tokens.appIds(), appId, newToken), tokens.secrets());
1036         });
1037         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer);
1038     }
1039 
1040     /**
1041      * Purges the {@link Token} of the specified {@code appId} that was removed before.
1042      *
1043      * <p>Note that this is a blocking method that should not be invoked in an event loop.
1044      */
1045     public Revision purgeToken(Author author, String appId) {
1046         requireNonNull(author, "author");
1047         requireNonNull(appId, "appId");
1048 
1049         final Collection<Project> projects = listProjectsWithoutInternal(projectManager.list(),
1050                                                                          User.SYSTEM_ADMIN).values();
1051         // Remove the token from projects that only have the token.
1052         for (Project project : projects) {
1053             // Fetch the metadata to get the latest information.
1054             final ProjectMetadata projectMetadata = fetchMetadata(project.name()).join();
1055             final boolean containsTargetTokenInTheProject =
1056                     projectMetadata.tokens().values()
1057                                    .stream()
1058                                    .anyMatch(token -> token.appId().equals(appId));
1059 
1060             if (containsTargetTokenInTheProject) {
1061                 removeToken(project.name(), author, appId, true).join();
1062             }
1063         }
1064 
1065         final String commitSummary = "Remove the token: " + appId;
1066 
1067         final TokensTransformer transformer = new TokensTransformer((headRevision, tokens) -> {
1068             final Token token = tokens.get(appId);
1069             final Map<String, Token> newAppIds = removeFromMap(tokens.appIds(), appId);
1070             final String secret = token.secret();
1071             assert secret != null;
1072             final Map<String, String> newSecrets = removeFromMap(tokens.secrets(), secret);
1073             return new Tokens(newAppIds, newSecrets);
1074         });
1075         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer)
1076                         .join();
1077     }
1078 
1079     /**
1080      * Activates the {@link Token} of the specified {@code appId}.
1081      */
1082     public CompletableFuture<Revision> activateToken(Author author, String appId) {
1083         requireNonNull(author, "author");
1084         requireNonNull(appId, "appId");
1085 
1086         final String commitSummary = "Enable the token: " + appId;
1087 
1088         final TokensTransformer transformer = new TokensTransformer((headRevision, tokens) -> {
1089             final Token token = tokens.get(appId); // Raise an exception if not found.
1090             if (token.deactivation() == null) {
1091                 throw new RedundantChangeException(headRevision, "The token is already activated: " + appId);
1092             }
1093             final String secret = token.secret();
1094             assert secret != null;
1095             final Map<String, String> newSecrets =
1096                     addToMap(tokens.secrets(), secret, appId); // Note that the key is secret not appId.
1097             final Token newToken = new Token(token.appId(), secret, token.isSystemAdmin(),
1098                                              token.allowGuestAccess(), token.creation());
1099             return new Tokens(updateMap(tokens.appIds(), appId, newToken), newSecrets);
1100         });
1101         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer);
1102     }
1103 
1104     /**
1105      * Deactivates the {@link Token} of the specified {@code appId}.
1106      */
1107     public CompletableFuture<Revision> deactivateToken(Author author, String appId) {
1108         requireNonNull(author, "author");
1109         requireNonNull(appId, "appId");
1110 
1111         final String commitSummary = "Deactivate the token: " + appId;
1112         final UserAndTimestamp userAndTimestamp = UserAndTimestamp.of(author);
1113 
1114         final TokensTransformer transformer = new TokensTransformer((headRevision, tokens) -> {
1115             final Token token = tokens.get(appId);
1116             if (token.deactivation() != null) {
1117                 throw new RedundantChangeException(headRevision, "The token is already deactivated: " + appId);
1118             }
1119             final String secret = token.secret();
1120             assert secret != null;
1121             final Token newToken = new Token(token.appId(), secret, token.isSystemAdmin(),
1122                                              token.isSystemAdmin(), token.allowGuestAccess(), token.creation(),
1123                                              userAndTimestamp, null);
1124             final Map<String, Token> newAppIds = updateMap(tokens.appIds(), appId, newToken);
1125             final Map<String, String> newSecrets =
1126                     removeFromMap(tokens.secrets(), secret); // Note that the key is secret not appId.
1127             return new Tokens(newAppIds, newSecrets);
1128         });
1129         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer);
1130     }
1131 
1132     /**
1133      * Update the {@link Token} of the specified {@code appId} to user or admin.
1134      */
1135     public CompletableFuture<Revision> updateTokenLevel(Author author, String appId, boolean toBeSystemAdmin) {
1136         requireNonNull(author, "author");
1137         requireNonNull(appId, "appId");
1138         final String commitSummary =
1139                 "Update the token level: " + appId + " to " + (toBeSystemAdmin ? "admin" : "user");
1140         final TokensTransformer transformer = new TokensTransformer((headRevision, tokens) -> {
1141             final Token token = tokens.get(appId); // Raise an exception if not found.
1142             if (toBeSystemAdmin == token.isSystemAdmin()) {
1143                 throw new RedundantChangeException(
1144                         headRevision,
1145                         "The token is already " + (toBeSystemAdmin ? "admin" : "user"));
1146             }
1147 
1148             final Token newToken = token.withSystemAdmin(toBeSystemAdmin);
1149             return new Tokens(updateMap(tokens.appIds(), appId, newToken), tokens.secrets());
1150         });
1151         return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer);
1152     }
1153 
1154     /**
1155      * Returns a {@link Token} which has the specified {@code appId}.
1156      */
1157     public Token findTokenByAppId(String appId) {
1158         requireNonNull(appId, "appId");
1159         return getTokens().get(appId);
1160     }
1161 
1162     /**
1163      * Returns a {@link Token} which has the specified {@code secret}.
1164      */
1165     public Token findTokenBySecret(String secret) {
1166         requireNonNull(secret, "secret");
1167         validateSecret(secret);
1168         return getTokens().findBySecret(secret);
1169     }
1170 
1171     /**
1172      * Ensures that the specified {@code user} is a member of the specified {@code project}.
1173      */
1174     private static void ensureProjectMember(ProjectMetadata project, User user) {
1175         requireNonNull(project, "project");
1176         requireNonNull(user, "user");
1177 
1178         if (project.members().values().stream().noneMatch(member -> member.login().equals(user.id()))) {
1179             throw new MemberNotFoundException(user.id(), project.name());
1180         }
1181     }
1182 
1183     /**
1184      * Ensures that the specified {@code appId} is a token of the specified {@code project}.
1185      */
1186     private static void ensureProjectToken(ProjectMetadata project, String appId) {
1187         requireNonNull(project, "project");
1188         requireNonNull(appId, "appId");
1189 
1190         if (!project.tokens().containsKey(appId)) {
1191             throw new TokenNotFoundException(
1192                     appId + " is not a token of the project '" + project.name() + '\'');
1193         }
1194     }
1195 
1196     private static <T> ImmutableMap<String, T> addToMap(Map<String, T> map, String key, T value) {
1197         return ImmutableMap.<String, T>builderWithExpectedSize(map.size() + 1)
1198                            .putAll(map)
1199                            .put(key, value)
1200                            .build();
1201     }
1202 
1203     private static <T> Map<String, T> updateMap(Map<String, T> map, String key, T value) {
1204         final ImmutableMap.Builder<String, T> builder = ImmutableMap.builderWithExpectedSize(map.size());
1205         for (Entry<String, T> entry : map.entrySet()) {
1206             if (entry.getKey().equals(key)) {
1207                 builder.put(key, value);
1208             } else {
1209                 builder.put(entry);
1210             }
1211         }
1212         return builder.build();
1213     }
1214 
1215     private static <T> ImmutableMap<String, T> removeFromMap(Map<String, T> map, String id) {
1216         return map.entrySet().stream()
1217                   .filter(e -> !e.getKey().equals(id))
1218                   .collect(toImmutableMap(Entry::getKey, Entry::getValue));
1219     }
1220 
1221     /**
1222      * Updates the {@link ServerStatus} of the specified {@code repoName}.
1223      */
1224     public CompletableFuture<Revision> updateRepositoryStatus(
1225             Author author, String projectName, String repoName, RepositoryStatus repositoryStatus) {
1226         requireNonNull(author, "author");
1227         requireNonNull(projectName, "projectName");
1228         requireNonNull(repoName, "repoName");
1229         requireNonNull(repositoryStatus, "repositoryStatus");
1230         final String newRepoName;
1231         if (Project.REPO_META.equals(repoName)) {
1232             newRepoName = Project.REPO_DOGMA; // Use dogma repository because meta repository will be removed.
1233         } else {
1234             newRepoName = repoName;
1235         }
1236 
1237         final ProjectMetadataTransformer transformer;
1238         if (Project.REPO_DOGMA.equals(newRepoName)) {
1239             // Have to use ProjectMetadataTransformer because the repository metadata of dogma repository
1240             // might not exist.
1241             transformer = new ProjectMetadataTransformer((headRevision, projectMetadata) -> {
1242                 final RepositoryMetadata repositoryMetadata = projectMetadata.repos().get(Project.REPO_DOGMA);
1243                 if (repositoryMetadata != null) {
1244                     throwIfRedundant(repositoryStatus, headRevision, repositoryMetadata, Project.REPO_DOGMA);
1245                 }
1246                 final RepositoryMetadata newRepositoryMetadata = RepositoryMetadata.ofDogma(repositoryStatus);
1247                 final Builder<String, RepositoryMetadata> builder = ImmutableMap.builder();
1248                 builder.put(Project.REPO_DOGMA, newRepositoryMetadata);
1249                 projectMetadata.repos().forEach((name, metadata) -> {
1250                     if (!Project.REPO_DOGMA.equals(name)) {
1251                         builder.put(name, metadata);
1252                     }
1253                 });
1254                 return new ProjectMetadata(projectMetadata.name(),
1255                                            builder.build(),
1256                                            projectMetadata.members(),
1257                                            projectMetadata.tokens(),
1258                                            projectMetadata.creation(),
1259                                            projectMetadata.removal());
1260             });
1261         } else {
1262             transformer = new RepositoryMetadataTransformer(
1263                     newRepoName, (headRevision, repositoryMetadata) -> {
1264                 throwIfRedundant(repositoryStatus, headRevision, repositoryMetadata, newRepoName);
1265 
1266                 return new RepositoryMetadata(repositoryMetadata.name(),
1267                                               repositoryMetadata.roles(),
1268                                               repositoryMetadata.creation(),
1269                                               repositoryMetadata.removal(),
1270                                               repositoryStatus);
1271             });
1272         }
1273 
1274         final String commitSummary = "Update the status of '" + projectName + '/' + newRepoName +
1275                                      "'. status: " + repositoryStatus;
1276         return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer, true);
1277     }
1278 
1279     private static void throwIfRedundant(RepositoryStatus repositoryStatus, Revision headRevision,
1280                                          RepositoryMetadata repositoryMetadata, String newRepoName) {
1281         if (repositoryMetadata.status() == repositoryStatus) {
1282             throw new RedundantChangeException(
1283                     headRevision,
1284                     "the status of '" + newRepoName + "' isn't changed. status: " + repositoryStatus);
1285         }
1286     }
1287 }