diff --git a/opa-builtins/build.gradle.kts b/opa-builtins/build.gradle.kts
index 5c16ab85..2def8f35 100644
--- a/opa-builtins/build.gradle.kts
+++ b/opa-builtins/build.gradle.kts
@@ -14,6 +14,7 @@ dependencies {
api(project(":opa-builtins:opa-builtins-net"))
api(project(":opa-builtins:opa-builtins-crypto"))
api(project(":opa-builtins:opa-builtins-json"))
+ api(project(":opa-builtins:opa-builtins-providers-aws"))
}
java {
diff --git a/opa-builtins/opa-builtins-providers-aws/build.gradle.kts b/opa-builtins/opa-builtins-providers-aws/build.gradle.kts
new file mode 100644
index 00000000..99184b4a
--- /dev/null
+++ b/opa-builtins/opa-builtins-providers-aws/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ `java-library`
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ api(project(":opa-evaluator"))
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
diff --git a/opa-builtins/opa-builtins-providers-aws/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/ProvidersAwsBuiltins.java b/opa-builtins/opa-builtins-providers-aws/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/ProvidersAwsBuiltins.java
new file mode 100644
index 00000000..25609ed5
--- /dev/null
+++ b/opa-builtins/opa-builtins-providers-aws/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/ProvidersAwsBuiltins.java
@@ -0,0 +1,608 @@
+package io.github.open_policy_agent.opa.ast.builtin.impls;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.BiFunction;
+import java.util.stream.Collectors;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider;
+import io.github.open_policy_agent.opa.ast.builtin.OpaBuiltin;
+import io.github.open_policy_agent.opa.ast.builtin.OpaType;
+import io.github.open_policy_agent.opa.ast.types.RegoArray;
+import io.github.open_policy_agent.opa.ast.types.RegoBigInt;
+import io.github.open_policy_agent.opa.ast.types.RegoBoolean;
+import io.github.open_policy_agent.opa.ast.types.RegoDecimal;
+import io.github.open_policy_agent.opa.ast.types.RegoInt32;
+import io.github.open_policy_agent.opa.ast.types.RegoNull;
+import io.github.open_policy_agent.opa.ast.types.RegoNumber;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.ast.types.RegoSet;
+import io.github.open_policy_agent.opa.ast.types.RegoString;
+import io.github.open_policy_agent.opa.ast.types.RegoValue;
+import io.github.open_policy_agent.opa.rego.EvaluationContext;
+import io.github.open_policy_agent.opa.rego.TypeError;
+
+/**
+ * Implements the {@code providers.aws.sign_req} builtin, which signs an HTTP request object using
+ * the AWS Signature Version 4 (header-based, single-chunk) scheme.
+ *
+ *
The behavior mirrors the reference OPA implementation in {@code topdown/providers.go} and
+ * {@code internal/providers/aws/signing_v4.go}.
+ */
+public class ProvidersAwsBuiltins implements BuiltinProvider {
+
+ private static final DateTimeFormatter DATE_FMT =
+ DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC);
+ private static final DateTimeFormatter ISO8601_FMT =
+ DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
+
+ private static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
+ private static final String AMZ_CONTENT_SHA256 = "x-amz-content-sha256";
+
+ // Required AWS config keys (order irrelevant; formatted sorted in error messages).
+ private static final List REQUIRED_AWS_KEYS =
+ List.of("aws_service", "aws_access_key", "aws_secret_access_key", "aws_region");
+ // Required HTTP request keys.
+ private static final List REQUIRED_REQUEST_KEYS = List.of("method", "url");
+
+ // Headers that may be mutated in transit and are therefore excluded from the canonical request.
+ private static final Set IGNORED_HEADERS =
+ Set.of("authorization", "user-agent", "x-amzn-trace-id");
+
+ @Override
+ public Map> builtins() {
+ ProvidersAwsBuiltins instance = new ProvidersAwsBuiltins();
+ return Map.ofEntries(Map.entry("providers.aws.sign_req", instance::signReq));
+ }
+
+ @OpaBuiltin(
+ name = "providers.aws.sign_req",
+ description =
+ "Signs an HTTP request object for Amazon Web Services using AWS Signature Version 4.",
+ categories = {"providers.aws"},
+ args = {
+ @OpaType(type = "object", name = "request", description = "HTTP request object"),
+ @OpaType(type = "object", name = "aws_config", description = "AWS configuration object"),
+ @OpaType(type = "number", name = "time_ns", description = "nanoseconds since the epoch")
+ },
+ result = @OpaType(type = "object", name = "signed_request", description = "signed request"))
+ public RegoValue signReq(EvaluationContext ctx, RegoValue[] args) {
+ // Operand 1: request object.
+ if (!(args[0] instanceof RegoObject)) {
+ throw new TypeError("operand 1 must be object but got " + args[0].getTypeName());
+ }
+ RegoObject reqObj = (RegoObject) args[0];
+
+ // Operand 2: AWS config object.
+ if (!(args[1] instanceof RegoObject)) {
+ throw new TypeError("operand 2 must be object but got " + args[1].getTypeName());
+ }
+ RegoObject awsConfigObj = (RegoObject) args[1];
+ validateAwsAuthParameters(awsConfigObj);
+
+ String service = stringFromProperty(awsConfigObj, "aws_service");
+ String accessKey = stringFromProperty(awsConfigObj, "aws_access_key");
+ String secretKey = stringFromProperty(awsConfigObj, "aws_secret_access_key");
+ String region = stringFromProperty(awsConfigObj, "aws_region");
+ String sessionToken = stringFromProperty(awsConfigObj, "aws_session_token");
+
+ // Operand 3: timestamp in nanoseconds.
+ Long ts = toUnixNanos(args[2]);
+ if (ts == null || ts < 0) {
+ throw new TypeError("operand 3 could not convert time_ns value into a unix timestamp");
+ }
+ Instant signingInstant =
+ Instant.ofEpochSecond(Math.floorDiv(ts, 1_000_000_000L), Math.floorMod(ts, 1_000_000_000L));
+
+ // Validate required request keys exist.
+ validateHttpRequestOperand(reqObj);
+
+ // Required request fields (must be strings).
+ RegoValue reqUrl = reqObj.getProperty("url");
+ RegoValue reqMethod = reqObj.getProperty("method");
+ List invalidRequestParams = new ArrayList<>();
+ if (!(reqUrl instanceof RegoString)) {
+ invalidRequestParams.add("url");
+ }
+ if (!(reqMethod instanceof RegoString)) {
+ invalidRequestParams.add("method");
+ }
+ if (!invalidRequestParams.isEmpty()) {
+ throw new TypeError(
+ "operand 1 invalid values for required request parameters(s): "
+ + formatKeySet(invalidRequestParams));
+ }
+
+ String method = ((RegoString) reqMethod).getValue();
+ URI url = parseUrl(((RegoString) reqUrl).getValue());
+
+ // Optional headers object from the request.
+ RegoObject headersObj = new RegoObject();
+ RegoValue headersTerm = reqObj.getProperty("headers");
+ if (headersTerm != null) {
+ if (!(headersTerm instanceof RegoObject)) {
+ throw new TypeError("operand 0 must be object but got " + headersTerm.getTypeName());
+ }
+ headersObj = (RegoObject) headersTerm;
+ }
+
+ // disable_payload_signing (optional boolean).
+ boolean disablePayloadSigning = false;
+ RegoValue dps = awsConfigObj.getProperty("disable_payload_signing");
+ if (dps != null) {
+ if (dps instanceof RegoBoolean) {
+ disablePayloadSigning = ((RegoBoolean) dps).getValue();
+ } else {
+ throw new TypeError("operand 2 invalid value for 'disable_payload_signing' in AWS config");
+ }
+ }
+
+ byte[] body = getReqBodyBytes(reqObj);
+
+ // ---- AWS SigV4 signing (mirrors internal/providers/aws.SignV4) ----
+ String contentSha256 = disablePayloadSigning ? UNSIGNED_PAYLOAD : sha256Hex(body);
+ String dateNow = DATE_FMT.format(signingInstant);
+ String iso8601Now = ISO8601_FMT.format(signingInstant);
+
+ // AWS-managed headers.
+ Map awsHeaders = new LinkedHashMap<>();
+ awsHeaders.put("host", hostFromUrl(url));
+ awsHeaders.put("x-amz-date", iso8601Now);
+ if ("s3".equals(service) || "glacier".equals(service)) {
+ awsHeaders.put(AMZ_CONTENT_SHA256, contentSha256);
+ }
+ if (sessionToken != null && !sessionToken.isEmpty()) {
+ awsHeaders.put("x-amz-security-token", sessionToken);
+ }
+
+ // Original request headers (original case) restricted to string key/value pairs.
+ Map requestHeaders = objectToMap(headersObj);
+
+ // Headers to sign: aws headers first, then request headers (lowercased) which overwrite.
+ Map headersToSign = new LinkedHashMap<>(awsHeaders);
+ for (Map.Entry e : requestHeaders.entrySet()) {
+ String lc = e.getKey().toLowerCase(java.util.Locale.ROOT);
+ if (!IGNORED_HEADERS.contains(lc)) {
+ headersToSign.put(lc, e.getValue());
+ }
+ }
+
+ // Canonical request.
+ TreeMap sortedHeaders = new TreeMap<>(headersToSign);
+ StringBuilder canonicalReq = new StringBuilder();
+ canonicalReq.append(method).append('\n');
+ canonicalReq.append(escapedPath(url)).append('\n');
+ canonicalReq.append(rawQuery(url)).append('\n');
+ for (Map.Entry e : sortedHeaders.entrySet()) {
+ canonicalReq.append(e.getKey()).append(':').append(e.getValue()).append('\n');
+ }
+ canonicalReq.append('\n');
+ String headerList = String.join(";", sortedHeaders.keySet());
+ canonicalReq.append(headerList).append('\n');
+ canonicalReq.append(contentSha256);
+
+ // String to sign.
+ String credentialScope = dateNow + "/" + region + "/" + service + "/aws4_request";
+ String strToSign =
+ "AWS4-HMAC-SHA256\n"
+ + iso8601Now
+ + "\n"
+ + credentialScope
+ + "\n"
+ + sha256Hex(canonicalReq.toString().getBytes(StandardCharsets.UTF_8));
+
+ // Signing key via HMAC-SHA256 chaining.
+ byte[] signingKey = hmacSha256(("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8), dateNow);
+ signingKey = hmacSha256(signingKey, region);
+ signingKey = hmacSha256(signingKey, service);
+ signingKey = hmacSha256(signingKey, "aws4_request");
+
+ String signature = bytesToHex(hmacSha256(signingKey, strToSign));
+
+ String authHeader =
+ "AWS4-HMAC-SHA256 Credential="
+ + accessKey
+ + "/"
+ + credentialScope
+ + ",SignedHeaders="
+ + headerList
+ + ",Signature="
+ + signature;
+
+ // ---- Build the signed request object ----
+ RegoObject signedHeaders = new RegoObject();
+ // Restore original request headers (original case).
+ for (Map.Entry e : requestHeaders.entrySet()) {
+ signedHeaders.setProp(new RegoString(e.getKey()), new RegoString(e.getValue()));
+ }
+ signedHeaders.setProp(new RegoString("Authorization"), new RegoString(authHeader));
+ // AWS signature headers overwrite any conflicting request headers.
+ for (Map.Entry e : awsHeaders.entrySet()) {
+ signedHeaders.setProp(new RegoString(e.getKey()), new RegoString(e.getValue()));
+ }
+
+ RegoObject out = new RegoObject();
+ reqObj.stream().forEach(e -> out.setProp(e.getKey(), e.getValue()));
+ out.setProp(new RegoString("headers"), signedHeaders);
+ return out;
+ }
+
+ // ------------------------------------------------------------------
+ // Validation helpers
+ // ------------------------------------------------------------------
+
+ private void validateAwsAuthParameters(RegoObject config) {
+ List missing = new ArrayList<>();
+ for (String key : REQUIRED_AWS_KEYS) {
+ if (config.getProperty(key) == null) {
+ missing.add(key);
+ }
+ }
+ if (!missing.isEmpty()) {
+ throw new TypeError(
+ "operand 2 missing required AWS config parameters(s): " + formatKeySet(missing));
+ }
+
+ List invalid = new ArrayList<>();
+ for (String key : REQUIRED_AWS_KEYS) {
+ RegoValue v = config.getProperty(key);
+ if (!(v instanceof RegoString)) {
+ invalid.add(key);
+ }
+ }
+ if (!invalid.isEmpty()) {
+ throw new TypeError(
+ "operand 2 invalid values for required AWS config parameters(s): "
+ + formatKeySet(invalid));
+ }
+ }
+
+ private void validateHttpRequestOperand(RegoObject reqObj) {
+ List missing = new ArrayList<>();
+ for (String key : REQUIRED_REQUEST_KEYS) {
+ if (reqObj.getProperty(key) == null) {
+ missing.add(key);
+ }
+ }
+ if (!missing.isEmpty()) {
+ throw new TypeError(
+ "operand 1 missing required request parameters(s): " + formatKeySet(missing));
+ }
+ }
+
+ /** Formats a collection of keys as an OPA set literal: {@code {"a", "b"}} (sorted, quoted). */
+ private static String formatKeySet(Collection keys) {
+ return keys.stream()
+ .sorted()
+ .map(k -> "\"" + k + "\"")
+ .collect(Collectors.joining(", ", "{", "}"));
+ }
+
+ // ------------------------------------------------------------------
+ // Timestamp handling
+ // ------------------------------------------------------------------
+
+ /**
+ * Converts the timestamp operand into an integer number of nanoseconds, or {@code null} if it
+ * cannot be represented as a signed 64-bit integer.
+ */
+ private static Long toUnixNanos(RegoValue value) {
+ if (!(value instanceof RegoNumber)) {
+ return null;
+ }
+ if (value instanceof RegoInt32) {
+ return (long) ((RegoInt32) value).getValue();
+ }
+ if (value instanceof RegoBigInt) {
+ BigInteger b = ((RegoBigInt) value).getValue();
+ if (b.bitLength() > 63) {
+ return null;
+ }
+ return b.longValue();
+ }
+ if (value instanceof RegoDecimal) {
+ Double d = ((RegoDecimal) value).getValue();
+ if (d == null || d.isInfinite() || d.isNaN() || d != Math.floor(d)) {
+ return null;
+ }
+ if (d < (double) Long.MIN_VALUE || d > (double) Long.MAX_VALUE) {
+ return null;
+ }
+ return d.longValue();
+ }
+ return null;
+ }
+
+ // ------------------------------------------------------------------
+ // Request body / URL helpers
+ // ------------------------------------------------------------------
+
+ /** raw_body takes precedence over body; body is JSON-serialized. */
+ private byte[] getReqBodyBytes(RegoObject reqObj) {
+ RegoValue rawBody = reqObj.getProperty("raw_body");
+ if (rawBody != null) {
+ String s = (rawBody instanceof RegoString) ? ((RegoString) rawBody).getValue() : "";
+ return s.getBytes(StandardCharsets.UTF_8);
+ }
+ RegoValue body = reqObj.getProperty("body");
+ if (body != null) {
+ return canonicalJson(body).getBytes(StandardCharsets.UTF_8);
+ }
+ return new byte[0];
+ }
+
+ private static URI parseUrl(String value) {
+ try {
+ return new URI(value);
+ } catch (URISyntaxException e) {
+ throw new TypeError("providers.aws.sign_req: could not parse url: " + value);
+ }
+ }
+
+ private static String hostFromUrl(URI url) {
+ String host = url.getHost();
+ if (host == null) {
+ return "";
+ }
+ int port = url.getPort();
+ if (port == -1) {
+ return host;
+ }
+ return host + ":" + port;
+ }
+
+ private static String escapedPath(URI url) {
+ String path = url.getRawPath();
+ return path == null ? "" : path;
+ }
+
+ private static String rawQuery(URI url) {
+ String query = url.getRawQuery();
+ return query == null ? "" : query;
+ }
+
+ /** Extracts string key/value header pairs, preserving original key case. */
+ private static Map objectToMap(RegoObject obj) {
+ Map out = new LinkedHashMap<>();
+ obj.stream()
+ .forEach(
+ e -> {
+ if (e.getKey() instanceof RegoString) {
+ String k = ((RegoString) e.getKey()).getValue();
+ String v =
+ (e.getValue() instanceof RegoString)
+ ? ((RegoString) e.getValue()).getValue()
+ : "";
+ out.put(k, v);
+ }
+ });
+ return out;
+ }
+
+ // ------------------------------------------------------------------
+ // Canonical JSON serialization (compact, sorted object keys, sets as arrays)
+ // ------------------------------------------------------------------
+
+ private static String canonicalJson(RegoValue value) {
+ StringBuilder sb = new StringBuilder();
+ writeJson(value, sb);
+ return sb.toString();
+ }
+
+ private static void writeJson(RegoValue value, StringBuilder sb) {
+ if (value == null || value instanceof RegoNull) {
+ sb.append("null");
+ } else if (value instanceof RegoString) {
+ writeJsonString(((RegoString) value).getValue(), sb);
+ } else if (value instanceof RegoBoolean) {
+ sb.append(((RegoBoolean) value).getValue() ? "true" : "false");
+ } else if (value instanceof RegoInt32) {
+ sb.append(((RegoInt32) value).getValue());
+ } else if (value instanceof RegoBigInt) {
+ sb.append(((RegoBigInt) value).getValue().toString());
+ } else if (value instanceof RegoDecimal) {
+ appendGoJsonFloat(((RegoDecimal) value).getValue(), sb);
+ } else if (value instanceof RegoArray) {
+ sb.append('[');
+ List items = ((RegoArray) value).getValues();
+ for (int i = 0; i < items.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ writeJson(items.get(i), sb);
+ }
+ sb.append(']');
+ } else if (value instanceof RegoSet) {
+ // A set serializes to a JSON array (elements in the set's sorted order).
+ sb.append('[');
+ boolean first = true;
+ for (RegoValue item : ((RegoSet) value).getValue()) {
+ if (!first) {
+ sb.append(',');
+ }
+ first = false;
+ writeJson(item, sb);
+ }
+ sb.append(']');
+ } else if (value instanceof RegoObject) {
+ // Object keys are sorted lexicographically to match Go's json.Marshal.
+ TreeMap sorted = new TreeMap<>();
+ ((RegoObject) value)
+ .stream()
+ .forEach(
+ e -> {
+ String k =
+ (e.getKey() instanceof RegoString)
+ ? ((RegoString) e.getKey()).getValue()
+ : e.getKey().toString();
+ sorted.put(k, e.getValue());
+ });
+ sb.append('{');
+ boolean first = true;
+ for (Map.Entry e : sorted.entrySet()) {
+ if (!first) {
+ sb.append(',');
+ }
+ first = false;
+ writeJsonString(e.getKey(), sb);
+ sb.append(':');
+ writeJson(e.getValue(), sb);
+ }
+ sb.append('}');
+ } else {
+ writeJsonString(value.toString(), sb);
+ }
+ }
+
+ /**
+ * Formats a float64 for canonical JSON, matching Go {@code encoding/json}'s {@code floatEncoder}.
+ *
+ * Go uses {@code strconv.FormatFloat} with precision {@code -1} and format {@code 'f'} when
+ * {@code 1e-6 <= |f| < 1e21}, otherwise format {@code 'e'}, then strips a single leading zero
+ * from negative exponents (e.g. {@code e-07} to {@code e-7}). See {@code encode.go} in Go 1.21+.
+ */
+ private static void appendGoJsonFloat(double f, StringBuilder sb) {
+ if (Double.isNaN(f) || Double.isInfinite(f)) {
+ sb.append(Double.toString(f));
+ return;
+ }
+ // Go encodes -0.0 as "0" so the sign is not preserved in JSON output.
+ if (f == 0.0) {
+ sb.append('0');
+ return;
+ }
+ double abs = Math.abs(f);
+ boolean useExp = abs < 1e-6 || abs >= 1e21;
+ String formatted = useExp ? formatGoJsonFloatE(f) : formatGoJsonFloatF(f);
+ if (useExp) {
+ formatted = cleanupGoJsonExponent(formatted);
+ }
+ sb.append(formatted);
+ }
+
+ private static String formatGoJsonFloatF(double f) {
+ return BigDecimal.valueOf(f).stripTrailingZeros().toPlainString();
+ }
+
+ private static String formatGoJsonFloatE(double f) {
+ String s = Double.toString(f).replace('E', 'e');
+ int eIdx = s.indexOf('e');
+ if (eIdx < 0) {
+ return s;
+ }
+ String mantissa = s.substring(0, eIdx);
+ String exponent = s.substring(eIdx + 1);
+ if (mantissa.endsWith(".0")) {
+ mantissa = mantissa.substring(0, mantissa.length() - 2);
+ }
+ if (!exponent.startsWith("-")) {
+ exponent = "+" + exponent;
+ }
+ return mantissa + 'e' + exponent;
+ }
+
+ /** Mirrors Go's post-processing for {@code e-0N} where N is a single digit. */
+ private static String cleanupGoJsonExponent(String s) {
+ int n = s.length();
+ if (n >= 4 && s.charAt(n - 4) == 'e' && s.charAt(n - 3) == '-' && s.charAt(n - 2) == '0') {
+ return s.substring(0, n - 3) + s.charAt(n - 1);
+ }
+ return s;
+ }
+
+ private static void writeJsonString(String s, StringBuilder sb) {
+ sb.append('"');
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ case '&':
+ sb.append("\\u0026");
+ break;
+ case '<':
+ sb.append("\\u003c");
+ break;
+ case '>':
+ sb.append("\\u003e");
+ break;
+ default:
+ if (c < 0x20 || c == '\u2028' || c == '\u2029') {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ break;
+ }
+ }
+ sb.append('"');
+ }
+
+ // ------------------------------------------------------------------
+ // Crypto helpers (byte-oriented HMAC-SHA256 for key derivation chaining)
+ // ------------------------------------------------------------------
+
+ private static byte[] hmacSha256(byte[] key, String message) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
+ } catch (NoSuchAlgorithmException e) {
+ throw new TypeError("providers.aws.sign_req: HMAC algorithm not available");
+ } catch (InvalidKeyException e) {
+ throw new TypeError("providers.aws.sign_req: invalid HMAC key");
+ }
+ }
+
+ private static String sha256Hex(byte[] data) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return bytesToHex(digest.digest(data));
+ } catch (NoSuchAlgorithmException e) {
+ throw new TypeError("providers.aws.sign_req: SHA-256 algorithm not available");
+ }
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder hex = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ hex.append(String.format("%02x", b));
+ }
+ return hex.toString();
+ }
+
+ private static String stringFromProperty(RegoObject obj, String key) {
+ RegoValue v = obj.getProperty(key);
+ return (v instanceof RegoString) ? ((RegoString) v).getValue() : "";
+ }
+}
diff --git a/opa-builtins/opa-builtins-providers-aws/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider b/opa-builtins/opa-builtins-providers-aws/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider
new file mode 100644
index 00000000..3637d89b
--- /dev/null
+++ b/opa-builtins/opa-builtins-providers-aws/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider
@@ -0,0 +1 @@
+io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsCanonicalJsonTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsCanonicalJsonTest.java
new file mode 100644
index 00000000..303b42c8
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsCanonicalJsonTest.java
@@ -0,0 +1,82 @@
+package io.github.open_policy_agent.opa.ir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins;
+import io.github.open_policy_agent.opa.ast.types.RegoDecimal;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.ast.types.RegoString;
+import io.github.open_policy_agent.opa.ast.types.RegoValue;
+import java.lang.reflect.Method;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Verifies canonical JSON serialization matches Go's {@code encoding/json} behavior.
+ */
+@DisplayName("providers.aws canonical JSON")
+class ProvidersAwsCanonicalJsonTest {
+
+ /**
+ * Expected values verified against Go 1.25 {@code json.Marshal(float64)} via the Go playground.
+ *
+ *
Note: {@code json.Number("1e21")} preserves the original literal as {@code 1e21}, but
+ * marshaling a {@code float64} {@code 1e21} produces {@code 1e+21}. {@code RegoDecimal} follows
+ * the float64 path.
+ */
+ static Stream goFloatMarshalCases() {
+ return Stream.of(
+ Arguments.of(1e21, "1e+21"),
+ Arguments.of(3.14, "3.14"),
+ Arguments.of(100.0, "100"),
+ Arguments.of(1e20, "100000000000000000000"),
+ Arguments.of(1e-7, "1e-7"),
+ Arguments.of(1e-6, "0.000001"),
+ Arguments.of(1.23e10, "12300000000"),
+ Arguments.of(42.0, "42"),
+ Arguments.of(-1e21, "-1e+21"),
+ Arguments.of(-0.0, "0"));
+ }
+
+ @ParameterizedTest(name = "{0} => {1}")
+ @MethodSource("goFloatMarshalCases")
+ void floatFormattingMatchesGoJsonMarshal(double value, String expected) throws Exception {
+ assertEquals(expected, canonicalJson(new RegoDecimal(value)));
+ }
+
+ @Test
+ void floatInObjectMatchesGoJsonMarshal() throws Exception {
+ RegoObject body = new RegoObject();
+ body.setProp(new RegoString("magnitude"), new RegoDecimal(1e21));
+ body.setProp(new RegoString("ratio"), new RegoDecimal(3.14));
+ body.setProp(new RegoString("count"), new RegoDecimal(100.0));
+ assertEquals(
+ "{\"count\":100,\"magnitude\":1e+21,\"ratio\":3.14}", canonicalJson(body));
+ }
+
+ @Test
+ void htmlEscapesMatchGoEncodingJson() throws Exception {
+ // Go json.Marshal escapes &, <, >, U+2028, and U+2029 in strings.
+ String input = "a&bd\u2028e\u2029f";
+ String expectedJson = "\"a\\u0026b\\u003cc\\u003ed\\u2028e\\u2029f\"";
+
+ String actualJson = canonicalJson(new RegoString(input));
+ assertEquals(expectedJson, actualJson);
+
+ RegoObject body = new RegoObject();
+ body.setProp(new RegoString("text"), new RegoString("a&bd"));
+ String expectedObjectJson = "{\"text\":\"a\\u0026b\\u003cc\\u003ed\"}";
+ assertEquals(expectedObjectJson, canonicalJson(body));
+ }
+
+ private static String canonicalJson(RegoValue value) throws Exception {
+ Method method =
+ ProvidersAwsBuiltins.class.getDeclaredMethod("canonicalJson", RegoValue.class);
+ method.setAccessible(true);
+ return (String) method.invoke(null, value);
+ }
+}
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsHostFromUrlTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsHostFromUrlTest.java
new file mode 100644
index 00000000..f4e6ba13
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsHostFromUrlTest.java
@@ -0,0 +1,55 @@
+package io.github.open_policy_agent.opa.ir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins;
+import io.github.open_policy_agent.opa.ast.types.RegoBigInt;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.ast.types.RegoString;
+import io.github.open_policy_agent.opa.ast.types.RegoValue;
+import io.github.open_policy_agent.opa.rego.EvaluationContext;
+import java.math.BigInteger;
+import java.util.function.BiFunction;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/** Verifies SigV4 host header reconstruction matches Go's {@code url.Host} (host:port when present). */
+@DisplayName("providers.aws host header")
+class ProvidersAwsHostFromUrlTest {
+
+ private static final long SIGNING_TS = 1451311705000000000L;
+
+ @Test
+ void explicitPortIncludedInHostHeader() {
+ assertHostHeader("https://example.com:8443/path", "example.com:8443");
+ }
+
+ @Test
+ void portlessUrlUsesHostOnly() {
+ assertHostHeader("http://example.com", "example.com");
+ }
+
+ private static void assertHostHeader(String url, String expectedHost) {
+ BiFunction fn =
+ new ProvidersAwsBuiltins().builtins().get("providers.aws.sign_req");
+
+ RegoObject req = new RegoObject();
+ req.setProp(new RegoString("method"), new RegoString("get"));
+ req.setProp(new RegoString("url"), new RegoString(url));
+
+ RegoObject config = new RegoObject();
+ config.setProp(new RegoString("aws_access_key"), new RegoString("MYAWSACCESSKEYGOESHERE"));
+ config.setProp(
+ new RegoString("aws_secret_access_key"), new RegoString("MYAWSSECRETACCESSKEYGOESHERE"));
+ config.setProp(new RegoString("aws_service"), new RegoString("s3"));
+ config.setProp(new RegoString("aws_region"), new RegoString("us-east-1"));
+
+ RegoValue[] args =
+ new RegoValue[] {req, config, new RegoBigInt(BigInteger.valueOf(SIGNING_TS))};
+
+ RegoObject signed = (RegoObject) fn.apply(null, args);
+ RegoObject headers = (RegoObject) signed.getProperty("headers");
+ String actualHost = ((RegoString) headers.getProperty("host")).getValue();
+ assertEquals(expectedHost, actualHost);
+ }
+}
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsSignReqTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsSignReqTest.java
new file mode 100644
index 00000000..6d257f3c
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsSignReqTest.java
@@ -0,0 +1,305 @@
+package io.github.open_policy_agent.opa.ir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.NullNode;
+import io.github.open_policy_agent.opa.OpaException;
+import io.github.open_policy_agent.opa.ast.builtin.BuiltinRegistry;
+import io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins;
+import io.github.open_policy_agent.opa.ast.types.RegoBigInt;
+import io.github.open_policy_agent.opa.ast.types.RegoBoolean;
+import io.github.open_policy_agent.opa.ast.types.RegoInt32;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.ast.types.RegoSet;
+import io.github.open_policy_agent.opa.ast.types.RegoString;
+import io.github.open_policy_agent.opa.ast.types.RegoValue;
+import io.github.open_policy_agent.opa.ir.policy.Policy;
+import io.github.open_policy_agent.opa.jackson.RegoValueModule;
+import io.github.open_policy_agent.opa.rego.EvaluationContext;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.function.BiFunction;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Focused tests for the {@code providers.aws.sign_req} builtin.
+ *
+ * Part 1 runs each compiled compliance plan end-to-end (using the fixtures' exact inputs and
+ * expected values). Part 2 calls the builtin directly for every success case and prints the actual
+ * computed {@code Authorization} header alongside the expected value extracted from the fixture.
+ */
+@DisplayName("providers.aws.sign_req")
+public class ProvidersAwsSignReqTest {
+
+ private static final String SUCCESS_FIXTURE =
+ "compliance/Tests/RegoComplianceTests/TestData/v1/providers-aws/aws-sign_req.json";
+ private static final String ERROR_FIXTURE =
+ "compliance/Tests/RegoComplianceTests/TestData/v1/providers-aws/aws-sign_req-errors.json";
+
+ private static final PolicyReader POLICY_READER =
+ ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow();
+
+ private static final Pattern AUTH_PATTERN =
+ Pattern.compile("\"Authorization\": \"(AWS4-HMAC-SHA256[^\"]*)\"");
+
+ private static final long SIGNING_TS = 1451311705000000000L;
+
+ private static ObjectMapper mapper() {
+ return new ObjectMapper().registerModule(new RegoValueModule());
+ }
+
+ private static JsonNode loadCases(String resource) throws Exception {
+ try (InputStream in =
+ ProvidersAwsSignReqTest.class.getClassLoader().getResourceAsStream(resource)) {
+ assertNotNull(in, "fixture not found on classpath: " + resource);
+ return mapper().readTree(in).get("cases");
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Part 1: end-to-end evaluation of the compiled plans (all 18 cases)
+ // ------------------------------------------------------------------
+
+ static List allComplianceCases() throws Exception {
+ List out = new ArrayList<>();
+ for (JsonNode c : loadCases(SUCCESS_FIXTURE)) {
+ out.add(Arguments.of(shortNote(c), c, false));
+ }
+ for (JsonNode c : loadCases(ERROR_FIXTURE)) {
+ out.add(Arguments.of(shortNote(c), c, true));
+ }
+ return out;
+ }
+
+ @ParameterizedTest(name = "plan[{0}]")
+ @MethodSource("allComplianceCases")
+ void endToEndPlan(String note, JsonNode root, boolean expectError) throws Exception {
+ ObjectMapper mapper = mapper();
+ Policy policy =
+ POLICY_READER.read(new ByteArrayInputStream(mapper.writeValueAsBytes(root.get("plan"))));
+ String entrypoint = root.get("entrypoints").get(0).asText();
+ BuiltinRegistry registry = BuiltinRegistry.allCapabilities();
+ EvaluationContext.Builder ctxBuilder =
+ new EvaluationContext.Builder()
+ .withBuiltinRegistry(registry)
+ .withSortedSets()
+ .withEntrypoint(entrypoint);
+ if (root.has("strict_error") && root.get("strict_error").asBoolean()) {
+ ctxBuilder.withStrictBuiltinErrors();
+ }
+ EvaluationContext ctx = ctxBuilder.build();
+ Evaluator evaluator =
+ new Evaluator.Builder().withPolicy(policy).withBuiltinRegistry(registry).build();
+
+ RegoObject data = mapper.treeToValue(mapper.readTree("{}"), RegoObject.class);
+
+ if (expectError) {
+ String wantError = root.get("want_error").asText();
+ String wantCode = root.get("want_error_code").asText();
+ try {
+ evaluator.evaluate(ctx, parseNull(mapper), data);
+ fail("[" + note + "] expected error but evaluation succeeded");
+ } catch (OpaException e) {
+ assertEquals(wantCode, e.getErrorCode(), "[" + note + "] error code");
+ String actual = e.getMessage();
+ assertTrue(
+ actual.contains(wantError) || wantError.contains(actual),
+ "[" + note + "] error message mismatch\n expected: " + wantError + "\n actual: "
+ + actual);
+ System.out.println("PASS error [" + note + "] " + wantError);
+ }
+ } else {
+ RegoValue[] result = evaluator.evaluate(ctx, parseNull(mapper), data);
+ String actual = mapper.writeValueAsString(result);
+ String expected = mapper.writeValueAsString(root.get("want_result"));
+ assertEquals(expected, actual, "[" + note + "] plan result");
+ System.out.println("PASS plan [" + note + "] result=" + actual);
+ }
+ }
+
+ private static RegoValue parseNull(ObjectMapper mapper) throws Exception {
+ return mapper.treeToValue(NullNode.getInstance(), RegoObject.class);
+ }
+
+ // ------------------------------------------------------------------
+ // Part 2: direct builtin invocation, showing computed vs expected signatures
+ // ------------------------------------------------------------------
+
+ static List successCases() throws Exception {
+ List out = new ArrayList<>();
+ for (JsonNode c : loadCases(SUCCESS_FIXTURE)) {
+ String note = shortNote(c);
+ String module = c.get("modules").get(0).asText();
+ Matcher m = AUTH_PATTERN.matcher(module);
+ assertTrue(m.find(), "could not extract expected Authorization for " + note);
+ out.add(Arguments.of(note, m.group(1)));
+ }
+ return out;
+ }
+
+ @ParameterizedTest(name = "sign[{0}]")
+ @MethodSource("successCases")
+ void computedSignature(String note, String expectedAuthorization) {
+ BiFunction fn =
+ new ProvidersAwsBuiltins().builtins().get("providers.aws.sign_req");
+ RegoValue[] args = buildArgs(note);
+
+ RegoObject signed = (RegoObject) fn.apply(null, args);
+ RegoObject headers = (RegoObject) signed.getProperty("headers");
+ String actualAuth = ((RegoString) headers.getProperty("Authorization")).getValue();
+ RegoValue contentSha = headers.getProperty("x-amz-content-sha256");
+ String contentShaStr = contentSha == null ? "(none)" : ((RegoString) contentSha).getValue();
+
+ String expectedSig = signatureOf(expectedAuthorization);
+ String actualSig = signatureOf(actualAuth);
+
+ System.out.println("---- " + note + " ----");
+ System.out.println(" x-amz-content-sha256: " + contentShaStr);
+ System.out.println(" expected Signature=" + expectedSig);
+ System.out.println(" actual Signature=" + actualSig);
+ System.out.println(" match: " + expectedSig.equals(actualSig));
+
+ assertEquals(expectedAuthorization, actualAuth, "[" + note + "] Authorization header");
+ }
+
+ private static String signatureOf(String authHeader) {
+ int i = authHeader.indexOf("Signature=");
+ return i < 0 ? authHeader : authHeader.substring(i + "Signature=".length());
+ }
+
+ // ------------------------------------------------------------------
+ // Input builders (transcribed from the fixture modules; any mistake surfaces
+ // as an Authorization mismatch against the fixture-derived expected value).
+ // ------------------------------------------------------------------
+
+ private static RegoValue[] buildArgs(String note) {
+ RegoObject req;
+ RegoObject config = baseConfig();
+ switch (note) {
+ case "success-simple-no body":
+ req = req("get", "http://example.com");
+ break;
+ case "success-simple-with headers no body":
+ req = req("get", "http://example.com");
+ req.setProp(str("headers"), obj("foo", str("bar")));
+ break;
+ case "success-simple-no body-with session token":
+ req = req("get", "http://example.com");
+ config.setProp(str("aws_session_token"), str("MYAWSSECURITYTOKENGOESHERE"));
+ break;
+ case "success-simple-body":
+ req = req("get", "http://example.com");
+ req.setProp(str("body"), exampleBody());
+ break;
+ case "success-simple-raw_body":
+ req = req("get", "http://example.com");
+ req.setProp(str("raw_body"), str("{\"example\": {1, 2, 3, 4}}"));
+ break;
+ case "success-simple-body-and-raw_body":
+ req = req("get", "http://example.com");
+ req.setProp(str("body"), exampleBody());
+ req.setProp(str("raw_body"), str("{\"example\": {1, 2, 3, 4}}"));
+ break;
+ case "success-simple-with-headers-no-body-with-payload-signing":
+ req = req("get", "https://example.com");
+ req.setProp(str("headers"), obj("foo", str("bar")));
+ config.setProp(str("disable_payload_signing"), RegoBoolean.FALSE);
+ break;
+ case "success-simple-with-headers-no-body-no-payload-signing":
+ req = req("get", "https://example.com");
+ req.setProp(str("headers"), obj("foo", str("bar")));
+ config.setProp(str("disable_payload_signing"), RegoBoolean.TRUE);
+ break;
+ case "success-simple-with-headers-with-body-with-payload-signing":
+ req = req("get", "https://example.com");
+ req.setProp(str("headers"), obj("foo", str("bar")));
+ req.setProp(str("body"), exampleBody());
+ config.setProp(str("disable_payload_signing"), RegoBoolean.FALSE);
+ break;
+ case "success-simple-with-headers-with-body-no-payload-signing":
+ req = req("get", "https://example.com");
+ req.setProp(str("headers"), obj("foo", str("bar")));
+ req.setProp(str("body"), exampleBody());
+ config.setProp(str("disable_payload_signing"), RegoBoolean.TRUE);
+ break;
+ case "success-simple-with-existing-sha-header-with-body-with-payload-signing":
+ req = req("get", "https://example.com");
+ req.setProp(str("headers"), existingShaHeaders());
+ req.setProp(str("body"), exampleBody());
+ config.setProp(str("disable_payload_signing"), RegoBoolean.FALSE);
+ break;
+ case "success-simple-with-existing-sha-header-with-body-no-payload-signing":
+ req = req("get", "https://example.com");
+ req.setProp(str("headers"), existingShaHeaders());
+ req.setProp(str("body"), exampleBody());
+ config.setProp(str("disable_payload_signing"), RegoBoolean.TRUE);
+ break;
+ default:
+ throw new IllegalArgumentException("unhandled success case: " + note);
+ }
+ return new RegoValue[] {req, config, new RegoBigInt(BigInteger.valueOf(SIGNING_TS))};
+ }
+
+ private static RegoString str(String s) {
+ return new RegoString(s);
+ }
+
+ private static RegoObject req(String method, String url) {
+ RegoObject o = new RegoObject();
+ o.setProp(str("method"), str(method));
+ o.setProp(str("url"), str(url));
+ return o;
+ }
+
+ private static RegoObject obj(String key, RegoValue value) {
+ RegoObject o = new RegoObject();
+ o.setProp(str(key), value);
+ return o;
+ }
+
+ private static RegoObject existingShaHeaders() {
+ RegoObject o = new RegoObject();
+ o.setProp(str("foo"), str("bar"));
+ o.setProp(str("x-amz-content-sha256"), str("existing-value"));
+ return o;
+ }
+
+ private static RegoObject baseConfig() {
+ RegoObject o = new RegoObject();
+ o.setProp(str("aws_access_key"), str("MYAWSACCESSKEYGOESHERE"));
+ o.setProp(str("aws_secret_access_key"), str("MYAWSSECRETACCESSKEYGOESHERE"));
+ o.setProp(str("aws_service"), str("s3"));
+ o.setProp(str("aws_region"), str("us-east-1"));
+ return o;
+ }
+
+ /** body := {"example": {1, 2, 3, 4}} */
+ private static RegoObject exampleBody() {
+ RegoSet set = new RegoSet(true);
+ set.addValue(RegoInt32.of(1));
+ set.addValue(RegoInt32.of(2));
+ set.addValue(RegoInt32.of(3));
+ set.addValue(RegoInt32.of(4));
+ return obj("example", set);
+ }
+
+ private static String shortNote(JsonNode c) {
+ String note = c.get("note").asText();
+ int slash = note.indexOf('/');
+ return slash < 0 ? note : note.substring(slash + 1);
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 16254e0d..310c0593 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,4 +25,5 @@ include("opa-builtins:opa-builtins-semver")
include("opa-builtins:opa-builtins-net")
include("opa-builtins:opa-builtins-crypto")
include("opa-builtins:opa-builtins-json")
+include("opa-builtins:opa-builtins-providers-aws")
include("opa-slf4j")