Introduce API Tokens with cluster_permissions and index_permissions directly associated with the token#5443
Conversation
…4921) Signed-off-by: Derek Ho <dxho@amazon.com>
…ect#4967) Signed-off-by: Derek Ho <dxho@amazon.com>
Signed-off-by: Derek Ho <dxho@amazon.com>
Signed-off-by: Derek Ho <dxho@amazon.com>
Signed-off-by: Derek Ho <dxho@amazon.com>
…00 tokens outstanding (opensearch-project#5147) Signed-off-by: Derek Ho <dxho@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
cluster_permissions and index_permissions directly associated with the token
Signed-off-by: Craig Perkins <cwperx@amazon.com>
nibix
left a comment
There was a problem hiding this comment.
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.
| pluginIdToActionPrivileges.put( | ||
| entry.getKey(), | ||
| new SubjectBasedActionPrivileges(entry.getValue(), flattenedActionGroups) | ||
| ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
i vote for single thread-safe map
|
|
||
| private final Map<String, RoleV7> jtis = new ConcurrentHashMap<>(); | ||
|
|
||
| void reloadApiTokensFromIndex(ActionListener<Void> listener) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Cache seems to be update only when a token is created or deleted. We should add one that loads on node bootstrap.
| 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; |
There was a problem hiding this comment.
This seems to be a quite low limit. Is there a reason for that?
| 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( |
There was a problem hiding this comment.
Is the parameter name an identifier for the token? Do we need uniqueness guarantees for this?
There was a problem hiding this comment.
yea it is a human readable identifier and uniqueness is not enforced...similar to github personal access tokens.
src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java
Outdated
Show resolved
Hide resolved
| private final List<TokenListener> tokenListener = new ArrayList<>(); | ||
| private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); | ||
|
|
||
| private final Map<String, RoleV7> jtis = new ConcurrentHashMap<>(); |
There was a problem hiding this comment.
What do you think about changing RoleV7 into SubjectBasedActionPrivileges here? PrivilegesEvaluator could then retrieve these instances just from here.
There was a problem hiding this comment.
+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?
There was a problem hiding this comment.
yea I don't think its straightforward, but we should capture this as a refactor that would be good to have
| public Map<String, RoleV7> getJtis() { | ||
| return jtis; | ||
| } |
There was a problem hiding this comment.
IMHO, this map should be kept private and managed only by this class.
There was a problem hiding this comment.
Made package-private and annotated as @VisibleForTesting
| public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { | ||
| xContentBuilder.startObject(); | ||
| xContentBuilder.field("enabled", enabled); | ||
| xContentBuilder.field("signing_key", signing_key); |
There was a problem hiding this comment.
i think it fails in ApiTokenAuthenticator if it is anything lower than 512 bits.
There was a problem hiding this comment.
I think at some point in the future we should look to support the opensearch-keystore for values referenced in the security config
| 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; | ||
| } |
There was a problem hiding this comment.
This feels like a fragile way to deny certain endpoints, especially with the hardcoded path prefixes.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Shouldn't rest-admin-only restriction block access to endpoints anyway?
There was a problem hiding this comment.
yea the purpose is to prevent using API Tokens to issue API Tokens. Similar to how OBO Tokens cannot be used to issue new OBO Tokens. Any ideas on how to refactor this?
DarshitChanpura
left a comment
There was a problem hiding this comment.
Thank you @cwperks for taking this over. Left some comments around testing and general usage.
| 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Removed the first constructor and made it so that creationTime should be supplied by the caller.
src/main/java/org/opensearch/security/action/apitokens/ApiToken.java
Outdated
Show resolved
Hide resolved
src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java
Outdated
Show resolved
Hide resolved
|
|
||
| public ApiToken(String name, List<String> clusterPermissions, List<IndexPermission> indexPermissions, Long expiration) { | ||
| this.creationTime = Instant.now(); | ||
| this.name = name; |
There was a problem hiding this comment.
Should this be UUID instead of name? or does the name field have any association with users?
There was a problem hiding this comment.
name is not necessarily unique per token, but it is unique per active token. i.e. on a token's expiry you would be able to regenerate a new one with the same perms.
The metadata which includes the perms for the token is stored as a doc in the .opensearch_security_api_tokens index
| private final List<TokenListener> tokenListener = new ArrayList<>(); | ||
| private static final Logger log = LogManager.getLogger(ApiTokenRepository.class); | ||
|
|
||
| private final Map<String, RoleV7> jtis = new ConcurrentHashMap<>(); |
There was a problem hiding this comment.
+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?
src/integrationTest/java/org/opensearch/security/privileges/ApiTokenTest.java
Show resolved
Hide resolved
| public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { | ||
| xContentBuilder.startObject(); | ||
| xContentBuilder.field("enabled", enabled); | ||
| xContentBuilder.field("signing_key", signing_key); |
There was a problem hiding this comment.
i think it fails in ApiTokenAuthenticator if it is anything lower than 512 bits.
| } | ||
|
|
||
| @Test | ||
| public void issueApiToken_success() throws Exception { |
There was a problem hiding this comment.
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")); |
There was a problem hiding this comment.
shouldn't signing key at minimum of 512 bits, based on ApiTokenAuthenticator.MINIMUM_SIGNING_KEY_BIT_LENGTH
There was a problem hiding this comment.
This message is coming from nimbus and we have a separate and stronger check then nimbus does.
| throw new RuntimeException(ex); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
we should add test to check for index permissions, i.e. ability to search from, write to an index.
There was a problem hiding this comment.
we should do this before we merge this.
There was a problem hiding this comment.
We do have testApiTokenWithIndexPermissions_canSearchAllowedIndex
There was a problem hiding this comment.
Adding a test to test out write perms as well
Signed-off-by: Craig Perkins <cwperx@amazon.com>
|
Hi @cwperks ! First I wanted to thank you for your work on this PR. Really appreciate effort you put it in to move forward with this feature! Just wanted to ask whether is there any plan to soon merge your changes? Is there any blocking issues/ any help you would need? It would be really cool to have API tokens feature working :) |
|
I'll resolve the conflicts ASAP and try to push this forward for V1. Its important to know that this PR will still have a lot of limitations, but paves the way for expansion. For instance, in this PR only the admin can issue tokens. |
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
DarshitChanpura
left a comment
There was a problem hiding this comment.
thank you for picking this up @cwperks . Left a few comments. Main comment is addition of e2e test which checks authn+authz with API token,
src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java
Outdated
Show resolved
Hide resolved
|
|
||
| // First check token count | ||
| apiTokenRepository.getTokenCount(ActionListener.wrap(tokenCount -> { | ||
| if (tokenCount >= 100) { |
There was a problem hiding this comment.
Let's make this value configurable with default to 100?
maybe maxed at 1k active tokens?
There was a problem hiding this comment.
Agreed. making this configurable.
| updateRequest, | ||
| ActionListener.wrap( | ||
| updateResponse -> listener.onResponse(response), | ||
| exception -> listener.onFailure(new ApiTokenException("Failed to refresh cache", exception)) |
There was a problem hiding this comment.
this message should be "Failed to update API token"
CHANGELOG.md
Outdated
| * Create a mechanism for plugins to explicitly declare actions they need to perform with their assigned PluginSubject ([#5341](https://github.com/opensearch-project/security/pull/5341)) | ||
| * Moves OpenSAML jars to a Shadow Jar configuration to facilitate its use in FIPS enabled environments ([#5400](https://github.com/opensearch-project/security/pull/5404)) | ||
| * Replaced the standard distribution of BouncyCastle with BC-FIPS ([#5439](https://github.com/opensearch-project/security/pull/5439)) | ||
| * Introduce API Tokens with `cluster_permissions` and `index_permissions` directly associated with the token ([#5443](https://github.com/opensearch-project/security/pull/5443)) |
There was a problem hiding this comment.
change log needs to be cleaned up to only add this entry.
| if (!issueApiTokenAllowed()) { | ||
| throw new OpenSearchSecurityException("Api token generation is not enabled."); | ||
| } | ||
| final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); |
There was a problem hiding this comment.
doesn't seem like this is used?
There was a problem hiding this comment.
its used in the log message in the catch block down below
| throw new RuntimeException(ex); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
we should do this before we merge this.
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Description
Re-basing #5225 with the latest changes from
main.This PR introduces API Tokens — a new capability in the Security plugin that allows security admins to issue long-lived, scoped tokens and associate permissions directly with the token.
How it works
API Tokens are opaque tokens with the format
os_<random>. When a token is created, a SHA-256 hash of the plaintext token is stored in a system index,.opensearch_security_api_tokens. The plaintext token is returned once at creation time and never stored. On each request, the incoming token is hashed and looked up in an in-memory cache populated from the index.Tokens are authenticated via the
Authorization: ApiKey <token>header.What is novel about this approach compared to OBO tokens is that permissions are scoped directly to the token rather than derived from the issuing user's roles. An admin can issue a token with only the permissions it needs — for example, read-only access to a single index — regardless of the admin's own permissions. This enforces the principle of least privilege and is a key building block toward deprecating Roles Injection, the current practice for how plugins run async jobs with user-scoped permissions.
API Reference
Create API Token
POST /_plugins/_security/api/apitokensRequest:
{ "name": "my-token", "cluster_permissions": ["cluster:monitor/health"], "index_permissions": [ { "index_pattern": ["logs-*"], "allowed_actions": ["indices:data/read/search"] } ], "expiration": 1800000 }Response:
{ "id": "Nd_pMRWeAC93ZGMhRa5CxX", "token": "os_abc123..." }The
idis used to manage the token, such as listing or revoking it. The plaintext token is returned once and never stored — save it immediately.List API Tokens
GET /_plugins/_security/api/apitokensResponse:
[ { "id": "Nd_pMRWeAC93ZGMhRa5CxX", "name": "my-token", "iat": 1742000000000, "expiration": 1800000, "cluster_permissions": ["cluster:monitor/health"], "index_permissions": [ { "index_pattern": ["logs-*"], "allowed_actions": ["indices:data/read/search"] } ] } ]Revoke API Token
DELETE /_plugins/_security/api/apitokens/{id}Response:
{ "message": "Token Nd_pMRWeAC93ZGMhRa5CxX deleted successfully." }Revocation takes effect immediately — any in-flight requests using the token will be rejected after the cache is refreshed across nodes.
Using a Token
Pass the token in the
Authorizationheader using theApiKeyscheme:Authorization: ApiKey os_abc123...Example — search a permitted index:
Response:
{ "hits": { "total": { "value": 3, "relation": "eq" }, "hits": [ ... ] } }Example — attempt a forbidden action:
Response:
{ "error": { "type": "security_exception", "reason": "no permissions for [indices:admin/delete]" }, "status": 403 }Issues Resolved
Partially resolves #4009, limited to security admins in the initial release.
Check List
--signoff