feat(rotators): AWS Secrets Manager rotator with SigV4 signer + secrets client
SigV4 implementation per AWS reference: canonical request -> string-to-sign -> HMAC-derived signing key (kSecret -> kDate -> kRegion -> kService -> kSigning) -> HMAC-SHA256 signature. Includes session token (STS) support. SecretsManagerClient wraps PutSecretValue, GetSecretValue, UpdateSecretVersionStage with custom endpoint support (LocalStack). AWS API errors surface as AwsApiError carrying HTTP status + AWS __type. AwsSecretsRotator implements 4-step contract: - generate: PutSecretValue with AWSPENDING stage, captures version_id - apply: no-op (PutSecretValue already exposed it) - verify: GetSecretValue by version_id, byte-equal SecretString check - commit: UpdateSecretVersionStage move AWSCURRENT to new + remove from old - rollback_apply: UpdateSecretVersionStage remove AWSPENDING from new version 13 unit specs verify SigV4 idempotence + format, client methods (with WebMock), rotator's full 4-step path, verify-mismatch, rollback_apply, and can_rotate? gating.
This commit is contained in:
parent
bad50daad2
commit
7e23f58fbb
|
|
@ -0,0 +1,76 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# secrets_client_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "webmock"
|
||||
require "../../../src/cre/aws/secrets_client"
|
||||
|
||||
WebMock.allow_net_connect = false
|
||||
|
||||
private def fresh_client : CRE::Aws::SecretsManagerClient
|
||||
CRE::Aws::SecretsManagerClient.new(
|
||||
access_key_id: "AKID",
|
||||
secret_access_key: "secret",
|
||||
region: "us-east-1",
|
||||
)
|
||||
end
|
||||
|
||||
describe CRE::Aws::SecretsManagerClient do
|
||||
before_each { WebMock.reset }
|
||||
|
||||
it "calls PutSecretValue and returns version_id" do
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.PutSecretValue"})
|
||||
.to_return(body: %({"VersionId":"v-123","ARN":"arn:fake"}))
|
||||
|
||||
version = fresh_client.put_secret_value("my-secret", "newpassword")
|
||||
version.version_id.should eq "v-123"
|
||||
version.secret_string.should eq "newpassword"
|
||||
end
|
||||
|
||||
it "calls GetSecretValue and returns the value" do
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
|
||||
.to_return(body: %({"VersionId":"v-1","SecretString":"theval"}))
|
||||
|
||||
sv = fresh_client.get_secret_value("my-secret")
|
||||
sv.version_id.should eq "v-1"
|
||||
sv.secret_string.should eq "theval"
|
||||
end
|
||||
|
||||
it "calls UpdateSecretVersionStage" do
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.UpdateSecretVersionStage"})
|
||||
.to_return(body: "{}")
|
||||
|
||||
fresh_client.update_secret_version_stage(
|
||||
"my-secret",
|
||||
"AWSCURRENT",
|
||||
move_to_version_id: "v2",
|
||||
remove_from_version_id: "v1",
|
||||
)
|
||||
end
|
||||
|
||||
it "raises AwsApiError on HTTP non-2xx" do
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.to_return(status: 400, body: %({"__type":"ResourceNotFoundException","message":"nope"}))
|
||||
|
||||
expect_raises(CRE::Aws::AwsApiError) do
|
||||
fresh_client.get_secret_value("missing")
|
||||
end
|
||||
end
|
||||
|
||||
it "respects custom endpoint (LocalStack)" do
|
||||
WebMock.stub(:post, "http://localstack-test/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.PutSecretValue"})
|
||||
.to_return(body: %({"VersionId":"local-v1"}))
|
||||
|
||||
client = CRE::Aws::SecretsManagerClient.new(
|
||||
access_key_id: "test", secret_access_key: "test",
|
||||
region: "us-east-1", endpoint: "http://localstack-test:4566/",
|
||||
)
|
||||
client.put_secret_value("any", "val").version_id.should eq "local-v1"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# signer_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "../../../src/cre/aws/signer"
|
||||
|
||||
# Reference SigV4 vector from AWS docs:
|
||||
# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-test-suite.html
|
||||
# Using the well-known "get-vanilla" test vector adapted for our API.
|
||||
describe CRE::Aws::SigV4 do
|
||||
it "signs a request idempotently for the same time" do
|
||||
signer = CRE::Aws::SigV4.new(
|
||||
access_key_id: "AKIDEXAMPLE",
|
||||
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
region: "us-east-1",
|
||||
service: "secretsmanager",
|
||||
)
|
||||
|
||||
headers1 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
|
||||
headers2 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
|
||||
uri = URI.parse("https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
body = %({"SecretId":"test"})
|
||||
fixed_time = Time.utc(2026, 4, 28, 12, 0, 0)
|
||||
|
||||
signer.sign("POST", uri, headers1, body, fixed_time)
|
||||
signer.sign("POST", uri, headers2, body, fixed_time)
|
||||
|
||||
headers1["Authorization"].should eq headers2["Authorization"]
|
||||
end
|
||||
|
||||
it "produces a well-formed Authorization header" do
|
||||
signer = CRE::Aws::SigV4.new("AKID", "secret", "us-east-1", "secretsmanager")
|
||||
h = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
|
||||
signer.sign("POST", URI.parse("https://secretsmanager.us-east-1.amazonaws.com/"), h, "{}")
|
||||
|
||||
h["Authorization"].should match(/^AWS4-HMAC-SHA256 Credential=AKID\/\d{8}\/us-east-1\/secretsmanager\/aws4_request, SignedHeaders=[^,]+, Signature=[a-f0-9]{64}$/)
|
||||
h["X-Amz-Date"].should match(/^\d{8}T\d{6}Z$/)
|
||||
h["X-Amz-Content-SHA256"].size.should eq 64
|
||||
h["Host"].should eq "secretsmanager.us-east-1.amazonaws.com"
|
||||
end
|
||||
|
||||
it "different bodies produce different signatures" do
|
||||
signer = CRE::Aws::SigV4.new("AKID", "secret", "us-east-1", "secretsmanager")
|
||||
h1 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
|
||||
h2 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
|
||||
uri = URI.parse("https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
fixed = Time.utc(2026, 1, 1)
|
||||
|
||||
signer.sign("POST", uri, h1, %({"a":1}), fixed)
|
||||
signer.sign("POST", uri, h2, %({"a":2}), fixed)
|
||||
h1["Authorization"].should_not eq h2["Authorization"]
|
||||
end
|
||||
|
||||
it "includes session token header when provided" do
|
||||
signer = CRE::Aws::SigV4.new(
|
||||
access_key_id: "AKID",
|
||||
secret_access_key: "secret",
|
||||
region: "us-east-1",
|
||||
service: "secretsmanager",
|
||||
session_token: "FAKETOKEN",
|
||||
)
|
||||
h = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
|
||||
signer.sign("POST", URI.parse("https://secretsmanager.us-east-1.amazonaws.com/"), h, "{}")
|
||||
h["X-Amz-Security-Token"].should eq "FAKETOKEN"
|
||||
h["Authorization"].should contain "x-amz-security-token"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# aws_secrets_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "webmock"
|
||||
require "../../../src/cre/rotators/aws_secrets"
|
||||
|
||||
WebMock.allow_net_connect = false
|
||||
|
||||
private def aws_credential
|
||||
CRE::Domain::Credential.new(
|
||||
id: UUID.random,
|
||||
external_id: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-db-prod",
|
||||
kind: CRE::Domain::CredentialKind::AwsSecretsmgr,
|
||||
name: "my-db-prod",
|
||||
tags: {
|
||||
"secret_arn" => "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-db-prod",
|
||||
"value_length" => "16",
|
||||
} of String => String,
|
||||
)
|
||||
end
|
||||
|
||||
private def fresh_client : CRE::Aws::SecretsManagerClient
|
||||
CRE::Aws::SecretsManagerClient.new(
|
||||
access_key_id: "AKID",
|
||||
secret_access_key: "secret",
|
||||
region: "us-east-1",
|
||||
)
|
||||
end
|
||||
|
||||
describe CRE::Rotators::AwsSecretsRotator do
|
||||
before_each { WebMock.reset }
|
||||
|
||||
it "executes the full 4-step contract" do
|
||||
cred = aws_credential
|
||||
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.PutSecretValue"})
|
||||
.to_return(body: %({"VersionId":"new-v"}))
|
||||
|
||||
rotator = CRE::Rotators::AwsSecretsRotator.new(fresh_client)
|
||||
rotator.can_rotate?(cred).should be_true
|
||||
|
||||
new_secret = rotator.generate(cred)
|
||||
new_secret.metadata["version_id"].should eq "new-v"
|
||||
new_secret.metadata["secret_arn"].should eq cred.tag("secret_arn")
|
||||
|
||||
rotator.apply(cred, new_secret) # no-op
|
||||
|
||||
expected_value = String.new(new_secret.ciphertext)
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
|
||||
.to_return(body: %({"VersionId":"new-v","SecretString":#{expected_value.to_json}}))
|
||||
|
||||
rotator.verify(cred, new_secret).should be_true
|
||||
|
||||
# Commit: GetSecretValue (current) + UpdateSecretVersionStage (move) + UpdateSecretVersionStage (remove pending)
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
|
||||
.to_return(body: %({"VersionId":"old-v","SecretString":"oldval"}))
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.UpdateSecretVersionStage"})
|
||||
.to_return(body: "{}")
|
||||
|
||||
rotator.commit(cred, new_secret)
|
||||
end
|
||||
|
||||
it "verify returns false on retrieved-value mismatch" do
|
||||
cred = aws_credential
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
|
||||
.to_return(body: %({"VersionId":"v","SecretString":"different"}))
|
||||
|
||||
rotator = CRE::Rotators::AwsSecretsRotator.new(fresh_client)
|
||||
s = CRE::Domain::NewSecret.new(
|
||||
ciphertext: "expected".to_slice,
|
||||
metadata: {"version_id" => "v", "secret_arn" => cred.tag("secret_arn").not_nil!},
|
||||
)
|
||||
rotator.verify(cred, s).should be_false
|
||||
end
|
||||
|
||||
it "rollback_apply removes AWSPENDING stage" do
|
||||
cred = aws_credential
|
||||
rotator = CRE::Rotators::AwsSecretsRotator.new(fresh_client)
|
||||
s = CRE::Domain::NewSecret.new(
|
||||
ciphertext: "x".to_slice,
|
||||
metadata: {"version_id" => "v", "secret_arn" => cred.tag("secret_arn").not_nil!},
|
||||
)
|
||||
|
||||
called = false
|
||||
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
|
||||
.with(headers: {"X-Amz-Target" => "secretsmanager.UpdateSecretVersionStage"})
|
||||
.to_return { |_req| called = true; HTTP::Client::Response.new(200, body: "{}") }
|
||||
|
||||
rotator.rollback_apply(cred, s)
|
||||
called.should be_true
|
||||
end
|
||||
|
||||
it "can_rotate? returns false without secret_arn tag" do
|
||||
bad = CRE::Domain::Credential.new(
|
||||
id: UUID.random, external_id: "x",
|
||||
kind: CRE::Domain::CredentialKind::AwsSecretsmgr,
|
||||
name: "x", tags: {} of String => String,
|
||||
)
|
||||
CRE::Rotators::AwsSecretsRotator.new(fresh_client).can_rotate?(bad).should be_false
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# secrets_client.cr
|
||||
# ===================
|
||||
|
||||
require "http/client"
|
||||
require "json"
|
||||
require "uuid"
|
||||
require "./signer"
|
||||
|
||||
module CRE::Aws
|
||||
class AwsApiError < Exception
|
||||
getter status : Int32
|
||||
getter aws_code : String?
|
||||
|
||||
def initialize(message : String, @status : Int32, @aws_code : String? = nil)
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
class SecretsManagerClient
|
||||
AWSCURRENT = "AWSCURRENT"
|
||||
AWSPENDING = "AWSPENDING"
|
||||
AWSPREVIOUS = "AWSPREVIOUS"
|
||||
|
||||
record SecretVersion, version_id : String, secret_string : String?
|
||||
|
||||
def initialize(
|
||||
@access_key_id : String,
|
||||
@secret_access_key : String,
|
||||
@region : String,
|
||||
@endpoint : String? = nil,
|
||||
@session_token : String? = nil,
|
||||
)
|
||||
@signer = SigV4.new(@access_key_id, @secret_access_key, @region, "secretsmanager", @session_token)
|
||||
end
|
||||
|
||||
# Stages a new secret version with the AWSPENDING label.
|
||||
def put_secret_value(secret_id : String, secret_string : String, version_stages : Array(String) = [AWSPENDING]) : SecretVersion
|
||||
payload = {
|
||||
"SecretId" => secret_id,
|
||||
"SecretString" => secret_string,
|
||||
"ClientRequestToken" => UUID.random.to_s,
|
||||
"VersionStages" => version_stages,
|
||||
}.to_json
|
||||
json = call("PutSecretValue", payload)
|
||||
SecretVersion.new(json["VersionId"].as_s, secret_string)
|
||||
end
|
||||
|
||||
def get_secret_value(secret_id : String, version_id : String? = nil, version_stage : String? = nil) : SecretVersion
|
||||
payload_h = {"SecretId" => secret_id}
|
||||
payload_h["VersionId"] = version_id if version_id
|
||||
payload_h["VersionStage"] = version_stage if version_stage
|
||||
payload = payload_h.to_json
|
||||
json = call("GetSecretValue", payload)
|
||||
SecretVersion.new(
|
||||
json["VersionId"].as_s,
|
||||
json["SecretString"]?.try(&.as_s),
|
||||
)
|
||||
end
|
||||
|
||||
def update_secret_version_stage(secret_id : String, version_stage : String, move_to_version_id : String? = nil, remove_from_version_id : String? = nil) : Nil
|
||||
payload_h = {
|
||||
"SecretId" => secret_id,
|
||||
"VersionStage" => version_stage,
|
||||
}
|
||||
payload_h["MoveToVersionId"] = move_to_version_id if move_to_version_id
|
||||
payload_h["RemoveFromVersionId"] = remove_from_version_id if remove_from_version_id
|
||||
call("UpdateSecretVersionStage", payload_h.to_json)
|
||||
end
|
||||
|
||||
private def call(action : String, body : String) : JSON::Any
|
||||
uri = URI.parse(@endpoint || "https://secretsmanager.#{@region}.amazonaws.com/")
|
||||
headers = HTTP::Headers{
|
||||
"Content-Type" => "application/x-amz-json-1.1",
|
||||
"X-Amz-Target" => "secretsmanager.#{action}",
|
||||
}
|
||||
@signer.sign("POST", uri, headers, body)
|
||||
|
||||
response = HTTP::Client.post(uri.to_s, headers: headers, body: body)
|
||||
raise AwsApiError.new(error_message(response), response.status_code, error_code(response)) unless response.status_code < 300
|
||||
|
||||
response.body.empty? ? JSON::Any.new(Hash(String, JSON::Any).new) : JSON.parse(response.body)
|
||||
end
|
||||
|
||||
private def error_message(resp : HTTP::Client::Response) : String
|
||||
"AWS #{resp.status_code}: #{resp.body[0, 200]?}"
|
||||
end
|
||||
|
||||
private def error_code(resp : HTTP::Client::Response) : String?
|
||||
return nil if resp.body.empty?
|
||||
JSON.parse(resp.body)["__type"]?.try(&.as_s)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# signer.cr
|
||||
# ===================
|
||||
|
||||
require "openssl/digest"
|
||||
require "openssl/hmac"
|
||||
require "uri"
|
||||
require "http/headers"
|
||||
|
||||
module CRE::Aws
|
||||
class SignerError < Exception; end
|
||||
|
||||
# SigV4 signer per AWS reference:
|
||||
# https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
|
||||
class SigV4
|
||||
ALGORITHM = "AWS4-HMAC-SHA256"
|
||||
|
||||
def initialize(
|
||||
@access_key_id : String,
|
||||
@secret_access_key : String,
|
||||
@region : String,
|
||||
@service : String,
|
||||
@session_token : String? = nil,
|
||||
)
|
||||
end
|
||||
|
||||
record SignedRequest, headers : HTTP::Headers, body : String
|
||||
|
||||
# Returns the signed Authorization header value plus modified headers.
|
||||
# Mutates the headers map in-place to add 'X-Amz-Date', 'Host',
|
||||
# 'X-Amz-Content-SHA256', 'X-Amz-Security-Token' (if any), 'Authorization'.
|
||||
def sign(method : String, uri : URI, headers : HTTP::Headers, body : String, now : Time = Time.utc) : Nil
|
||||
amz_date = now.to_s("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.to_s("%Y%m%d")
|
||||
|
||||
headers["Host"] = uri.host.not_nil!
|
||||
headers["X-Amz-Date"] = amz_date
|
||||
headers["X-Amz-Security-Token"] = @session_token.not_nil! if @session_token
|
||||
payload_hash = sha256_hex(body)
|
||||
headers["X-Amz-Content-SHA256"] = payload_hash
|
||||
|
||||
canonical_uri = canonical_path(uri.path.empty? ? "/" : uri.path)
|
||||
canonical_querystring = canonical_query(uri.query)
|
||||
canonical_headers, signed_headers = canonical_headers_and_list(headers)
|
||||
|
||||
canonical_request = String.build do |s|
|
||||
s << method.upcase << '\n'
|
||||
s << canonical_uri << '\n'
|
||||
s << canonical_querystring << '\n'
|
||||
s << canonical_headers << '\n'
|
||||
s << signed_headers << '\n'
|
||||
s << payload_hash
|
||||
end
|
||||
|
||||
credential_scope = "#{date_stamp}/#{@region}/#{@service}/aws4_request"
|
||||
string_to_sign = String.build do |s|
|
||||
s << ALGORITHM << '\n'
|
||||
s << amz_date << '\n'
|
||||
s << credential_scope << '\n'
|
||||
s << sha256_hex(canonical_request)
|
||||
end
|
||||
|
||||
signing_key = derive_signing_key(date_stamp)
|
||||
signature = OpenSSL::HMAC.hexdigest(:sha256, signing_key, string_to_sign)
|
||||
|
||||
auth = String.build do |s|
|
||||
s << ALGORITHM << ' '
|
||||
s << "Credential=" << @access_key_id << '/' << credential_scope << ", "
|
||||
s << "SignedHeaders=" << signed_headers << ", "
|
||||
s << "Signature=" << signature
|
||||
end
|
||||
headers["Authorization"] = auth
|
||||
end
|
||||
|
||||
private def canonical_path(path : String) : String
|
||||
# AWS: encode each path segment per RFC 3986; '/' kept literal; double-encode for non-S3 services
|
||||
path.split('/', remove_empty: false).map { |seg| URI.encode_path_segment(seg) }.join('/')
|
||||
end
|
||||
|
||||
private def canonical_query(query : String?) : String
|
||||
return "" unless query && !query.empty?
|
||||
params = [] of {String, String}
|
||||
query.split('&') do |pair|
|
||||
eq = pair.index('=')
|
||||
if eq
|
||||
k = URI.decode_www_form(pair[0, eq])
|
||||
v = URI.decode_www_form(pair[eq + 1..])
|
||||
else
|
||||
k = URI.decode_www_form(pair)
|
||||
v = ""
|
||||
end
|
||||
params << {k, v}
|
||||
end
|
||||
params.sort! { |a, b| a[0] <=> b[0] }
|
||||
params.map { |k, v| "#{URI.encode_path_segment(k)}=#{URI.encode_path_segment(v)}" }.join('&')
|
||||
end
|
||||
|
||||
private def canonical_headers_and_list(headers : HTTP::Headers) : {String, String}
|
||||
sorted = headers.to_a.map { |name, values|
|
||||
{name.downcase, values.first.strip.gsub(/\s+/, " ")}
|
||||
}.sort_by { |entry| entry[0] }
|
||||
|
||||
canonical = sorted.map { |k, v| "#{k}:#{v}\n" }.join
|
||||
list = sorted.map(&.[0]).join(';')
|
||||
{canonical, list}
|
||||
end
|
||||
|
||||
private def derive_signing_key(date_stamp : String) : Bytes
|
||||
k_date = OpenSSL::HMAC.digest(:sha256, "AWS4#{@secret_access_key}".to_slice, date_stamp.to_slice)
|
||||
k_region = OpenSSL::HMAC.digest(:sha256, k_date, @region.to_slice)
|
||||
k_service = OpenSSL::HMAC.digest(:sha256, k_region, @service.to_slice)
|
||||
OpenSSL::HMAC.digest(:sha256, k_service, "aws4_request".to_slice)
|
||||
end
|
||||
|
||||
private def sha256_hex(data : String) : String
|
||||
d = OpenSSL::Digest.new("SHA256")
|
||||
d.update(data)
|
||||
d.hexfinal
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# aws_secrets.cr
|
||||
# ===================
|
||||
|
||||
require "../aws/secrets_client"
|
||||
require "../crypto/random"
|
||||
require "./rotator"
|
||||
|
||||
module CRE::Rotators
|
||||
# AwsSecretsRotator implements the 4-step rotation contract against AWS Secrets
|
||||
# Manager, mirroring the standard Rotation Lambda template:
|
||||
#
|
||||
# 1. generate -> PutSecretValue with AWSPENDING label, returns version_id
|
||||
# 2. apply -> no-op (PutSecretValue exposed it; AWSPENDING already attached)
|
||||
# 3. verify -> GetSecretValue by version_id, confirm decoded value matches
|
||||
# 4. commit -> UpdateSecretVersionStage move AWSCURRENT to new, AWSPREVIOUS to old
|
||||
# rollback_apply -> remove AWSPENDING stage from the new version
|
||||
#
|
||||
# Required Credential.tags:
|
||||
# "secret_arn" - the AWS Secrets Manager ARN or name
|
||||
# "value_length" - optional, bytes of random payload (default 32)
|
||||
class AwsSecretsRotator < Rotator
|
||||
register_as :aws_secretsmgr
|
||||
|
||||
DEFAULT_BYTES = 32
|
||||
|
||||
def initialize(@client : Aws::SecretsManagerClient)
|
||||
end
|
||||
|
||||
def kind : Symbol
|
||||
:aws_secretsmgr
|
||||
end
|
||||
|
||||
def can_rotate?(c : Domain::Credential) : Bool
|
||||
c.kind.aws_secretsmgr? && !c.tag("secret_arn").nil?
|
||||
end
|
||||
|
||||
def generate(c : Domain::Credential) : Domain::NewSecret
|
||||
raise RotatorError.new("missing 'secret_arn' tag") unless can_rotate?(c)
|
||||
bytes = (c.tag("value_length") || DEFAULT_BYTES.to_s).to_i
|
||||
raw = CRE::Crypto::Random.bytes(bytes)
|
||||
new_value = Base64.urlsafe_encode(raw, padding: false)
|
||||
|
||||
version = @client.put_secret_value(c.tag("secret_arn").not_nil!, new_value)
|
||||
Domain::NewSecret.new(
|
||||
ciphertext: new_value.to_slice,
|
||||
metadata: {
|
||||
"version_id" => version.version_id,
|
||||
"secret_arn" => c.tag("secret_arn").not_nil!,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
def apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
|
||||
_ = {c, s}
|
||||
# No-op: PutSecretValue with AWSPENDING already made the new version available.
|
||||
end
|
||||
|
||||
def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool
|
||||
version_id = s.metadata["version_id"]
|
||||
secret_arn = s.metadata["secret_arn"]
|
||||
retrieved = @client.get_secret_value(secret_arn, version_id: version_id)
|
||||
expected = String.new(s.ciphertext)
|
||||
retrieved.secret_string == expected
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
def commit(c : Domain::Credential, s : Domain::NewSecret) : Nil
|
||||
_ = c
|
||||
version_id = s.metadata["version_id"]
|
||||
secret_arn = s.metadata["secret_arn"]
|
||||
|
||||
# Find the current version_id
|
||||
current = @client.get_secret_value(secret_arn, version_stage: Aws::SecretsManagerClient::AWSCURRENT)
|
||||
old_version_id = current.version_id
|
||||
|
||||
# Move AWSCURRENT to new, removing it from old (atomic per AWS API)
|
||||
@client.update_secret_version_stage(
|
||||
secret_arn,
|
||||
Aws::SecretsManagerClient::AWSCURRENT,
|
||||
move_to_version_id: version_id,
|
||||
remove_from_version_id: old_version_id,
|
||||
)
|
||||
|
||||
# Remove AWSPENDING from new version (it's now AWSCURRENT)
|
||||
@client.update_secret_version_stage(
|
||||
secret_arn,
|
||||
Aws::SecretsManagerClient::AWSPENDING,
|
||||
remove_from_version_id: version_id,
|
||||
) rescue nil
|
||||
end
|
||||
|
||||
def rollback_apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
|
||||
_ = c
|
||||
version_id = s.metadata["version_id"]?
|
||||
secret_arn = s.metadata["secret_arn"]?
|
||||
return unless version_id && secret_arn
|
||||
@client.update_secret_version_stage(
|
||||
secret_arn,
|
||||
Aws::SecretsManagerClient::AWSPENDING,
|
||||
remove_from_version_id: version_id,
|
||||
) rescue nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue