From 5912bf7dd52bda8c414ba1e25c9a1155f981b42d Mon Sep 17 00:00:00 2001 From: minux Date: Sun, 4 Aug 2024 16:50:31 +0900 Subject: [PATCH] Add xDS group service. (#981) Motivation: A user might want to manage her own xDS resources with a group of people who have permission to create and update these resources. By creating an xDS group (a Central Dogma repository), she can easily manage these xDS resources and grant write permissions to specific people, allowing them to manage the resources under Central Dogma's authorization system. Modifications: - Added `XdsGroupService` to create a group, which is a Central Dogma repository. - With this group, a user can easily manage privileged people under the Central Dogma authorization system. - `xds_group.proto` file is also added. - All proto files might be published in a separate module later for other clients such as LEGY. - Removed `MetadataServiceInjector`. - `MetadataService` is injected when a decorator is created using a `DependencyInjector`. Result: - You can now create xDS groups to manage your xDS resources under the Central Dogma authorization system. --- dependencies.toml | 11 ++ .../centraldogma/server/CentralDogma.java | 93 +++++++------- .../server/internal/admin/auth/AuthUtil.java | 8 +- .../internal/api/RepositoryServiceUtil.java | 43 +++++++ .../internal/api/RepositoryServiceV1.java | 16 +-- .../api/auth/RequiresPermissionDecorator.java | 30 +++-- .../api/auth/RequiresRoleDecorator.java | 14 ++- .../metadata/MetadataServiceInjector.java | 71 ----------- .../server/plugin/PluginInitContext.java | 13 ++ .../internal/api/auth/PermissionTest.java | 18 +-- .../xds/group/v1/XdsGroupService.java | 119 ++++++++++++++++++ .../xds/group/v1/package-info.java | 22 ++++ .../xds/internal/ControlPlanePlugin.java | 9 +- .../centraldogma/xds/group/v1/xds_group.proto | 62 +++++++++ .../xds/group/v1/XdsGroupServiceTest.java | 110 ++++++++++++++++ 15 files changed, 493 insertions(+), 146 deletions(-) create mode 100644 server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java delete mode 100644 server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataServiceInjector.java create mode 100644 xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java create mode 100644 xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/package-info.java create mode 100644 xds/src/main/proto/centraldogma/xds/group/v1/xds_group.proto create mode 100644 xds/src/test/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupServiceTest.java diff --git a/dependencies.toml b/dependencies.toml index bd3ef54613..1860b6dde6 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 653b3b60be..2ce91d59b0 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; @@ -615,8 +617,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); @@ -639,7 +642,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)) { @@ -742,45 +745,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 @@ -788,7 +784,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); @@ -802,7 +799,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)) @@ -830,12 +826,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 @@ -857,10 +855,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 b941a96d82..eba80c77ae 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 @@ -51,6 +51,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; @@ -69,7 +70,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/"; @@ -149,6 +150,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()); + } +}