diff --git a/dependencies.toml b/dependencies.toml index 40c5a9c4e3..e2c9b1a74e 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -20,6 +20,7 @@ dropwizard-metrics = "4.2.21" eddsa = "0.3.0" findbugs = "3.0.2" futures-completable = "0.3.6" +grpc-java = "1.64.0" guava = "33.2.1-jre" guava-failureaccess = "1.0.1" hamcrest-library = "2.2" @@ -53,6 +54,8 @@ nexus-publish-plugin = "2.0.0" node-gradle-plugin = "7.0.2" osdetector = "1.7.3" proguard = "7.4.2" +protobuf = "3.25.1" +protobuf-gradle-plugin = "0.8.19" quartz = "2.3.2" shadow-gradle-plugin = "7.1.2" shiro = "1.3.2" @@ -72,6 +75,7 @@ zookeeper = "3.7.2" [boms] armeria = { module = "com.linecorp.armeria:armeria-bom", version.ref = "armeria" } jackson = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" } +grpc-java = { module = "io.grpc:grpc-bom", version.ref = "grpc-java" } junit5 = { module = "org.junit:junit-bom", version.ref = "junit5" } [libraries.armeria] @@ -328,6 +332,13 @@ version.ref = "mockito" module = "com.guardsquare:proguard-gradle" version.ref = "proguard" +[libraries.protobuf-protoc] +module = "com.google.protobuf:protoc" +version.ref = "protobuf" +[libraries.protobuf-gradle-plugin] +module = "com.google.protobuf:protobuf-gradle-plugin" +version.ref = "protobuf-gradle-plugin" + [libraries.quartz] module = "org.quartz-scheduler:quartz" version.ref = "quartz" diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java index 2eaad083b9..01e1669d72 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -141,6 +141,9 @@ import com.linecorp.centraldogma.server.internal.api.TokenService; import com.linecorp.centraldogma.server.internal.api.WatchService; import com.linecorp.centraldogma.server.internal.api.auth.ApplicationTokenAuthorizer; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresPermissionDecorator.RequiresReadPermissionDecoratorFactory; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresPermissionDecorator.RequiresWritePermissionDecoratorFactory; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresRoleDecorator.RequiresRoleDecoratorFactory; import com.linecorp.centraldogma.server.internal.api.converter.HttpApiRequestConverter; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin; import com.linecorp.centraldogma.server.internal.replication.ZooKeeperCommandExecutor; @@ -154,7 +157,6 @@ import com.linecorp.centraldogma.server.management.ServerStatus; import com.linecorp.centraldogma.server.management.ServerStatusManager; import com.linecorp.centraldogma.server.metadata.MetadataService; -import com.linecorp.centraldogma.server.metadata.MetadataServiceInjector; import com.linecorp.centraldogma.server.mirror.MirrorProvider; import com.linecorp.centraldogma.server.plugin.AllReplicasPlugin; import com.linecorp.centraldogma.server.plugin.Plugin; @@ -616,8 +618,9 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, HttpHeaders.of(HttpHeaderNames.AUTHORIZATION, "Bearer " + CsrfToken.ANONYMOUS)) .build()); - - configureHttpApi(sb, projectApiManager, executor, watchService, mds, authProvider, sessionManager, + final Function authService = + authService(mds, authProvider, sessionManager); + configureHttpApi(sb, projectApiManager, executor, watchService, mds, authProvider, authService, meterRegistry); configureMetrics(sb, meterRegistry); @@ -640,7 +643,7 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, if (pluginsForAllReplicas != null) { final PluginInitContext pluginInitContext = new PluginInitContext(config(), pm, executor, meterRegistry, purgeWorker, sb, - projectInitializer); + authService, projectInitializer); pluginsForAllReplicas.plugins() .forEach(p -> { if (!(p instanceof AllReplicasPlugin)) { @@ -743,45 +746,38 @@ private void configureThriftService(ServerBuilder sb, ProjectApiManager projectA sb.service("/cd/thrift/v1", thriftService); } + private Function authService( + MetadataService mds, @Nullable AuthProvider authProvider, @Nullable SessionManager sessionManager) { + if (authProvider == null) { + return AuthService.newDecorator(new CsrfTokenAuthorizer()); + } + final AuthConfig authCfg = cfg.authConfig(); + assert authCfg != null : "authCfg"; + assert sessionManager != null : "sessionManager"; + final Authorizer tokenAuthorizer = + new ApplicationTokenAuthorizer(mds::findTokenBySecret) + .orElse(new SessionTokenAuthorizer(sessionManager, + authCfg.administrators())); + return AuthService.builder() + .add(tokenAuthorizer) + .onFailure(new CentralDogmaAuthFailureHandler()) + .newDecorator(); + } + private void configureHttpApi(ServerBuilder sb, ProjectApiManager projectApiManager, CommandExecutor executor, WatchService watchService, MetadataService mds, @Nullable AuthProvider authProvider, - @Nullable SessionManager sessionManager, MeterRegistry meterRegistry) { - Function decorator; - - if (authProvider != null) { - sb.service("/security_enabled", new AbstractHttpService() { - @Override - protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) { - return HttpResponse.of(HttpStatus.OK); - } - }); - - final AuthConfig authCfg = cfg.authConfig(); - assert authCfg != null : "authCfg"; - assert sessionManager != null : "sessionManager"; - final Authorizer tokenAuthorizer = - new ApplicationTokenAuthorizer(mds::findTokenBySecret) - .orElse(new SessionTokenAuthorizer(sessionManager, - authCfg.administrators())); - decorator = MetadataServiceInjector - .newDecorator(mds) - .andThen(AuthService.builder() - .add(tokenAuthorizer) - .onFailure(new CentralDogmaAuthFailureHandler()) - .newDecorator()); - } else { - decorator = MetadataServiceInjector - .newDecorator(mds) - .andThen(AuthService.newDecorator(new CsrfTokenAuthorizer())); - } - + Function authService, + MeterRegistry meterRegistry) { final DependencyInjector dependencyInjector = DependencyInjector.ofSingletons( // Use the default ObjectMapper without any configuration. // See JacksonRequestConverterFunctionTest new JacksonRequestConverterFunction(new ObjectMapper()), - new HttpApiRequestConverter(projectApiManager) + new HttpApiRequestConverter(projectApiManager), + new RequiresReadPermissionDecoratorFactory(mds), + new RequiresWritePermissionDecoratorFactory(mds), + new RequiresRoleDecoratorFactory(mds) ); sb.dependencyInjector(dependencyInjector, false) // TODO(ikhoon): Consider exposing ReflectiveDependencyInjector as a public API via @@ -789,7 +785,8 @@ protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) { .dependencyInjector(new ReflectiveDependencyInjector(), false); // Enable content compression for API responses. - decorator = decorator.andThen(contentEncodingDecorator()); + final Function decorator = + authService.andThen(contentEncodingDecorator()); for (String path : ImmutableList.of(API_V0_PATH_PREFIX, API_V1_PATH_PREFIX)) { final DecoratingServiceBindingBuilder decoratorBuilder = sb.routeDecorator().pathPrefix(path); @@ -803,7 +800,6 @@ protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) { } assert statusManager != null; - final ContextPathServicesBuilder apiV0ServiceBuilder = sb.contextPath(API_V0_PATH_PREFIX); final ContextPathServicesBuilder apiV1ServiceBuilder = sb.contextPath(API_V1_PATH_PREFIX); apiV1ServiceBuilder .annotatedService(new AdministrativeService(executor, statusManager)) @@ -831,12 +827,14 @@ public String serviceName(ServiceRequestContext ctx) { }) .build(new ContentServiceV1(executor, watchService, meterRegistry)); - sb.annotatedService() - .decorator(decorator) - .decorator(DecodingService.newDecorator()) - .build(new GitHttpService(projectApiManager)); - if (authProvider != null) { + sb.service("/security_enabled", new AbstractHttpService() { + @Override + protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.of(HttpStatus.OK); + } + }); + final AuthConfig authCfg = cfg.authConfig(); assert authCfg != null : "authCfg"; apiV1ServiceBuilder @@ -858,10 +856,15 @@ public String serviceName(ServiceRequestContext ctx) { authProvider.moreServices().forEach(sb::service); } + sb.annotatedService() + .decorator(decorator) + .decorator(DecodingService.newDecorator()) + .build(new GitHttpService(projectApiManager)); + if (cfg.isWebAppEnabled()) { - apiV0ServiceBuilder - .annotatedService(new UserService(executor)) - .annotatedService(new RepositoryService(projectApiManager, executor)); + sb.contextPath(API_V0_PATH_PREFIX) + .annotatedService(new UserService(executor)) + .annotatedService(new RepositoryService(projectApiManager, executor)); if (authProvider != null) { // Will redirect to /web/auth/login by default. diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/admin/auth/AuthUtil.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/admin/auth/AuthUtil.java index 6a1f3fcd5c..206a5fdb43 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/admin/auth/AuthUtil.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/admin/auth/AuthUtil.java @@ -41,14 +41,18 @@ public final class AuthUtil { public static Author currentAuthor(ServiceRequestContext ctx) { final User user = ctx.attr(CURRENT_USER); assert user != null; - return user == User.DEFAULT ? Author.DEFAULT - : new Author(user.name(), user.email()); + return getAuthor(user); } public static Author currentAuthor() { return currentAuthor(RequestContext.current()); } + public static Author getAuthor(User user) { + return user == User.DEFAULT ? Author.DEFAULT + : new Author(user.name(), user.email()); + } + public static User currentUser(ServiceRequestContext ctx) { return ctx.attr(CURRENT_USER); } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java new file mode 100644 index 0000000000..62fd90fb49 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.server.internal.api; + +import java.util.concurrent.CompletableFuture; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.Revision; +import com.linecorp.centraldogma.server.command.Command; +import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.metadata.MetadataService; + +public final class RepositoryServiceUtil { + + public static CompletableFuture createRepository( + CommandExecutor commandExecutor, MetadataService mds, + Author author, String projectName, String repoName) { + return commandExecutor.execute(Command.createRepository(author, projectName, repoName)) + .thenCompose(unused -> mds.addRepo(author, projectName, repoName)); + } + + public static CompletableFuture removeRepository( + CommandExecutor commandExecutor, MetadataService mds, Author author, + String projectName, String repoName) { + return commandExecutor.execute(Command.removeRepository(author, projectName, repoName)) + .thenCompose(unused -> mds.removeRepo(author, projectName, repoName)); + } + + private RepositoryServiceUtil() {} +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java index 47578bf3ef..2c2052d4da 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java @@ -119,13 +119,15 @@ public CompletableFuture> listRepositories(ServiceRequestCon public CompletableFuture createRepository(ServiceRequestContext ctx, Project project, CreateRepositoryRequest request, Author author) { - if (Project.isReservedRepoName(request.name())) { + final String repoName = request.name(); + if (Project.isReservedRepoName(repoName)) { return HttpApiUtil.throwResponse(ctx, HttpStatus.FORBIDDEN, "A reserved repository cannot be created."); } - return execute(Command.createRepository(author, project.name(), request.name())) - .thenCompose(unused -> mds.addRepo(author, project.name(), request.name())) - .handle(returnOrThrow(() -> DtoConverter.convert(project.repos().get(request.name())))); + final CommandExecutor commandExecutor = executor(); + final CompletableFuture future = + RepositoryServiceUtil.createRepository(commandExecutor, mds, author, project.name(), repoName); + return future.handle(returnOrThrow(() -> DtoConverter.convert(project.repos().get(repoName)))); } /** @@ -143,9 +145,9 @@ public CompletableFuture removeRepository(ServiceRequestContext ctx, return HttpApiUtil.throwResponse(ctx, HttpStatus.FORBIDDEN, "A reserved repository cannot be removed."); } - return execute(Command.removeRepository(author, repository.parent().name(), repository.name())) - .thenCompose(unused -> mds.removeRepo(author, repository.parent().name(), repository.name())) - .handle(HttpApiUtil::throwUnsafelyIfNonNull); + return RepositoryServiceUtil.removeRepository(executor(), mds, author, + repository.parent().name(), repoName) + .handle(HttpApiUtil::throwUnsafelyIfNonNull); } /** diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresPermissionDecorator.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresPermissionDecorator.java index c91b14efb2..3c663ac88c 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresPermissionDecorator.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresPermissionDecorator.java @@ -42,7 +42,6 @@ import com.linecorp.centraldogma.server.internal.api.GitHttpService; import com.linecorp.centraldogma.server.internal.api.HttpApiUtil; import com.linecorp.centraldogma.server.metadata.MetadataService; -import com.linecorp.centraldogma.server.metadata.MetadataServiceInjector; import com.linecorp.centraldogma.server.metadata.Permission; import com.linecorp.centraldogma.server.metadata.User; import com.linecorp.centraldogma.server.storage.project.Project; @@ -52,16 +51,19 @@ */ public final class RequiresPermissionDecorator extends SimpleDecoratingHttpService { + private final MetadataService mds; + private final Permission requiredPermission; @Nullable private final String projectName; @Nullable private final String repoName; - RequiresPermissionDecorator(HttpService delegate, Permission requiredPermission, + RequiresPermissionDecorator(HttpService delegate, MetadataService mds, Permission requiredPermission, @Nullable String projectName, @Nullable String repoName) { super(delegate); + this.mds = requireNonNull(mds, "mds"); this.requiredPermission = requireNonNull(requiredPermission, "requiredPermission"); this.projectName = projectName; this.repoName = repoName; @@ -69,7 +71,6 @@ public final class RequiresPermissionDecorator extends SimpleDecoratingHttpServi @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { - final MetadataService mds = MetadataServiceInjector.getMetadataService(ctx); final User user = AuthUtil.currentUser(ctx); String projectName = this.projectName; @@ -89,7 +90,7 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exc } return unwrap().serve(ctx, req); } - return serveUserRepo(ctx, req, mds, user, projectName, maybeRemoveGitSuffix(repoName)); + return serveUserRepo(ctx, req, user, projectName, maybeRemoveGitSuffix(repoName)); } private static HttpResponse throwForbiddenResponse(ServiceRequestContext ctx, String projectName, @@ -111,8 +112,7 @@ private static String maybeRemoveGitSuffix(String repoName) { } private HttpResponse serveUserRepo(ServiceRequestContext ctx, HttpRequest req, - MetadataService mds, User user, - String projectName, String repoName) throws Exception { + User user, String projectName, String repoName) throws Exception { final CompletionStage> f; try { f = mds.findPermissions(projectName, repoName, user); @@ -144,10 +144,17 @@ private HttpResponse serveUserRepo(ServiceRequestContext ctx, HttpRequest req, */ public static final class RequiresReadPermissionDecoratorFactory implements DecoratorFactoryFunction { + + private final MetadataService mds; + + public RequiresReadPermissionDecoratorFactory(MetadataService mds) { + this.mds = requireNonNull(mds, "mds"); + } + @Override public Function newDecorator(RequiresReadPermission parameter) { - return delegate -> new RequiresPermissionDecorator(delegate, Permission.READ, + return delegate -> new RequiresPermissionDecorator(delegate, mds, Permission.READ, Strings.emptyToNull(parameter.project()), Strings.emptyToNull(parameter.repository())); } @@ -159,10 +166,17 @@ public static final class RequiresReadPermissionDecoratorFactory */ public static final class RequiresWritePermissionDecoratorFactory implements DecoratorFactoryFunction { + + private final MetadataService mds; + + public RequiresWritePermissionDecoratorFactory(MetadataService mds) { + this.mds = requireNonNull(mds, "mds"); + } + @Override public Function newDecorator(RequiresWritePermission parameter) { - return delegate -> new RequiresPermissionDecorator(delegate, Permission.WRITE, + return delegate -> new RequiresPermissionDecorator(delegate, mds, Permission.WRITE, Strings.emptyToNull(parameter.project()), Strings.emptyToNull(parameter.repository())); } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresRoleDecorator.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresRoleDecorator.java index 4754ffbe63..13f114f5fc 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresRoleDecorator.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresRoleDecorator.java @@ -39,7 +39,6 @@ import com.linecorp.centraldogma.server.internal.admin.auth.AuthUtil; import com.linecorp.centraldogma.server.internal.api.HttpApiUtil; import com.linecorp.centraldogma.server.metadata.MetadataService; -import com.linecorp.centraldogma.server.metadata.MetadataServiceInjector; import com.linecorp.centraldogma.server.metadata.ProjectRole; import com.linecorp.centraldogma.server.metadata.User; @@ -48,11 +47,13 @@ */ public final class RequiresRoleDecorator extends SimpleDecoratingHttpService { + private final MetadataService mds; private final Set accessibleRoles; private final String roleNames; - RequiresRoleDecorator(HttpService delegate, Set accessibleRoles) { + RequiresRoleDecorator(HttpService delegate, MetadataService mds, Set accessibleRoles) { super(delegate); + this.mds = requireNonNull(mds, "mds"); this.accessibleRoles = ImmutableSet.copyOf(requireNonNull(accessibleRoles, "accessibleRoles")); roleNames = String.join(",", accessibleRoles.stream().map(ProjectRole::name).collect(toImmutableList())); @@ -60,7 +61,6 @@ public final class RequiresRoleDecorator extends SimpleDecoratingHttpService { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { - final MetadataService mds = MetadataServiceInjector.getMetadataService(ctx); final User user = AuthUtil.currentUser(ctx); final String projectName = ctx.pathParam("projectName"); @@ -101,9 +101,15 @@ static HttpResponse handleException(ServiceRequestContext ctx, Throwable cause) public static final class RequiresRoleDecoratorFactory implements DecoratorFactoryFunction { + private final MetadataService mds; + + public RequiresRoleDecoratorFactory(MetadataService mds) { + this.mds = mds; + } + @Override public Function newDecorator(RequiresRole parameter) { - return delegate -> new RequiresRoleDecorator(delegate, ImmutableSet.copyOf(parameter.roles())); + return delegate -> new RequiresRoleDecorator(delegate, mds, ImmutableSet.copyOf(parameter.roles())); } } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataServiceInjector.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataServiceInjector.java deleted file mode 100644 index bedbd99a32..0000000000 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataServiceInjector.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2019 LINE Corporation - * - * LINE Corporation licenses this file to you under the Apache License, - * version 2.0 (the "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at: - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package com.linecorp.centraldogma.server.metadata; - -import static java.util.Objects.requireNonNull; - -import java.util.function.Function; - -import com.linecorp.armeria.common.HttpRequest; -import com.linecorp.armeria.common.HttpResponse; -import com.linecorp.armeria.server.HttpService; -import com.linecorp.armeria.server.Service; -import com.linecorp.armeria.server.ServiceRequestContext; -import com.linecorp.armeria.server.SimpleDecoratingHttpService; - -import io.netty.util.AttributeKey; - -/** - * Injects the {@link MetadataService} instance into the attribute of the {@link ServiceRequestContext}. - */ -public final class MetadataServiceInjector extends SimpleDecoratingHttpService { - - /** - * Returns a newly created {@link Service} decorator from the specified {@link MetadataService}. - */ - public static Function newDecorator(MetadataService mds) { - requireNonNull(mds, "mds"); - return service -> new MetadataServiceInjector(service, mds); - } - - private static final AttributeKey METADATA_SERVICE_ATTRIBUTE_KEY = - AttributeKey.valueOf(MetadataServiceInjector.class, "METADATA"); - - private final MetadataService mds; - - private MetadataServiceInjector(HttpService delegate, MetadataService mds) { - super(delegate); - this.mds = requireNonNull(mds, "mds"); - } - - @Override - public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { - ctx.setAttr(METADATA_SERVICE_ATTRIBUTE_KEY, mds); - return unwrap().serve(ctx, req); - } - - /** - * Returns the {@link MetadataService} instance from the specified {@link ServiceRequestContext}. - */ - public static MetadataService getMetadataService(ServiceRequestContext ctx) { - final MetadataService mds = ctx.attr(METADATA_SERVICE_ATTRIBUTE_KEY); - if (mds != null) { - return mds; - } - throw new IllegalStateException("No metadata service instance exists."); - } -} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java b/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java index faf230ce65..ad8ea34bd9 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/plugin/PluginInitContext.java @@ -19,8 +19,11 @@ import static java.util.Objects.requireNonNull; import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Function; +import com.linecorp.armeria.server.HttpService; import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.auth.AuthService; import com.linecorp.centraldogma.server.CentralDogmaConfig; import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer; @@ -34,6 +37,7 @@ public final class PluginInitContext extends PluginContext { private final ServerBuilder serverBuilder; + private final Function authService; /** * Creates a new instance. @@ -43,9 +47,11 @@ public PluginInitContext(CentralDogmaConfig config, CommandExecutor commandExecutor, MeterRegistry meterRegistry, ScheduledExecutorService purgeWorker, ServerBuilder serverBuilder, + Function authService, InternalProjectInitializer projectInitializer) { super(config, projectManager, commandExecutor, meterRegistry, purgeWorker, projectInitializer); this.serverBuilder = requireNonNull(serverBuilder, "serverBuilder"); + this.authService = requireNonNull(authService, "authService"); } /** @@ -54,4 +60,11 @@ public PluginInitContext(CentralDogmaConfig config, public ServerBuilder serverBuilder() { return serverBuilder; } + + /** + * Returns the {@link AuthService} of the Central Dogma server. + */ + public Function authService() { + return authService; + } } diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/auth/PermissionTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/auth/PermissionTest.java index a8b2c4fb3e..cb99fcbb88 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/auth/PermissionTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/auth/PermissionTest.java @@ -24,7 +24,6 @@ import java.io.File; import java.util.Set; import java.util.concurrent.ForkJoinPool; -import java.util.function.Function; import java.util.stream.Stream; import org.junit.jupiter.api.Order; @@ -39,13 +38,13 @@ import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.DependencyInjector; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.metric.NoopMeterRegistry; -import com.linecorp.armeria.server.HttpService; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.annotation.Get; @@ -59,10 +58,12 @@ import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.command.StandaloneCommandExecutor; import com.linecorp.centraldogma.server.internal.api.HttpApiExceptionHandler; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresPermissionDecorator.RequiresReadPermissionDecoratorFactory; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresPermissionDecorator.RequiresWritePermissionDecoratorFactory; +import com.linecorp.centraldogma.server.internal.api.auth.RequiresRoleDecorator.RequiresRoleDecoratorFactory; import com.linecorp.centraldogma.server.internal.storage.project.DefaultProjectManager; import com.linecorp.centraldogma.server.management.ServerStatusManager; import com.linecorp.centraldogma.server.metadata.MetadataService; -import com.linecorp.centraldogma.server.metadata.MetadataServiceInjector; import com.linecorp.centraldogma.server.metadata.PerRolePermissions; import com.linecorp.centraldogma.server.metadata.Permission; import com.linecorp.centraldogma.server.metadata.ProjectRole; @@ -124,10 +125,11 @@ protected void configure(ServerBuilder sb) throws Exception { // app-2 is a member and it has read-only permission. mds.addToken(AUTHOR, "project1", APP_ID_2, ProjectRole.MEMBER) .toCompletableFuture().join(); - - final Function decorator = - MetadataServiceInjector.newDecorator(mds).andThen(AuthService.newDecorator( - new ApplicationTokenAuthorizer(mds::findTokenBySecret))); + sb.dependencyInjector( + DependencyInjector.ofSingletons(new RequiresReadPermissionDecoratorFactory(mds), + new RequiresWritePermissionDecoratorFactory(mds), + new RequiresRoleDecoratorFactory(mds)), + false); sb.annotatedService(new Object() { @Get("/projects/{projectName}") @RequiresRole(roles = { ProjectRole.OWNER, ProjectRole.MEMBER }) @@ -148,7 +150,7 @@ public HttpResponse read(@Param String projectName, @Param String repoName) { return HttpResponse.of(HttpStatus.OK); } - }, decorator); + }, AuthService.newDecorator(new ApplicationTokenAuthorizer(mds::findTokenBySecret))); sb.errorHandler(new HttpApiExceptionHandler()); } diff --git a/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java b/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java new file mode 100644 index 0000000000..ed9d4dd200 --- /dev/null +++ b/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java @@ -0,0 +1,119 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.xds.group.v1; + +import static com.linecorp.centraldogma.server.internal.admin.auth.AuthUtil.currentAuthor; +import static com.linecorp.centraldogma.server.internal.api.RepositoryServiceUtil.createRepository; +import static com.linecorp.centraldogma.server.internal.api.RepositoryServiceUtil.removeRepository; +import static com.linecorp.centraldogma.xds.internal.ControlPlanePlugin.XDS_CENTRAL_DOGMA_PROJECT; + +import com.google.protobuf.Empty; + +import com.linecorp.centraldogma.common.RepositoryExistsException; +import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.metadata.MetadataService; +import com.linecorp.centraldogma.server.storage.project.Project; +import com.linecorp.centraldogma.server.storage.project.ProjectManager; +import com.linecorp.centraldogma.xds.group.v1.XdsGroupServiceGrpc.XdsGroupServiceImplBase; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +/** + * An {@link XdsGroupServiceImplBase} implementation that provides methods to manage XDS groups. + */ +public final class XdsGroupService extends XdsGroupServiceImplBase { + + private final ProjectManager projectManager; + private final CommandExecutor commandExecutor; + private final MetadataService mds; + + /** + * Creates a new instance. + */ + public XdsGroupService(ProjectManager projectManager, CommandExecutor commandExecutor) { + this.projectManager = projectManager; + this.commandExecutor = commandExecutor; + mds = new MetadataService(projectManager, commandExecutor); + } + + @Override + public void createGroup(CreateGroupRequest request, + StreamObserver responseObserver) { + final String groupName = request.getGroup().getName(); + final String name = removePrefix("groups/", groupName); + if (projectManager.get(XDS_CENTRAL_DOGMA_PROJECT).repos().exists(name)) { + throw alreadyExistsException(name); + } + createRepository(commandExecutor, mds, currentAuthor(), XDS_CENTRAL_DOGMA_PROJECT, name) + .handle((unused, cause) -> { + if (cause != null) { + if (cause instanceof RepositoryExistsException) { + responseObserver.onError(alreadyExistsException(name)); + } else { + responseObserver.onError( + Status.INTERNAL.withCause(cause).asRuntimeException()); + } + return null; + } + responseObserver.onNext(Group.newBuilder().setName(groupName).build()); + responseObserver.onCompleted(); + return null; + }); + } + + private static String removePrefix(String prefix, String name) { + if (!name.startsWith(prefix)) { + throw new StatusRuntimeException( + Status.INVALID_ARGUMENT.withDescription(name + " does not start with prefix: " + prefix)); + } + return name.substring(prefix.length()); + } + + private static RuntimeException alreadyExistsException(String groupName) { + return Status.ALREADY_EXISTS.withDescription("Group already exists: " + groupName) + .asRuntimeException(); + } + + @Override + public void deleteGroup(DeleteGroupRequest request, StreamObserver responseObserver) { + final String groupName = request.getName(); + final String name = removePrefix("groups/", groupName); + if (!projectManager.get(XDS_CENTRAL_DOGMA_PROJECT).repos().exists(name)) { + throw Status.NOT_FOUND.withDescription("Group does not exist: " + groupName) + .asRuntimeException(); + } + if (Project.isReservedRepoName(name)) { + throw Status.PERMISSION_DENIED.withDescription("Now allowed to delete " + groupName) + .asRuntimeException(); + } + + // TODO(minwoox): Check the permission. + removeRepository(commandExecutor, mds, currentAuthor(), XDS_CENTRAL_DOGMA_PROJECT, name) + .handle((unused, cause1) -> { + if (cause1 != null) { + responseObserver.onError( + Status.INTERNAL.withDescription("Failed to delete " + groupName) + .withCause(cause1).asRuntimeException()); + return null; + } + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + return null; + }); + } +} diff --git a/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/package-info.java b/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/package-info.java new file mode 100644 index 0000000000..cb2e809f4a --- /dev/null +++ b/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +/** + * xDS group service. + */ +@NonNullByDefault +package com.linecorp.centraldogma.xds.group.v1; + +import com.linecorp.centraldogma.common.util.NonNullByDefault; diff --git a/xds/src/main/java/com/linecorp/centraldogma/xds/internal/ControlPlanePlugin.java b/xds/src/main/java/com/linecorp/centraldogma/xds/internal/ControlPlanePlugin.java index 241f6875f9..a74721f48b 100644 --- a/xds/src/main/java/com/linecorp/centraldogma/xds/internal/ControlPlanePlugin.java +++ b/xds/src/main/java/com/linecorp/centraldogma/xds/internal/ControlPlanePlugin.java @@ -50,6 +50,7 @@ import com.linecorp.centraldogma.server.storage.repository.DiffResultType; import com.linecorp.centraldogma.server.storage.repository.Repository; import com.linecorp.centraldogma.server.storage.repository.RepositoryManager; +import com.linecorp.centraldogma.xds.group.v1.XdsGroupService; import io.envoyproxy.controlplane.cache.v3.SimpleCache; import io.envoyproxy.controlplane.server.DiscoveryServerCallbacks; @@ -68,7 +69,7 @@ public final class ControlPlanePlugin extends AllReplicasPlugin { private static final Logger logger = LoggerFactory.getLogger(ControlPlanePlugin.class); - static final String XDS_CENTRAL_DOGMA_PROJECT = "@xds"; + public static final String XDS_CENTRAL_DOGMA_PROJECT = "@xds"; static final String CLUSTERS_DIRECTORY = "/clusters/"; static final String ENDPOINTS_DIRECTORY = "/endpoints/"; @@ -148,6 +149,12 @@ private void init0(PluginInitContext pluginInitContext) { .useBlockingTaskExecutor(true) .build(); sb.route().build(grpcService); + final GrpcService xdsApplicationService = + GrpcService.builder() + .addService(new XdsGroupService( + projectManager, pluginInitContext.commandExecutor())) + .enableHttpJsonTranscoding(true).build(); + sb.service(xdsApplicationService, pluginInitContext.authService()); } private void setXdsResources(String path, String contentAsText, String repoName) diff --git a/xds/src/main/proto/centraldogma/xds/group/v1/xds_group.proto b/xds/src/main/proto/centraldogma/xds/group/v1/xds_group.proto new file mode 100644 index 0000000000..8e8df0ac58 --- /dev/null +++ b/xds/src/main/proto/centraldogma/xds/group/v1/xds_group.proto @@ -0,0 +1,62 @@ +// Copyright 2024 LINE Corporation +// +// LINE Corporation licenses this file to you under the Apache License, +// version 2.0 (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at: +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +syntax = "proto3"; + +package centraldogma.xds.group.v1; + +option java_multiple_files = true; +option java_outer_classname = "XdsGroupProto"; +option java_package = "com.linecorp.centraldogma.xds.group.v1"; + +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/empty.proto"; + +// An XdsGroupService provides methods to manage groups. +service XdsGroupService { + + // Creates a new group. + rpc CreateGroup(CreateGroupRequest) returns (Group) { + option (google.api.http) = { + post: "/api/v1/xds/groups" + body: "group" + }; + } + + // Deletes an group. + rpc DeleteGroup(DeleteGroupRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + delete: "/api/v1/xds/{name=groups/*}" + }; + } +} + +message CreateGroupRequest { + Group group = 1 [(google.api.field_behavior) = REQUIRED]; +} + +message DeleteGroupRequest { + // Format: groups/{group} + string name = 1 [(google.api.field_behavior) = REQUIRED]; + + // If set to true, any xds Resources from this group will also be deleted. + // (Otherwise, the request will only work if the group has no xDS resources.) + // bool force = 2; +} + +message Group { + // Format: groups/{group} + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; +} diff --git a/xds/src/test/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupServiceTest.java b/xds/src/test/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupServiceTest.java new file mode 100644 index 0000000000..d2fab15124 --- /dev/null +++ b/xds/src/test/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupServiceTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.xds.group.v1; + +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.protobuf.Empty; + +import com.linecorp.armeria.client.grpc.GrpcClients; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; +import com.linecorp.centraldogma.xds.group.v1.XdsGroupServiceGrpc.XdsGroupServiceBlockingStub; + +import io.grpc.Status; + +class XdsGroupServiceTest { + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension(); + + @Test + void createGroupViaHttp() { + // Invalid name. + AggregatedHttpResponse response = createGroup("invalid/foo"); + assertThat(response.status()).isSameAs(HttpStatus.BAD_REQUEST); + + response = createGroup("groups/foo"); + assertThat(response.status()).isSameAs(HttpStatus.OK); + assertThat(response.headers().get("grpc-status")).isEqualTo("0"); + assertThatJson(response.contentUtf8()).isEqualTo("{\"name\":\"groups/foo\"}"); + + // Cannot create with the same name. + response = createGroup("groups/foo"); + assertThat(response.status()).isSameAs(HttpStatus.CONFLICT); + assertThat(response.headers().get("grpc-status")) + .isEqualTo(Integer.toString(Status.ALREADY_EXISTS.getCode().value())); + } + + private static AggregatedHttpResponse createGroup(String groupName) { + final RequestHeaders headers = RequestHeaders.builder(HttpMethod.POST, "/api/v1/xds/groups") + .set(HttpHeaderNames.AUTHORIZATION, "Bearer anonymous") + .contentType(MediaType.JSON_UTF_8).build(); + return dogma.httpClient().execute(headers, "{\"group\": {\"name\":\"" + groupName + "\"}}") + .aggregate().join(); + } + + @Test + void deleteGroupViaHttp() { + AggregatedHttpResponse response = deleteGroup("groups/bar"); + assertThat(response.status()).isSameAs(HttpStatus.NOT_FOUND); + + response = createGroup("groups/bar"); + assertThat(response.status()).isSameAs(HttpStatus.OK); + + // Add permission test. + + response = deleteGroup("groups/bar"); + assertThat(response.status()).isSameAs(HttpStatus.OK); + assertThat(response.headers().get("grpc-status")).isEqualTo("0"); + assertThat(response.contentUtf8()).isEqualTo("{}"); + } + + private static AggregatedHttpResponse deleteGroup(String groupName) { + final RequestHeaders headers = + RequestHeaders.builder(HttpMethod.DELETE, "/api/v1/xds/" + groupName) + .set(HttpHeaderNames.AUTHORIZATION, "Bearer anonymous") + .build(); + return dogma.httpClient().execute(headers).aggregate().join(); + } + + @Test + void createAndDeleteGroupViaStub() { + final XdsGroupServiceBlockingStub client = + GrpcClients.builder(dogma.httpClient().uri()) + .setHeader(HttpHeaderNames.AUTHORIZATION, "Bearer anonymous") + .build(XdsGroupServiceBlockingStub.class); + final Group group = client.createGroup( + CreateGroupRequest.newBuilder() + .setGroup(Group.newBuilder() + .setName("groups/baz")) + .build()); + assertThat(group.getName()).isEqualTo("groups/baz"); + // No exception is thrown. + final Empty ignored = client.deleteGroup(DeleteGroupRequest.newBuilder() + .setName("groups/baz") + .build()); + } +}