From c6830246d893bd2c4850660569eae4b849b12822 Mon Sep 17 00:00:00 2001 From: Sam Sternberg Date: Tue, 7 Apr 2026 08:45:31 -0400 Subject: [PATCH 1/3] feat(auth): Add JSON object context support for Conditional Data Access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ctx claim in bearer tokens and signed data tokens previously only accepted a String, which meant structured CEL expressions like request.context.role == 'admin' could not be satisfied. Add setCtx(Map) overloads to BearerToken and SignedDataTokens builders so the JWT ctx claim is serialized as a nested JSON object. Also add setContext(Map) and getContextAsObject() to Credentials for use with the high-level Skyflow client. All changes are backwards compatible — existing setCtx(String) and setContext(String) APIs are unchanged. Refs SK-2679 Co-Authored-By: Claude --- README.md | 101 +++++++++--------- ...arerTokenGenerationWithContextExample.java | 37 ++++++- .../SignedTokenGenerationExample.java | 40 ++++++- .../java/com/skyflow/config/Credentials.java | 11 +- .../java/com/skyflow/errors/ErrorMessage.java | 2 + src/main/java/com/skyflow/logs/ErrorLogs.java | 2 + .../serviceaccount/util/BearerToken.java | 38 +++++-- .../serviceaccount/util/SignedDataTokens.java | 38 +++++-- src/main/java/com/skyflow/utils/Utils.java | 29 +++-- .../utils/validations/Validations.java | 42 +++++++- .../com/skyflow/config/CredentialsTests.java | 91 ++++++++++++++++ .../serviceaccount/util/BearerTokenTests.java | 90 ++++++++++++++++ .../util/SignedDataTokensTests.java | 96 +++++++++++++++++ 13 files changed, 527 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 023bb288..0f37e1a7 100644 --- a/README.md +++ b/README.md @@ -2771,10 +2771,14 @@ public class BearerTokenGenerationExample { ## Generate bearer tokens with context -**Context-aware authorization** embeds context values into a bearer token during its generation and so you can reference those values in your policies. This enables more flexible access controls, such as helping you track end-user identity when making API calls using service accounts, and facilitates using signed data tokens during detokenization. . +**Context-aware authorization** embeds context values into a bearer token during its generation and so you can reference those values in your policies. This enables more flexible access controls, such as helping you track end-user identity when making API calls using service accounts, and facilitates using signed data tokens during detokenization. A service account with the `context_id` identifier generates bearer tokens containing context information, represented as a JWT claim in a Skyflow-generated bearer token. Tokens generated from such service accounts include a `context_identifier` claim, are valid for 60 minutes, and can be used to make API calls to the Data and Management APIs, depending on the service account's permissions. +The context can be provided as a simple string or as a `Map` for structured context. Use a `Map` when your policies use Conditional Data Access with CEL expressions that reference nested context fields (e.g., `request.context.role == 'admin'`). + +### String context + [Example](https://github.com/skyflowapi/skyflow-java/blob/main/samples/src/main/java/com/example/serviceaccount/BearerTokenGenerationWithContextExample.java): ```java @@ -2783,60 +2787,44 @@ import com.skyflow.serviceaccount.util.BearerToken; import java.io.File; -/** - * Example program to generate a Bearer Token using Skyflow's BearerToken utility. - * The token is generated using two approaches: - * 1. By providing the credentials.json file path. - * 2. By providing the contents of credentials.json as a string. - */ -public class BearerTokenGenerationWithContextExample { - public static void main(String[] args) { - // Variable to store the generated Bearer Token - String bearerToken = null; +// Generate Bearer Token with a simple string context +String filePath = ""; - // Approach 1: Generate Bearer Token by specifying the path to the credentials.json file - try { - // Replace with the full path to your credentials.json file - String filePath = ""; +BearerToken token = BearerToken.builder() + .setCredentials(new File(filePath)) + .setCtx("abc") // Simple string context + .build(); - // Create a BearerToken object using the file path - BearerToken token = BearerToken.builder() - .setCredentials(new File(filePath)) // Set credentials using a File object - .setCtx("abc") // Set context string (example: "abc") - .build(); // Build the BearerToken object +String bearerToken = token.getBearerToken(); +``` - // Retrieve the Bearer Token as a string - bearerToken = token.getBearerToken(); +### JSON object context (Conditional Data Access) - // Print the generated Bearer Token to the console - System.out.println(bearerToken); - } catch (SkyflowException e) { - // Handle exceptions specific to Skyflow operations - e.printStackTrace(); - } +Skyflow's [Conditional Data Access](https://docs.skyflow.com/docs/governance/roles/conditional-data-access/overview) feature enables dynamic, context-aware access control by allowing roles to activate only when specific conditions are met at runtime. Conditions are defined using Common Expression Language (CEL) expressions that evaluate against `request.context`, `request.time`, and `request.originIP`. - // Approach 2: Generate Bearer Token by specifying the contents of credentials.json as a string - try { - // Replace with the actual contents of your credentials.json file - String fileContents = ""; +To satisfy context-based conditions, pass a `Map` to `setCtx()`. The map is embedded as a nested JSON object in the JWT `ctx` claim, allowing CEL expressions to reference individual fields. - // Create a BearerToken object using the file contents as a string - BearerToken token = BearerToken.builder() - .setCredentials(fileContents) // Set credentials using a string representation of the file - .setCtx("abc") // Set context string (example: "abc") - .build(); // Build the BearerToken object +```java +import com.skyflow.errors.SkyflowException; +import com.skyflow.serviceaccount.util.BearerToken; - // Retrieve the Bearer Token as a string - bearerToken = token.getBearerToken(); +import java.io.File; +import java.util.HashMap; +import java.util.Map; - // Print the generated Bearer Token to the console - System.out.println(bearerToken); - } catch (SkyflowException e) { - // Handle exceptions specific to Skyflow operations - e.printStackTrace(); - } - } -} +// Create a context map matching your Conditional Data Access policy +// For example, if your policy condition is: +// request.context.role == 'admin' && request.context.project_id == 'proj_123' +Map context = new HashMap<>(); +context.put("role", "admin"); +context.put("project_id", "proj_123"); + +BearerToken token = BearerToken.builder() + .setCredentials(new File("")) + .setCtx(context) // JSON object context for Conditional Data Access + .build(); + +String bearerToken = token.getBearerToken(); ``` ## Generate scoped bearer tokens @@ -2903,6 +2891,8 @@ with the private key of the service account credentials, which adds an additiona be detokenized by passing the signed data token and a bearer token generated from service account credentials. The service account must have appropriate permissions and context to detokenize the signed data tokens. +Like bearer tokens, the context can be provided as a simple string or as a `Map` for Conditional Data Access. + [Example](https://github.com/skyflowapi/skyflow-java/blob/main/samples/src/main/java/com/example/serviceaccount/SignedTokenGenerationExample.java): ```java @@ -2912,12 +2902,15 @@ import com.skyflow.serviceaccount.util.SignedDataTokens; import java.io.File; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class SignedTokenGenerationExample { public static void main(String[] args) { List signedTokenValues; - // Generate Signed data token with context by specifying credentials.json file path + + // Generate Signed data token with string context try { String filePath = ""; String context = "abc"; @@ -2935,15 +2928,17 @@ public class SignedTokenGenerationExample { e.printStackTrace(); } - // Generate Signed data token with context by specifying credentials.json as string + // Generate Signed data token with JSON object context for Conditional Data Access try { - String fileContents = ""; - String context = "abc"; + String filePath = ""; + Map context = new HashMap<>(); + context.put("role", "admin"); + context.put("project_id", "proj_123"); ArrayList dataTokens = new ArrayList<>(); dataTokens.add("YOUR_DATA_TOKEN_1"); SignedDataTokens signedToken = SignedDataTokens.builder() - .setCredentials(fileContents) - .setCtx(context) + .setCredentials(new File(filePath)) + .setCtx(context) // JSON object context .setTimeToLive(30) // in seconds .setDataTokens(dataTokens) .build(); diff --git a/samples/src/main/java/com/example/serviceaccount/BearerTokenGenerationWithContextExample.java b/samples/src/main/java/com/example/serviceaccount/BearerTokenGenerationWithContextExample.java index 3ed9e267..efd7f657 100644 --- a/samples/src/main/java/com/example/serviceaccount/BearerTokenGenerationWithContextExample.java +++ b/samples/src/main/java/com/example/serviceaccount/BearerTokenGenerationWithContextExample.java @@ -4,12 +4,15 @@ import com.skyflow.serviceaccount.util.BearerToken; import java.io.File; +import java.util.HashMap; +import java.util.Map; /** * Example program to generate a Bearer Token using Skyflow's BearerToken utility. - * The token is generated using two approaches: - * 1. By providing the credentials.json file path. - * 2. By providing the contents of credentials.json as a string. + * The token is generated using three approaches: + * 1. By providing the credentials.json file path with a string context. + * 2. By providing the contents of credentials.json as a string with a string context. + * 3. By providing a JSON object context for Conditional Data Access. */ public class BearerTokenGenerationWithContextExample { public static void main(String[] args) { @@ -57,5 +60,33 @@ public static void main(String[] args) { // Handle exceptions specific to Skyflow operations e.printStackTrace(); } + + // Approach 3: Generate Bearer Token with a JSON object context for Conditional Data Access + // Use this approach when your Skyflow policy uses CEL expressions that reference nested + // context fields, such as: request.context.role == 'admin' + try { + // Replace with the full path to your credentials.json file + String filePath = ""; + + // Create a context map with key-value pairs matching your Conditional Data Access policy + Map context = new HashMap<>(); + context.put("role", "admin"); // Evaluated as request.context.role + context.put("project_id", "proj_123"); // Evaluated as request.context.project_id + + // Create a BearerToken object with the JSON object context + BearerToken token = BearerToken.builder() + .setCredentials(new File(filePath)) // Set credentials using a File object + .setCtx(context) // Set context as a JSON object + .build(); // Build the BearerToken object + + // Retrieve the Bearer Token as a string + bearerToken = token.getBearerToken(); + + // Print the generated Bearer Token to the console + System.out.println(bearerToken); + } catch (SkyflowException e) { + // Handle exceptions specific to Skyflow operations + e.printStackTrace(); + } } } diff --git a/samples/src/main/java/com/example/serviceaccount/SignedTokenGenerationExample.java b/samples/src/main/java/com/example/serviceaccount/SignedTokenGenerationExample.java index 98f552f3..d2789c65 100644 --- a/samples/src/main/java/com/example/serviceaccount/SignedTokenGenerationExample.java +++ b/samples/src/main/java/com/example/serviceaccount/SignedTokenGenerationExample.java @@ -6,12 +6,15 @@ import java.io.File; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** - * This example demonstrates how to generate Signed Data Tokens using two methods: - * 1. Specifying the path to a credentials JSON file. - * 2. Providing the credentials JSON as a string. + * This example demonstrates how to generate Signed Data Tokens using three methods: + * 1. Specifying the path to a credentials JSON file with a string context. + * 2. Providing the credentials JSON as a string with a string context. + * 3. Using a JSON object context for Conditional Data Access. *

* Signed data tokens are used to verify and securely transmit data with a specified context and TTL. */ @@ -70,5 +73,36 @@ public static void main(String[] args) { System.out.println("Error occurred while generating signed tokens using credentials string:"); e.printStackTrace(); } + + // Example 3: Generate Signed Data Token with a JSON object context for Conditional Data Access + // Use this approach when your Skyflow policy uses CEL expressions that reference nested + // context fields, such as: request.context.role == 'admin' + try { + // Step 1: Specify the path to the service account credentials JSON file + String filePath = ""; // Replace with the actual file path + + // Step 2: Create a context map with key-value pairs matching your Conditional Data Access policy + Map context = new HashMap<>(); + context.put("role", "admin"); // Evaluated as request.context.role + context.put("project_id", "proj_123"); // Evaluated as request.context.project_id + + ArrayList dataTokens = new ArrayList<>(); + dataTokens.add("YOUR_DATA_TOKEN_1"); // Replace with your actual data token(s) + + // Step 3: Build the SignedDataTokens object with the JSON object context + SignedDataTokens signedToken = SignedDataTokens.builder() + .setCredentials(new File(filePath)) // Provide the credentials file + .setCtx(context) // Set context as a JSON object + .setTimeToLive(30) // Set the TTL (in seconds) + .setDataTokens(dataTokens) // Set the data tokens to sign + .build(); + + // Step 4: Retrieve and print the signed data tokens + signedTokenValues = signedToken.getSignedDataTokens(); + System.out.println("Signed Tokens (using JSON object context): " + signedTokenValues); + } catch (SkyflowException e) { + System.out.println("Error occurred while generating signed tokens with JSON object context:"); + e.printStackTrace(); + } } } diff --git a/src/main/java/com/skyflow/config/Credentials.java b/src/main/java/com/skyflow/config/Credentials.java index f1865dc7..e70dd124 100644 --- a/src/main/java/com/skyflow/config/Credentials.java +++ b/src/main/java/com/skyflow/config/Credentials.java @@ -1,11 +1,12 @@ package com.skyflow.config; import java.util.ArrayList; +import java.util.Map; public class Credentials { private String path; private ArrayList roles; - private String context; + private Object context; private String credentialsString; private String token; private String apiKey; @@ -33,6 +34,10 @@ public void setRoles(ArrayList roles) { } public String getContext() { + return context instanceof String ? (String) context : null; + } + + public Object getContextAsObject() { return context; } @@ -40,6 +45,10 @@ public void setContext(String context) { this.context = context; } + public void setContext(Map context) { + this.context = context; + } + public String getCredentialsString() { return credentialsString; } diff --git a/src/main/java/com/skyflow/errors/ErrorMessage.java b/src/main/java/com/skyflow/errors/ErrorMessage.java index 8885a6c8..63e992af 100644 --- a/src/main/java/com/skyflow/errors/ErrorMessage.java +++ b/src/main/java/com/skyflow/errors/ErrorMessage.java @@ -34,6 +34,8 @@ public enum ErrorMessage { EmptyRoles("%s0 Initialization failed. Invalid roles. Specify at least one role."), EmptyRoleInRoles("%s0 Initialization failed. Invalid role. Specify a valid role."), EmptyContext("%s0 Initialization failed. Invalid context. Specify a valid context."), + InvalidCtxType("%s0 Initialization failed. Invalid context type. Context must be a string or a map."), + InvalidCtxMapKey("%s0 Initialization failed. Invalid context map key '%s1'. Context map keys must contain only alphanumeric characters and underscores."), // Bearer token generation FileNotFound("%s0 Initialization failed. Credential file not found at %s1. Verify the file path."), diff --git a/src/main/java/com/skyflow/logs/ErrorLogs.java b/src/main/java/com/skyflow/logs/ErrorLogs.java index eb5ea742..42df67fc 100644 --- a/src/main/java/com/skyflow/logs/ErrorLogs.java +++ b/src/main/java/com/skyflow/logs/ErrorLogs.java @@ -25,6 +25,8 @@ public enum ErrorLogs { EMPTY_ROLES("Invalid credentials. Roles can not be empty."), EMPTY_OR_NULL_ROLE_IN_ROLES("Invalid credentials. Role can not be null or empty in roles at index %s1."), EMPTY_OR_NULL_CONTEXT("Invalid credentials. Context can not be empty."), + INVALID_CTX_TYPE("Invalid credentials. Context must be a string or a map."), + INVALID_CTX_MAP_KEY("Invalid credentials. Context map key '%s1' is invalid. Keys must match ^[a-zA-Z0-9_]+$."), // Bearer token generation INVALID_BEARER_TOKEN("Bearer token is invalid or expired."), diff --git a/src/main/java/com/skyflow/serviceaccount/util/BearerToken.java b/src/main/java/com/skyflow/serviceaccount/util/BearerToken.java index 21000f0f..92c617ab 100644 --- a/src/main/java/com/skyflow/serviceaccount/util/BearerToken.java +++ b/src/main/java/com/skyflow/serviceaccount/util/BearerToken.java @@ -17,6 +17,8 @@ import com.skyflow.utils.logger.LogUtil; import io.jsonwebtoken.Jwts; +import com.skyflow.utils.validations.Validations; + import java.io.File; import java.io.FileNotFoundException; import java.io.FileReader; @@ -24,6 +26,7 @@ import java.security.PrivateKey; import java.util.ArrayList; import java.util.Date; +import java.util.Map; import java.util.Objects; public class BearerToken { @@ -31,7 +34,7 @@ public class BearerToken { private static final ApiClientBuilder API_CLIENT_BUILDER = new ApiClientBuilder(); private final File credentialsFile; private final String credentialsString; - private final String ctx; + private final Object ctx; private final ArrayList roles; private final String credentialsType; @@ -48,7 +51,7 @@ public static BearerTokenBuilder builder() { } private static V1GetAuthTokenResponse generateBearerTokenFromCredentials( - File credentialsFile, String context, ArrayList roles + File credentialsFile, Object context, ArrayList roles ) throws SkyflowException { LogUtil.printInfoLog(InfoLogs.GENERATE_BEARER_TOKEN_FROM_CREDENTIALS_TRIGGERED.getLog()); try { @@ -71,7 +74,7 @@ private static V1GetAuthTokenResponse generateBearerTokenFromCredentials( } private static V1GetAuthTokenResponse generateBearerTokenFromCredentialString( - String credentials, String context, ArrayList roles + String credentials, Object context, ArrayList roles ) throws SkyflowException { LogUtil.printInfoLog(InfoLogs.GENERATE_BEARER_TOKEN_FROM_CREDENTIALS_STRING_TRIGGERED.getLog()); try { @@ -89,7 +92,7 @@ private static V1GetAuthTokenResponse generateBearerTokenFromCredentialString( } private static V1GetAuthTokenResponse getBearerTokenFromCredentials( - JsonObject credentials, String context, ArrayList roles + JsonObject credentials, Object context, ArrayList roles ) throws SkyflowException { try { JsonElement privateKey = credentials.get("privateKey"); @@ -144,8 +147,19 @@ private static V1GetAuthTokenResponse getBearerTokenFromCredentials( } private static String getSignedToken( - String clientID, String keyID, String tokenURI, PrivateKey pvtKey, String context - ) { + String clientID, String keyID, String tokenURI, PrivateKey pvtKey, Object context + ) throws SkyflowException { + // Validate and normalize context + Object validatedContext = context; + if (context instanceof Map) { + Map ctxMap = (Map) context; + if (ctxMap.isEmpty()) { + validatedContext = null; + } else { + Validations.validateCtxMapKeys(ctxMap); + } + } + final Date createdDate = new Date(); final Date expirationDate = new Date(createdDate.getTime() + (3600 * 1000)); return Jwts.builder() @@ -153,7 +167,7 @@ private static String getSignedToken( .claim("key", keyID) .claim("aud", tokenURI) .claim("sub", clientID) - .claim("ctx", context) + .claim("ctx", validatedContext) .expiration(expirationDate) .signWith(pvtKey, Jwts.SIG.RS256) .compact(); @@ -188,7 +202,7 @@ public synchronized String getBearerToken() throws SkyflowException { public static class BearerTokenBuilder { private File credentialsFile; private String credentialsString; - private String ctx; + private Object ctx; private ArrayList roles; private String credentialsType; @@ -216,6 +230,14 @@ public BearerTokenBuilder setCtx(String ctx) { return this; } + public BearerTokenBuilder setCtx(Map ctx) throws SkyflowException { + if (ctx != null && !ctx.isEmpty()) { + Validations.validateCtxMapKeys(ctx); + } + this.ctx = ctx; + return this; + } + public BearerTokenBuilder setRoles(ArrayList roles) { this.roles = roles; return this; diff --git a/src/main/java/com/skyflow/serviceaccount/util/SignedDataTokens.java b/src/main/java/com/skyflow/serviceaccount/util/SignedDataTokens.java index 70a5a330..394835e0 100644 --- a/src/main/java/com/skyflow/serviceaccount/util/SignedDataTokens.java +++ b/src/main/java/com/skyflow/serviceaccount/util/SignedDataTokens.java @@ -13,6 +13,8 @@ import com.skyflow.utils.logger.LogUtil; import io.jsonwebtoken.Jwts; +import com.skyflow.utils.validations.Validations; + import java.io.File; import java.io.FileNotFoundException; import java.io.FileReader; @@ -20,13 +22,14 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Objects; public class SignedDataTokens { private final File credentialsFile; private final String credentialsString; private final String credentialsType; - private final String ctx; + private final Object ctx; private final ArrayList dataTokens; private final Integer timeToLive; @@ -44,7 +47,7 @@ public static SignedDataTokensBuilder builder() { } private static List generateSignedTokenFromCredentialsFile( - File credentialsFile, ArrayList dataTokens, Integer timeToLive, String context + File credentialsFile, ArrayList dataTokens, Integer timeToLive, Object context ) throws SkyflowException { LogUtil.printInfoLog(InfoLogs.GENERATE_SIGNED_TOKENS_FROM_CREDENTIALS_FILE_TRIGGERED.getLog()); List responseToken; @@ -69,7 +72,7 @@ private static List generateSignedTokenFromCredentialsF } private static List generateSignedTokensFromCredentialsString( - String credentials, ArrayList dataTokens, Integer timeToLive, String context + String credentials, ArrayList dataTokens, Integer timeToLive, Object context ) throws SkyflowException { LogUtil.printInfoLog(InfoLogs.GENERATE_SIGNED_TOKENS_FROM_CREDENTIALS_STRING_TRIGGERED.getLog()); List responseToken; @@ -89,7 +92,7 @@ private static List generateSignedTokensFromCredentials } private static List generateSignedTokensFromCredentials( - JsonObject credentials, ArrayList dataTokens, Integer timeToLive, String context + JsonObject credentials, ArrayList dataTokens, Integer timeToLive, Object context ) throws SkyflowException { List signedDataTokens = null; try { @@ -122,8 +125,19 @@ private static List generateSignedTokensFromCredentials private static List getSignedToken( String clientID, String keyID, PrivateKey pvtKey, - ArrayList dataTokens, Integer timeToLive, String context - ) { + ArrayList dataTokens, Integer timeToLive, Object context + ) throws SkyflowException { + // Validate and normalize context + Object validatedContext = context; + if (context instanceof Map) { + Map ctxMap = (Map) context; + if (ctxMap.isEmpty()) { + validatedContext = null; + } else { + Validations.validateCtxMapKeys(ctxMap); + } + } + final Date createdDate = new Date(); final Date expirationDate; @@ -140,7 +154,7 @@ private static List getSignedToken( .claim("iat", (createdDate.getTime() / 1000)) .claim("key", keyID) .claim("sub", clientID) - .claim("ctx", context) + .claim("ctx", validatedContext) .claim("tok", dataToken) .expiration(expirationDate) .signWith(pvtKey, Jwts.SIG.RS256) @@ -168,7 +182,7 @@ public static class SignedDataTokensBuilder { private Integer timeToLive; private File credentialsFile; private String credentialsString; - private String ctx; + private Object ctx; private String credentialsType; private SignedDataTokensBuilder() { @@ -195,6 +209,14 @@ public SignedDataTokensBuilder setCtx(String ctx) { return this; } + public SignedDataTokensBuilder setCtx(Map ctx) throws SkyflowException { + if (ctx != null && !ctx.isEmpty()) { + Validations.validateCtxMapKeys(ctx); + } + this.ctx = ctx; + return this; + } + public SignedDataTokensBuilder setDataTokens(ArrayList dataTokens) { this.dataTokens = dataTokens; return this; diff --git a/src/main/java/com/skyflow/utils/Utils.java b/src/main/java/com/skyflow/utils/Utils.java index 165c6a80..eec2d024 100644 --- a/src/main/java/com/skyflow/utils/Utils.java +++ b/src/main/java/com/skyflow/utils/Utils.java @@ -47,21 +47,30 @@ public static String getVaultURL(String clusterId, Env env) { return sb.toString(); } + @SuppressWarnings("unchecked") public static String generateBearerToken(Credentials credentials) throws SkyflowException { if (credentials.getPath() != null) { - return BearerToken.builder() + BearerToken.BearerTokenBuilder builder = BearerToken.builder() .setCredentials(new File(credentials.getPath())) - .setRoles(credentials.getRoles()) - .setCtx(credentials.getContext()) - .build() - .getBearerToken(); + .setRoles(credentials.getRoles()); + Object ctx = credentials.getContextAsObject(); + if (ctx instanceof Map) { + builder.setCtx((Map) ctx); + } else { + builder.setCtx((String) ctx); + } + return builder.build().getBearerToken(); } else if (credentials.getCredentialsString() != null) { - return BearerToken.builder() + BearerToken.BearerTokenBuilder builder = BearerToken.builder() .setCredentials(credentials.getCredentialsString()) - .setRoles(credentials.getRoles()) - .setCtx(credentials.getContext()) - .build() - .getBearerToken(); + .setRoles(credentials.getRoles()); + Object ctx = credentials.getContextAsObject(); + if (ctx instanceof Map) { + builder.setCtx((Map) ctx); + } else { + builder.setCtx((String) ctx); + } + return builder.build().getBearerToken(); } else { return credentials.getToken(); } diff --git a/src/main/java/com/skyflow/utils/validations/Validations.java b/src/main/java/com/skyflow/utils/validations/Validations.java index 1d078fa9..908c3a14 100644 --- a/src/main/java/com/skyflow/utils/validations/Validations.java +++ b/src/main/java/com/skyflow/utils/validations/Validations.java @@ -42,9 +42,36 @@ import com.skyflow.vault.tokens.TokenizeRequest; public class Validations { + private static final Pattern CTX_MAP_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); + private Validations() { } + public static void validateCtxMapKeys(Map ctxMap) throws SkyflowException { + for (Object key : ctxMap.keySet()) { + if (key == null) { + LogUtil.printErrorLog(Utils.parameterizedString( + ErrorLogs.INVALID_CTX_MAP_KEY.getLog(), "null" + )); + throw new SkyflowException(ErrorCode.INVALID_INPUT.getCode(), + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "null")); + } + String keyStr = key.toString(); + if (!CTX_MAP_KEY_PATTERN.matcher(keyStr).matches()) { + LogUtil.printErrorLog(Utils.parameterizedString( + ErrorLogs.INVALID_CTX_MAP_KEY.getLog(), keyStr + )); + throw new SkyflowException(ErrorCode.INVALID_INPUT.getCode(), + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), keyStr)); + } + // Recursively validate nested maps + Object value = ctxMap.get(key); + if (value instanceof Map) { + validateCtxMapKeys((Map) value); + } + } + } + public static void validateVaultConfig(VaultConfig vaultConfig) throws SkyflowException { String vaultId = vaultConfig.getVaultId(); String clusterId = vaultConfig.getClusterId(); @@ -162,7 +189,7 @@ public static void validateCredentials(Credentials credentials) throws SkyflowEx String credentialsString = credentials.getCredentialsString(); String token = credentials.getToken(); String apiKey = credentials.getApiKey(); - String context = credentials.getContext(); + Object context = credentials.getContextAsObject(); ArrayList roles = credentials.getRoles(); if (path != null) nonNullMembers++; @@ -217,9 +244,16 @@ public static void validateCredentials(Credentials credentials) throws SkyflowEx } } } - if (context != null && context.trim().isEmpty()) { - LogUtil.printErrorLog(ErrorLogs.EMPTY_OR_NULL_CONTEXT.getLog()); - throw new SkyflowException(ErrorCode.INVALID_INPUT.getCode(), ErrorMessage.EmptyContext.getMessage()); + if (context != null) { + if (context instanceof String && ((String) context).trim().isEmpty()) { + LogUtil.printErrorLog(ErrorLogs.EMPTY_OR_NULL_CONTEXT.getLog()); + throw new SkyflowException(ErrorCode.INVALID_INPUT.getCode(), ErrorMessage.EmptyContext.getMessage()); + } else if (context instanceof Map && ((Map) context).isEmpty()) { + LogUtil.printErrorLog(ErrorLogs.EMPTY_OR_NULL_CONTEXT.getLog()); + throw new SkyflowException(ErrorCode.INVALID_INPUT.getCode(), ErrorMessage.EmptyContext.getMessage()); + } else if (context instanceof Map) { + validateCtxMapKeys((Map) context); + } } } diff --git a/src/test/java/com/skyflow/config/CredentialsTests.java b/src/test/java/com/skyflow/config/CredentialsTests.java index ff9fcfda..6b3381d6 100644 --- a/src/test/java/com/skyflow/config/CredentialsTests.java +++ b/src/test/java/com/skyflow/config/CredentialsTests.java @@ -3,6 +3,7 @@ import com.skyflow.errors.ErrorCode; import com.skyflow.errors.ErrorMessage; import com.skyflow.errors.SkyflowException; +import com.skyflow.utils.Utils; import com.skyflow.utils.validations.Validations; import org.junit.Assert; import org.junit.Before; @@ -10,6 +11,8 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; public class CredentialsTests { private static final String INVALID_EXCEPTION_THROWN = "Should not have thrown any exception"; @@ -266,4 +269,92 @@ public void testEmptyContextInCredentials() { } } + @Test + public void testValidCredentialsWithMapContext() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("role", "admin"); + mapContext.put("department", "engineering"); + Credentials credentials = new Credentials(); + credentials.setApiKey(validApiKey); + credentials.setContext(mapContext); + Validations.validateCredentials(credentials); + Assert.assertNull(credentials.getContext()); + Assert.assertEquals(mapContext, credentials.getContextAsObject()); + } catch (SkyflowException e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testInvalidCtxMapKeyWithHyphenInCredentials() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid-key", "value"); + Credentials credentials = new Credentials(); + credentials.setPath(path); + credentials.setContext(mapContext); + Validations.validateCredentials(credentials); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid-key"), + e.getMessage() + ); + } + } + + @Test + public void testInvalidCtxMapKeyWithDotInCredentials() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid.key", "value"); + Credentials credentials = new Credentials(); + credentials.setPath(path); + credentials.setContext(mapContext); + Validations.validateCredentials(credentials); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid.key"), + e.getMessage() + ); + } + } + + @Test + public void testInvalidCtxMapKeyWithSpaceInCredentials() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid key", "value"); + Credentials credentials = new Credentials(); + credentials.setPath(path); + credentials.setContext(mapContext); + Validations.validateCredentials(credentials); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid key"), + e.getMessage() + ); + } + } + + @Test + public void testEmptyMapContextInCredentials() { + try { + Credentials credentials = new Credentials(); + credentials.setPath(path); + credentials.setContext(new HashMap<>()); + Validations.validateCredentials(credentials); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals(ErrorMessage.EmptyContext.getMessage(), e.getMessage()); + } + } + } diff --git a/src/test/java/com/skyflow/serviceaccount/util/BearerTokenTests.java b/src/test/java/com/skyflow/serviceaccount/util/BearerTokenTests.java index 4bfb697c..83d3f602 100644 --- a/src/test/java/com/skyflow/serviceaccount/util/BearerTokenTests.java +++ b/src/test/java/com/skyflow/serviceaccount/util/BearerTokenTests.java @@ -10,6 +10,8 @@ import java.io.File; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; public class BearerTokenTests { private static final String INVALID_EXCEPTION_THROWN = "Should not have thrown any exception"; @@ -196,6 +198,94 @@ public void testInvalidPrivateKeyInCredentialsForCredentials() { } } + @Test + public void testBearerTokenBuilderWithMapContext() { + try { + File file = new File(credentialsFilePath); + Map mapContext = new HashMap<>(); + mapContext.put("role", "admin"); + mapContext.put("department", "engineering"); + BearerToken.builder().setCredentials(file).setCtx(mapContext).setRoles(roles).build(); + } catch (Exception e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testBearerTokenBuilderWithNestedMapContext() { + try { + File file = new File(credentialsFilePath); + Map nestedContext = new HashMap<>(); + Map user = new HashMap<>(); + user.put("role", "admin"); + user.put("level", 5); + nestedContext.put("user", user); + nestedContext.put("project_id", "proj_123"); + BearerToken.builder().setCredentials(file).setCtx(nestedContext).build(); + } catch (Exception e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testBearerTokenBuilderWithMapContextAndCredentialsString() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("role", "admin"); + BearerToken.builder().setCredentials(credentialsString).setCtx(mapContext).build(); + } catch (Exception e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testBearerTokenWithInvalidCtxMapKeyContainingHyphen() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid-key", "value"); + BearerToken.builder().setCtx(mapContext); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid-key"), + e.getMessage() + ); + } + } + + @Test + public void testBearerTokenWithInvalidCtxMapKeyContainingDot() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid.key", "value"); + BearerToken.builder().setCtx(mapContext); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid.key"), + e.getMessage() + ); + } + } + + @Test + public void testBearerTokenWithInvalidCtxMapKeyContainingSpace() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid key", "value"); + BearerToken.builder().setCtx(mapContext); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid key"), + e.getMessage() + ); + } + } + @Test public void testInvalidKeySpecInCredentialsForCredentials() { String credentialsString = "{\"privateKey\": \"-----BEGIN PRIVATE KEY-----\\ncHJpdmF0ZV9rZXlfdmFsdWU=\\n-----END PRIVATE KEY-----\", \"clientID\": \"client_id_value\", \"keyID\": \"key_id_value\", \"tokenURI\": \"invalid_token_uri\"}"; diff --git a/src/test/java/com/skyflow/serviceaccount/util/SignedDataTokensTests.java b/src/test/java/com/skyflow/serviceaccount/util/SignedDataTokensTests.java index f431042d..620b9fa0 100644 --- a/src/test/java/com/skyflow/serviceaccount/util/SignedDataTokensTests.java +++ b/src/test/java/com/skyflow/serviceaccount/util/SignedDataTokensTests.java @@ -10,6 +10,8 @@ import java.io.File; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; public class SignedDataTokensTests { private static final String INVALID_EXCEPTION_THROWN = "Should not have thrown any exception"; @@ -60,6 +62,100 @@ public void testSignedDataTokensBuilderWithCredentialsString() { } + @Test + public void testSignedDataTokensBuilderWithMapContext() { + try { + File file = new File(credentialsFilePath); + Map mapContext = new HashMap<>(); + mapContext.put("role", "admin"); + mapContext.put("department", "engineering"); + SignedDataTokens.builder() + .setCredentials(file).setCtx(mapContext).setDataTokens(dataTokens).setTimeToLive(ttl) + .build(); + } catch (Exception e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testSignedDataTokensBuilderWithNestedMapContext() { + try { + File file = new File(credentialsFilePath); + Map nestedContext = new HashMap<>(); + Map user = new HashMap<>(); + user.put("role", "admin"); + user.put("level", 5); + nestedContext.put("user", user); + nestedContext.put("project_id", "proj_123"); + SignedDataTokens.builder() + .setCredentials(file).setCtx(nestedContext).setDataTokens(dataTokens).setTimeToLive(ttl) + .build(); + } catch (Exception e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testSignedDataTokensBuilderWithMapContextAndCredentialsString() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("role", "admin"); + SignedDataTokens.builder() + .setCredentials(credentialsString).setCtx(mapContext).setDataTokens(dataTokens).setTimeToLive(ttl) + .build(); + } catch (Exception e) { + Assert.fail(INVALID_EXCEPTION_THROWN); + } + } + + @Test + public void testSignedDataTokensWithInvalidCtxMapKeyContainingHyphen() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid-key", "value"); + SignedDataTokens.builder().setCtx(mapContext); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid-key"), + e.getMessage() + ); + } + } + + @Test + public void testSignedDataTokensWithInvalidCtxMapKeyContainingDot() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid.key", "value"); + SignedDataTokens.builder().setCtx(mapContext); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid.key"), + e.getMessage() + ); + } + } + + @Test + public void testSignedDataTokensWithInvalidCtxMapKeyContainingSpace() { + try { + Map mapContext = new HashMap<>(); + mapContext.put("invalid key", "value"); + SignedDataTokens.builder().setCtx(mapContext); + Assert.fail(EXCEPTION_NOT_THROWN); + } catch (SkyflowException e) { + Assert.assertEquals(ErrorCode.INVALID_INPUT.getCode(), e.getHttpCode()); + Assert.assertEquals( + Utils.parameterizedString(ErrorMessage.InvalidCtxMapKey.getMessage(), "invalid key"), + e.getMessage() + ); + } + } + @Test public void testEmptyCredentialsFilePath() { try { From 364b8bbae1bcc3cb1c0a97ad4315fd904d87d66c Mon Sep 17 00:00:00 2001 From: Sam Sternberg Date: Tue, 28 Apr 2026 08:12:34 -0400 Subject: [PATCH 2/3] refactor: move CTX_MAP_KEY_REGEX to Constants to avoid hard-coded string Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/main/java/com/skyflow/utils/Constants.java | 1 + src/main/java/com/skyflow/utils/validations/Validations.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/skyflow/utils/Constants.java b/src/main/java/com/skyflow/utils/Constants.java index 6af03281..79cb612e 100644 --- a/src/main/java/com/skyflow/utils/Constants.java +++ b/src/main/java/com/skyflow/utils/Constants.java @@ -16,6 +16,7 @@ public final class Constants { public static final String SIGNED_DATA_TOKEN_PREFIX = "signed_token_"; public static final String ORDER_ASCENDING = "ASCENDING"; public static final String API_KEY_REGEX = "^sky-[a-zA-Z0-9]{5}-[a-fA-F0-9]{32}$"; + public static final String CTX_MAP_KEY_REGEX = "^[a-zA-Z0-9_]+$"; public static final String ENV_CREDENTIALS_KEY_NAME = "SKYFLOW_CREDENTIALS"; public static final String SDK_NAME = "Skyflow Java SDK"; public static final String DEFAULT_SDK_VERSION = "v2"; diff --git a/src/main/java/com/skyflow/utils/validations/Validations.java b/src/main/java/com/skyflow/utils/validations/Validations.java index 908c3a14..87e5236f 100644 --- a/src/main/java/com/skyflow/utils/validations/Validations.java +++ b/src/main/java/com/skyflow/utils/validations/Validations.java @@ -42,7 +42,7 @@ import com.skyflow.vault.tokens.TokenizeRequest; public class Validations { - private static final Pattern CTX_MAP_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); + private static final Pattern CTX_MAP_KEY_PATTERN = Pattern.compile(Constants.CTX_MAP_KEY_REGEX); private Validations() { } From 4f83032538036c11b3ce4806e17631aa88ccf578 Mon Sep 17 00:00:00 2001 From: Sam Sternberg Date: Tue, 28 Apr 2026 08:14:00 -0400 Subject: [PATCH 3/3] refactor: add typed getContextAsMap() to Credentials, remove unchecked cast in Utils The suppression now lives in Credentials next to the typed setters that guarantee the invariant, keeping call sites in Utils cast-free. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../java/com/skyflow/config/Credentials.java | 5 +++++ src/main/java/com/skyflow/utils/Utils.java | 17 ++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/skyflow/config/Credentials.java b/src/main/java/com/skyflow/config/Credentials.java index e70dd124..85c0bfde 100644 --- a/src/main/java/com/skyflow/config/Credentials.java +++ b/src/main/java/com/skyflow/config/Credentials.java @@ -41,6 +41,11 @@ public Object getContextAsObject() { return context; } + @SuppressWarnings("unchecked") + public Map getContextAsMap() { + return context instanceof Map ? (Map) context : null; + } + public void setContext(String context) { this.context = context; } diff --git a/src/main/java/com/skyflow/utils/Utils.java b/src/main/java/com/skyflow/utils/Utils.java index eec2d024..2fe26aff 100644 --- a/src/main/java/com/skyflow/utils/Utils.java +++ b/src/main/java/com/skyflow/utils/Utils.java @@ -47,28 +47,27 @@ public static String getVaultURL(String clusterId, Env env) { return sb.toString(); } - @SuppressWarnings("unchecked") public static String generateBearerToken(Credentials credentials) throws SkyflowException { if (credentials.getPath() != null) { BearerToken.BearerTokenBuilder builder = BearerToken.builder() .setCredentials(new File(credentials.getPath())) .setRoles(credentials.getRoles()); - Object ctx = credentials.getContextAsObject(); - if (ctx instanceof Map) { - builder.setCtx((Map) ctx); + Map ctxMap = credentials.getContextAsMap(); + if (ctxMap != null) { + builder.setCtx(ctxMap); } else { - builder.setCtx((String) ctx); + builder.setCtx(credentials.getContext()); } return builder.build().getBearerToken(); } else if (credentials.getCredentialsString() != null) { BearerToken.BearerTokenBuilder builder = BearerToken.builder() .setCredentials(credentials.getCredentialsString()) .setRoles(credentials.getRoles()); - Object ctx = credentials.getContextAsObject(); - if (ctx instanceof Map) { - builder.setCtx((Map) ctx); + Map ctxMap = credentials.getContextAsMap(); + if (ctxMap != null) { + builder.setCtx(ctxMap); } else { - builder.setCtx((String) ctx); + builder.setCtx(credentials.getContext()); } return builder.build().getBearerToken(); } else {