Skip to content

Security: schovi/activejob-temporal

Security

docs/security.md

Security

This document outlines security practices for the activejob-temporal gem.

Dependency Management

Vulnerability Scanning

We use bundle-audit to automatically check for known vulnerabilities in gem dependencies:

# Local scan
rvm 4.0.3 do bundle-audit check --update

# CI automatically scans on each push/PR

Vulnerabilities are tracked in .github/workflows/ci.yml and block merges.

Updating Dependencies

To update gems safely:

# Update all gems
rvm 4.0.3 do bundle update

# Update specific gem
rvm 4.0.3 do bundle update rails

# Run security check after updating
./tools/security-check.sh

Dependency Constraints

See activejob-temporal.gemspec for version constraints:

spec.add_dependency "activejob", ">= 7.2", "< 9"
spec.add_dependency "activemodel", ">= 7.2", "< 9"
spec.add_dependency "concurrent-ruby", "~> 1.1"
spec.add_dependency "globalid", ">= 0.3"
spec.add_dependency "temporalio", ">= 1.4.0", "< 1.5"

Conservative constraints prevent unpredictable breaking changes. TLS certificate file watching lazy-loads the optional listen gem only when tls_cert_watch is enabled; applications that use file watching should add gem "listen", "~> 3.9" to their own Gemfile.

Code Security

Input Validation and Sanitization

The gem takes the following security measures:

Job Cancellation (Query Injection Prevention)

Public lookup APIs build Temporal search queries using the job class name and job ID. To prevent query injection attacks:

  1. Safe String Validation: Job IDs must be strings, non-blank, valid UTF-8, bounded to 255 characters, and free of control characters
  2. Query Quoting: String values are quoted before query construction, and embedded single quotes are escaped
  3. Early Rejection: Unsafe job ID values raise ArgumentError before any queries are executed
# UUID, custom, and schedule-style job IDs are accepted when safe.
ActiveJob::Temporal.cancel(MyJob, "550e8400-e29b-41d4-a716-446655440000")
ActiveJob::Temporal.cancel(MyJob, "invoice-123")
ActiveJob::Temporal.cancel(MyJob, "ajschwf:daily-report-2026-05-25T20:07:45Z:run-123")

# Unsafe input is rejected with ArgumentError before querying Temporal.
ActiveJob::Temporal.cancel(MyJob, "job\n123")

Rationale: ActiveJob normally generates UUIDs, but this gem also supports custom workflow IDs and recurring schedule IDs. Validation rejects unsafe strings while query quoting protects non-UUID identifiers that contain punctuation such as quotes or colons.

Batch cancellation accepts only its supported search attribute filters (ajClass, ajQueue, ajJobId, ajEnqueuedAt, ajTenantId). String values are quoted before query construction, and ajTenantId must be an integer.

Status inspection and signal/query/update helpers use the same safe job ID validation before building Temporal visibility queries.

General Security Practices

The gem does not:

  • ❌ Build SQL queries from user input (except validated Temporal search queries as above)
  • ❌ Render user input in HTML
  • ❌ Execute shell commands with job arguments

By default, job arguments are serialized through ActiveJob's JSON/GlobalID-compatible serializers and passed through Temporal safely. Opt-in Marshal payload serialization is different: loading a Marshal payload can instantiate Ruby objects, so use it only with trusted Temporal histories and compatible application code.

TLS Certificate Validation and Rotation

When using mTLS with Temporal, store certificate material outside source control and point the worker at secured files:

ActiveJob::Temporal.configure do |config|
  config.tls_cert_path = "/etc/certs/client.pem"
  config.tls_key_path = "/etc/certs/client-key.pem"
  config.tls_server_root_ca_cert_path = "/etc/certs/root-ca.pem"
  config.tls_domain = "temporal.example.com"
  config.tls_cert_watch = true
end

The client certificate and private key paths must be configured together. The worker reads the files when building a Temporal client and can reload without restart when either file changes. Replace certificate files atomically, for example write a new file and rename it into place, so the watcher never observes a partially written PEM.

File watching is controlled by config.tls_cert_watch or ACTIVEJOB_TEMPORAL_TLS_CERT_WATCH=true and requires the optional listen gem in the application bundle. Workers also trap SIGHUP by default for manual reload, which does not require listen:

kill -HUP <worker-pid>

Legacy TEMPORAL_TLS_CERT, TEMPORAL_TLS_KEY, and TEMPORAL_TLS_SERVER_NAME environment variables are still accepted for PEM content, but running processes cannot observe changed environment values. Use file paths for zero-downtime certificate rotation.

Payload Serialization

Job arguments are normalized through ActiveJob before any optional payload encoding is applied:

  1. ActiveJob argument types: Uses ActiveJob::Arguments.serialize
  2. Size limits: Payloads over the configured limit are rejected before enqueue
  3. GlobalID support: References instead of full object serialization
  4. Optional encryption: config.encrypt_payload = true encrypts job execution payloads with AES-256-GCM before sending them to Temporal
# Serialization via GlobalID
MyModel.new(id: 123)
# Serialized as: "gid://app/MyModel/123"

# Full object serialization is not supported.
# That would try to serialize entire object state

The default payload_serializer = :json is the safest and most portable format. :message_pack uses the optional msgpack gem and is intended for applications that want a compact binary representation of the ActiveJob-normalized payload. It still stores a Base64 envelope in Temporal, so measure representative payloads before assuming a size reduction.

payload_serializer = :marshal should only be used when the Temporal namespace, workflow history, writers, and workers are trusted. Ruby Marshal can instantiate Ruby objects during load, and payloads can become unreadable when Ruby or application classes change. Prefer JSON or MessagePack for portability, and enable payload encryption when serialized arguments or execution metadata include sensitive data.

Payload Encryption

Enable payload encryption for jobs that may carry sensitive arguments:

ActiveJob::Temporal.configure do |config|
  config.encrypt_payload = true
  config.encryption_key = {
    id: "2026-05",
    key: ENV.fetch("ACTIVEJOB_TEMPORAL_ENCRYPTION_KEY")
  }
end

Keys must be Base64-encoded 32-byte values, for example SecureRandom.base64(32). Store keys in a secret manager or encrypted environment, not in source control.

The encrypted envelope protects job class, job ID, queue name, serialized arguments, execution counters, and workflow-control fields. New encrypted payloads are bound to the Temporal namespace, workflow ID, and encryption key ID as AES-GCM authenticated data. Plaintext copies of these workflow-control fields remain in the envelope because Temporal workflows must read them during deterministic replay:

  • scheduled_at
  • activity timeout options
  • retry policy metadata
  • per-job Temporal options
  • workflow identity metadata
  • dead letter metadata
  • rate-limit metadata

Payload encryption does not hide all Temporal metadata. Default workflow IDs include job class and job ID, search attributes are plaintext in Temporal visibility APIs, workflow identity metadata and rate-limit keys stay in workflow-control metadata, and dead letter queue entries keep failure and auto-discard metadata plaintext so operators can inspect and triage failed jobs. For privacy-sensitive workloads, configure workflow_id_generator or class-level workflow ID strategies so workflow IDs do not embed sensitive identifiers, and disable or carefully constrain search attributes and custom tags. Do not put secrets in workflow IDs, workflow identity names, queue names, tags, tenant IDs, rate-limit keys, DLQ failure messages, or custom search metadata.

For key rotation, set the new primary key metadata in encryption_key and keep previous key metadata in encryption_old_keys until all workflows encrypted with old keys complete or age out of Temporal history:

ActiveJob::Temporal.configure do |config|
  config.encryption_key = {
    id: "2026-06",
    key: ENV.fetch("ACTIVEJOB_TEMPORAL_ENCRYPTION_KEY")
  }
  config.encryption_old_keys = [
    {
      id: "2026-05",
      key: ENV.fetch("ACTIVEJOB_TEMPORAL_OLD_ENCRYPTION_KEY"),
      decrypt_until: Time.utc(2026, 9, 1)
    }
  ]
end

Version 2 encrypted payloads use encrypted_key_id to select the configured key and do not try every key. Version 1 encrypted payloads from older gem versions do not have key IDs and remain decryptable with Base64 string keys. Removing an old key too early prevents workers from decrypting existing workflow payloads.

If you roll back by setting encrypt_payload = false, keep the keys deployed on workers until all previously encrypted workflows have completed or aged out. The setting only controls encryption of new payloads; encrypted payloads always require a configured key to run.

Logging & Observability

Structured Logging

We use structured JSON logging to avoid leaking sensitive data in plaintext logs.

Logs include:

  • ✅ workflow_id, job_class, job_id, queue, status
  • ✅ audit lifecycle events when config.audit_log = true
  • ✅ failure error_class and SHA256 error_fingerprint
  • ❌ Job arguments (too sensitive)
  • ❌ Job return values (application data)
  • ❌ Raw exception messages or backtraces in audit events
  • ❌ Retry backoff details (implementation detail)

Security Reporting

To report security vulnerabilities privately:

  1. DO NOT open a public GitHub issue
  2. DO email the maintainers with details:
    • Description of vulnerability
    • Affected versions
    • Proof of concept (if possible)
  3. Maintainers will:
    • Confirm and assess severity
    • Release patch within 30 days
    • Credit reporter (if desired)

CI Security Scanning

Automated Checks

Every push and pull request runs:

  • bundle-audit: Scans Gemfile.lock for known CVEs in dependencies
  • Blocks merge if vulnerabilities are found

Local Security Checks

Run security checks locally before committing:

./tools/security-check.sh

This script:

  1. Installs bundle-audit if needed
  2. Updates vulnerability database
  3. Scans dependencies for known issues
  4. Optionally runs Brakeman (if installed)

Dependency Update Strategy

  • Security patches: Apply immediately (e.g., Rails 7.2.1 → 7.2.2)
  • Minor updates: Apply quarterly (e.g., Rspec 3.12 → 3.13)
  • Major updates: Plan carefully (e.g., Rails 7 → 8)

Update Process

  1. Update Gemfile
  2. Run rvm 4.0.3 do bundle update
  3. Run local tests: rvm 4.0.3 do bundle exec rake spec:unit
  4. Run security check: ./tools/security-check.sh
  5. Commit and create PR
  6. Wait for CI to pass
  7. Merge and release

Known Vulnerabilities

Currently: 0 known vulnerabilities

Last scanned: [Auto-updated in CI]

Related

There aren't any published security advisories