redis-cloud-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/lib/redis_cloud_auth/providers/azure.rb +25 -0
- data/lib/redis_cloud_auth/providers/elasticache.rb +84 -0
- data/lib/redis_cloud_auth/providers/gcp.rb +65 -0
- data/lib/redis_cloud_auth/reauth_middleware.rb +114 -0
- data/lib/redis_cloud_auth/registry.rb +82 -0
- data/lib/redis_cloud_auth/token_credential.rb +28 -0
- data/lib/redis_cloud_auth/version.rb +5 -0
- data/lib/redis_cloud_auth.rb +69 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b9f70dde3145f339c34f39691b3dfd41c8e10b9f9168686291ae2eff33f03911
|
|
4
|
+
data.tar.gz: ce98ed3815f8a8874b33464273b8c65af7c696c4a7822a2397144fe509f21f97
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b7824bce2e067f56cba7b05015c2f09749b1e4c019f46ae243fb4a2a4635082b981f8a230cc2785a27645d3de413b76e5390ad7059a7b83f43a78ff3cfd8cc4f
|
|
7
|
+
data.tar.gz: '09e1726c98e063ee9ebf1e2bd86fa5b5c2ba0b2570f2315ecceff72a20d44e2a7014606cda7e51bedae2dd6141ad9767ce1428a51da6a5f07219af99f33aa4f1'
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GitLab B.V.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# redis-cloud-auth
|
|
2
|
+
|
|
3
|
+
Authenticate Redis connections with short-lived **cloud identity** tokens
|
|
4
|
+
instead of a static password, from any `redis-client` / `redis-rb` / Sidekiq
|
|
5
|
+
application.
|
|
6
|
+
|
|
7
|
+
Managed Redis services increasingly support token-based authentication tied to a
|
|
8
|
+
cloud IAM identity:
|
|
9
|
+
|
|
10
|
+
- **Azure** Managed Redis / Azure Cache for Redis, with Microsoft Entra
|
|
11
|
+
- **AWS** ElastiCache, with IAM authentication
|
|
12
|
+
- **Google Cloud** Memorystore for Redis Cluster / Valkey, with IAM
|
|
13
|
+
|
|
14
|
+
In each case the Redis `AUTH` password is a short-lived token minted for a
|
|
15
|
+
workload identity (for example via Kubernetes Workload Identity), not a secret
|
|
16
|
+
you store. `redis-cloud-auth` fetches those tokens, uses them to authenticate
|
|
17
|
+
new connections, and transparently re-authenticates long-lived connections
|
|
18
|
+
before the token expires, so you can point a normal Redis client at a managed,
|
|
19
|
+
token-only Redis with a couple of config lines.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "redis-cloud-auth"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then add the backend gem for the provider(s) you use (they are loaded lazily and
|
|
28
|
+
are not hard dependencies):
|
|
29
|
+
|
|
30
|
+
- `:azure` → `az-identity`
|
|
31
|
+
- `:aws` ElastiCache → `aws-sigv4`
|
|
32
|
+
- `:gcp` → `googleauth`
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require "redis-client"
|
|
38
|
+
require "redis_cloud_auth"
|
|
39
|
+
|
|
40
|
+
RedisCloudAuth.register! # once at boot (registers the middleware)
|
|
41
|
+
RedisCloudAuth.logger = my_logger # optional; logs re-auth failures
|
|
42
|
+
|
|
43
|
+
descriptor = { provider: :azure, resource: "https://redis.azure.com" }
|
|
44
|
+
|
|
45
|
+
config = RedisClient.config(
|
|
46
|
+
host: "cache.example.com",
|
|
47
|
+
port: 10000,
|
|
48
|
+
ssl: true, # token auth requires TLS
|
|
49
|
+
username: "<identity-id>",
|
|
50
|
+
password: RedisCloudAuth.credential(descriptor), # token at connect time
|
|
51
|
+
custom: { credentials_provider: descriptor } # enables re-auth middleware
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
config.new_client.call("PING")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The **descriptor** is a plain Hash of simple values, so the whole Redis config
|
|
58
|
+
stays marshallable (important for Sidekiq, which `Marshal`s its config). The same
|
|
59
|
+
descriptor goes in both `password` (via `credential`) and
|
|
60
|
+
`custom[:credentials_provider]` (for the middleware).
|
|
61
|
+
|
|
62
|
+
Works the same with `redis-rb` 5+ (which is built on `redis-client`) and Sidekiq
|
|
63
|
+
7+.
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
Two small pieces wire a provider into a connection:
|
|
68
|
+
|
|
69
|
+
- **`RedisCloudAuth.credential(descriptor)`** returns a marshallable callable
|
|
70
|
+
used as the `password`. `redis-client` invokes it on every (re)connect, so
|
|
71
|
+
each new connection authenticates with a current token. It holds only the
|
|
72
|
+
descriptor and resolves the live provider at call time, so it survives
|
|
73
|
+
`Marshal` round-trips.
|
|
74
|
+
- **The re-auth middleware** (`RedisCloudAuth.register!`) re-issues `AUTH` on a
|
|
75
|
+
live connection shortly before its token expires, tracking expiry per
|
|
76
|
+
connection and understanding RESP3 `HELLO ... AUTH` preludes. It is a no-op on
|
|
77
|
+
connections without a `credentials_provider` descriptor.
|
|
78
|
+
|
|
79
|
+
A **provider** is the only cloud-specific part. It is a tiny duck type:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
provider.call # => [username, password] (fresh token each call)
|
|
83
|
+
provider.expires_at # => Time, or nil before the first token is fetched
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Built-in providers
|
|
87
|
+
|
|
88
|
+
| `:provider` | Service | Backend gem | Key descriptor options |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| `:azure` | Azure Managed Redis / Entra | `az-identity` | `:username`, `:resource`, `:environment` |
|
|
91
|
+
| `:elasticache` | AWS ElastiCache IAM | `aws-sigv4` | `:user_id`, `:cache_name`, `:region`, `:credentials` |
|
|
92
|
+
| `:gcp` | GCP Memorystore IAM | `googleauth` | `:username`, `:scope`, `:credentials` |
|
|
93
|
+
|
|
94
|
+
Providers fetch tokens from the ambient workload identity by default (Azure
|
|
95
|
+
Workload Identity, AWS IRSA/Pod Identity, GKE Workload Identity, or the instance
|
|
96
|
+
metadata server), so no key material lives in the descriptor.
|
|
97
|
+
|
|
98
|
+
> All three providers are verified against a live managed Redis: Entra on Azure
|
|
99
|
+
> Managed Redis, IAM on GCP Memorystore for Redis Cluster, and IAM on AWS
|
|
100
|
+
> ElastiCache Serverless.
|
|
101
|
+
>
|
|
102
|
+
> The `:elasticache` provider signs the token with `AWS_ACCESS_KEY_ID` /
|
|
103
|
+
> `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` from the environment, or with a
|
|
104
|
+
> `:credentials` object you pass in the descriptor. On EC2/EKS, point it at the
|
|
105
|
+
> instance/pod role credentials (for example via the AWS SDK credential chain).
|
|
106
|
+
|
|
107
|
+
## Custom providers
|
|
108
|
+
|
|
109
|
+
Register any object that responds to `#call` and `#expires_at`:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
RedisCloudAuth::Registry.register_provider(:myprovider) do |descriptor|
|
|
113
|
+
MyProvider.new(descriptor)
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedisCloudAuth
|
|
4
|
+
module Providers
|
|
5
|
+
# Microsoft Entra provider. Thin adapter over the `az-identity` gem, which
|
|
6
|
+
# already implements the provider interface (#call => [username, token],
|
|
7
|
+
# #expires_at). The username defaults to the managed identity / service
|
|
8
|
+
# principal object (principal) ID from the token's +oid+ claim.
|
|
9
|
+
#
|
|
10
|
+
# Descriptor keys: :username (optional), :resource (for example
|
|
11
|
+
# "https://redis.azure.com"), :environment (optional Azure cloud).
|
|
12
|
+
module Azure
|
|
13
|
+
def self.build(descriptor)
|
|
14
|
+
# Lazily required so az-identity is only pulled in when Azure is used.
|
|
15
|
+
require "az_identity"
|
|
16
|
+
|
|
17
|
+
AzIdentity::RedisCloudAuth.new(
|
|
18
|
+
username: descriptor[:username],
|
|
19
|
+
resource: descriptor[:resource],
|
|
20
|
+
environment: descriptor[:environment]
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedisCloudAuth
|
|
4
|
+
module Providers
|
|
5
|
+
# Amazon ElastiCache IAM provider. Verified live against ElastiCache
|
|
6
|
+
# Serverless (IAM auth + TLS).
|
|
7
|
+
#
|
|
8
|
+
# ElastiCache IAM authentication uses a short-lived "auth token" that is a
|
|
9
|
+
# SigV4-presigned GET URL (service "elasticache") with Action=connect and
|
|
10
|
+
# User=<user-id>, valid ~15 minutes. The token sent as the Redis password is
|
|
11
|
+
# that presigned URL with the scheme stripped; the Redis AUTH username is the
|
|
12
|
+
# ElastiCache user id.
|
|
13
|
+
#
|
|
14
|
+
# Descriptor keys:
|
|
15
|
+
# :user_id ElastiCache IAM-enabled user (also the Redis AUTH username)
|
|
16
|
+
# :cache_name replication group / cluster id (the presign host label)
|
|
17
|
+
# :region AWS region
|
|
18
|
+
# :credentials optional object with #access_key_id/#secret_access_key/
|
|
19
|
+
# #session_token; defaults to the standard AWS env vars
|
|
20
|
+
#
|
|
21
|
+
# Implements the same interface as the Azure provider:
|
|
22
|
+
# #call => [username, token] #expires_at => Time
|
|
23
|
+
class ElastiCache
|
|
24
|
+
TOKEN_TTL = 900 # seconds; ElastiCache IAM tokens are valid for 15 minutes.
|
|
25
|
+
REFRESH_MARGIN = 60
|
|
26
|
+
|
|
27
|
+
def self.build(descriptor)
|
|
28
|
+
new(descriptor)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(descriptor)
|
|
32
|
+
@user_id = descriptor.fetch(:user_id)
|
|
33
|
+
@cache_name = descriptor.fetch(:cache_name)
|
|
34
|
+
@region = descriptor.fetch(:region)
|
|
35
|
+
@credentials = descriptor[:credentials]
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
generate! if @token.nil? || @expires_at.nil? || (@expires_at - REFRESH_MARGIN) <= Time.now
|
|
42
|
+
[@user_id, @token]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expires_at
|
|
47
|
+
@mutex.synchronize { @expires_at }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def generate!
|
|
53
|
+
require "aws-sigv4"
|
|
54
|
+
require "cgi"
|
|
55
|
+
|
|
56
|
+
signer = Aws::Sigv4::Signer.new(
|
|
57
|
+
service: "elasticache",
|
|
58
|
+
region: @region,
|
|
59
|
+
credentials: aws_credentials
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
url = signer.presign_url(
|
|
63
|
+
http_method: "GET",
|
|
64
|
+
url: "https://#{@cache_name}/?Action=connect&User=#{CGI.escape(@user_id)}",
|
|
65
|
+
body: "",
|
|
66
|
+
expires_in: TOKEN_TTL
|
|
67
|
+
).to_s
|
|
68
|
+
|
|
69
|
+
@token = url.sub(%r{\Ahttps?://}, "")
|
|
70
|
+
@expires_at = Time.now + TOKEN_TTL
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def aws_credentials
|
|
74
|
+
return @credentials if @credentials
|
|
75
|
+
|
|
76
|
+
Aws::Sigv4::Credentials.new(
|
|
77
|
+
access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"),
|
|
78
|
+
secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"),
|
|
79
|
+
session_token: ENV.fetch("AWS_SESSION_TOKEN", nil)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedisCloudAuth
|
|
4
|
+
module Providers
|
|
5
|
+
# Google Cloud provider for Memorystore for Redis Cluster / Valkey IAM
|
|
6
|
+
# authentication. The Redis password is a Google OAuth2 access token; the
|
|
7
|
+
# identity needs the `roles/redis.dbConnectionUser` role.
|
|
8
|
+
#
|
|
9
|
+
# Uses Application Default Credentials (the `googleauth` gem), so it picks up
|
|
10
|
+
# GKE Workload Identity, the GCE/GKE metadata server, or a service-account
|
|
11
|
+
# key automatically, with no key material in the descriptor.
|
|
12
|
+
#
|
|
13
|
+
# Descriptor keys:
|
|
14
|
+
# :username Redis AUTH username (defaults to "default"; Memorystore
|
|
15
|
+
# verifies the token and ignores the username, confirmed live:
|
|
16
|
+
# token-only, "default", and the SA email all authenticate)
|
|
17
|
+
# :scope OAuth scope(s) (defaults to cloud-platform)
|
|
18
|
+
# :credentials optional prebuilt googleauth/Signet credentials object
|
|
19
|
+
#
|
|
20
|
+
# Implements the common interface: #call => [username, token],
|
|
21
|
+
# #expires_at => Time.
|
|
22
|
+
#
|
|
23
|
+
# NOTE: Memorystore keeps an already-authenticated connection alive even
|
|
24
|
+
# after its token expires, so for GCP the connect-time token (TokenCredential)
|
|
25
|
+
# is the load-bearing part; the re-auth middleware is harmless but less
|
|
26
|
+
# critical than on providers that drop the connection at expiry.
|
|
27
|
+
class GCP
|
|
28
|
+
DEFAULT_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
|
29
|
+
DEFAULT_USERNAME = "default"
|
|
30
|
+
REFRESH_MARGIN = 60
|
|
31
|
+
|
|
32
|
+
def self.build(descriptor)
|
|
33
|
+
new(descriptor)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(descriptor)
|
|
37
|
+
@username = descriptor[:username] || DEFAULT_USERNAME
|
|
38
|
+
@scope = descriptor[:scope] || DEFAULT_SCOPE
|
|
39
|
+
@credentials = descriptor[:credentials]
|
|
40
|
+
@mutex = Mutex.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
creds = credentials
|
|
46
|
+
creds.fetch_access_token! if creds.access_token.nil? || creds.expires_within?(REFRESH_MARGIN)
|
|
47
|
+
[@username, creds.access_token]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def expires_at
|
|
52
|
+
@mutex.synchronize { @credentials&.expires_at }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def credentials
|
|
58
|
+
@credentials ||= begin
|
|
59
|
+
require "googleauth"
|
|
60
|
+
Google::Auth.get_application_default(Array(@scope))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedisCloudAuth
|
|
4
|
+
# redis-client middleware that proactively re-issues AUTH on a live connection
|
|
5
|
+
# shortly before a rotating access token expires, so pooled connections are
|
|
6
|
+
# refreshed before the server rejects a command. Connect-time authentication
|
|
7
|
+
# is handled by the callable password ({TokenCredential}); this only covers
|
|
8
|
+
# already-established, long-lived connections.
|
|
9
|
+
#
|
|
10
|
+
# The provider caches a single token process-wide and advances its
|
|
11
|
+
# `expires_at` whenever any connection triggers a refresh. Each pooled
|
|
12
|
+
# connection, however, stays authenticated with whatever token it last sent
|
|
13
|
+
# AUTH with. We therefore track expiry per connection (redis-client builds one
|
|
14
|
+
# middleware instance per connection) rather than reading the shared provider
|
|
15
|
+
# expiry, so a connection still holding the old token re-auths instead of
|
|
16
|
+
# being skipped because another connection already refreshed.
|
|
17
|
+
#
|
|
18
|
+
# Register globally via RedisClient.register (see
|
|
19
|
+
# RedisCloudAuth.register!). It is a no-op unless a credentials
|
|
20
|
+
# provider descriptor is present in the connection's `custom` config under
|
|
21
|
+
# :credentials_provider.
|
|
22
|
+
module ReauthMiddleware
|
|
23
|
+
# Re-authenticate at least this many seconds before the token expires.
|
|
24
|
+
# Microsoft recommends sending the new token at least 3 minutes early.
|
|
25
|
+
REAUTHENTICATION_LEEWAY = 180.0
|
|
26
|
+
# Extra random lead time (0..this) added per connection so pooled
|
|
27
|
+
# connections do not all re-authenticate at the same instant.
|
|
28
|
+
REAUTHENTICATION_JITTER = 30.0
|
|
29
|
+
|
|
30
|
+
def call(command, config)
|
|
31
|
+
# Skip when we are issuing our own AUTH below, to avoid recursing.
|
|
32
|
+
reauthenticate(config) unless auth_command?(command)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call_pipelined(commands, config)
|
|
37
|
+
# The connection prelude authenticates itself via the password; when an
|
|
38
|
+
# AUTH is already part of the pipeline, skip re-auth and instead record
|
|
39
|
+
# the expiry of the token this connection just authenticated with.
|
|
40
|
+
if commands.any? { |command| auth_command?(command) }
|
|
41
|
+
result = super
|
|
42
|
+
track_token_expiry(config)
|
|
43
|
+
result
|
|
44
|
+
else
|
|
45
|
+
reauthenticate(config)
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def auth_command?(command)
|
|
53
|
+
name = command.first.to_s
|
|
54
|
+
return true if name.casecmp?("AUTH")
|
|
55
|
+
|
|
56
|
+
# RESP3 connections (for example Sidekiq 7) authenticate in the prelude as
|
|
57
|
+
# `HELLO 3 AUTH <user> <token>`, so treat that as an AUTH too.
|
|
58
|
+
name.casecmp?("HELLO") && command.any? { |arg| arg.to_s.casecmp?("AUTH") }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Per-connection lead time, fixed once so this connection re-authenticates
|
|
62
|
+
# at a stable, slightly randomized point before expiry.
|
|
63
|
+
def reauthentication_leeway
|
|
64
|
+
@reauthentication_leeway ||= REAUTHENTICATION_LEEWAY + (rand * REAUTHENTICATION_JITTER)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reauthenticate(config)
|
|
68
|
+
descriptor = config.custom && config.custom[:credentials_provider]
|
|
69
|
+
return unless descriptor
|
|
70
|
+
|
|
71
|
+
provider = Registry.fetch(descriptor)
|
|
72
|
+
|
|
73
|
+
# Decide from the expiry of the token THIS connection last authed with,
|
|
74
|
+
# falling back to the provider's current expiry before the first AUTH is
|
|
75
|
+
# observed. A provider that exposes no expiry can't tell us when to
|
|
76
|
+
# refresh, so leave that to the reconnect path.
|
|
77
|
+
expires_at = @token_expires_at || provider.expires_at
|
|
78
|
+
return unless expires_at
|
|
79
|
+
return if (expires_at - reauthentication_leeway) > Time.now
|
|
80
|
+
|
|
81
|
+
token = provider.call.last
|
|
82
|
+
return unless token
|
|
83
|
+
|
|
84
|
+
client.call("AUTH", config.username, token)
|
|
85
|
+
@token_expires_at = provider.expires_at
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
# A transient token-refresh (or AUTH) failure inside the leeway window
|
|
88
|
+
# must not fail the command: the current token is still valid until
|
|
89
|
+
# expiry, so log and let the operation proceed on the existing creds.
|
|
90
|
+
log_reauthentication_failure(e)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Log at most once per leeway window per connection. A sustained token
|
|
94
|
+
# endpoint outage would otherwise re-enter this path and log on every Redis
|
|
95
|
+
# command until the token actually expires.
|
|
96
|
+
def log_reauthentication_failure(error)
|
|
97
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
98
|
+
return if @last_failure_log_at && (now - @last_failure_log_at) < REAUTHENTICATION_LEEWAY
|
|
99
|
+
|
|
100
|
+
@last_failure_log_at = now
|
|
101
|
+
RedisCloudAuth.logger&.warn("Redis token re-authentication failed: #{error.message}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Remember the expiry of the token this connection authenticated with during
|
|
105
|
+
# the prelude, so subsequent re-auth decisions track this connection rather
|
|
106
|
+
# than the process-shared provider expiry.
|
|
107
|
+
def track_token_expiry(config)
|
|
108
|
+
descriptor = config.custom && config.custom[:credentials_provider]
|
|
109
|
+
return unless descriptor
|
|
110
|
+
|
|
111
|
+
@token_expires_at = Registry.fetch(descriptor).expires_at
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedisCloudAuth
|
|
4
|
+
# Process-global registry of providers, keyed by a marshallable descriptor
|
|
5
|
+
# (a Hash of simple values). Providers are memoized so every pooled
|
|
6
|
+
# connection and thread shares one provider, which caches the access token
|
|
7
|
+
# and refreshes it as needed.
|
|
8
|
+
#
|
|
9
|
+
# The indirection through a descriptor is deliberate: the descriptor (not the
|
|
10
|
+
# live provider) is embedded in the Redis config, so the config stays
|
|
11
|
+
# marshallable when Sidekiq round-trips it through Marshal.dump/load. The
|
|
12
|
+
# provider itself (holds tokens, does I/O) is resolved here at runtime and
|
|
13
|
+
# never serialized.
|
|
14
|
+
#
|
|
15
|
+
# Built-in providers: +:azure+ (Microsoft Entra, via the az-identity gem),
|
|
16
|
+
# +:elasticache+ (Amazon ElastiCache IAM, via aws-sigv4), and +:gcp+ (Google
|
|
17
|
+
# Memorystore IAM, via googleauth). Register more with {register_provider}.
|
|
18
|
+
module Registry
|
|
19
|
+
MUTEX = Mutex.new
|
|
20
|
+
|
|
21
|
+
@providers = {} # descriptor => live provider
|
|
22
|
+
@builders = {} # provider name (Symbol) => ->(descriptor) { provider }
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Returns the memoized provider for +descriptor+, building it on first
|
|
26
|
+
# use. The middleware calls this on every command on a token-authed
|
|
27
|
+
# connection, so the cache-hit path is lock-free; only the first build
|
|
28
|
+
# per descriptor takes the mutex.
|
|
29
|
+
def fetch(descriptor)
|
|
30
|
+
@providers[descriptor] || MUTEX.synchronize { @providers[descriptor] ||= build(descriptor) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Register a builder for a provider name. The block receives the
|
|
34
|
+
# descriptor Hash and must return an object responding to #call
|
|
35
|
+
# (=> [username, password]) and #expires_at (=> Time or nil).
|
|
36
|
+
def register_provider(name, &builder)
|
|
37
|
+
MUTEX.synchronize { @builders[name.to_sym] = builder }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Inject a prebuilt provider for a descriptor. Intended for tests.
|
|
41
|
+
def store(descriptor, provider)
|
|
42
|
+
MUTEX.synchronize { @providers[descriptor] = provider }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear!
|
|
46
|
+
MUTEX.synchronize { @providers = {} }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build(descriptor)
|
|
52
|
+
name = descriptor[:provider].to_s.to_sym
|
|
53
|
+
builder = @builders[name] || default_builder(name)
|
|
54
|
+
raise Error, "unknown Redis credentials provider: #{descriptor[:provider].inspect}" unless builder
|
|
55
|
+
|
|
56
|
+
builder.call(descriptor)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Built-in providers are required lazily so their (optional) backing gems
|
|
60
|
+
# are only loaded when actually used.
|
|
61
|
+
def default_builder(name)
|
|
62
|
+
case name
|
|
63
|
+
when :azure
|
|
64
|
+
lambda do |descriptor|
|
|
65
|
+
require_relative "providers/azure"
|
|
66
|
+
Providers::Azure.build(descriptor)
|
|
67
|
+
end
|
|
68
|
+
when :elasticache
|
|
69
|
+
lambda do |descriptor|
|
|
70
|
+
require_relative "providers/elasticache"
|
|
71
|
+
Providers::ElastiCache.build(descriptor)
|
|
72
|
+
end
|
|
73
|
+
when :gcp
|
|
74
|
+
lambda do |descriptor|
|
|
75
|
+
require_relative "providers/gcp"
|
|
76
|
+
Providers::GCP.build(descriptor)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RedisCloudAuth
|
|
4
|
+
# A marshallable callable used as the redis-client `password`. redis-client
|
|
5
|
+
# invokes `#call(username)` on every (re)connect, so returning a fresh access
|
|
6
|
+
# token here authenticates new connections with a current token.
|
|
7
|
+
#
|
|
8
|
+
# It holds only the descriptor (simple values) and resolves the shared
|
|
9
|
+
# provider from {Registry} at call time, so it can be Marshal.dump'd by
|
|
10
|
+
# Sidekiq (unlike a Proc or a live provider object).
|
|
11
|
+
class TokenCredential
|
|
12
|
+
def initialize(descriptor)
|
|
13
|
+
@descriptor = descriptor
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(_username = nil)
|
|
17
|
+
Registry.fetch(@descriptor).call.last
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
# redis-client only retries its own connection errors, and failsafes
|
|
20
|
+
# (for example RedisCacheStore) only rescue RedisClient::Error. Map a
|
|
21
|
+
# token-fetch failure to a connection error so a transient token-endpoint
|
|
22
|
+
# outage is retried/handled as a reconnect rather than surfacing as a 500.
|
|
23
|
+
# The original error is kept as the cause.
|
|
24
|
+
require "redis-client"
|
|
25
|
+
raise RedisClient::CannotConnectError, "failed to acquire Redis token: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
require_relative "redis_cloud_auth/version"
|
|
6
|
+
require_relative "redis_cloud_auth/registry"
|
|
7
|
+
require_relative "redis_cloud_auth/token_credential"
|
|
8
|
+
require_relative "redis_cloud_auth/reauth_middleware"
|
|
9
|
+
|
|
10
|
+
# Rotating-token ("credentials provider") authentication for Redis, usable from
|
|
11
|
+
# any redis-client / redis-rb / Sidekiq consumer.
|
|
12
|
+
#
|
|
13
|
+
# A *provider* turns a cloud identity (Microsoft Entra, Amazon ElastiCache IAM,
|
|
14
|
+
# ...) into short-lived Redis credentials. It is a simple duck type:
|
|
15
|
+
#
|
|
16
|
+
# provider.call # => [username, password] (fresh each call)
|
|
17
|
+
# provider.expires_at # => Time, or nil when no token has been fetched yet
|
|
18
|
+
#
|
|
19
|
+
# Two pieces wire a provider into a connection:
|
|
20
|
+
#
|
|
21
|
+
# * {TokenCredential} is a marshallable callable used as the redis-client
|
|
22
|
+
# `password`. It authenticates every (re)connect with a current token and
|
|
23
|
+
# survives Marshal round-trips (Sidekiq), because it holds only a descriptor
|
|
24
|
+
# and resolves the live provider from {Registry} at call time.
|
|
25
|
+
# * {ReauthMiddleware} is a redis-client middleware that re-issues AUTH on a
|
|
26
|
+
# long-lived connection shortly before its token expires.
|
|
27
|
+
#
|
|
28
|
+
# Usage:
|
|
29
|
+
#
|
|
30
|
+
# RedisCloudAuth.register! # once, at boot
|
|
31
|
+
#
|
|
32
|
+
# descriptor = { provider: :azure, resource: "https://redis.azure.com" }
|
|
33
|
+
# RedisClient.config(
|
|
34
|
+
# host: "cache.example.com", port: 10000, ssl: true,
|
|
35
|
+
# username: "<object-id>",
|
|
36
|
+
# password: RedisCloudAuth.credential(descriptor),
|
|
37
|
+
# custom: { credentials_provider: descriptor }
|
|
38
|
+
# )
|
|
39
|
+
module RedisCloudAuth
|
|
40
|
+
class Error < StandardError; end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Logger for re-auth failures (throttled). Defaults to a no-op; consumers
|
|
44
|
+
# (GitLab, mail_room, gitlab-exporter) can inject their own.
|
|
45
|
+
attr_writer :logger
|
|
46
|
+
|
|
47
|
+
def logger
|
|
48
|
+
@logger ||= Logger.new(File::NULL)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The process-global provider registry.
|
|
52
|
+
def registry
|
|
53
|
+
Registry
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Convenience: a marshallable callable password for +descriptor+.
|
|
57
|
+
def credential(descriptor)
|
|
58
|
+
TokenCredential.new(descriptor)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Register the re-auth middleware with redis-client. Call once at boot.
|
|
62
|
+
# It is a no-op on connections whose config has no credentials_provider
|
|
63
|
+
# descriptor in `custom`.
|
|
64
|
+
def register!
|
|
65
|
+
require "redis-client"
|
|
66
|
+
RedisClient.register(ReauthMiddleware)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: redis-cloud-auth
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- GitLab
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: redis-client
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.14'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.14'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: logger
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
description: Pluggable credentials providers (Microsoft Entra, Amazon ElastiCache
|
|
41
|
+
IAM, ...) that authenticate Redis connections with short-lived tokens, plus a redis-client
|
|
42
|
+
middleware that re-authenticates long-lived connections before the token expires.
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/redis_cloud_auth.rb
|
|
50
|
+
- lib/redis_cloud_auth/providers/azure.rb
|
|
51
|
+
- lib/redis_cloud_auth/providers/elasticache.rb
|
|
52
|
+
- lib/redis_cloud_auth/providers/gcp.rb
|
|
53
|
+
- lib/redis_cloud_auth/reauth_middleware.rb
|
|
54
|
+
- lib/redis_cloud_auth/registry.rb
|
|
55
|
+
- lib/redis_cloud_auth/token_credential.rb
|
|
56
|
+
- lib/redis_cloud_auth/version.rb
|
|
57
|
+
homepage: https://gitlab.com/gitlab-org/ruby/gems/redis-cloud-auth
|
|
58
|
+
licenses:
|
|
59
|
+
- MIT
|
|
60
|
+
metadata:
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '3.1'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 4.0.23
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Rotating-token (cloud identity) authentication for Redis via redis-client
|
|
79
|
+
middleware
|
|
80
|
+
test_files: []
|