Skip to content
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

Adds JTI and expiration field support for API Tokens #4967

Merged
merged 35 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0a708f9
Add api token action to handle creation of api token index
derek-ho Nov 15, 2024
3a16424
spotless apply and checkstyle
derek-ho Nov 15, 2024
064ffa4
Stash context
derek-ho Nov 15, 2024
65532ca
Add TODO comments
derek-ho Nov 18, 2024
a56163d
Remove dynamic changes for now
derek-ho Nov 18, 2024
72f2838
Add a indexmanager to handle document creation
derek-ho Nov 19, 2024
6bfc31c
Clean up
derek-ho Nov 19, 2024
0b1da6d
Refactor to use repository model
derek-ho Nov 20, 2024
77e18d9
Implement simple functionality for get and delete APIs as well
derek-ho Nov 20, 2024
6464a8c
Clean up and add basic test
derek-ho Nov 20, 2024
784809c
Add comprehensive tests and clean up indexing and getting to xcontent
derek-ho Dec 10, 2024
11880ec
Add ignore unknown properties
derek-ho Dec 10, 2024
ade56be
@JsonCreator
derek-ho Dec 10, 2024
e504960
Json property mode
derek-ho Dec 10, 2024
58478d4
Fix fragment issue
derek-ho Dec 10, 2024
0c43e9d
Add support for cluster and index creation parsing
derek-ho Dec 11, 2024
1468c9c
Add tests for validation functions
derek-ho Dec 11, 2024
725c3f8
Cleanup according to PR feedback
derek-ho Dec 11, 2024
16c1615
Cleanup using constants for better refactorability
derek-ho Dec 11, 2024
dad767b
General cleanup
derek-ho Dec 11, 2024
98d3847
Add to dynamic config model and expiration
derek-ho Dec 13, 2024
2152448
Merge branch 'feature/api-tokens' of github.com:opensearch-project/se…
derek-ho Dec 16, 2024
fddd37c
Spotless and missing merge conflict
derek-ho Dec 16, 2024
ae4e8f8
Inject security token manager and now test jti gets indexed
derek-ho Dec 17, 2024
3a2e483
Add logic to return to user the token and store an encrypted version …
derek-ho Dec 17, 2024
9abd1d0
Clean up issuing, encrypting, and decrypting logic, add tests for the…
derek-ho Dec 18, 2024
53169bc
Removes unecessary mock calls
derek-ho Dec 19, 2024
da8cf45
PR fixup
derek-ho Dec 19, 2024
2287742
PR review
derek-ho Dec 19, 2024
98301f8
PR cleanup
derek-ho Dec 19, 2024
b84d2cf
Add test for signing key too short
derek-ho Dec 19, 2024
af1de45
Remove file from PR
derek-ho Dec 19, 2024
08e0890
PR review
derek-ho Dec 20, 2024
09018dc
Remove null check and fix test
derek-ho Dec 20, 2024
7ab4a2a
Add it back to OBO and take away from api token
derek-ho Dec 20, 2024
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 @@ -636,7 +636,7 @@ public List<RestHandler> getRestHandlers(
)
);
handlers.add(new CreateOnBehalfOfTokenAction(tokenManager));
handlers.add(new ApiTokenAction(cs, threadPool, localClient));
handlers.add(new ApiTokenAction(cs, localClient, tokenManager));
handlers.addAll(
SecurityRestApiActions.getHandler(
settings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonIgnore;

import org.opensearch.core.xcontent.ToXContent;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.core.xcontent.XContentParser;
Expand All @@ -28,35 +30,38 @@ public class ApiToken implements ToXContent {
public static final String INDEX_PERMISSIONS_FIELD = "index_permissions";
public static final String INDEX_PATTERN_FIELD = "index_pattern";
public static final String ALLOWED_ACTIONS_FIELD = "allowed_actions";
public static final String EXPIRATION_FIELD = "expiration";

private String name;
private final String name;
private final String jti;
private final Instant creationTime;
private List<String> clusterPermissions;
private List<IndexPermission> indexPermissions;
private final List<String> clusterPermissions;
private final List<IndexPermission> indexPermissions;
private final long expiration;

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

this.expiration = expiration;
}

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

this.expiration = expiration;
}

public static class IndexPermission implements ToXContent {
Expand Down Expand Up @@ -84,6 +89,36 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
builder.endObject();
return builder;
}

public static IndexPermission fromXContent(XContentParser parser) throws IOException {
List<String> indexPatterns = new ArrayList<>();
List<String> allowedActions = new ArrayList<>();

XContentParser.Token token;
String currentFieldName = null;

while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
if (token == XContentParser.Token.FIELD_NAME) {
currentFieldName = parser.currentName();
} else if (token == XContentParser.Token.START_ARRAY) {
switch (currentFieldName) {
case INDEX_PATTERN_FIELD:
while (parser.nextToken() != XContentParser.Token.END_ARRAY) {
indexPatterns.add(parser.text());
}
break;
case ALLOWED_ACTIONS_FIELD:
while (parser.nextToken() != XContentParser.Token.END_ARRAY) {
allowedActions.add(parser.text());
}
break;
}
}
}

return new IndexPermission(indexPatterns, allowedActions);
}

}

/**
Expand All @@ -109,6 +144,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException {
List<String> clusterPermissions = new ArrayList<>();
List<IndexPermission> indexPermissions = new ArrayList<>();
Instant creationTime = null;
long expiration = 0;

XContentParser.Token token;
String currentFieldName = null;
Expand All @@ -127,6 +163,9 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException {
case CREATION_TIME_FIELD:
creationTime = Instant.ofEpochMilli(parser.longValue());
break;
case EXPIRATION_FIELD:
expiration = parser.longValue();
break;
}
} else if (token == XContentParser.Token.START_ARRAY) {
switch (currentFieldName) {
Expand All @@ -146,7 +185,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException {
}
}

return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime);
return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime, expiration);
}

private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException {
Expand Down Expand Up @@ -174,18 +213,18 @@ private static IndexPermission parseIndexPermission(XContentParser parser) throw
}
}
}

return new IndexPermission(indexPatterns, allowedActions);
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
public Long getExpiration() {
return expiration;
}

@JsonIgnore
public String getJti() {
return jti;
}
Expand All @@ -198,12 +237,8 @@ public List<String> getClusterPermissions() {
return clusterPermissions;
}

public void setClusterPermissions(List<String> clusterPermissions) {
this.clusterPermissions = clusterPermissions;
}

@Override
public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Params params) throws IOException {
public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException {
xContentBuilder.startObject();
xContentBuilder.field(NAME_FIELD, name);
xContentBuilder.field(JTI_FIELD, jti);
Expand All @@ -217,8 +252,4 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Pa
public List<IndexPermission> getIndexPermissions() {
return indexPermissions;
}

public void setIndexPermissions(List<IndexPermission> indexPermissions) {
this.indexPermissions = indexPermissions;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
package org.opensearch.security.action.apitokens;

import java.io.IOException;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import com.google.common.collect.ImmutableList;
Expand All @@ -28,19 +30,21 @@
import org.opensearch.rest.BytesRestResponse;
import org.opensearch.rest.RestHandler;
import org.opensearch.rest.RestRequest;
import org.opensearch.security.util.ParsingUtils;
import org.opensearch.threadpool.ThreadPool;
import org.opensearch.security.identity.SecurityTokenManager;

import static org.opensearch.rest.RestRequest.Method.DELETE;
import static org.opensearch.rest.RestRequest.Method.GET;
import static org.opensearch.rest.RestRequest.Method.POST;
import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD;
import static org.opensearch.security.action.apitokens.ApiToken.CLUSTER_PERMISSIONS_FIELD;
import static org.opensearch.security.action.apitokens.ApiToken.CREATION_TIME_FIELD;
import static org.opensearch.security.action.apitokens.ApiToken.EXPIRATION_FIELD;
import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PATTERN_FIELD;
import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD;
import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD;
import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix;
import static org.opensearch.security.util.ParsingUtils.safeMapList;
import static org.opensearch.security.util.ParsingUtils.safeStringList;

public class ApiTokenAction extends BaseRestHandler {
private final ApiTokenRepository apiTokenRepository;
Expand All @@ -53,8 +57,8 @@ public class ApiTokenAction extends BaseRestHandler {
)
);

public ApiTokenAction(ClusterService clusterService, ThreadPool threadPool, Client client) {
this.apiTokenRepository = new ApiTokenRepository(client, clusterService);
public ApiTokenAction(ClusterService clusterService, Client client, SecurityTokenManager securityTokenManager) {
this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager);
}

@Override
Expand Down Expand Up @@ -94,6 +98,9 @@ private RestChannelConsumer handleGet(RestRequest request, NodeClient client) {
builder.startObject();
builder.field(NAME_FIELD, token.getName());
builder.field(CREATION_TIME_FIELD, token.getCreationTime().toEpochMilli());
builder.field(EXPIRATION_FIELD, token.getExpiration());
builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions());
builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions());
builder.endObject();
}
builder.endArray();
Expand Down Expand Up @@ -122,11 +129,12 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) {
String token = apiTokenRepository.createApiToken(
(String) requestBody.get(NAME_FIELD),
clusterPermissions,
indexPermissions
indexPermissions,
(Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30))
);

builder.startObject();
builder.field("token", token);
builder.field("Api Token: ", token);
builder.endObject();

response = new BytesRestResponse(RestStatus.OK, builder);
Expand All @@ -146,14 +154,14 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) {
* Extracts cluster permissions from the request body
*/
List<String> extractClusterPermissions(Map<String, Object> requestBody) {
return ParsingUtils.safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD);
return safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD);
}

/**
* Extracts and builds index permissions from the request body
*/
List<ApiToken.IndexPermission> extractIndexPermissions(Map<String, Object> requestBody) {
List<Map<String, Object>> indexPerms = ParsingUtils.safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD);
List<Map<String, Object>> indexPerms = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD);
return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList());
}

Expand All @@ -166,10 +174,10 @@ ApiToken.IndexPermission createIndexPermission(Map<String, Object> indexPerm) {
if (indexPatternObj instanceof String) {
indexPatterns = Collections.singletonList((String) indexPatternObj);
} else {
indexPatterns = ParsingUtils.safeStringList(indexPatternObj, INDEX_PATTERN_FIELD);
indexPatterns = safeStringList(indexPatternObj, INDEX_PATTERN_FIELD);
}

List<String> allowedActions = ParsingUtils.safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD);
List<String> allowedActions = safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD);

return new ApiToken.IndexPermission(indexPatterns, allowedActions);
}
Expand All @@ -182,6 +190,13 @@ void validateRequestParameters(Map<String, Object> requestBody) {
throw new IllegalArgumentException("Missing required parameter: " + NAME_FIELD);
}

if (requestBody.containsKey(EXPIRATION_FIELD)) {
Object expiration = requestBody.get(EXPIRATION_FIELD);
if (!(expiration instanceof Long)) {
throw new IllegalArgumentException(EXPIRATION_FIELD + " must be a long");
}
}

if (requestBody.containsKey(CLUSTER_PERMISSIONS_FIELD)) {
Object permissions = requestBody.get(CLUSTER_PERMISSIONS_FIELD);
if (!(permissions instanceof List)) {
Expand All @@ -190,10 +205,7 @@ void validateRequestParameters(Map<String, Object> requestBody) {
}

if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) {
List<Map<String, Object>> indexPermsList = ParsingUtils.safeMapList(
requestBody.get(INDEX_PERMISSIONS_FIELD),
INDEX_PERMISSIONS_FIELD
);
List<Map<String, Object>> indexPermsList = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD);
validateIndexPermissionsList(indexPermsList);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,51 @@
import java.util.List;
import java.util.Map;

import com.google.common.annotations.VisibleForTesting;

import org.opensearch.client.Client;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.index.IndexNotFoundException;
import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken;
import org.opensearch.security.identity.SecurityTokenManager;

public class ApiTokenRepository {
private final ApiTokenIndexHandler apiTokenIndexHandler;
private final SecurityTokenManager securityTokenManager;

public ApiTokenRepository(Client client, ClusterService clusterService) {
public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) {
apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService);
securityTokenManager = tokenManager;
}

private ApiTokenRepository(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) {
this.apiTokenIndexHandler = apiTokenIndexHandler;
this.securityTokenManager = securityTokenManager;
}

@VisibleForTesting
static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) {
return new ApiTokenRepository(apiTokenIndexHandler, securityTokenManager);
}

public String createApiToken(String name, List<String> clusterPermissions, List<ApiToken.IndexPermission> indexPermissions) {
public String createApiToken(
String name,
List<String> clusterPermissions,
List<ApiToken.IndexPermission> indexPermissions,
Long expiration
) {
apiTokenIndexHandler.createApiTokenIndexIfAbsent();
// TODO: Implement logic of creating JTI to match against during authc/z
// TODO: Add validation on whether user is creating a token with a subset of their permissions
return apiTokenIndexHandler.indexTokenMetadata(new ApiToken(name, "test-token", clusterPermissions, indexPermissions));
ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration, clusterPermissions, indexPermissions);
ApiToken apiToken = new ApiToken(
name,
securityTokenManager.encryptToken(token.getCompleteToken()),
clusterPermissions,
indexPermissions,
expiration
);
apiTokenIndexHandler.indexTokenMetadata(apiToken);
return token.getCompleteToken();
}

public void deleteApiToken(String name) throws ApiTokenException, IndexNotFoundException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
*/
package org.opensearch.security.authtoken.jwt;

import java.time.Duration;
import java.time.Instant;
import java.util.Date;

import org.opensearch.identity.tokens.BearerAuthToken;
Expand All @@ -26,6 +28,13 @@ public ExpiringBearerAuthToken(final String serializedToken, final String subjec
this.expiresInSeconds = expiresInSeconds;
}

public ExpiringBearerAuthToken(final String serializedToken, final String subject, final Date expiry) {
super(serializedToken);
this.subject = subject;
this.expiry = expiry;
this.expiresInSeconds = Duration.between(Instant.now(), expiry.toInstant()).getSeconds();
}

public String getSubject() {
return subject;
}
Expand Down
Loading
Loading