wurk 1.5.0 → 1.7.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.
Files changed (173) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/app/controllers/wurk/api/serializers.rb +51 -1
  4. data/app/controllers/wurk/api_controller.rb +42 -6
  5. data/app/controllers/wurk/dashboard_controller.rb +67 -10
  6. data/config/routes.rb +30 -3
  7. data/exe/wurk +6 -2
  8. data/lib/generators/wurk/install/install_generator.rb +3 -3
  9. data/lib/sidekiq/job_retry.rb +4 -0
  10. data/lib/sidekiq/manager.rb +4 -0
  11. data/lib/sidekiq/processor.rb +4 -0
  12. data/lib/wurk/api/app.rb +186 -0
  13. data/lib/wurk/api/auth.rb +167 -0
  14. data/lib/wurk/api/flows.rb +66 -0
  15. data/lib/wurk/api/idempotency.rb +168 -0
  16. data/lib/wurk/api/jobs.rb +180 -0
  17. data/lib/wurk/api/page.rb +98 -0
  18. data/lib/wurk/api/problem.rb +121 -0
  19. data/lib/wurk/api/queues.rb +126 -0
  20. data/lib/wurk/api/read_only.rb +68 -0
  21. data/lib/wurk/api/request.rb +46 -0
  22. data/lib/wurk/api/response.rb +28 -0
  23. data/lib/wurk/api/roll_up.rb +126 -0
  24. data/lib/wurk/api/router.rb +88 -0
  25. data/lib/wurk/api/serializers.rb +232 -0
  26. data/lib/wurk/api/swarm.rb +297 -0
  27. data/lib/wurk/api/throttle.rb +109 -0
  28. data/lib/wurk/api/validation.rb +306 -0
  29. data/lib/wurk/api.rb +96 -0
  30. data/lib/wurk/batch/server_middleware.rb +2 -2
  31. data/lib/wurk/batch.rb +12 -3
  32. data/lib/wurk/capsule.rb +8 -1
  33. data/lib/wurk/cli.rb +110 -8
  34. data/lib/wurk/client/buffered.rb +1 -1
  35. data/lib/wurk/client.rb +42 -3
  36. data/lib/wurk/collapse.rb +383 -0
  37. data/lib/wurk/command_builder.rb +49 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +16 -3
  40. data/lib/wurk/configuration.rb +303 -4
  41. data/lib/wurk/debounce.rb +125 -0
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/capped.rb +218 -0
  44. data/lib/wurk/fetcher/reliable.rb +22 -66
  45. data/lib/wurk/fetcher/unit_of_work.rb +107 -0
  46. data/lib/wurk/flow/builder.rb +271 -0
  47. data/lib/wurk/flow/chain.rb +42 -0
  48. data/lib/wurk/flow/completion.rb +114 -0
  49. data/lib/wurk/flow/creation.rb +254 -0
  50. data/lib/wurk/flow/node.rb +124 -0
  51. data/lib/wurk/flow/status.rb +206 -0
  52. data/lib/wurk/flow.rb +255 -0
  53. data/lib/wurk/flow_set.rb +53 -0
  54. data/lib/wurk/health.rb +8 -7
  55. data/lib/wurk/heartbeat.rb +21 -3
  56. data/lib/wurk/job/options.rb +11 -1
  57. data/lib/wurk/job.rb +19 -0
  58. data/lib/wurk/job_logger.rb +6 -2
  59. data/lib/wurk/job_retry.rb +6 -1
  60. data/lib/wurk/job_util.rb +110 -9
  61. data/lib/wurk/keys.rb +125 -0
  62. data/lib/wurk/launcher.rb +1 -1
  63. data/lib/wurk/leader.rb +1 -1
  64. data/lib/wurk/limiter/bucket.rb +1 -1
  65. data/lib/wurk/limiter/points.rb +1 -1
  66. data/lib/wurk/lua/debounce.lua +75 -0
  67. data/lib/wurk/lua/fetch_slot.lua +83 -0
  68. data/lib/wurk/lua/flow_abandon.lua +65 -0
  69. data/lib/wurk/lua/flow_advance.lua +177 -0
  70. data/lib/wurk/lua/flow_create.lua +141 -0
  71. data/lib/wurk/lua/flow_fail.lua +52 -0
  72. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  73. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  74. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  75. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  76. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  77. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  78. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  79. data/lib/wurk/lua/limiter_register.lua +27 -0
  80. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  81. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  82. data/lib/wurk/lua/loader.rb +23 -0
  83. data/lib/wurk/lua/queue_slot.lua +83 -0
  84. data/lib/wurk/lua/refresh_slots.lua +38 -0
  85. data/lib/wurk/lua/status_write.lua +29 -0
  86. data/lib/wurk/lua/throttle_slot.lua +71 -0
  87. data/lib/wurk/lua.rb +3 -3
  88. data/lib/wurk/manager.rb +12 -0
  89. data/lib/wurk/metrics/history.rb +34 -2
  90. data/lib/wurk/metrics/query.rb +1 -1
  91. data/lib/wurk/metrics/statsd.rb +14 -0
  92. data/lib/wurk/middleware/expiry.rb +71 -10
  93. data/lib/wurk/middleware/status.rb +274 -0
  94. data/lib/wurk/middleware/timeout.rb +137 -0
  95. data/lib/wurk/pid_cache.rb +43 -0
  96. data/lib/wurk/processor.rb +66 -22
  97. data/lib/wurk/profiler.rb +5 -3
  98. data/lib/wurk/queue_slot.rb +285 -0
  99. data/lib/wurk/rails.rb +3 -3
  100. data/lib/wurk/redis_pool.rb +5 -2
  101. data/lib/wurk/status/progress.rb +103 -0
  102. data/lib/wurk/status/record.rb +81 -0
  103. data/lib/wurk/status.rb +155 -0
  104. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  105. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  106. data/lib/wurk/telemetry.rb +137 -0
  107. data/lib/wurk/throttle.rb +142 -0
  108. data/lib/wurk/unique.rb +10 -2
  109. data/lib/wurk/version.rb +1 -1
  110. data/lib/wurk/watchdog.rb +188 -0
  111. data/lib/wurk/web/config.rb +77 -10
  112. data/lib/wurk/web/extension.rb +12 -4
  113. data/lib/wurk/web/locale_negotiator.rb +67 -0
  114. data/lib/wurk/web.rb +1 -0
  115. data/lib/wurk/worker.rb +39 -4
  116. data/lib/wurk.rb +42 -1
  117. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  118. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  119. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  120. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  121. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  122. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  123. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  124. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  125. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  126. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  127. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  128. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  129. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  130. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  131. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  132. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  133. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  134. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  135. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  136. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  137. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  138. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  139. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  140. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  141. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  142. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  143. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  144. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  145. data/vendor/assets/dashboard/index.html +42 -11
  146. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  147. metadata +102 -33
  148. data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
  149. data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
  150. data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
  151. data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
  152. data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
  153. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
  154. data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
  155. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
  156. data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
  157. data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
  158. data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
  159. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
  160. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
  161. data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
  162. data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
  163. data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
  164. data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
  165. data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
  166. data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
  167. data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
  168. data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
  169. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
  170. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  171. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
  172. data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
  173. data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require_relative '../api'
5
+ require_relative '../version'
6
+ require_relative 'auth'
7
+ require_relative 'flows'
8
+ require_relative 'jobs'
9
+ require_relative 'problem'
10
+ require_relative 'queues'
11
+ require_relative 'read_only'
12
+ require_relative 'request'
13
+ require_relative 'response'
14
+ require_relative 'router'
15
+ require_relative 'swarm'
16
+ require_relative 'throttle'
17
+
18
+ module Wurk
19
+ module API
20
+ # The machine-facing HTTP API. Plain Rack under lib/, with no reference to
21
+ # Rails or the engine, so one implementation serves all three mounts:
22
+ # engine-nested (`<mount>/api/v1`), separately mounted
23
+ # (`mount Wurk::API => '/wurk-api'`), and standalone (`run Wurk::API`).
24
+ #
25
+ # Nothing here hardcodes '/wurk'. Every URL the API emits is built from
26
+ # SCRIPT_NAME (Request#url_for) — the same mount-agnostic rule the SPA shell
27
+ # follows in dashboard_controller.rb.
28
+ class App
29
+ # `config` is read on every request rather than captured, so a token
30
+ # registered after the first request still takes effect. Injectable
31
+ # because a test must be able to hand this app its own token table
32
+ # instead of mutating the process-wide one.
33
+ def initialize(config: nil)
34
+ @config = config
35
+ @router = Router.new
36
+ draw(@router)
37
+ end
38
+
39
+ def call(env)
40
+ request = Request.new(env)
41
+ # Stamped once, before anything reads it, so every handler sees the
42
+ # same config the auth gate did — including a test's injected one.
43
+ request.config = config
44
+ response = handle(request)
45
+ # HEAD is routed as GET, so the handler built a body it must not send.
46
+ request.head? ? [response[0], response[1], []] : response
47
+ end
48
+
49
+ private
50
+
51
+ def config
52
+ @config || ::Wurk.configuration
53
+ end
54
+
55
+ # One table per plane, each owning its own routes and handlers. Queues,
56
+ # Flows and Swarm join Jobs here.
57
+ def draw(router)
58
+ router.get('/', scope: Auth::ANY) { |request| root(request) }
59
+ Jobs.draw(router)
60
+ Queues.draw(router)
61
+ Flows.draw(router)
62
+ Swarm.draw(router)
63
+ end
64
+
65
+ # Authentication runs before the version gate and before routing, so an
66
+ # unauthenticated prober can't enumerate which paths or versions exist
67
+ # from the difference between a 404 and a 405.
68
+ #
69
+ # Then two refusals that are true of the whole deployment rather than of
70
+ # any one route, in cost order. Read-only is a fact this process already
71
+ # holds, so it answers without touching Redis. The throttle needs a
72
+ # credential to charge, which is why it cannot run before authentication —
73
+ # and it charges every authenticated request, routed or not: a client
74
+ # walking paths that don't exist is exactly the traffic a ceiling is for.
75
+ def handle(request)
76
+ config = request.config
77
+ return not_found(request) unless Auth.configured?(config)
78
+
79
+ principal = Auth.authenticate(request, config)
80
+ return Auth.unauthorized(request) unless principal
81
+
82
+ request.principal = principal
83
+ refusal = ReadOnly.refuse(request) || Throttle.refuse(request)
84
+ refusal || route(request)
85
+ rescue StandardError => e
86
+ internal_error(request, e)
87
+ end
88
+
89
+ def route(request)
90
+ path = request.path_info.to_s
91
+ return unsupported_version(request) unless versioned?(path)
92
+
93
+ dispatch(request, path.delete_prefix(VERSION_PREFIX))
94
+ end
95
+
96
+ def dispatch(request, path)
97
+ match = @router.match(request.request_method, path)
98
+ return route_miss(request, match) unless match.handler
99
+ return Auth.forbidden(request, match.scope) unless request.principal.permits?(match.scope)
100
+
101
+ request.path_params = match.params
102
+ match.handler.call(request)
103
+ end
104
+
105
+ # The document a client hits to confirm which mount and which contract it
106
+ # is talking to before it starts guessing paths. It carries the two
107
+ # deployment-wide refusals as well: both are things a producer would
108
+ # otherwise only learn by having a write refused, and a client that can
109
+ # read "this one is frozen" at startup fails in its own logs instead of
110
+ # halfway through a batch.
111
+ def root(request)
112
+ Response.json(
113
+ 200,
114
+ api_version: API_VERSION, wurk_version: ::Wurk::VERSION, url: request.url_for(VERSION_PREFIX),
115
+ read_only: ReadOnly.enabled?(request), rate_limit: rate_limit(request.config)
116
+ )
117
+ end
118
+
119
+ # nil, not an object with nil members: "there is no ceiling here" and
120
+ # "there is one, of unknown size" are different answers.
121
+ def rate_limit(config)
122
+ limit = config.api_rate_limit
123
+ return nil unless limit
124
+
125
+ { limit: limit, interval_seconds: Throttle.interval_seconds(config.api_rate_limit_interval) }
126
+ end
127
+
128
+ def versioned?(path)
129
+ path == VERSION_PREFIX || path.start_with?(NESTED_PREFIX)
130
+ end
131
+
132
+ def route_miss(request, match)
133
+ return not_found(request) if match.allowed.empty?
134
+
135
+ Problem.render(
136
+ Problem::METHOD_NOT_ALLOWED,
137
+ status: 405,
138
+ detail: "#{request.request_method} is not allowed on #{request.path_info}.",
139
+ instance: request.path,
140
+ headers: { 'allow' => allow_header(match.allowed) }
141
+ )
142
+ end
143
+
144
+ # A path that answers GET answers HEAD too, so advertise both.
145
+ def allow_header(verbs)
146
+ verbs = verbs.dup
147
+ verbs << 'HEAD' if verbs.include?('GET')
148
+ verbs.join(', ')
149
+ end
150
+
151
+ def not_found(request)
152
+ Problem.render(
153
+ Problem::NOT_FOUND,
154
+ status: 404,
155
+ detail: "No route matches #{request.request_method} #{request.path_info}.",
156
+ instance: request.path
157
+ )
158
+ end
159
+
160
+ # 404 rather than 400: an unknown version is an address that does not
161
+ # exist, and the client's own request was well-formed.
162
+ def unsupported_version(request)
163
+ Problem.render(
164
+ Problem::UNSUPPORTED_API_VERSION,
165
+ status: 404,
166
+ detail: "The Wurk HTTP API is served under #{VERSION_PREFIX}.",
167
+ instance: request.path,
168
+ supported_versions: SUPPORTED_VERSIONS
169
+ )
170
+ end
171
+
172
+ # The exception details stay in the log. A machine client gets a stable
173
+ # slug it can retry on, never a backtrace naming host internals.
174
+ def internal_error(request, error)
175
+ ::Wurk.logger.error { "Wurk::API #{request.request_method} #{request.path}: #{error.class}: #{error.message}" }
176
+ ::Wurk.logger.debug { Array(error.backtrace).join("\n") }
177
+ Problem.render(
178
+ Problem::INTERNAL_ERROR,
179
+ status: 500,
180
+ detail: 'The request could not be completed.',
181
+ instance: request.path
182
+ )
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'openssl'
5
+ require_relative 'problem'
6
+
7
+ module Wurk
8
+ module API
9
+ # Bearer-token authentication and scope checks for the machine-facing HTTP
10
+ # API. Nothing behind /v1 answers without passing through here.
11
+ #
12
+ # Deliberately a separate plane from the dashboard's. That one is cookie-
13
+ # authenticated and CSRF-guarded by Wurk::SameOriginGuard, which denies any
14
+ # request that doesn't carry `Sec-Fetch-Site: same-origin` — a header the
15
+ # browser sets and a Python or Go producer never sends. Reusing it would
16
+ # mean either 403ing every legitimate machine client or relaxing the one
17
+ # check that stops a cross-site page from driving a logged-in operator's
18
+ # dashboard.
19
+ module Auth
20
+ # `admin` grants the other two. An operator token allowed to stop a
21
+ # process but refused a queue listing would be a surprise, not a
22
+ # safeguard.
23
+ SCOPES = %i[enqueue read admin].freeze
24
+
25
+ # Required scope for a route every authenticated client needs whatever it
26
+ # was granted — the discovery document, which an enqueue-only producer
27
+ # has to read to confirm the contract it is about to post to.
28
+ ANY = :any
29
+
30
+ ROUTE_SCOPES = (SCOPES + [ANY]).freeze
31
+
32
+ # Under 20 characters the host almost certainly typed the token instead of
33
+ # generating one (`SecureRandom.urlsafe_base64(16)` is 22). A guessable
34
+ # token on this API is remote code selection, not just a data leak.
35
+ MIN_TOKEN_LENGTH = 20
36
+
37
+ # Printable ASCII, no spaces — what an Authorization header carries
38
+ # intact. A token that loses a byte in transit fails as "wrong token",
39
+ # the least debuggable 401 there is, so reject the shape up front.
40
+ TOKEN_FORMAT = /\A[\x21-\x7e]+\z/
41
+
42
+ # RFC 6750 §2.1. The scheme is case-insensitive (RFC 9110 §11.1); the
43
+ # credential is not.
44
+ BEARER = /\ABearer[ \t]+([\x21-\x7e]+)[ \t]*\z/i
45
+
46
+ REALM = 'Bearer realm="wurk"'
47
+
48
+ # Domain-separated so the label below is not a digest of the raw token —
49
+ # a value that leaked into a log would otherwise be a free head start on
50
+ # confirming a guessed token offline.
51
+ FINGERPRINT_DOMAIN = 'wurk.api.token.'
52
+
53
+ # 128 bits of the digest. Long enough that two credentials never share a
54
+ # label, short enough to read in a log line.
55
+ FINGERPRINT_LENGTH = 32
56
+
57
+ # What the request proved about itself: the scopes it was granted, and a
58
+ # stable label for the credential that granted them. `id` is a truncated
59
+ # domain-separated digest, never the token — enough to namespace
60
+ # per-credential state (an Idempotency-Key record belongs to the producer
61
+ # that chose it, not to whoever guesses the same string), and nothing a
62
+ # holder could work backwards from.
63
+ Principal = Struct.new(:id, :scopes) do
64
+ def permits?(scope)
65
+ scope == ANY || scopes.include?(scope) || scopes.include?(:admin)
66
+ end
67
+ end
68
+
69
+ module_function
70
+
71
+ # The gate the whole API hangs off. No token registered → the engine
72
+ # never mounts the app, and an app mounted directly answers 404 rather
73
+ # than advertising a surface with nothing behind it.
74
+ def configured?(config)
75
+ !config.api_tokens.empty?
76
+ end
77
+
78
+ # Validates a credential where it is declared, so a typo raises in the
79
+ # initializer that wrote it instead of surfacing as a 401 in production.
80
+ #
81
+ # @return [Array(String, Array<Symbol>)] token value and granted scopes
82
+ def credential!(token, scopes)
83
+ value = token.to_s
84
+ unless value.length >= MIN_TOKEN_LENGTH
85
+ raise ArgumentError, "api_token must be at least #{MIN_TOKEN_LENGTH} characters"
86
+ end
87
+ raise ArgumentError, 'api_token must be printable ASCII with no spaces' unless TOKEN_FORMAT.match?(value)
88
+
89
+ [value, granted!(scopes)]
90
+ end
91
+
92
+ def granted!(scopes)
93
+ granted = Array(scopes).map { |scope| scope.to_s.to_sym }.uniq
94
+ raise ArgumentError, 'api_token requires at least one scope' if granted.empty?
95
+
96
+ unknown = granted - SCOPES
97
+ unless unknown.empty?
98
+ raise ArgumentError, "unknown api_token scope #{unknown.inspect}; valid scopes are #{SCOPES.inspect}"
99
+ end
100
+
101
+ granted.freeze
102
+ end
103
+
104
+ # @return [Principal, nil] nil when no credential was presented, or the
105
+ # one presented matches nothing registered.
106
+ def authenticate(request, config)
107
+ presented = bearer(request.get_header('HTTP_AUTHORIZATION'))
108
+ return nil unless presented
109
+
110
+ token, scopes = lookup(presented, config.api_tokens)
111
+ scopes && Principal.new(fingerprint(token), scopes)
112
+ end
113
+
114
+ def bearer(header)
115
+ match = BEARER.match(header.to_s)
116
+ match && match[1]
117
+ end
118
+
119
+ # Walks every registered token with no early return on a hit, so the
120
+ # clock can't tell a client where in the table its guess landed — or
121
+ # whether it landed at all. Both sides are digested first: OpenSSL's
122
+ # compare demands equal lengths, and the bytesize pre-check the obvious
123
+ # implementation reaches for (Rack::Utils.secure_compare does exactly
124
+ # that) answers "how long is the real token" to anyone who can time this.
125
+ def lookup(presented, tokens)
126
+ digest = ::Digest::SHA256.digest(presented)
127
+ found = nil
128
+ tokens.each do |token, scopes|
129
+ found = [token, scopes] if OpenSSL.fixed_length_secure_compare(digest, ::Digest::SHA256.digest(token))
130
+ end
131
+ found
132
+ end
133
+
134
+ def fingerprint(token)
135
+ ::Digest::SHA256.hexdigest("#{FINGERPRINT_DOMAIN}#{token}")[0, FINGERPRINT_LENGTH]
136
+ end
137
+
138
+ # RFC 6750 §3. The challenge names `invalid_token` only when the client
139
+ # actually presented one — telling a client that sent no credential that
140
+ # its credential was rejected sends it hunting for the wrong bug.
141
+ def unauthorized(request)
142
+ presented = request.get_header('HTTP_AUTHORIZATION')
143
+ Problem.render(
144
+ Problem::UNAUTHORIZED,
145
+ status: 401,
146
+ detail: presented ? 'The bearer token presented is not valid.' : 'A bearer token is required.',
147
+ instance: request.path,
148
+ headers: { 'www-authenticate' => presented ? %(#{REALM}, error="invalid_token") : REALM }
149
+ )
150
+ end
151
+
152
+ # 403, not a 404 hiding the route: the client already authenticated, so
153
+ # concealing the address buys nothing and costs it the one fact it needs
154
+ # — which scope to ask its operator for.
155
+ def forbidden(request, scope)
156
+ Problem.render(
157
+ Problem::INSUFFICIENT_SCOPE,
158
+ status: 403,
159
+ detail: "This token is not granted the #{scope} scope.",
160
+ instance: request.path,
161
+ headers: { 'www-authenticate' => %(#{REALM}, error="insufficient_scope", scope="#{scope}") },
162
+ required_scope: scope
163
+ )
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'problem'
4
+ require_relative 'response'
5
+ require_relative 'serializers'
6
+ require_relative 'validation'
7
+
8
+ module Wurk
9
+ module API
10
+ # One route: read a flow.
11
+ #
12
+ # A producer that posted a graph needs to know how far it got, and a fid is
13
+ # the only thing it holds — so this answers by fid and nothing else. There
14
+ # is no listing: the `flows` index is a dashboard affordance for a human
15
+ # who lost an id, and paging a machine client through every flow in the
16
+ # deployment is not a question a producer has.
17
+ #
18
+ # Nor is there a write. Abandonment is an operator decision made with the
19
+ # graph on screen (slice 11 decision 4), and the dashboard is where that
20
+ # screen is; a producer that could abandon its own flows over HTTP is one
21
+ # bad retry away from killing a run that was merely slow.
22
+ #
23
+ # Reads through {Wurk::Flow::Status}, the same view the dashboard uses, for
24
+ # the reason every other observe-plane route reads through an inspector: a
25
+ # second reader of the flow key schema is a second thing to keep in step
26
+ # with the completion scripts that write it.
27
+ module Flows
28
+ module_function
29
+
30
+ TABLE = [
31
+ [:get, '/flows/:fid', :read, :show]
32
+ ].freeze
33
+
34
+ def draw(router)
35
+ TABLE.each do |verb, pattern, scope, handler|
36
+ router.public_send(verb, pattern, scope: scope) do |request|
37
+ public_send(handler, request)
38
+ rescue Validation::Invalid => e
39
+ Problem.from(e, instance: request.path)
40
+ end
41
+ end
42
+ end
43
+
44
+ def show(request)
45
+ fid = Validation.fid!(request.path_params[:fid])
46
+ status = ::Wurk::Flow::Status.new(fid)
47
+ return not_found(request, fid) unless status.exists?
48
+
49
+ Response.json(200, Serializers.flow(status))
50
+ end
51
+
52
+ # Expiry is named because it is the likeliest answer for a producer whose
53
+ # fid used to work: a flow is kept on the batch clock and then goes, and
54
+ # a client that assumed otherwise is the one reading this.
55
+ def not_found(request, fid)
56
+ Problem.render(
57
+ Problem::FLOW_NOT_FOUND,
58
+ status: 404,
59
+ detail: "No flow has fid #{fid}; it was never created, or it has expired.",
60
+ instance: request.path,
61
+ fid: fid
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative '../keys'
5
+ require_relative 'problem'
6
+ require_relative 'response'
7
+ require_relative 'validation'
8
+
9
+ module Wurk
10
+ module API
11
+ # `Idempotency-Key` replay protection for the produce plane.
12
+ #
13
+ # A producer whose connection drops mid-`POST /jobs` cannot tell a lost
14
+ # request from a lost response, so its only safe move is to send it again —
15
+ # and without this, the retry is a second job. The header turns that retry
16
+ # into a replay: the first request's response comes back verbatim and
17
+ # nothing is enqueued twice.
18
+ #
19
+ # Entirely client-driven. No header means no key, no record and no round
20
+ # trip, so a producer that does not need this pays nothing for it.
21
+ module Idempotency
22
+ HEADER = 'HTTP_IDEMPOTENCY_KEY'
23
+
24
+ # The same shape a bearer token has to survive: printable ASCII, no
25
+ # spaces. A key that loses a byte in transit would silently address a
26
+ # different record, which is the one failure this module exists to
27
+ # prevent.
28
+ KEY_FORMAT = /\A[\x21-\x7e]{1,255}\z/
29
+
30
+ # Marks a replayed response, so a client can tell "your job was enqueued"
31
+ # from "your job was enqueued, earlier".
32
+ REPLAY_HEADER = 'idempotency-replayed'
33
+
34
+ # A record is `<status>\n<body digest>\n<body>`, and status `0` means a
35
+ # claim still in flight. Line-oriented rather than JSON because it is
36
+ # read back under contention: a truncated or hand-poisoned value has to
37
+ # degrade to "still in progress", not to a parser exception on the
38
+ # request path.
39
+ PENDING = 0
40
+
41
+ module_function
42
+
43
+ # Wraps a state-changing handler. Without the header this is a bare call
44
+ # through; with it, exactly one of the requests sharing a key runs the
45
+ # handler and the rest replay its answer.
46
+ #
47
+ # @param raw_body [String] the bytes the response is pinned to, so the
48
+ # same key sent with a different body is a client bug, not a replay.
49
+ def around(request, raw_body, config, &handler)
50
+ key = presented(request)
51
+ return handler.call unless key
52
+
53
+ slot = slot_for(request, key)
54
+ fingerprint = ::Digest::SHA256.hexdigest(raw_body)
55
+ stored = claim(slot, fingerprint, config.api_idempotency_ttl)
56
+ return replay(request, stored, fingerprint) if stored
57
+
58
+ settle(slot, fingerprint, &handler)
59
+ end
60
+
61
+ def presented(request)
62
+ key = request.get_header(HEADER)
63
+ return nil if key.nil?
64
+ return key if KEY_FORMAT.match?(key)
65
+
66
+ raise Validation::Invalid, 'Idempotency-Key must be 1-255 printable ASCII characters with no spaces.'
67
+ end
68
+
69
+ # Scoped to the credential and to the route. The key is a string the
70
+ # client chose: two producers that both sent `1` must not see each
71
+ # other's jids, and the same key on `/jobs` and `/jobs/bulk` addresses two
72
+ # different requests. Hashed rather than concatenated so the client's own
73
+ # string never reaches Redis in the clear and every slot is one length.
74
+ def slot_for(request, key)
75
+ ::Wurk::Keys.idempotency(
76
+ ::Digest::SHA256.hexdigest("#{request.principal.id}\n#{request.path_info}\n#{key}")
77
+ )
78
+ end
79
+
80
+ # One round trip: reserve the key if it is free, and read what is there
81
+ # either way. Deliberately not a MULTI — the SET already picked the
82
+ # winner, and the GET only has to explain the loss.
83
+ #
84
+ # @return [String, nil] nil when this request owns the key; otherwise the
85
+ # record the owner left, or is still to leave. An empty string when the
86
+ # owner's record expired in between, which reads as "in flight" and is
87
+ # the safe answer: it never enqueues a second job.
88
+ def claim(slot, fingerprint, ttl)
89
+ reserved, existing = ::Wurk.redis do |conn|
90
+ conn.pipelined do |pipe|
91
+ pipe.call('SET', slot, encode(PENDING, fingerprint, ''), 'NX', 'EX', ttl)
92
+ pipe.call('GET', slot)
93
+ end
94
+ end
95
+ reserved ? nil : existing.to_s
96
+ end
97
+
98
+ # Only a success is worth replaying. A rejected body is a request the
99
+ # client should be able to correct and send again under the same key, and
100
+ # a raise is a request whose outcome nobody knows — both release the key
101
+ # rather than pinning an answer to it.
102
+ def settle(slot, fingerprint)
103
+ response = yield
104
+ response[0] < 300 ? record(slot, fingerprint, response) : release(slot)
105
+ response
106
+ rescue StandardError
107
+ release(slot)
108
+ raise
109
+ end
110
+
111
+ # KEEPTTL so the window is the first request's, not this write's, and XX
112
+ # so a claim whose TTL lapsed mid-flight is not resurrected as a record
113
+ # with no expiry at all.
114
+ def record(slot, fingerprint, response)
115
+ status, _headers, body = response
116
+ ::Wurk.redis { |conn| conn.call('SET', slot, encode(status, fingerprint, body.join), 'XX', 'KEEPTTL') }
117
+ end
118
+
119
+ def release(slot)
120
+ ::Wurk.redis { |conn| conn.call('DEL', slot) }
121
+ end
122
+
123
+ def replay(request, stored, fingerprint)
124
+ record = decode(stored)
125
+ return in_progress(request) unless record
126
+
127
+ status, digest, body = record
128
+ return reused(request) unless digest == fingerprint
129
+ return in_progress(request) if status == PENDING
130
+
131
+ [status, Response::HEADERS.merge(REPLAY_HEADER => 'true'), [body]]
132
+ end
133
+
134
+ def encode(status, digest, body) = "#{status}\n#{digest}\n#{body}"
135
+
136
+ # @return [Array(Integer, String, String), nil] nil for a record that is
137
+ # gone or unreadable — the caller treats both as still in flight.
138
+ def decode(stored)
139
+ status, digest, body = stored.split("\n", 3)
140
+ return nil if digest.nil? || digest.empty?
141
+
142
+ [status.to_i, digest, body.to_s]
143
+ end
144
+
145
+ # 409 rather than 400: the request is well-formed, and what it collides
146
+ # with is a fact about the server's state that the client can resolve by
147
+ # rotating the key.
148
+ def reused(request)
149
+ Problem.render(
150
+ Problem::IDEMPOTENCY_KEY_REUSED,
151
+ status: 409,
152
+ detail: 'This Idempotency-Key was already used for a different request body.',
153
+ instance: request.path
154
+ )
155
+ end
156
+
157
+ def in_progress(request)
158
+ Problem.render(
159
+ Problem::REQUEST_IN_PROGRESS,
160
+ status: 409,
161
+ detail: 'A request with this Idempotency-Key is still in flight.',
162
+ instance: request.path,
163
+ headers: { 'retry-after' => '1' }
164
+ )
165
+ end
166
+ end
167
+ end
168
+ end