Skip to content

Introduce API Tokens with cluster_permissions and index_permissions directly associated with the token #5443

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: main
Choose a base branch
from

Conversation

cwperks
Copy link
Member

@cwperks cwperks commented Jun 25, 2025

Description

Re-basing #5225 with latest changes from main.

This PR creates a new capability in the security plugin where users can issue tokens and associate permissions directly with the token.

Currently this is limited to security admins.

What is novel about this PR is that we can associated permissions directly with the token instead of OBO tokens which contain a claim with the user's roles that the OBO authenticator uses for authz. With API Tokens an admin can request that the token is scoped down from their own permissions to give the token exactly the permissions it needs. For instance, if the admin only wants the token to have search permissions on a single index, then that can be configured and the token will be unable to perform any other action.

This is a key building block to be able to deprecate and replace Roles Injection which is the current practice for how plugins ensure that tasks or jobs that run asynchronously run with the same restrictions as the user that created the job. Instead, I envision that we require users to issue tokens scoped to exactly the needs for the job to ensure the job runs with only the permissions it needs and no more (principal of least privilege)

I'll update this PR Description with details about new REST APIs with example request and response.

  • Category (Enhancement, New feature, Bug fix, Test fix, Refactoring, Maintenance, Documentation)

Enhancement

Issues Resolved

Partially resolves #4009, but limited to security admins in initial release

Check List

  • New functionality includes testing
  • New functionality has been documented
  • New Roles/Permissions have a corresponding security dashboards plugin PR
  • API changes companion pull request created
  • Commits are signed per the DCO using --signoff

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

derek-ho and others added 23 commits November 14, 2024 10:47
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
@cwperks cwperks requested a review from willyborankin as a code owner June 25, 2025 17:56
@cwperks cwperks changed the title Feature/api tokens cwperx Introduce API Tokens with cluster_permissions and index_permissions directly associated with the token Jun 25, 2025
Signed-off-by: Craig Perkins <[email protected]>
Copy link

codecov bot commented Jun 25, 2025

Codecov Report

Attention: Patch coverage is 72.13376% with 175 lines in your changes missing coverage. Please review.

Project coverage is 72.34%. Comparing base (a670d4c) to head (dad7551).

Files with missing lines Patch % Lines
...arch/security/action/apitokens/ApiTokenAction.java 58.43% 61 Missing and 8 partials ⚠️
...pensearch/security/http/ApiTokenAuthenticator.java 65.38% 17 Missing and 10 partials ⚠️
.../security/action/apitokens/ApiTokenRepository.java 81.53% 10 Missing and 2 partials ⚠️
...urity/action/apitokens/ApiTokenUpdateResponse.java 14.28% 12 Missing ⚠️
...ction/apitokens/TransportApiTokenUpdateAction.java 57.69% 11 Missing ⚠️
...ecurity/action/apitokens/ApiTokenIndexHandler.java 82.75% 10 Missing ⚠️
...opensearch/security/action/apitokens/ApiToken.java 91.30% 2 Missing and 6 partials ⚠️
...search/security/identity/SecurityTokenManager.java 75.00% 4 Missing and 2 partials ⚠️
...ecurity/authtoken/jwt/ExpiringBearerAuthToken.java 0.00% 5 Missing ⚠️
...curity/action/apitokens/ApiTokenUpdateRequest.java 33.33% 4 Missing ⚠️
... and 7 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #5443      +/-   ##
==========================================
- Coverage   72.36%   72.34%   -0.02%     
==========================================
  Files         395      408      +13     
  Lines       24317    24933     +616     
  Branches     3713     3779      +66     
==========================================
+ Hits        17597    18039     +442     
- Misses       4892     5033     +141     
- Partials     1828     1861      +33     
Files with missing lines Coverage Δ
.../opensearch/security/OpenSearchSecurityPlugin.java 84.01% <100.00%> (+0.14%) ⬆️
...ecurity/action/apitokens/ApiTokenUpdateAction.java 100.00% <100.00%> (ø)
...rity/authtoken/jwt/claims/ApiJwtClaimsBuilder.java 100.00% <100.00%> (ø)
...ensearch/security/compliance/ComplianceConfig.java 89.17% <100.00%> (-0.58%) ⬇️
...rg/opensearch/security/dlic/rest/api/Endpoint.java 100.00% <100.00%> (ø)
...dlic/rest/api/RestApiAdminPrivilegesEvaluator.java 73.07% <100.00%> (+0.52%) ⬆️
...ch/security/securityconf/DynamicConfigFactory.java 64.70% <100.00%> (+0.23%) ⬆️
...arch/security/securityconf/DynamicConfigModel.java 100.00% <ø> (ø)
...ch/security/securityconf/DynamicConfigModelV7.java 75.54% <100.00%> (+1.25%) ⬆️
...g/opensearch/security/ssl/util/ExceptionUtils.java 45.83% <100.00%> (+2.35%) ⬆️
... and 19 more

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Collaborator

@nibix nibix left a comment

Choose a reason for hiding this comment

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

Thanks for this! Cool to see API tokens going forward.

One general question: I am wondering whether we also need to modify the DLS/FLS code to be API token aware. Otherwise, the deny-by-default policy might block index access.

Comment on lines +234 to +237
pluginIdToActionPrivileges.put(
entry.getKey(),
new SubjectBasedActionPrivileges(entry.getValue(), flattenedActionGroups)
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

With this, we might need to make the pluginIdToActionPrivileges HashMap thread-safe, for example, by converting it into a ConcurrentHashMap.

Alternatively, we could have two HashMaps, one for plugins and one for API tokens.

Copy link
Member

Choose a reason for hiding this comment

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

i vote for single thread-safe map


private final Map<String, RoleV7> jtis = new ConcurrentHashMap<>();

void reloadApiTokensFromIndex(ActionListener<Void> listener) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I see this is getting called when an update action is received. I think I did not see anything regarding initial node startup. Did I miss something?

Copy link
Member

Choose a reason for hiding this comment

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

Cache seems to be update only when a token is created or deleted. We should add one that loads on node bootstrap.

Comment on lines +199 to +205
if (tokenCount >= 100) {
sendErrorResponse(
channel,
RestStatus.TOO_MANY_REQUESTS,
"Maximum limit of 100 API tokens reached. Please delete existing tokens before creating new ones."
);
return;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to be a quite low limit. Is there a reason for that?

Comment on lines +128 to +138
public void createApiToken(
String name,
List<String> clusterPermissions,
List<ApiToken.IndexPermission> indexPermissions,
Long expiration,
ActionListener<String> listener
) {
apiTokenIndexHandler.createApiTokenIndexIfAbsent(ActionListener.wrap(() -> {
ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration);
ApiToken apiToken = new ApiToken(name, clusterPermissions, indexPermissions, expiration);
apiTokenIndexHandler.indexTokenMetadata(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the parameter name an identifier for the token? Do we need uniqueness guarantees for this?

Comment on lines +193 to +197
private void logDebug(String message, Object... args) {
if (log.isDebugEnabled()) {
log.debug(message, args);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: This method is not really necessary. It can be replaced just by log.debug() without any disadvantages. As a slight advantage, one also avoids the codecov warning for missing test coverage.

private final List<TokenListener> tokenListener = new ArrayList<>();
private static final Logger log = LogManager.getLogger(ApiTokenRepository.class);

private final Map<String, RoleV7> jtis = new ConcurrentHashMap<>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

What do you think about changing RoleV7 into SubjectBasedActionPrivileges here? PrivilegesEvaluator could then retrieve these instances just from here.

Copy link
Member

Choose a reason for hiding this comment

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

+1. It might not be straightforward since JTIs seem to be populated on getTokenMetadata request and there doesn't seem to be a way to pass flattenedActionGroups to that call. I may be seeing the complete picture here, but something like updateJTIs(FlattenedAGs) from PrivilegeEvaluator to update jtis and then use that to populate pluginIdToActionPrivileges which will then be used to create PrivilegeEvalContext?

Comment on lines +109 to +111
public Map<String, RoleV7> getJtis() {
return jtis;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

IMHO, this map should be kept private and managed only by this class.

public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException {
xContentBuilder.startObject();
xContentBuilder.field("enabled", enabled);
xContentBuilder.field("signing_key", signing_key);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this a hs512 key?

Copy link
Member

Choose a reason for hiding this comment

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

i think it fails in ApiTokenAuthenticator if it is anything lower than 512 bits.

Comment on lines +199 to +208
public Boolean isRequestAllowed(final SecurityRequest request) {
Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path());
final String suffix = matcher.matches() ? matcher.group(2) : null;
if (isAccessToRestrictedEndpoints(request, suffix)) {
final OpenSearchException exception = ExceptionUtils.invalidUsageOfApiTokenException();
log.error(exception.toString());
return false;
}
return true;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This feels like a fragile way to deny certain endpoints, especially with the hardcoded path prefixes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, do I understand it correctly that the goal of this code is that we cannot call the "issue API token" API with API tokens?

Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't rest-admin-only restriction block access to endpoints anyway?

@DarshitChanpura DarshitChanpura mentioned this pull request Jun 26, 2025
5 tasks
Copy link
Member

@DarshitChanpura DarshitChanpura left a comment

Choose a reason for hiding this comment

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

Thank you @cwperks for taking this over. Left some comments around testing and general usage.

Comment on lines +45 to +66
public ApiToken(String name, List<String> clusterPermissions, List<IndexPermission> indexPermissions, Long expiration) {
this.creationTime = Instant.now();
this.name = name;
this.clusterPermissions = clusterPermissions;
this.indexPermissions = indexPermissions;
this.expiration = expiration;
}

public ApiToken(
String name,
List<String> clusterPermissions,
List<IndexPermission> indexPermissions,
Instant creationTime,
Long expiration
) {
this.name = name;
this.clusterPermissions = clusterPermissions;
this.indexPermissions = indexPermissions;
this.creationTime = creationTime;
this.expiration = expiration;
}

Copy link
Member

Choose a reason for hiding this comment

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

nit:
can be combined into 1 constructor:

public ApiToken(
            String name,
            List<String> clusterPermissions,
            List<IndexPermission> indexPermissions,
            Instant creationTime,   // nullable
            Long expiration) {

        this.name = name;
        this.clusterPermissions = clusterPermissions;
        this.indexPermissions = indexPermissions;
        this.creationTime = (creationTime != null) ? creationTime : Instant.now();
        this.expiration = expiration;   
    }

Can the name field be null?

* Expected class structure
* {
* name: "token_name",
* jti: "encrypted_token",
Copy link
Member

Choose a reason for hiding this comment

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

should this field be stored in the object?

String name = (String) requestBody.get(NAME_FIELD);
long expiration = (Long) requestBody.getOrDefault(
EXPIRATION_FIELD,
Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)
Copy link
Member

Choose a reason for hiding this comment

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

should we impose an upper limit on expiry?


public ApiToken(String name, List<String> clusterPermissions, List<IndexPermission> indexPermissions, Long expiration) {
this.creationTime = Instant.now();
this.name = name;
Copy link
Member

Choose a reason for hiding this comment

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

Should this be UUID instead of name? or does the name field have any association with users?

private final List<TokenListener> tokenListener = new ArrayList<>();
private static final Logger log = LogManager.getLogger(ApiTokenRepository.class);

private final Map<String, RoleV7> jtis = new ConcurrentHashMap<>();
Copy link
Member

Choose a reason for hiding this comment

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

+1. It might not be straightforward since JTIs seem to be populated on getTokenMetadata request and there doesn't seem to be a way to pass flattenedActionGroups to that call. I may be seeing the complete picture here, but something like updateJTIs(FlattenedAGs) from PrivilegeEvaluator to update jtis and then use that to populate pluginIdToActionPrivileges which will then be used to create PrivilegeEvalContext?

}

@Test
public void shouldNotAuthenticateWithInvalidExpiration() {
Copy link
Member

Choose a reason for hiding this comment

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

we should add a case to test against expired token

public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException {
xContentBuilder.startObject();
xContentBuilder.field("enabled", enabled);
xContentBuilder.field("signing_key", signing_key);
Copy link
Member

Choose a reason for hiding this comment

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

i think it fails in ApiTokenAuthenticator if it is anything lower than 512 bits.

@@ -337,4 +339,25 @@ public void testCreateJwtWithBadRoles() {
});
assertThat(exception.getMessage(), is("java.lang.IllegalArgumentException: Roles cannot be null"));
}

@Test
public void issueApiToken_success() throws Exception {
Copy link
Member

Choose a reason for hiding this comment

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

we should add a test for failure path

Settings settings = Settings.builder().put("signing_key", tooShortKey).build();
final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings); });

assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits"));
Copy link
Member

Choose a reason for hiding this comment

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

shouldn't signing key at minimum of 512 bits, based on ApiTokenAuthenticator.MINIMUM_SIGNING_KEY_BIT_LENGTH

throw new RuntimeException(ex);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

we should add test to check for index permissions, i.e. ability to search from, write to an index.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[RFC] Support for API Keys in OpenSearch Security Plugin
4 participants