Skip to content

Commit

Permalink
Expose internal projects to users who have MEMBER permission (#1053)
Browse files Browse the repository at this point in the history
Motivation:

Currently non-admin users can't access `@xds` project so they need to
access the resources only through API. The absence of UI will be quite
inconvenient for users. We plan to create UI for xDS, but we don't know
when it will be ready.

As a workaround, it might be better to view the xDS confiugrations by
browsing UI first and switch to the dedicated UI later. For that I
propose to open internal projects to `MEMBER` users.

Modifications:

- Fixed `ProjectApiManager` to contain internal projects if the login
user is a member of the internal project.
- Fixed REST API services to inject the current user to
`ProjectApiManager` when listing projects.
- As an exception, Thrift API does not allow access to internal
projects.

Result:

Administrators now allow users to access the `@xds` project by
registering them as members.
  • Loading branch information
ikhoon authored Nov 8, 2024
1 parent 3cf9011 commit 3f547af
Show file tree
Hide file tree
Showing 19 changed files with 391 additions and 86 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ public CompletableFuture<Void> whenEndpointReady() {
}

private static void validateProjectName(String projectName) {
Util.validateProjectName(projectName, "projectName");
Util.validateProjectName(projectName, "projectName", false);
}

private static void validateProjectAndRepositoryName(String projectName, String repositoryName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
import com.linecorp.centraldogma.common.MergedEntry;
import com.linecorp.centraldogma.common.MirrorException;
import com.linecorp.centraldogma.common.PathPattern;
import com.linecorp.centraldogma.common.PermissionException;
import com.linecorp.centraldogma.common.ProjectExistsException;
import com.linecorp.centraldogma.common.ProjectNotFoundException;
import com.linecorp.centraldogma.common.PushResult;
Expand Down Expand Up @@ -135,6 +136,7 @@ public final class ArmeriaCentralDogma extends AbstractCentralDogma {
.put(InvalidPushException.class.getName(), InvalidPushException::new)
.put(ReadOnlyException.class.getName(), ReadOnlyException::new)
.put(MirrorException.class.getName(), MirrorException::new)
.put(PermissionException.class.getName(), PermissionException::new)
.build();

private final WebClient client;
Expand Down Expand Up @@ -904,7 +906,9 @@ private <T> CompletableFuture<T> watch(Revision lastKnownRevision, long timeoutM
}

private static void validateProjectName(String projectName) {
Util.validateProjectName(projectName, "projectName");
// We don't know if the token has the permission to access internal projects.
// The server will reject the request if the token does not have the permission.
Util.validateProjectName(projectName, "projectName", true);
}

private static void validateProjectAndRepositoryName(String projectName, String repositoryName) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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.common;

/**
* A {@link CentralDogmaException} that is raised when a client does not have the required permission
* for an operation.
*/
public final class PermissionException extends CentralDogmaException {
private static final long serialVersionUID = -1034292242865864558L;

/**
* Creates a new instance.
*/
public PermissionException() {}

/**
* Creates a new instance.
*/
public PermissionException(String message) {
super(message);
}

/**
* Creates a new instance.
*/
public PermissionException(String message, Throwable cause) {
super(message, cause);
}

/**
* Creates a new instance.
*/
public PermissionException(Throwable cause) {
super(cause);
}
}
23 changes: 19 additions & 4 deletions common/src/main/java/com/linecorp/centraldogma/internal/Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,29 @@ public static boolean isValidPathPattern(String pathPattern) {
return PATH_PATTERN_PATTERN.matcher(pathPattern).matches();
}

public static String validateProjectName(String projectName, String paramName) {
public static String validateProjectName(String projectName, String paramName, boolean allowInternal) {
requireNonNull(projectName, paramName);
checkArgument(isValidProjectName(projectName),
"%s: %s (expected: %s)", paramName, projectName,
USER_INPUT_PROJECT_AND_REPO_NAME_PATTERN);
if (allowInternal) {
checkArgument(isValidProjectName(projectName, true),
"%s: %s (expected: %s)", paramName, projectName,
PROJECT_AND_REPO_NAME_PATTERN);
} else {
checkArgument(isValidProjectName(projectName, false),
"%s: %s (expected: %s)", paramName, projectName,
USER_INPUT_PROJECT_AND_REPO_NAME_PATTERN);
}
return projectName;
}

public static boolean isValidProjectName(String projectName, boolean allowInternal) {
requireNonNull(projectName, "projectName");
if (allowInternal) {
return PROJECT_AND_REPO_NAME_PATTERN.matcher(projectName).matches();
} else {
return USER_INPUT_PROJECT_AND_REPO_NAME_PATTERN.matcher(projectName).matches();
}
}

public static boolean isValidProjectName(String projectName) {
requireNonNull(projectName, "projectName");
return USER_INPUT_PROJECT_AND_REPO_NAME_PATTERN.matcher(projectName).matches();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public class CreateProjectRequest {
public CreateProjectRequest(@JsonProperty("name") String name,
@JsonProperty("owners") @Nullable Set<String> owners,
@JsonProperty("members") @Nullable Set<String> members) {
this.name = validateProjectName(name, "name");
this.name = validateProjectName(name, "name", false);
this.owners = owners != null ? ImmutableSet.copyOf(owners) : ImmutableSet.of();
this.members = members != null ? ImmutableSet.copyOf(members) : ImmutableSet.of();
}
Expand Down
6 changes: 6 additions & 0 deletions it/xds-member-permission/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
dependencies {
testImplementation(project(':server'))
testImplementation(project(":server-auth:shiro"))
testImplementation libs.shiro.core
testImplementation(project(':xds'))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/*
* 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.test;

import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD2;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME2;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.concurrent.CompletionException;

import org.apache.shiro.config.Ini;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import com.linecorp.armeria.client.BlockingWebClient;
import com.linecorp.armeria.client.WebClient;
import com.linecorp.armeria.client.logging.LoggingClient;
import com.linecorp.armeria.common.AggregatedHttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.MediaType;
import com.linecorp.armeria.common.auth.AuthToken;
import com.linecorp.centraldogma.client.CentralDogma;
import com.linecorp.centraldogma.client.CentralDogmaRepository;
import com.linecorp.centraldogma.client.armeria.ArmeriaCentralDogmaBuilder;
import com.linecorp.centraldogma.common.CentralDogmaException;
import com.linecorp.centraldogma.common.Change;
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
import com.linecorp.centraldogma.server.auth.shiro.ShiroAuthProviderFactory;
import com.linecorp.centraldogma.server.internal.credential.NoneCredential;
import com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil;
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension;

class XdsMemberPermissionTest {

@RegisterExtension
static final CentralDogmaExtension dogma = new CentralDogmaExtension() {

@Override
protected void configure(CentralDogmaBuilder builder) {
builder.administrators(USERNAME)
.cors("*")
.authProviderFactory(new ShiroAuthProviderFactory(unused -> {
final Ini iniConfig = new Ini();
final Ini.Section users = iniConfig.addSection("users");
users.put(USERNAME, PASSWORD);
users.put(USERNAME2, PASSWORD2);
return iniConfig;
}));
}

@Override
protected void scaffold(CentralDogma client) {
}
};

@Test
void shouldAllowMembersToAccessInternalProjects() throws Exception {
final String adminToken = TestAuthMessageUtil.getAccessToken(dogma.httpClient(), USERNAME, PASSWORD);
final String userToken = TestAuthMessageUtil.getAccessToken(dogma.httpClient(), USERNAME2, PASSWORD2);

final CentralDogma adminClient = new ArmeriaCentralDogmaBuilder()
.host("127.0.0.1", dogma.serverAddress().getPort())
.accessToken(adminToken)
.build();
adminClient.createProject("foo").join();
final BlockingWebClient adminWebClient =
WebClient.builder("http://127.0.0.1:" + dogma.serverAddress().getPort())
.decorator(LoggingClient.newDecorator())
.auth(AuthToken.ofOAuth2(adminToken))
.build()
.blocking();

final CentralDogma userClient = new ArmeriaCentralDogmaBuilder()
.host("127.0.0.1", dogma.serverAddress().getPort())
.accessToken(userToken)
.build();

final BlockingWebClient userWebClient =
WebClient.builder("http://127.0.0.1:" + dogma.serverAddress().getPort())
.decorator(LoggingClient.newDecorator())
.auth(AuthToken.ofOAuth2(userToken))
.build()
.blocking();

assertThat(adminClient.listProjects().join()).containsOnly("dogma", "foo", "@xds");
// Internal projects are not visible to the user by default.
assertThat(userClient.listProjects().join()).containsOnly("foo");

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

final AggregatedHttpResponse credentialResponse =
adminWebClient.prepare()
.post("/api/v1/projects/@xds/credentials")
.contentJson(new NoneCredential("test", true))
.execute();
assertThat(credentialResponse.status()).isEqualTo(HttpStatus.CREATED);

// All CRUD operations should be blocked.
assertThatThrownBy(() -> {
userClient.createRepository("@xds", "test2").join();
}).isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(CentralDogmaException.class)
.hasMessageContaining(":status=403");

final CentralDogmaRepository userRepo = userClient.forRepo("@xds", "test");
assertThatThrownBy(() -> {
userRepo.commit("Update test.txt", Change.ofTextUpsert("/text.txt", "bar"))
.push()
.join();
}).isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(CentralDogmaException.class)
.hasMessageContaining(":status=403");

assertThatThrownBy(() -> {
userRepo.file("/text.txt")
.get()
.join();
}).isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(CentralDogmaException.class)
.hasMessageContaining(":status=403");

assertThat(userWebClient.prepare()
.get("/api/v1/projects/@xds/credentials/test")
.execute().status())
.isEqualTo(HttpStatus.FORBIDDEN);

// Grant the user permission to access the internal project.
final AggregatedHttpResponse res =
adminWebClient.prepare()
.post("/api/v1/metadata/@xds/members")
.content(MediaType.JSON, "{\"id\":\"" + USERNAME2 + "\",\"role\":\"MEMBER\"}")
.execute();
assertThat(res.status()).isEqualTo(HttpStatus.OK);

// @xds project should be visible to member users.
assertThat(userClient.listProjects().join()).containsOnly("foo", "@xds");
// Read and write permission should be granted as well.
userRepo.commit("Update test.txt", Change.ofTextUpsert("/text.txt", "bar"))
.push()
.join();
assertThat(userRepo.file("/text.txt").get().join().contentAsText())
.isEqualTo("bar\n");

// But the user should not be able to access the credentials.
assertThat(userWebClient.prepare()
.get("/api/v1/projects/@xds/credentials/test")
.execute().status())
.isEqualTo(HttpStatus.FORBIDDEN);
}
}
Loading

0 comments on commit 3f547af

Please sign in to comment.