Skip to content

Commit 51f97ba

Browse files
authored
Add a web UI for the xDS control plane (#1317)
Motivation: - The xds module exposes Envoy control plane features (LDS/RDS/CDS/EDS plus group, credential and Kubernetes-aggregator management) over gRPC and HTTP/JSON, but had no UI. Operators had to call the HTTP API by hand to create a group and manage its resources, which is error-prone and hard to inspect. Modifications: - Add a standalone Next.js web app under xds/webapp (Chakra UI, Redux Toolkit / RTK Query, Monaco editor), served under '/xds' with its own layout: a group selector, a per-group resource sidebar, and a JSON editor. Wire it into the Gradle build and serve it from ControlPlaneService when bundled. - Group management (a group is a repository under the internal '@xds' project): list with paging and search, create, and ADMIN-only deletion in a dedicated "Danger Zone" section. Per-group Permissions, Credentials and Kubernetes endpoint aggregator management. - Resource management for LDS/RDS/CDS/EDS: list, create from templates, read-only view with an explicit Edit toggle, update and delete. A "References" panel links a resource to the children it references (LDS -> RDS/CDS, RDS -> CDS, CDS -> EDS), and missing resources/groups are reported with explicit messages instead of raw exceptions. - Surface Kubernetes-aggregator-generated endpoints as read-only. - Authentication/authorization: reuse the main Central Dogma session auth, redirect unauthenticated users to the main login, and add an "xDS" navbar link in the main web app when the xDS web is enabled. Scope the discovery API per app identity (mTLS certificate or access token) and expose an ungated EDS read API so endpoints are readable without the READ role. - Add a runnable XdsTestServer (embedded fabric8 Kubernetes mock and sample data) for manual testing, plus integration tests for mTLS/token discovery scoping, EDS read permission, write permission, and web app serving. You can run the server via `./gradlew :xds:runXdsTestServer` Result: - Operators can manage xDS groups and their LDS/RDS/CDS/EDS resources, credentials, Kubernetes endpoint aggregators and permissions through a dedicated web UI served under '/xds', instead of hand-crafting HTTP calls.
1 parent 07b6fdb commit 51f97ba

73 files changed

Lines changed: 6705 additions & 249 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

it/xds-member-permission/src/test/java/com/linecorp/centraldogma/server/test/XdsMemberPermissionTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ void shouldAllowMembersToAccessInternalProjects() throws Exception {
9292
.blocking();
9393

9494
assertThat(adminClient.listProjects().join()).containsOnly("dogma", "foo", "@xds");
95-
// Internal projects are not visible to the user by default.
96-
assertThat(nonAdminClient.listProjects().join()).containsOnly("foo");
95+
// The xDS project is a self-service project, so it is visible to any authenticated user even before
96+
// being granted a role. Other internal projects (e.g. dogma) remain hidden.
97+
assertThat(nonAdminClient.listProjects().join()).containsOnly("foo", "@xds");
9798

9899
final CentralDogmaRepository adminRepo = adminClient.createRepository("@xds", "test").join();
99100
adminRepo.commit("Add test.txt", Change.ofTextUpsert("/text.txt", "foo"))

server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresRepositoryRoleDecorator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ private static String maybeRemoveGitSuffix(String repoName) {
115115

116116
private HttpResponse serveUserRepo(ServiceRequestContext ctx, HttpRequest req,
117117
User user, String projectName, String repoName) throws Exception {
118-
final CompletionStage<RepositoryRole> f;
118+
final CompletionStage<@Nullable RepositoryRole> f;
119119
try {
120120
f = mds.findRepositoryRole(projectName, repoName, user);
121121
} catch (Throwable cause) {

server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/ProjectApiManager.java

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
import com.linecorp.centraldogma.server.metadata.MetadataService;
3636
import com.linecorp.centraldogma.server.metadata.ProjectMetadata;
3737
import com.linecorp.centraldogma.server.metadata.User;
38-
import com.linecorp.centraldogma.server.metadata.UserWithAppIdentity;
3938
import com.linecorp.centraldogma.server.storage.encryption.EncryptionStorageException;
4039
import com.linecorp.centraldogma.server.storage.encryption.EncryptionStorageManager;
4140
import com.linecorp.centraldogma.server.storage.encryption.WrappedDekDetails;
@@ -48,6 +47,10 @@
4847
*/
4948
public final class ProjectApiManager {
5049

50+
// The xDS project is a self-service internal project: any authenticated user may access it (e.g. to list
51+
// and create groups via the xDS web UI). It is intentionally more permissive than other internal projects.
52+
private static final String INTERNAL_PROJECT_XDS = "@xds";
53+
5154
private final ProjectManager projectManager;
5255
private final CommandExecutor commandExecutor;
5356
private final MetadataService metadataService;
@@ -85,22 +88,10 @@ public static Map<String, Project> listProjectsWithoutInternal(Map<String, Proje
8588
final Map<String, Project> result = new LinkedHashMap<>(projects.size() - 1);
8689
for (Map.Entry<String, Project> entry : projects.entrySet()) {
8790
if (isInternalProject(entry.getKey())) {
88-
if (user != null) {
89-
final ProjectMetadata metadata = entry.getValue().metadata();
90-
if (metadata != null) {
91-
// Only show internal projects to the members of the project.
92-
if (user instanceof UserWithAppIdentity) {
93-
// TODO(minwoox): Add the type that distinguishes between users and app identies.
94-
// login is appId for UserWithAppIdentity
95-
if (metadata.appIdentityOrDefault(user.login(), null) != null) {
96-
result.put(entry.getKey(), entry.getValue());
97-
}
98-
} else {
99-
if (metadata.memberOrDefault(user.id(), null) != null) {
100-
result.put(entry.getKey(), entry.getValue());
101-
}
102-
}
103-
}
91+
// Only the accessible internal project (the xDS project) is shown, and only to authenticated
92+
// users.
93+
if (user != null && isAccessibleInternalProject(entry.getKey())) {
94+
result.put(entry.getKey(), entry.getValue());
10495
}
10596
} else {
10697
result.put(entry.getKey(), entry.getValue());
@@ -180,27 +171,36 @@ public Project getProject(String projectName, @Nullable User user) {
180171
if (user.isSystemAdmin()) {
181172
return project;
182173
}
183-
final ProjectMetadata metadata = project.metadata();
184-
if (metadata != null) {
185-
// Only show internal projects to the members of the project.
186-
if (user instanceof UserWithAppIdentity) {
187-
if (metadata.appIdentityOrDefault(user.login(), null) != null) {
188-
return project;
189-
}
190-
} else if (metadata.memberOrDefault(user.id(), null) != null) {
191-
return project;
192-
}
174+
if (isAccessibleInternalProject(projectName)) {
175+
return project;
193176
}
194177
throw new PermissionException("Cannot access " + projectName);
195178
}
196179

180+
/**
181+
* Returns whether the specified internal project is accessible through this API. Among the internal
182+
* projects, only the xDS project is accessible, and it is open to any authenticated user (e.g. to list and
183+
* create groups via the xDS web UI).
184+
*/
185+
private static boolean isAccessibleInternalProject(String projectName) {
186+
return INTERNAL_PROJECT_XDS.equals(projectName);
187+
}
188+
197189
private static boolean isInternalProject(String projectName) {
198190
return projectName.startsWith(INTERNAL_PROJECT_PREFIX) || INTERNAL_PROJECT_DOGMA.equals(projectName);
199191
}
200192

201193
public boolean exists(String projectName) {
202-
if (isInternalProject(projectName) && !isSystemAdmin()) {
203-
throw new IllegalArgumentException("Cannot access " + projectName);
194+
// Apply the same access rule as getProject(): the xDS project is accessible to any authenticated user,
195+
// while the other internal projects remain accessible to system administrators only.
196+
if (isInternalProject(projectName)) {
197+
final User user = AuthUtil.currentUserOrNull();
198+
if (user == null) {
199+
throw new IllegalArgumentException("Cannot access " + projectName);
200+
}
201+
if (!user.isSystemAdmin() && !isAccessibleInternalProject(projectName)) {
202+
throw new PermissionException("Cannot access " + projectName);
203+
}
204204
}
205205
return projectManager.exists(projectName);
206206
}

server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -927,18 +927,55 @@ public CompletableFuture<Revision> updateAppIdentityRepositoryRole(Author author
927927
* from the specified {@code repoName} in the specified {@code projectName}. If the {@link User}
928928
* is not found, it will return {@code null}.
929929
*/
930-
public CompletableFuture<RepositoryRole> findRepositoryRole(String projectName, String repoName,
931-
User user) {
930+
public CompletableFuture<@Nullable RepositoryRole> findRepositoryRole(String projectName, String repoName,
931+
User user) {
932932
requireNonNull(projectName, "projectName");
933933
requireNonNull(repoName, "repoName");
934934
requireNonNull(user, "user");
935+
return getProject(projectName).thenApply(metadata -> {
936+
// Preserve the existing behavior of raising RepositoryNotFoundException for a missing repository.
937+
metadata.repo(repoName);
938+
return findRepositoryRole(metadata, repoName, user);
939+
});
940+
}
941+
942+
/**
943+
* Resolves the effective {@link RepositoryRole} of the specified {@link User} on {@code repoName} from an
944+
* already-loaded {@link ProjectMetadata}, returning {@code null} if the user has no role.
945+
*/
946+
@Nullable
947+
public static RepositoryRole findRepositoryRole(ProjectMetadata metadata, String repoName, User user) {
948+
requireNonNull(metadata, "metadata");
949+
requireNonNull(repoName, "repoName");
950+
requireNonNull(user, "user");
935951
if (user.isSystemAdmin()) {
936-
return CompletableFuture.completedFuture(RepositoryRole.ADMIN);
952+
return RepositoryRole.ADMIN;
953+
}
954+
final RepositoryMetadata repositoryMetadata = metadata.repos().get(repoName);
955+
if (repositoryMetadata == null) {
956+
return null;
937957
}
958+
final Roles roles = repositoryMetadata.roles();
938959
if (user instanceof UserWithAppIdentity) {
939-
return findRepositoryRole(projectName, repoName, ((UserWithAppIdentity) user).appIdentity());
960+
final AppIdentity appIdentity = ((UserWithAppIdentity) user).appIdentity();
961+
final String appId = appIdentity.appId();
962+
final RepositoryRole repositoryRole = roles.appIds().get(appId);
963+
final AppIdentityRegistration registration = metadata.appIds().get(appId);
964+
final ProjectRole projectRole;
965+
if (registration != null) {
966+
projectRole = registration.role();
967+
} else if (repositoryRole != null || appIdentity.allowGuestAccess()) {
968+
projectRole = ProjectRole.GUEST;
969+
} else {
970+
// The app identity is not allowed with the GUEST permission.
971+
return null;
972+
}
973+
return repositoryRole(roles, repositoryRole, projectRole);
940974
}
941-
return findRepositoryRole0(projectName, repoName, user);
975+
final RepositoryRole repositoryRole = roles.users().get(user.id());
976+
final Member projectUser = metadata.memberOrDefault(user.id(), null);
977+
final ProjectRole projectRole = projectUser != null ? projectUser.role() : ProjectRole.GUEST;
978+
return repositoryRole(roles, repositoryRole, projectRole);
942979
}
943980

944981
/**
@@ -976,23 +1013,6 @@ public CompletableFuture<RepositoryRole> findRepositoryRole(String projectName,
9761013
});
9771014
}
9781015

979-
private CompletableFuture<RepositoryRole> findRepositoryRole0(String projectName, String repoName,
980-
User user) {
981-
requireNonNull(projectName, "projectName");
982-
requireNonNull(repoName, "repoName");
983-
requireNonNull(user, "user");
984-
985-
return getProject(projectName).thenApply(metadata -> {
986-
final RepositoryMetadata repositoryMetadata = metadata.repo(repoName);
987-
final Roles roles = repositoryMetadata.roles();
988-
final RepositoryRole userRepositoryRole = roles.users().get(user.id());
989-
990-
final Member projectUser = metadata.memberOrDefault(user.id(), null);
991-
final ProjectRole projectRole = projectUser != null ? projectUser.role() : ProjectRole.GUEST;
992-
return repositoryRole(roles, userRepositoryRole, projectRole);
993-
});
994-
}
995-
9961016
@Nullable
9971017
private static RepositoryRole repositoryRole(Roles roles, @Nullable RepositoryRole repositoryRole,
9981018
ProjectRole projectRole) {

webapp/build.gradle

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ plugins {
44
}
55

66
node {
7-
version = '22.3.0'
8-
npmVersion = '10.8.1'
7+
version = '22.14.0'
8+
npmVersion = '10.9.2'
99
download = true
1010
npmInstallCommand = "ci"
1111
}
@@ -32,15 +32,15 @@ sourceSets {
3232
}
3333
}
3434

35-
task installPlayWright(type: NpmTask) {
36-
dependsOn tasks.npmInstall
35+
tasks.register('installPlayWright', NpmTask) {
36+
dependsOn(tasks.named('npmInstall'))
3737
args = ['run', 'playwright:install']
3838
}
3939

4040
// Set `NEXT_ENV=development` to `.env.local` file to produce source maps for the minified JavaScript files.
41-
task buildWeb(type: NpmTask) {
42-
dependsOn tasks.npmInstall
43-
dependsOn installPlayWright
41+
tasks.register('buildWeb', NpmTask) {
42+
dependsOn(tasks.named('npmInstall'))
43+
dependsOn(tasks.named('installPlayWright'))
4444
args = ['run', 'build']
4545
inputs.dir('src')
4646
inputs.file('package.json')
@@ -50,30 +50,30 @@ task buildWeb(type: NpmTask) {
5050
outputs.dir('build/web')
5151
}
5252

53-
task copyWeb(type: Copy) {
54-
dependsOn buildWeb
53+
tasks.register('copyWeb', Copy) {
54+
dependsOn(tasks.named('buildWeb'))
5555

5656
from 'build/web'
5757
into 'build/javaweb/com/linecorp/centraldogma/webapp'
5858
}
5959

60-
task runTestServer(type: JavaExec) {
60+
tasks.register('runTestServer', JavaExec) {
6161
group = "Execution"
6262
description = "Run the simple Central Dogma server"
6363
classpath = sourceSets.test.runtimeClasspath
6464
mainClass = "com.linecorp.centraldogma.webapp.SimpleCentralDogmaTestServer"
6565
}
6666

67-
task runTestShiroServer(type: JavaExec) {
67+
tasks.register('runTestShiroServer', JavaExec) {
6868
group = "Execution"
6969
description = "Run the Central Dogma server with Apache Shiro"
7070
classpath = sourceSets.test.runtimeClasspath
7171
mainClass = "com.linecorp.centraldogma.webapp.ShiroCentralDogmaTestServer"
7272
}
7373

7474
if (!rootProject.hasProperty('noLint')) {
75-
task eslint(type: NpmTask) {
76-
dependsOn tasks.npmInstall
75+
tasks.register('eslint', NpmTask) {
76+
dependsOn(tasks.named('npmInstall'))
7777

7878
args = ['run', 'lint']
7979

@@ -84,8 +84,8 @@ if (!rootProject.hasProperty('noLint')) {
8484
outputs.upToDateWhen { true }
8585
}
8686

87-
task prettier(type: NpmTask) {
88-
dependsOn tasks.eslint
87+
tasks.register('prettier', NpmTask) {
88+
dependsOn(tasks.named('eslint'))
8989

9090
args = ['run', 'format']
9191

@@ -97,12 +97,14 @@ if (!rootProject.hasProperty('noLint')) {
9797
}
9898

9999
Task lintTask = project.ext.getLintTask()
100-
lintTask.dependsOn(tasks.prettier)
101-
tasks.buildWeb.dependsOn(tasks.prettier)
100+
lintTask.dependsOn(tasks.named('prettier'))
101+
tasks.named('buildWeb').configure {
102+
dependsOn(tasks.named('prettier'))
103+
}
102104
}
103105

104-
task testWeb(type: NpmTask) {
105-
dependsOn tasks.npmInstall
106+
tasks.register('testWeb', NpmTask) {
107+
dependsOn(tasks.named('npmInstall'))
106108

107109
args = ['run', 'test:ci']
108110

@@ -112,10 +114,12 @@ task testWeb(type: NpmTask) {
112114
inputs.file('next.config.js')
113115
outputs.upToDateWhen { true }
114116
}
115-
tasks.test.dependsOn(tasks.testWeb)
117+
tasks.named('test').configure {
118+
dependsOn(tasks.named('testWeb'))
119+
}
116120

117-
task testE2e(type: NpmTask) {
118-
dependsOn tasks.installPlayWright
121+
tasks.register('testE2e', NpmTask) {
122+
dependsOn(tasks.named('installPlayWright'))
119123

120124
args = ['run', 'test:e2e']
121125

webapp/src/dogma/common/components/DeleteConfirmationModal.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ interface DeleteConfirmationModalProps {
3232
onClose: () => void;
3333
type: string;
3434
id: string;
35+
// An explicit "from" label. Takes precedence over repoName/projectName when provided.
36+
from?: string;
3537
projectName?: string;
3638
repoName?: string;
3739
handleDelete: () => void;
@@ -43,17 +45,13 @@ export const DeleteConfirmationModal = ({
4345
onClose,
4446
id,
4547
type,
48+
from,
4649
projectName,
4750
repoName,
4851
handleDelete,
4952
isLoading,
5053
}: DeleteConfirmationModalProps): JSX.Element => {
51-
let from;
52-
if (repoName) {
53-
from = ` from ${repoName}`;
54-
} else if (projectName) {
55-
from = ` from ${projectName}`;
56-
}
54+
const fromName = from ?? repoName ?? projectName;
5755
return (
5856
<Modal isOpen={isOpen} onClose={onClose}>
5957
<ModalOverlay />
@@ -64,8 +62,8 @@ export const DeleteConfirmationModal = ({
6462
Delete {type}{' '}
6563
<Mark bg="gray.200" rounded="base" fontWeight="bold" px="1" py="1">
6664
{id}
67-
</Mark>{' '}
68-
{from}?
65+
</Mark>
66+
{fromName ? ` from ${fromName}` : ''}?
6967
</ModalBody>
7068
<ModalFooter>
7169
<HStack spacing={3}>

0 commit comments

Comments
 (0)