Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,33 @@ public CompletableFuture<AppIdentity> updateAppIdentity(ServiceRequestContext ct
});
}

/**
* POST /appIdentities/{appId}/secret
*
* <p>Regenerates the secret of the deactivated token of the specified {@code appId} and returns the
* token with a newly-generated secret. The token must be deactivated first and the new secret does
* not authenticate until the token is activated, so that the new secret can be distributed to the
* clients before it takes effect.
Comment on lines +255 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note) I understand that a user deactivates the token to call this API first. i.e. it is possible that an unlucky case happens where the token is purged before regenerate can be called.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A token has three states - Active -> Inactive(deactivated) -> Deleted
The purge scheduler only purges deleted tokens so deactivated tokens are not purged.

private static void purgeAppIdentities(MetadataService metadataService) {
final AppIdentityRegistry appIdentityRegistry = metadataService.getAppIdentityRegistry();
final List<String> purging = appIdentityRegistry.appIds().values()
.stream()
.filter(AppIdentity::isDeleted)

*/
@Post("/appIdentities/{appId}/secret")
public CompletableFuture<Token> regenerateTokenSecret(ServiceRequestContext ctx,
@Param String appId,
Author author, User loginUser) {
// Permission is checked at the HTTP layer; the metadata layer is caller-agnostic.
return getTokenOrRespondForbidden(ctx, appId, loginUser).thenCompose(
token -> {
if (token.isDeleted()) {
throw new IllegalArgumentException(
"You can't regenerate the secret of the token scheduled for deletion.");
}
if (token.isActive()) {
throw new IllegalArgumentException(
"You can't regenerate the secret of an active token. Deactivate it first.");
}
return mds.regenerateTokenSecret(author, appId);
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* PATCH /appIdentities/{appId}/level
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ int doApply(Revision headRevision, DirCache dirCache,
} catch (CentralDogmaException e) {
throw e;
} catch (Exception e) {
throw new ChangeConflictException("failed to transform the content: " + oldJsonNode +
" transformer: " + transformer, e);
// Do not include the old content in the message; it may contain sensitive data such as secrets.
throw new ChangeConflictException("failed to transform the content with " + transformer, e);
}
return 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,56 @@ Revision purgeAppIdentity(Author author, String appId) {
.join();
}

CompletableFuture<Token> regenerateTokenSecret(Author author, String appId) {
requireNonNull(author, "author");
requireNonNull(appId, "appId");

final String commitSummary = "Regenerate the secret of the token: " + appId;

final AppIdentityRegistryTransformer transformer = new AppIdentityRegistryTransformer(
(headRevision, registry) -> {
final AppIdentity appIdentity = registry.get(appId); // Raise an exception if not found.
if (appIdentity.deletion() != null) {
// Note that a ChangeConflictException is raised instead of an
// IllegalArgumentException so that the storage layer does not wrap it with
// another exception.
throw new ChangeConflictException(
"The app identity is already destroyed: " + appId);
}
throwIfInvalidType(appId, appIdentity, AppIdentityType.TOKEN);
if (appIdentity.deactivation() == null) {
// Regenerating the secret of an active token would break its clients with no
// way to prepare, so the token must be deactivated first.
throw new ChangeConflictException(
"The token must be deactivated before regenerating its secret: " + appId);
}

final Token token = (Token) appIdentity;
final String newSecret = SECRET_PREFIX + UUID.randomUUID();
if (registry.secrets().containsKey(newSecret)) {
throw new ChangeConflictException("Secret already exists");
}

final Token newToken = new Token(token.appId(), newSecret, token.isSystemAdmin(),
token.allowGuestAccess(), token.creation(),
token.deactivation(), null);
final Map<String, AppIdentity> newAppIds =
updateMap(registry.appIds(), appId, newToken);
// A deactivated token has no entry in the secret map; create a new map so that
// the new registry does not share the mutable map with the old one.
return new AppIdentityRegistry(newAppIds, ImmutableMap.copyOf(registry.secrets()),

@minwoox minwoox Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we remove the old token string and add new one to the registry.secrets()?
ref: https://github.com/line/centraldogma/pull/1340/changes#diff-e5adef6a1ce67aa6ebb68ee9fb85a90d8cd23507acdf33e8a1dac98a23aee844R110-R114

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only allow deactivated tokens to be regenerated. The newly generated token will be added to registry.secrets() when it is activated.

final Map<String, String> newSecrets =
addToMap(registry.secrets(), secret, appId); // The key is secret not appId.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, my bad. I missed it. 😓

registry.certificateIds());
});
// Read the registry back at the revision this commit produced so that the caller gets
// the secret of this commit even if another commit lands right after.
return appIdentityRegistryRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,
commitSummary, transformer)
.thenCompose(revision -> appIdentityRegistryRepo.fetch(
INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON,
revision))
.thenApply(holder -> (Token) holder.object().get(appId));
}

CompletableFuture<Revision> activateToken(Author author, String appId) {
requireNonNull(author, "author");
requireNonNull(appId, "appId");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,16 @@ public CompletableFuture<Revision> deactivateToken(Author author, String appId)
return appIdentityService.deactivateToken(author, appId);
}

/**
* Regenerates the secret of the deactivated {@link Token} of the specified {@code appId} and
* returns the {@link Token} with the newly-generated secret. The token must be deactivated first
* and the new secret does not authenticate until the token is activated. The regeneration fails
* with a {@link ChangeConflictException} if the token is still active.
*/
public CompletableFuture<Token> regenerateTokenSecret(Author author, String appId) {
return appIdentityService.regenerateTokenSecret(author, appId);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Returns an {@link AppIdentity} which has the specified {@code appId}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ CompletableFuture<HolderWithRevision<T>> fetch(String projectName, String repoNa
return fetch(projectManager().get(projectName).repos().get(repoName), path);
}

CompletableFuture<HolderWithRevision<T>> fetch(String projectName, String repoName, String path,
Revision revision) {
requireNonNull(projectName, "projectName");
requireNonNull(repoName, "repoName");
return fetch(projectManager().get(projectName).repos().get(repoName), path, revision);
}

private CompletableFuture<HolderWithRevision<T>> fetch(Repository repository, String path) {
requireNonNull(path, "path");
final Revision revision = normalize(repository);
Expand Down
Loading
Loading