Skip to content

Commit 1214b09

Browse files
committed
Support public repositories and creation-time guest access for app identities
Motivation: A repository whose guest role is READ is readable by any signed-in user, but application tokens created after #1093 are always blocked from guest access and there has been no way to allow it. Users keep asking to let other teams' tokens read guest-open repositories without registering them to the project, and #622 asks for the private/public repository distinction. Modifications: - Reframe a repository whose guest role is `READ` as a *public* repository. The authorization logic is unchanged: a public repository is readable by every signed-in user and by the app identities whose guest access is allowed. Guests can never write. - Allow choosing guest access when creating a token or a certificate via a new `allowGuestAccess` parameter of `POST /api/v1/appIdentities`. It is disabled by default; a system administrator-level app identity always allows guest access. - Add `allowPublicRepositories` to the project metadata with an owner-only `PUT /api/v1/metadata/{project}/allowPublicRepositories`. Disallowing is rejected while the project still has a public repository, and a repository cannot be made public in a project which disallows it. The internal xDS project disallows public repositories at start-up. - Accept `"isPublic": true` in `POST /api/v1/projects/{project}/repos` to create a public repository. - Web UI: replace the guest role radio with a Visibility section with confirmation dialogs, show public badges in the repository lists, dim inaccessible repositories with a tooltip, add an 'Allow public repositories' project setting, and add a scope choice to the app identity creation form and a scope column to the app identity list. - Fix a `NullPointerException` when the `guest` field is missing in the `roles/projects` payload and remove the unused `MetadataService.findRepositoryRole(AppIdentity)` overload. - Document public repositories in `auth.rst`. Result: - Closes #622. - A repository can be switched between public and private. A public repository is readable — never writable — by every signed-in user and by the app identities created with guest access allowed, without granting them any role. - During a rolling upgrade, a project-metadata update served by a not-yet-upgraded replica may drop the new `allowPublicRepositories` field. Re-apply the setting after the whole cluster is upgraded.
1 parent 5afa110 commit 1214b09

40 files changed

Lines changed: 1525 additions & 158 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,6 @@ typings/
143143

144144
# Codex
145145
AGENTS.md
146+
147+
# Claude
148+
.claude/settings.json

common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateRepositoryRequest.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,15 @@ public class CreateRepositoryRequest {
3232

3333
private final String name;
3434
private final boolean encrypt;
35+
private final boolean isPublic;
3536

3637
@JsonCreator
3738
public CreateRepositoryRequest(@JsonProperty("name") String name,
38-
@JsonProperty("encrypt") @Nullable Boolean encrypt) {
39+
@JsonProperty("encrypt") @Nullable Boolean encrypt,
40+
@JsonProperty("isPublic") @Nullable Boolean isPublic) {
3941
this.name = validateRepositoryName(name, "name");
4042
this.encrypt = firstNonNull(encrypt, false);
43+
this.isPublic = firstNonNull(isPublic, false);
4144
}
4245

4346
@JsonProperty
@@ -50,11 +53,17 @@ public boolean encrypt() {
5053
return encrypt;
5154
}
5255

56+
@JsonProperty("isPublic")
57+
public boolean isPublic() {
58+
return isPublic;
59+
}
60+
5361
@Override
5462
public String toString() {
5563
return MoreObjects.toStringHelper(this)
5664
.add("name", name())
5765
.add("encrypt", encrypt)
66+
.add("isPublic", isPublic)
5867
.toString();
5968
}
6069
}

server/src/main/java/com/linecorp/centraldogma/server/internal/api/MetadataApiService.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.linecorp.armeria.server.annotation.Patch;
3737
import com.linecorp.armeria.server.annotation.Post;
3838
import com.linecorp.armeria.server.annotation.ProducesJson;
39+
import com.linecorp.armeria.server.annotation.Put;
3940
import com.linecorp.centraldogma.common.Author;
4041
import com.linecorp.centraldogma.common.ProjectRole;
4142
import com.linecorp.centraldogma.common.RepositoryRole;
@@ -139,7 +140,7 @@ public CompletableFuture<Revision> updateRepositoryProjectRoles(
139140
JsonNode payload,
140141
Author author) throws JsonProcessingException {
141142
final JsonNode guest = payload.get("guest");
142-
if (guest.isTextual()) {
143+
if (guest != null && guest.isTextual()) {
143144
// TODO(ikhoon): Move this validation to the constructor of ProjectRoles once GUEST WRITE role is
144145
// migrated to GUEST READ.
145146
final String role = guest.asText();
@@ -151,6 +152,30 @@ public CompletableFuture<Revision> updateRepositoryProjectRoles(
151152
return mds.updateRepositoryProjectRoles(author, projectName, repoName, projectRoles);
152153
}
153154

155+
/**
156+
* PUT /metadata/{projectName}/allowPublicRepositories
157+
*
158+
* <p>Updates whether the repositories of the specified {@code projectName} can be made public.
159+
* The body of the request will be:
160+
* <pre>{@code
161+
* {
162+
* "allow": false
163+
* }
164+
* }</pre>
165+
*/
166+
@RequiresProjectRole(ProjectRole.OWNER)
167+
@Put("/metadata/{projectName}/allowPublicRepositories")
168+
public CompletableFuture<Revision> updateAllowPublicRepositories(
169+
@Param String projectName,
170+
JsonNode payload,
171+
Author author) {
172+
final JsonNode allow = payload.get("allow");
173+
if (allow == null || !allow.isBoolean()) {
174+
throw new IllegalArgumentException("The request must contain a boolean 'allow' field.");
175+
}
176+
return mds.updateAllowPublicRepositories(author, projectName, allow.asBoolean());
177+
}
178+
154179
/**
155180
* POST /metadata/{projectName}/repos/{repoName}/roles/users
156181
*

server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.linecorp.centraldogma.server.command.Command;
3131
import com.linecorp.centraldogma.server.command.CommandExecutor;
3232
import com.linecorp.centraldogma.server.metadata.MetadataService;
33+
import com.linecorp.centraldogma.server.metadata.ProjectRoles;
3334
import com.linecorp.centraldogma.server.metadata.RepositoryMetadata;
3435
import com.linecorp.centraldogma.server.metadata.Roles;
3536
import com.linecorp.centraldogma.server.metadata.UserAndTimestamp;
@@ -43,6 +44,14 @@ public static CompletableFuture<Revision> createRepository(
4344
CommandExecutor commandExecutor, MetadataService mds,
4445
Author author, String projectName, String repoName, boolean encrypt,
4546
@Nullable EncryptionStorageManager encryptionStorageManager) {
47+
return createRepository(commandExecutor, mds, author, projectName, repoName,
48+
DEFAULT_PROJECT_ROLES, encrypt, encryptionStorageManager);
49+
}
50+
51+
public static CompletableFuture<Revision> createRepository(
52+
CommandExecutor commandExecutor, MetadataService mds,
53+
Author author, String projectName, String repoName, ProjectRoles projectRoles, boolean encrypt,
54+
@Nullable EncryptionStorageManager encryptionStorageManager) {
4655
final Map<String, RepositoryRole> users;
4756
final Map<String, RepositoryRole> appIds;
4857
if (author.isAppIdentity()) {
@@ -54,7 +63,7 @@ public static CompletableFuture<Revision> createRepository(
5463
appIds = ImmutableMap.of();
5564
}
5665

57-
final Roles roles = new Roles(DEFAULT_PROJECT_ROLES, users, null, appIds);
66+
final Roles roles = new Roles(projectRoles, users, null, appIds);
5867
final RepositoryMetadata repositoryMetadata =
5968
RepositoryMetadata.of(repoName, roles, UserAndTimestamp.of(author));
6069

server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import static com.linecorp.centraldogma.server.internal.api.DtoConverter.newRepositoryDto;
2222
import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.checkUnremoveArgument;
2323
import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.returnOrThrow;
24+
import static com.linecorp.centraldogma.server.metadata.RepositoryMetadata.DEFAULT_PROJECT_ROLES;
25+
import static com.linecorp.centraldogma.server.metadata.RepositoryMetadata.PUBLIC_PROJECT_ROLES;
2426
import static java.util.Objects.requireNonNull;
2527

2628
import java.util.List;
@@ -66,6 +68,7 @@
6668
import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter;
6769
import com.linecorp.centraldogma.server.metadata.MetadataService;
6870
import com.linecorp.centraldogma.server.metadata.ProjectMetadata;
71+
import com.linecorp.centraldogma.server.metadata.ProjectRoles;
6972
import com.linecorp.centraldogma.server.metadata.RepositoryMetadata;
7073
import com.linecorp.centraldogma.server.metadata.User;
7174
import com.linecorp.centraldogma.server.storage.encryption.EncryptionStorageManager;
@@ -198,12 +201,25 @@ public CompletableFuture<RepositoryDto> createRepository(ServiceRequestContext c
198201
"Encryption is not enabled in the server.");
199202
}
200203

204+
if (request.isPublic()) {
205+
// Reject before the storage repository is created so a rejected public creation does not
206+
// leave an orphaned repository in the common case. MetadataService.addRepo re-validates
207+
// atomically.
208+
final ProjectMetadata metadata = project.metadata();
209+
if (metadata == null || !metadata.allowsPublicRepositories()) {
210+
return HttpApiUtil.throwResponse(ctx, HttpStatus.BAD_REQUEST,
211+
"Public repositories are not allowed in the project: %s",
212+
project.name());
213+
}
214+
}
215+
201216
final boolean encrypt = request.encrypt() || isEncryptedProject(project);
217+
final ProjectRoles projectRoles = request.isPublic() ? PUBLIC_PROJECT_ROLES : DEFAULT_PROJECT_ROLES;
202218

203219
final CommandExecutor commandExecutor = executor();
204220
final CompletableFuture<Revision> future =
205221
RepositoryServiceUtil.createRepository(commandExecutor, mds, author, project.name(), repoName,
206-
encrypt, encryptionStorageManager);
222+
projectRoles, encrypt, encryptionStorageManager);
207223
return future.handle(returnOrThrow(() -> {
208224
final Repository repository = project.repos().get(repoName);
209225
return newRepositoryDto(repository, repositoryStatus(repository));

server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/AppIdentityRegistryService.java

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ public CompletableFuture<ResponseEntity<AppIdentity>> createAppIdentity(
118118
@Param AppIdentityType type,
119119
@Param @Nullable String secret,
120120
@Param @Nullable String certificateId,
121+
@Param @Default("false") boolean allowGuestAccess,
121122
Author author, User loginUser) {
122123
if (!mtlsEnabled && type == AppIdentityType.CERTIFICATE) {
123124
throw new IllegalArgumentException(
@@ -137,16 +138,12 @@ public CompletableFuture<ResponseEntity<AppIdentity>> createAppIdentity(
137138
if (type == AppIdentityType.TOKEN) {
138139
checkArgument(certificateId == null,
139140
"TOKEN type cannot have a certificateId: %s", certificateId);
140-
if (secret != null) {
141-
future = mds.createToken(author, appId, secret, isSystemAdmin);
142-
} else {
143-
future = mds.createToken(author, appId, isSystemAdmin);
144-
}
141+
future = mds.createToken(author, appId, secret, isSystemAdmin, allowGuestAccess);
145142
} else {
146143
checkArgument(certificateId != null, "CERTIFICATE type must have a certificateId.");
147144
checkArgument(secret == null,
148145
"CERTIFICATE type cannot have a secret: %s", secret);
149-
future = mds.createCertificate(author, appId, certificateId, isSystemAdmin);
146+
future = mds.createCertificate(author, appId, certificateId, isSystemAdmin, allowGuestAccess);
150147
}
151148
return future.thenCompose(unused -> fetchAppIdentity(appId))
152149
.thenApply(appIdentity -> {
@@ -329,7 +326,7 @@ public Collection<Token> listTokens(User loginUser) {
329326
* <p>Returns a newly-generated token belonging to the current login user.
330327
*
331328
* @deprecated Use {@link #createAppIdentity(
332-
* String, boolean, AppIdentityType, String, String, Author, User)}.
329+
* String, boolean, AppIdentityType, String, String, boolean, Author, User)}.
333330
*/
334331
@Post("/tokens")
335332
@StatusCode(201)
@@ -338,8 +335,10 @@ public Collection<Token> listTokens(User loginUser) {
338335
public CompletableFuture<ResponseEntity<Token>> createToken(@Param String appId,
339336
@Param @Default("false") boolean isSystemAdmin,
340337
@Param @Nullable String secret,
338+
@Param @Default("false")
339+
boolean allowGuestAccess,
341340
Author author, User loginUser) {
342-
return createAppIdentity(appId, isSystemAdmin, AppIdentityType.TOKEN, secret, null,
341+
return createAppIdentity(appId, isSystemAdmin, AppIdentityType.TOKEN, secret, null, allowGuestAccess,
343342
author, loginUser)
344343
.thenApply(responseEntity -> {
345344
final AppIdentity app = responseEntity.content();

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ private void initializeMetadata(long creationTimeMillis, Author author) {
237237
members,
238238
null,
239239
appIds,
240+
null,
240241
userAndTimestamp, null);
241242
final CommitResult result =
242243
dogmaRepo.commit(headRev, creationTimeMillis, Author.SYSTEM,

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

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import java.util.UUID;
3434
import java.util.concurrent.CompletableFuture;
3535

36+
import org.jspecify.annotations.Nullable;
37+
3638
import com.fasterxml.jackson.core.JsonPointer;
3739
import com.fasterxml.jackson.databind.JsonNode;
3840
import com.google.common.collect.ImmutableMap;
@@ -69,26 +71,17 @@ AppIdentityRegistry getAppIdentityRegistry() {
6971
return projectInitializer.appIdentityRegistry();
7072
}
7173

72-
CompletableFuture<Revision> createToken(Author author, String appId) {
73-
return createToken(author, appId, false);
74-
}
75-
76-
CompletableFuture<Revision> createToken(Author author, String appId, boolean isSystemAdmin) {
77-
return createToken(author, appId, SECRET_PREFIX + UUID.randomUUID(), isSystemAdmin);
78-
}
79-
80-
CompletableFuture<Revision> createToken(Author author, String appId, String secret) {
81-
return createToken(author, appId, secret, false);
82-
}
83-
84-
CompletableFuture<Revision> createToken(Author author, String appId, String secret,
85-
boolean isSystemAdmin) {
74+
CompletableFuture<Revision> createToken(Author author, String appId, @Nullable String secret,
75+
boolean isSystemAdmin, boolean allowGuestAccess) {
8676
requireNonNull(author, "author");
8777
requireNonNull(appId, "appId");
88-
requireNonNull(secret, "secret");
78+
if (secret == null) {
79+
secret = SECRET_PREFIX + UUID.randomUUID();
80+
}
8981
validateSecret(secret);
9082

91-
final Token newToken = new Token(appId, secret, isSystemAdmin, isSystemAdmin,
83+
// A system admin app identity can access any repository, so guest access is implied.
84+
final Token newToken = new Token(appId, secret, isSystemAdmin, isSystemAdmin || allowGuestAccess,
9285
UserAndTimestamp.of(author));
9386
final AppIdentityRegistryTransformer transformer = new AppIdentityRegistryTransformer(
9487
(headRevision, tokens) -> {
@@ -280,14 +273,15 @@ private static void throwIfInvalidType(String appId, AppIdentity appIdentity,
280273
}
281274

282275
CompletableFuture<Revision> createCertificate(Author author, String appId, String certificateId,
283-
boolean isSystemAdmin) {
276+
boolean isSystemAdmin, boolean allowGuestAccess) {
284277
requireNonNull(author, "author");
285278
requireNonNull(appId, "appId");
286279
checkArgument(!isNullOrEmpty(certificateId), "certificateId must not be null or empty");
287280

288-
// Does not allow guest access for non admin certificate.
281+
// A system admin app identity can access any repository, so guest access is implied.
289282
final CertificateAppIdentity certificate =
290-
new CertificateAppIdentity(appId, certificateId, isSystemAdmin, isSystemAdmin,
283+
new CertificateAppIdentity(appId, certificateId, isSystemAdmin,
284+
isSystemAdmin || allowGuestAccess,
291285
UserAndTimestamp.of(author));
292286
final JsonPointer appIdPath = JsonPointer.compile("/appIds" + encodeSegment(certificate.appId()));
293287
final JsonPointer certificateIdPath =

0 commit comments

Comments
 (0)