pgbus 0.2.3 → 0.2.4
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/app/controllers/pgbus/application_controller.rb +9 -0
- data/lib/active_job/queue_adapters/pgbus_adapter.rb +7 -0
- data/lib/pgbus/active_job/executor.rb +1 -1
- data/lib/pgbus/client.rb +1 -1
- data/lib/pgbus/config_loader.rb +1 -1
- data/lib/pgbus/configuration.rb +6 -3
- data/lib/pgbus/event_bus/handler.rb +1 -1
- data/lib/pgbus/process/supervisor.rb +3 -3
- data/lib/pgbus/process/worker.rb +3 -0
- data/lib/pgbus/queue_name_validator.rb +45 -0
- data/lib/pgbus/recurring/config_loader.rb +1 -1
- data/lib/pgbus/serializer.rb +28 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/authentication.rb +20 -1
- data/lib/pgbus/web/data_source.rb +1 -4
- data/lib/pgbus.rb +8 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: daac3606ba66624f54b5b6cf8db0830827c2ce0492b7d6d38ce347d1e0f3aeb2
|
|
4
|
+
data.tar.gz: ad7eb6ea7beae1e07a813cac5cac37980671f8106f2ac7444a127a81d6f7a2e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7a513fd3e2f7aac5a3c0db040a3e7b6b11b6c4c0f619f6f0d37ebbf3e2cd6336737af20e4a7a00da19a1937d5be2b8e24d666483bf661005422c04bbac1805c
|
|
7
|
+
data.tar.gz: 255c0382fbc4f4b797577757ebe5bfa9611f350456292d78e41678d6ebe834c52a5588e2f902159b0c5467becb076f0b724b8dc782b21dfb0d423404dc31d178
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Breaking Changes
|
|
4
|
+
|
|
5
|
+
- **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
|
|
9
|
+
- Add `bundler-audit` to CI for dependency vulnerability scanning
|
|
10
|
+
- Add `QueueNameValidator` to enforce strict queue name validation (alphanumeric + underscores, 61 char max)
|
|
11
|
+
- Add `config.allowed_global_id_models` to restrict which models can be deserialized from event payloads
|
|
12
|
+
- Add security headers to dashboard (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
|
|
13
|
+
- Warn when dashboard `web_auth` is unconfigured
|
|
14
|
+
- Add `globalid` as an explicit runtime dependency (was used but only transitively available via activejob)
|
|
15
|
+
|
|
3
16
|
## [0.1.0] - 2026-03-30
|
|
4
17
|
|
|
5
18
|
- Initial release
|
|
@@ -6,6 +6,7 @@ module Pgbus
|
|
|
6
6
|
|
|
7
7
|
protect_from_forgery with: :exception
|
|
8
8
|
before_action :set_locale
|
|
9
|
+
after_action :set_security_headers
|
|
9
10
|
|
|
10
11
|
layout "pgbus/application"
|
|
11
12
|
|
|
@@ -21,6 +22,14 @@ module Pgbus
|
|
|
21
22
|
|
|
22
23
|
private
|
|
23
24
|
|
|
25
|
+
def set_security_headers
|
|
26
|
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
27
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
28
|
+
response.headers["X-XSS-Protection"] = "0"
|
|
29
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
30
|
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
31
|
+
end
|
|
32
|
+
|
|
24
33
|
def set_locale
|
|
25
34
|
I18n.locale = extract_locale || I18n.default_locale
|
|
26
35
|
end
|
|
@@ -19,6 +19,13 @@ module ActiveJob
|
|
|
19
19
|
true
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Called by ActiveJob::Continuation (Rails 8.1+) at each checkpoint.
|
|
23
|
+
# When true, continuable jobs save their cursor and re-enqueue
|
|
24
|
+
# themselves so the worker can shut down gracefully.
|
|
25
|
+
def stopping?
|
|
26
|
+
Pgbus.stopping
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
private
|
|
23
30
|
|
|
24
31
|
def adapter
|
data/lib/pgbus/client.rb
CHANGED
|
@@ -216,7 +216,7 @@ module Pgbus
|
|
|
216
216
|
|
|
217
217
|
def purge_archive(queue_name, older_than:, batch_size: 1000)
|
|
218
218
|
full_name = config.queue_name(queue_name)
|
|
219
|
-
sanitized =
|
|
219
|
+
sanitized = QueueNameValidator.sanitize!(full_name)
|
|
220
220
|
total = 0
|
|
221
221
|
|
|
222
222
|
sql = "DELETE FROM pgmq.a_#{sanitized} " \
|
data/lib/pgbus/config_loader.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Pgbus
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
def load(path, env: nil)
|
|
11
|
-
env ||= defined?(Rails) ? Rails.env
|
|
11
|
+
env ||= (defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV.fetch("PGBUS_ENV", "development")
|
|
12
12
|
raw = File.read(path)
|
|
13
13
|
parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
|
|
14
14
|
config_hash = parsed.fetch(env, parsed)
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Pgbus
|
|
|
36
36
|
attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size, :outbox_retention
|
|
37
37
|
|
|
38
38
|
# Event bus
|
|
39
|
-
attr_accessor :idempotency_ttl
|
|
39
|
+
attr_accessor :idempotency_ttl, :allowed_global_id_models
|
|
40
40
|
|
|
41
41
|
# Logging
|
|
42
42
|
attr_accessor :logger
|
|
@@ -108,8 +108,9 @@ module Pgbus
|
|
|
108
108
|
@outbox_retention = 24 * 3600 # 1 day
|
|
109
109
|
|
|
110
110
|
@idempotency_ttl = 7 * 24 * 3600 # 7 days
|
|
111
|
+
@allowed_global_id_models = nil # nil = allow all (for backwards compat)
|
|
111
112
|
|
|
112
|
-
@logger = defined?(Rails) ? Rails.logger
|
|
113
|
+
@logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stdout)
|
|
113
114
|
|
|
114
115
|
@listen_notify = true
|
|
115
116
|
@notify_throttle_ms = 250
|
|
@@ -138,7 +139,9 @@ module Pgbus
|
|
|
138
139
|
end
|
|
139
140
|
|
|
140
141
|
def queue_name(name)
|
|
141
|
-
"#{queue_prefix}_#{name}"
|
|
142
|
+
full = "#{queue_prefix}_#{name}"
|
|
143
|
+
QueueNameValidator.validate!(full)
|
|
144
|
+
full
|
|
142
145
|
end
|
|
143
146
|
|
|
144
147
|
def dead_letter_queue_name(name)
|
|
@@ -36,7 +36,7 @@ module Pgbus
|
|
|
36
36
|
|
|
37
37
|
def build_event(raw)
|
|
38
38
|
payload = raw["payload"]
|
|
39
|
-
payload =
|
|
39
|
+
payload = Serializer.locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
|
|
40
40
|
|
|
41
41
|
Event.new(
|
|
42
42
|
event_id: raw["event_id"],
|
|
@@ -143,7 +143,7 @@ module Pgbus
|
|
|
143
143
|
return true if config.recurring_tasks_file && File.exist?(config.recurring_tasks_file.to_s)
|
|
144
144
|
|
|
145
145
|
# Check default location
|
|
146
|
-
if defined?(Rails)
|
|
146
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
147
147
|
default_path = Rails.root.join("config", "recurring.yml")
|
|
148
148
|
return File.exist?(default_path.to_s)
|
|
149
149
|
end
|
|
@@ -155,7 +155,7 @@ module Pgbus
|
|
|
155
155
|
return if config.recurring_tasks&.any?
|
|
156
156
|
|
|
157
157
|
path = config.recurring_tasks_file
|
|
158
|
-
path ||= defined?(Rails) ? Rails.root.join("config", "recurring.yml") : nil
|
|
158
|
+
path ||= defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.join("config", "recurring.yml") : nil
|
|
159
159
|
return unless path && File.exist?(path.to_s)
|
|
160
160
|
|
|
161
161
|
config.recurring_tasks = Recurring::ConfigLoader.load(path)
|
|
@@ -289,7 +289,7 @@ module Pgbus
|
|
|
289
289
|
end
|
|
290
290
|
|
|
291
291
|
def load_rails_app
|
|
292
|
-
return unless defined?(Rails)
|
|
292
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
293
293
|
|
|
294
294
|
Rails.application.eager_load! if Rails.application.respond_to?(:eager_load!)
|
|
295
295
|
end
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -66,12 +66,14 @@ module Pgbus
|
|
|
66
66
|
|
|
67
67
|
def graceful_shutdown
|
|
68
68
|
Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
|
|
69
|
+
Pgbus.stopping = true
|
|
69
70
|
@lifecycle.transition_to(:draining)
|
|
70
71
|
@wake_signal.notify!
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def immediate_shutdown
|
|
74
75
|
Pgbus.logger.warn { "[Pgbus] Worker shutting down immediately!" }
|
|
76
|
+
Pgbus.stopping = true
|
|
75
77
|
@lifecycle.transition_to!(:stopped)
|
|
76
78
|
@wake_signal.notify!
|
|
77
79
|
@pool.kill
|
|
@@ -210,6 +212,7 @@ module Pgbus
|
|
|
210
212
|
def check_recycle
|
|
211
213
|
return unless @lifecycle.running? && recycle_needed?
|
|
212
214
|
|
|
215
|
+
Pgbus.stopping = true
|
|
213
216
|
@lifecycle.transition_to(:draining)
|
|
214
217
|
@wake_signal.notify!
|
|
215
218
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# Validates and sanitizes PGMQ queue names for safe use in SQL identifiers.
|
|
5
|
+
#
|
|
6
|
+
# PGMQ queue names are interpolated into SQL as table/sequence names
|
|
7
|
+
# (e.g., pgmq.q_<name>, pgmq.a_<name>). This module enforces strict
|
|
8
|
+
# validation to prevent SQL injection via crafted queue names.
|
|
9
|
+
module QueueNameValidator
|
|
10
|
+
# PostgreSQL identifier limit is 63 bytes (NAMEDATALEN - 1).
|
|
11
|
+
# PGMQ prefixes with "q_" or "a_" (2 chars), so limit the name itself.
|
|
12
|
+
MAX_QUEUE_NAME_LENGTH = 61
|
|
13
|
+
|
|
14
|
+
# Only alphanumeric characters and underscores are allowed.
|
|
15
|
+
VALID_QUEUE_NAME_PATTERN = /\A[a-zA-Z0-9_]+\z/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Validates a queue name for safe SQL identifier use.
|
|
20
|
+
# Returns the name if valid, raises ArgumentError if not.
|
|
21
|
+
def validate!(name)
|
|
22
|
+
name = name.to_s
|
|
23
|
+
raise ArgumentError, "Queue name cannot be blank" if name.empty?
|
|
24
|
+
if name.length > MAX_QUEUE_NAME_LENGTH
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"Queue name too long (#{name.length} chars, max #{MAX_QUEUE_NAME_LENGTH}): #{name.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless VALID_QUEUE_NAME_PATTERN.match?(name)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"Invalid queue name: #{name.inspect}. Only alphanumeric characters and underscores are allowed."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sanitizes a queue name by removing invalid characters, then validates.
|
|
38
|
+
# Use this for names from untrusted sources (e.g., URL params).
|
|
39
|
+
def sanitize!(name)
|
|
40
|
+
sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, "")
|
|
41
|
+
validate!(sanitized)
|
|
42
|
+
sanitized
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/pgbus/serializer.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Pgbus
|
|
|
41
41
|
data = JSON.parse(json_string)
|
|
42
42
|
payload = data["payload"]
|
|
43
43
|
|
|
44
|
-
data["payload"] =
|
|
44
|
+
data["payload"] = locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
|
|
45
45
|
|
|
46
46
|
Event.new(
|
|
47
47
|
event_id: data["event_id"],
|
|
@@ -49,5 +49,32 @@ module Pgbus
|
|
|
49
49
|
published_at: Time.parse(data["published_at"])
|
|
50
50
|
)
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
# Locate a GlobalID with optional type restriction.
|
|
54
|
+
# When allowed_global_id_models is configured, only those model classes
|
|
55
|
+
# can be resolved — prevents loading arbitrary objects from crafted payloads.
|
|
56
|
+
def locate_global_id(gid_string)
|
|
57
|
+
gid = GlobalID.parse(gid_string)
|
|
58
|
+
raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
|
|
59
|
+
|
|
60
|
+
allowed = Pgbus.configuration.allowed_global_id_models
|
|
61
|
+
if allowed&.empty?
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
|
|
64
|
+
"Set to nil to allow all models, or add permitted classes."
|
|
65
|
+
end
|
|
66
|
+
if allowed&.any? { |entry| !entry.is_a?(Class) && !entry.is_a?(Module) }
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"allowed_global_id_models must contain Class/Module objects, " \
|
|
69
|
+
"got: #{allowed.map(&:class).uniq.join(", ")}"
|
|
70
|
+
end
|
|
71
|
+
if allowed&.none? { |klass| gid.model_class <= klass }
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"GlobalID model #{gid.model_class} is not in allowed_global_id_models. " \
|
|
74
|
+
"Add it to Pgbus.configuration.allowed_global_id_models to permit deserialization."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
GlobalID::Locator.locate(gid)
|
|
78
|
+
end
|
|
52
79
|
end
|
|
53
80
|
end
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -9,16 +9,35 @@ module Pgbus
|
|
|
9
9
|
before_action :authenticate_pgbus!
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :auth_warned
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
private
|
|
13
17
|
|
|
14
18
|
def authenticate_pgbus!
|
|
15
19
|
auth_block = Pgbus.configuration.web_auth
|
|
16
|
-
|
|
20
|
+
|
|
21
|
+
if auth_block.nil?
|
|
22
|
+
warn_unauthenticated_dashboard
|
|
23
|
+
return
|
|
24
|
+
end
|
|
17
25
|
|
|
18
26
|
return if auth_block.respond_to?(:call) && auth_block.call(request)
|
|
19
27
|
|
|
20
28
|
head :unauthorized
|
|
21
29
|
end
|
|
30
|
+
|
|
31
|
+
def warn_unauthenticated_dashboard
|
|
32
|
+
return if Pgbus::Web::Authentication.auth_warned
|
|
33
|
+
|
|
34
|
+
Pgbus.logger.warn do
|
|
35
|
+
"[Pgbus] Dashboard is accessible without authentication. " \
|
|
36
|
+
"Configure Pgbus.configuration.web_auth to restrict access. " \
|
|
37
|
+
"See: https://github.com/mhenrixon/pgbus#dashboard-authentication"
|
|
38
|
+
end
|
|
39
|
+
Pgbus::Web::Authentication.auth_warned = true
|
|
40
|
+
end
|
|
22
41
|
end
|
|
23
42
|
end
|
|
24
43
|
end
|
|
@@ -662,10 +662,7 @@ module Pgbus
|
|
|
662
662
|
end
|
|
663
663
|
|
|
664
664
|
def sanitize_name(name)
|
|
665
|
-
|
|
666
|
-
raise ArgumentError, "Invalid queue name: #{name.inspect}" if sanitized.empty?
|
|
667
|
-
|
|
668
|
-
sanitized
|
|
665
|
+
QueueNameValidator.sanitize!(name)
|
|
669
666
|
end
|
|
670
667
|
|
|
671
668
|
def compute_throughput(queues)
|
data/lib/pgbus.rb
CHANGED
|
@@ -13,6 +13,14 @@ module Pgbus
|
|
|
13
13
|
class SchemaNotReady < Error; end
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
|
+
# Process-global flag set by Worker#graceful_shutdown so the adapter
|
|
17
|
+
# can report stopping? to ActiveJob::Continuation (Rails 8.1+).
|
|
18
|
+
attr_writer :stopping
|
|
19
|
+
|
|
20
|
+
def stopping
|
|
21
|
+
@stopping || false
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def loader
|
|
17
25
|
@loader ||= begin
|
|
18
26
|
loader = Zeitwerk::Loader.for_gem
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgbus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -43,6 +43,20 @@ dependencies:
|
|
|
43
43
|
- - ">="
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: 1.11.1
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: globalid
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '1.0'
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '1.0'
|
|
46
60
|
- !ruby/object:Gem::Dependency
|
|
47
61
|
name: pgmq-ruby
|
|
48
62
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -228,6 +242,7 @@ files:
|
|
|
228
242
|
- lib/pgbus/process/wake_signal.rb
|
|
229
243
|
- lib/pgbus/process/worker.rb
|
|
230
244
|
- lib/pgbus/queue_factory.rb
|
|
245
|
+
- lib/pgbus/queue_name_validator.rb
|
|
231
246
|
- lib/pgbus/rate_counter.rb
|
|
232
247
|
- lib/pgbus/recurring/already_recorded.rb
|
|
233
248
|
- lib/pgbus/recurring/command_job.rb
|