Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/five-hounds-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'hive': minor
---

Added opt-in AWS IAM authentication for ElastiCache Redis connections and Redis Cluster mode
support. When IAM is enabled, services authenticate to Redis using short-lived SigV4 pre-signed
tokens instead of static passwords, with automatic token refresh before expiry.

### New environment variables

| Variable | Service | Description |
| ---------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------- |
| `AWS_REGION` | schema, server, tokens, usage, workflows | Default AWS region for all AWS connections. |
| `REDIS_AWS_IAM_AUTH_ENABLED` | schema, server, tokens, usage, workflows | Set to `1` to enable IAM authentication for Redis. |
| `REDIS_AWS_IAM_CACHE_NAME` | schema, server, tokens, usage, workflows | The ElastiCache Redis cache instance name. Used as the host for the signer. |
| `REDIS_AWS_REGION` | schema, server, tokens, usage, workflows | Optional override for the Redis region (defaults to `AWS_REGION`). |
| `REDIS_CLUSTER_MODE_ENABLED` | schema, server, tokens, usage, workflows | Set to `1` to connect using Redis Cluster mode. |
| `REDIS_USERNAME` | schema, server, tokens, usage, workflows | Optional Redis username for ACL-based authentication (defaults to `default`). |

### To enable

- `REDIS_AWS_IAM_AUTH_ENABLED=1`
- `REDIS_TLS_ENABLED=1` must be set (IAM authentication requires TLS).
- `REDIS_AWS_REGION` or `AWS_REGION` must be set.
- `REDIS_AWS_IAM_CACHE_NAME` set to the name of the cache instance in AWS. This will be used as the hostname for the signer.
- The pod/instance must have AWS credentials available (e.g. IRSA, EKS Pod Identity, instance
profile) with the appropriate ElastiCache IAM permissions.

### Other changes
- Bumping ioredis to `5.10.1`.
1 change: 0 additions & 1 deletion integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
"graphql-sse": "2.6.0",
"graphql-yoga": "5.13.3",
"human-id": "4.1.1",
"ioredis": "5.8.2",
"set-cookie-parser": "2.7.1",
"strip-ansi": "7.1.2",
"tslib": "2.8.1",
Expand Down
22 changes: 17 additions & 5 deletions integration-tests/testkit/seed.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { formatISO, subHours } from 'date-fns';
import { humanId } from 'human-id';
import z from 'zod';
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
import { createPostgresDatabasePool, psql } from '@hive/postgres';
import { createRedisClient, type ServiceLogger } from '@hive/service-common';
import type { Report } from '../../packages/libraries/core/src/client/usage.js';
import { authenticate, userEmail } from './auth';
import {
Expand Down Expand Up @@ -235,15 +234,28 @@ export function initSeed() {
value: 'a894723a5d52a30d73790752b0169835e6f81dd77d2737dba809bee7fde39092',
};

const redis = createRedisClient(
'',
const noop = () => {};
const noopLogger = {
info: noop,
warn: noop,
error: noop,
debug: noop,
child: () => noopLogger,
} as unknown as ServiceLogger;

const redis = await createRedisClient(
{
host: ensureEnv('REDIS_HOST'),
password: ensureEnv('REDIS_PASSWORD'),
port: parseInt(ensureEnv('REDIS_PORT'), 10),
username: undefined,
tlsEnabled: false,
clusterModeEnabled: false,
awsIamAuthEnabled: false,
awsRegion: undefined,
awsIamAuthCacheName: undefined,
},
new NoopLogger(),
{ logger: noopLogger },
);

await redis.set(key, JSON.stringify(challenge));
Expand Down
2 changes: 1 addition & 1 deletion packages/libraries/pubsub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"ioredis": "^5.0.0"
"ioredis": "5.10.1"
},
"dependencies": {
"@graphql-yoga/redis-event-target": "3.0.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/libraries/pubsub/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createPubSub } from 'graphql-yoga';
import { Redis } from 'ioredis';
import type { Redis } from 'ioredis';
import { createRedisEventTarget } from '@graphql-yoga/redis-event-target';
import { bridgeGraphileLogger, type Logger } from './logger.js';
import type { HivePubSub } from './pub-sub.js';
Expand Down
1 change: 0 additions & 1 deletion packages/services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@
"graphql-parse-resolve-info": "4.13.0",
"graphql-scalars": "1.24.2",
"graphql-yoga": "5.13.3",
"ioredis": "5.8.2",
"ioredis-mock": "8.9.0",
"jsonwebtoken": "9.0.3",
"lodash": "4.18.1",
Expand Down
3 changes: 1 addition & 2 deletions packages/services/api/src/create.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { CONTEXT, createApplication, Provider, Scope } from 'graphql-modules';
import { Redis } from 'ioredis';
import { PostgresDatabasePool } from '@hive/postgres';
import { TaskScheduler } from '@hive/workflows/kit';
import { adminModule } from './modules/admin';
Expand Down Expand Up @@ -62,7 +61,7 @@ import { Logger } from './modules/shared/providers/logger';
import { Mutex } from './modules/shared/providers/mutex';
import { PrometheusConfig } from './modules/shared/providers/prometheus-config';
import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub';
import { REDIS_INSTANCE } from './modules/shared/providers/redis';
import { REDIS_INSTANCE, type Redis } from './modules/shared/providers/redis';
import { RedisRateLimiter } from './modules/shared/providers/redis-rate-limiter';
import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config';
import { Storage } from './modules/shared/providers/storage';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Inject, Injectable, Scope } from 'graphql-modules';
import { type Redis } from 'ioredis';
import z from 'zod';
import { Logger } from '../../shared/providers/logger';
import { REDIS_INSTANCE } from '../../shared/providers/redis';
import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis';
import { sha256 } from '../lib/supertokens-at-home/crypto';

@Injectable({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { BentoCache, bentostore } from 'bentocache';
import { memoryDriver } from 'bentocache/build/src/drivers/memory';
import { redisDriver } from 'bentocache/build/src/drivers/redis';
import { Inject, Injectable, Scope } from 'graphql-modules';
import type Redis from 'ioredis';
import { prometheusPlugin } from '@bentocache/plugin-prometheus';
import { PostgresDatabasePool } from '@hive/postgres';
import { AuthorizationPolicyStatement } from '../../auth/lib/authz';
import { Logger } from '../../shared/providers/logger';
import { PrometheusConfig } from '../../shared/providers/prometheus-config';
import { REDIS_INSTANCE } from '../../shared/providers/redis';
import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis';
import {
findById,
OrganizationAccessTokens,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { timingSafeEqual } from 'node:crypto';
import { Inject, Injectable, Scope } from 'graphql-modules';
import { type Redis } from 'ioredis';
import { type FastifyRequest } from '@hive/service-common';
import { sha256 } from '../../auth/lib/supertokens-at-home/crypto';
import { Logger } from './logger';
import { REDIS_INSTANCE } from './redis';
import { REDIS_INSTANCE, type Redis } from './redis';
import { RateLimitConfig } from './tokens';

@Injectable({
Expand Down
64 changes: 12 additions & 52 deletions packages/services/api/src/modules/shared/providers/redis.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,13 @@
import { InjectionToken } from 'graphql-modules';
import type { Redis as RedisInstance, RedisOptions } from 'ioredis';
import Redis from 'ioredis';
import { Logger } from './logger';

export type { RedisInstance as Redis };

export type RedisConfig = Required<Pick<RedisOptions, 'host' | 'port' | 'password'>> & {
tlsEnabled: boolean;
};

export const REDIS_INSTANCE = new InjectionToken<RedisInstance>('REDIS_INSTANCE');

export function createRedisClient(label: string, config: RedisConfig, logger: Logger) {
const redis = new Redis({
host: config.host,
port: config.port,
password: config.password,
retryStrategy(times) {
return Math.min(times * 500, 2000);
},
reconnectOnError(error) {
logger.warn('Redis reconnectOnError (error=%s)', error);
return 1;
},
db: 0,
maxRetriesPerRequest: null,
enableReadyCheck: false,
tls: config.tlsEnabled ? {} : undefined,
});

redis.on('error', err => {
logger.error('Redis connection error (error=%s,label=%s)', err, label);
});

redis.on('connect', () => {
logger.debug('Redis connection established (label=%s)', label);
});

redis.on('ready', () => {
logger.info('Redis connection ready (label=%s)', label);
});

redis.on('close', () => {
logger.info('Redis connection closed (label=%s)', label);
});

redis.on('reconnecting', (timeToReconnect?: number) => {
logger.info('Redis reconnecting in %s (label=%s)', timeToReconnect, label);
});

return redis;
}
import type { Redis, RedisConnectionConfig } from '@hive/service-common';

export type { Redis };
export { createRedisClient } from '@hive/service-common';
export type { RedisConnectionConfig as RedisConfig };

/**
* Dependency Injection token for injecting the Redis client into graphql-modules providers.
* This lives here rather than in @hive/service-common because it depends on
* graphql-modules, which is specific to the API service.
*/
export const REDIS_INSTANCE = new InjectionToken<Redis>('REDIS_INSTANCE');
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { BentoCache, bentostore } from 'bentocache';
import { memoryDriver } from 'bentocache/build/src/drivers/memory';
import { redisDriver } from 'bentocache/build/src/drivers/redis';
import { Inject, Injectable, Scope } from 'graphql-modules';
import type Redis from 'ioredis';
import { prometheusPlugin } from '@bentocache/plugin-prometheus';
import { PostgresDatabasePool } from '@hive/postgres';
import { findTargetById } from '@hive/storage';
import type { Target } from '../../../shared/entities';
import { isUUID } from '../../../shared/is-uuid';
import { PrometheusConfig } from '../../shared/providers/prometheus-config';
import { REDIS_INSTANCE } from '../../shared/providers/redis';
import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis';

/**
* Cache for performant Target lookups.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import { BentoCache, bentostore } from 'bentocache';
import { memoryDriver } from 'bentocache/build/src/drivers/memory';
import { redisDriver } from 'bentocache/build/src/drivers/redis';
import { Inject, Injectable, Scope } from 'graphql-modules';
import type Redis from 'ioredis';
import { prometheusPlugin } from '@bentocache/plugin-prometheus';
import { PostgresDatabasePool } from '@hive/postgres';
import { findTargetBySlug } from '@hive/storage';
import { PrometheusConfig } from '../../shared/providers/prometheus-config';
import { REDIS_INSTANCE } from '../../shared/providers/redis';
import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis';

/**
* Cache for performant Target lookups.
Expand Down
8 changes: 7 additions & 1 deletion packages/services/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ of subschemas. Supports Federation, Schema Stitching and Monolithic Schemas.
| `PORT` | **Yes** | The port on which this service runs. | `6250` |
| `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` |
| `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` |
| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"apollorocks"` |
| `REDIS_PASSWORD` | No | The password of your redis instance. | `"apollorocks"` |
| `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` |
| `REDIS_USERNAME` | No | The username for Redis Access Control List authentication. | `"default"` |
| `REDIS_CLUSTER_MODE_ENABLED` | No | Enable Redis Cluster mode. | `1` (enabled) or `0` (disabled) |
| `AWS_REGION` | No | The global AWS region for the service. Used as the default region for AWS connections. | `us-east-1` |
| `REDIS_AWS_IAM_AUTH_ENABLED` | No | Enable AWS IAM authentication for Redis (requires `AWS_REGION`). | `1` (enabled) or `0` (disabled) |
| `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` |
| `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` |
| `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` |
| `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` |
| `BODY_LIMIT` | No | Maximum payload size in bytes. Defaults to 11 MB. | `11000000` |
Expand Down
1 change: 0 additions & 1 deletion packages/services/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"fastq": "1.19.1",
"got": "14.4.7",
"graphql": "16.9.0",
"ioredis": "5.8.2",
"ioredis-mock": "8.9.0",
"p-timeout": "6.1.4",
"pino-pretty": "11.3.0",
Expand Down
3 changes: 1 addition & 2 deletions packages/services/schema/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createHash } from 'node:crypto';
import stringify from 'fast-json-stable-stringify';
import type { Redis } from 'ioredis';
import { TimeoutError } from 'p-timeout';
import type { ServiceLogger } from '@hive/service-common';
import type { Redis, ServiceLogger } from '@hive/service-common';
import { compositionCacheValueSizeBytes, schemaCompositionCounter } from './metrics';

function createChecksum<TInput>(input: TInput): string {
Expand Down
40 changes: 23 additions & 17 deletions packages/services/schema/src/environment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import zod from 'zod';
import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common';
import {
OpenTelemetryConfigurationModel,
parseRedisConfigFromEnvironment,
resolveServerListenOptions,
} from '@hive/service-common';

const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;

Expand All @@ -19,6 +23,10 @@ const emptyString = <T extends zod.ZodType>(input: T) => {
}, input);
};

function raiseInvariant(reason: string): never {
throw new Error(reason);
}

const EnvironmentModel = zod.object({
PORT: emptyString(NumberFromString().optional()),
SERVER_HOST: emptyString(zod.string().optional()),
Expand All @@ -27,6 +35,7 @@ const EnvironmentModel = zod.object({
ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()),
ENCRYPTION_SECRET: zod.string(),
AWS_REGION: emptyString(zod.string().optional()),
COMPOSITION_WORKER_COUNT: zod.number().min(1).default(4),
COMPOSITION_WORKER_MAX_OLD_GENERATION_SIZE_MB: NumberFromString(1).optional().default(512),
});
Expand Down Expand Up @@ -60,13 +69,6 @@ const SentryModel = zod.union([
}),
]);

const RedisModel = zod.object({
REDIS_HOST: zod.string(),
REDIS_PORT: NumberFromString(),
REDIS_PASSWORD: emptyString(zod.string().optional()),
REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});

const PrometheusModel = zod.object({
PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()),
PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()),
Expand Down Expand Up @@ -97,8 +99,6 @@ const configs = {

sentry: SentryModel.safeParse(process.env),

redis: RedisModel.safeParse(process.env),

prometheus: PrometheusModel.safeParse(process.env),

log: LogModel.safeParse(process.env),
Expand All @@ -118,6 +118,15 @@ for (const config of Object.values(configs)) {
}
}

const redisConfigResult = parseRedisConfigFromEnvironment(
process.env,
configs.base.success ? configs.base.data.AWS_REGION : undefined,
);

if (redisConfigResult.type === 'error') {
environmentErrors.push(...redisConfigResult.errors);
}

if (environmentErrors.length) {
const fullError = environmentErrors.join(`\n`);
console.error('❌ Invalid environment variables:', fullError);
Expand All @@ -133,7 +142,6 @@ function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Out

const base = extractConfig(configs.base);
const sentry = extractConfig(configs.sentry);
const redis = extractConfig(configs.redis);
const prometheus = extractConfig(configs.prometheus);
const log = extractConfig(configs.log);
const requestBroker = extractConfig(configs.requestBroker);
Expand All @@ -156,12 +164,10 @@ export const env = {
enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT,
collectorEndpoint: tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT,
},
redis: {
host: redis.REDIS_HOST,
port: redis.REDIS_PORT,
password: redis.REDIS_PASSWORD ?? '',
tlsEnabled: redis.REDIS_TLS_ENABLED === '1',
},
redis:
redisConfigResult?.type === 'ok'
? redisConfigResult.config
: raiseInvariant('Unreachable: redis config errors are caught above via process.exit(1)'),
sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null,
log: {
level: log.LOG_LEVEL ?? 'info',
Expand Down
Loading
Loading