hitch-rails 0.2.0 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +281 -7
  4. data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
  5. data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
  6. data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
  7. data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
  8. data/app/controllers/concerns/hitch/request_admission.rb +22 -0
  9. data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
  10. data/app/controllers/hitch/activations_controller.rb +162 -0
  11. data/app/controllers/hitch/application_controller.rb +30 -0
  12. data/app/controllers/hitch/authorizations_controller.rb +4 -26
  13. data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
  14. data/app/controllers/hitch/metadata_controller.rb +13 -2
  15. data/app/controllers/hitch/registrations_controller.rb +7 -1
  16. data/app/controllers/hitch/revocations_controller.rb +13 -4
  17. data/app/controllers/hitch/tokens_controller.rb +81 -16
  18. data/app/models/hitch/access_token.rb +238 -28
  19. data/app/models/hitch/authorization_request.rb +9 -26
  20. data/app/models/hitch/client.rb +19 -3
  21. data/app/models/hitch/client_authentication.rb +18 -2
  22. data/app/models/hitch/device_activation.rb +113 -0
  23. data/app/models/hitch/device_grant.rb +244 -0
  24. data/app/views/hitch/activations/confirm.html.erb +68 -0
  25. data/app/views/hitch/activations/done.html.erb +18 -0
  26. data/app/views/hitch/activations/new.html.erb +36 -0
  27. data/config/routes.rb +9 -0
  28. data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
  29. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  30. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  31. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  32. data/docs/adr/0006-device-authorization-grant.md +153 -0
  33. data/docs/operator/doctor.md +11 -7
  34. data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
  35. data/docs/upgrading/0.2-to-0.3.md +180 -0
  36. data/docs/upgrading/0.3-to-0.4.md +195 -0
  37. data/lib/generators/hitch/install/templates/initializer.rb +20 -0
  38. data/lib/hitch/configuration.rb +243 -11
  39. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  40. data/lib/hitch/doctor.rb +76 -18
  41. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  42. data/lib/hitch/engine.rb +22 -5
  43. data/lib/hitch/grant_types.rb +23 -0
  44. data/lib/hitch/mcp/configuration.rb +4 -1
  45. data/lib/hitch/rack_form_guard.rb +9 -2
  46. data/lib/hitch/rate_limit_store.rb +103 -5
  47. data/lib/hitch/version.rb +1 -1
  48. data/lib/hitch.rb +2 -0
  49. data/lib/tasks/hitch.rake +8 -1
  50. metadata +21 -3
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # RFC 8628 device authorization grant. Lifecycle:
5
+ #
6
+ # pending — codes minted, awaiting a person on /activate
7
+ # ↓ approve! (binds the signed-in principal) | deny!
8
+ # approved | denied
9
+ # ↓ exchange_device_code! — the polling client's next request
10
+ # consumed — an access token minted through the real exchange path
11
+ #
12
+ # Every transition is one conditional UPDATE whose WHERE clause is the
13
+ # guard, the same discipline as the authorization-code exchange: the
14
+ # database picks exactly one winner and no half-decided state — approved
15
+ # but unowned, consumed twice — is ever representable.
16
+ class DeviceGrant < ApplicationRecord
17
+ self.table_name = "hitch_device_grants"
18
+
19
+ # Crockford base32: RFC 8628 §6.1 wants no easily-confused characters,
20
+ # and this alphabet drops I, L, O, U while its decoding treats the
21
+ # survivors' look-alikes as equal. Eight characters is 40 bits, which
22
+ # together with the per-principal verification quota clears the §5.1
23
+ # brute-force bar with room to spare.
24
+ USER_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
25
+ USER_CODE_LENGTH = 8
26
+ TOKEN_ENDPOINT_AUTH_METHODS = %w[none client_secret_basic].freeze
27
+
28
+ # The token endpoint already rescues AccessToken::OAuthError; a second
29
+ # error type would only mean a second rescue.
30
+ OAuthError = Hitch::AccessToken::OAuthError
31
+
32
+ belongs_to :principal, polymorphic: true, optional: true
33
+
34
+ # Raw codes surface once, in the mint response; only digests are at rest.
35
+ attr_accessor :raw_device_code, :raw_user_code
36
+
37
+ validates :device_code_digest, :user_code_digest, presence: true, on: :create
38
+ validates :token_endpoint_auth_method, inclusion: { in: TOKEN_ENDPOINT_AUTH_METHODS }
39
+
40
+ scope :pending, -> {
41
+ where(approved_at: nil, denied_at: nil, consumed_at: nil)
42
+ .where("expires_at > ?", Time.current)
43
+ }
44
+
45
+ # Scopes are clamped here, at mint — every caller's, not just the
46
+ # endpoint's — so what a grant holds is always grantable and the
47
+ # exchange can honor it verbatim.
48
+ def self.mint!(client_id:, scopes:, resource_uri:, token_endpoint_auth_method: nil)
49
+ granted = Hitch.configuration.clamp_scopes(scopes)
50
+ token_endpoint_auth_method ||= authentication_method_for(client_id)
51
+ attempts = 0
52
+ begin
53
+ # Both codes regenerate on retry, so whichever unique constraint
54
+ # tripped, the next attempt is genuinely fresh.
55
+ raw_device_code = SecureRandom.urlsafe_base64(32)
56
+ raw_user_code = generate_user_code
57
+ # requires_new: the retry below must survive a collision inside a
58
+ # host's open transaction — on PostgreSQL a failed INSERT poisons
59
+ # the enclosing transaction without a savepoint, turning the retry
60
+ # into the very 500 it exists to prevent.
61
+ record = transaction(requires_new: true) do
62
+ create!(
63
+ client_id: client_id,
64
+ device_code_digest: Digest::SHA256.hexdigest(raw_device_code),
65
+ user_code_digest: Digest::SHA256.hexdigest(raw_user_code),
66
+ scopes: granted,
67
+ resource_uri: resource_uri,
68
+ token_endpoint_auth_method: token_endpoint_auth_method,
69
+ expires_at: Hitch.configuration.device_code_lifetime_seconds.seconds.from_now
70
+ )
71
+ end
72
+ rescue ActiveRecord::RecordNotUnique
73
+ # Forty-bit user codes will eventually land on a live pending one.
74
+ # Retrying keeps the collision invisible; giving up would turn an
75
+ # unauthenticated 500 into a collision oracle.
76
+ attempts += 1
77
+ retry if attempts < 3
78
+ raise
79
+ end
80
+ record.raw_device_code = raw_device_code
81
+ record.raw_user_code = raw_user_code
82
+ record
83
+ end
84
+
85
+ # Canonical form: the eight alphabet characters. Strips the punctuation
86
+ # display adds, uppercases, and maps the look-alikes Crockford base32
87
+ # deliberately treats as equal (§6.1 — the user's input is normalized,
88
+ # never rejected for a dash or a lowercase letter).
89
+ def self.normalize_user_code(input)
90
+ input.to_s.upcase.gsub(/[^0-9A-Z]/, "").tr("OIL", "011")
91
+ end
92
+
93
+ # XXXX-XXXX, the form mint responses and screens show. Accepts sloppy
94
+ # input; normalization is a no-op on canonical codes.
95
+ def self.display_user_code(input)
96
+ normalize_user_code(input).scan(/.{1,4}/).join("-")
97
+ end
98
+
99
+ def self.find_pending_by_user_code(input)
100
+ normalized = normalize_user_code(input)
101
+ return nil unless normalized.length == USER_CODE_LENGTH
102
+
103
+ pending.find_by(user_code_digest: Digest::SHA256.hexdigest(normalized))
104
+ end
105
+
106
+ # The decision, its owner, and the user code's erasure land in one
107
+ # statement: a grant is never approved-but-unowned, a second decision
108
+ # finds nothing to decide, and only still-pending grants keep a
109
+ # guessable code alive. Returns false when the code already met a
110
+ # decision, expired, or never existed.
111
+ def self.approve!(user_code:, principal:, client_name: nil)
112
+ raise ArgumentError, "principal must be persisted" unless principal&.persisted?
113
+
114
+ decide!(
115
+ user_code, :approved_at,
116
+ principal_type: principal.class.polymorphic_name,
117
+ principal_id: principal.id,
118
+ client_name: client_name
119
+ )
120
+ end
121
+
122
+ def self.deny!(user_code:)
123
+ decide!(user_code, :denied_at)
124
+ end
125
+
126
+ # The poll (RFC 8628 §3.4). Raises the §3.5 responses as OAuthError so
127
+ # the token endpoint's existing rescue renders them; nil is the generic
128
+ # invalid_grant, deliberately covering "never existed", "already
129
+ # consumed", and "lost the race" alike.
130
+ def self.exchange_device_code!(raw_device_code:, client_id:, resource_uri:, token_endpoint_auth_method:)
131
+ unless Hitch.configuration.device_authorization_enabled
132
+ raise OAuthError.new("unsupported_grant_type", "Device authorization is not enabled")
133
+ end
134
+
135
+ record = find_by(device_code_digest: Digest::SHA256.hexdigest(raw_device_code.to_s))
136
+ return nil unless record
137
+
138
+ unless record.client_id == client_id
139
+ raise OAuthError.new("invalid_grant", "Device code was not issued to this client")
140
+ end
141
+ unless record.token_endpoint_auth_method == token_endpoint_auth_method
142
+ raise ClientAuthentication::Invalid.new(
143
+ "invalid_client", "Client authentication failed", http_status: :unauthorized
144
+ )
145
+ end
146
+ unless record.resource_uri == resource_uri
147
+ raise OAuthError.new("invalid_target", "resource does not match the authorized resource")
148
+ end
149
+
150
+ # Before expiry, deliberately: a person's "no" answers access_denied
151
+ # however old the grant is. expired_token invites the client to start
152
+ # the flow over (§3.5), which is not what the person said.
153
+ if record.denied_at
154
+ raise OAuthError.new("access_denied", "The request was denied")
155
+ end
156
+
157
+ now = Time.current
158
+ if record.expires_at <= now
159
+ raise OAuthError.new("expired_token", "Device code expired")
160
+ end
161
+
162
+ if record.approved_at
163
+ # The approver may since have been deleted by the host. Consent does
164
+ # not outlive its owner; the grant is dead, not the server.
165
+ return nil if record.principal.nil?
166
+
167
+ return consume!(record, now: now)
168
+ end
169
+
170
+ # §3.5: one poll per interval claims the window; the rest hear
171
+ # slow_down. A rejected poll matches zero rows and writes nothing,
172
+ # so it never advances the window — a client polling too fast is
173
+ # throttled to the interval, not locked out. A little grace, scaled
174
+ # so it can never halve the window: a conformant fixed-rate poller's
175
+ # network jitter must not cost it the permanent +5s a slow_down
176
+ # demands (§3.5) — the window bounds load, not guessing.
177
+ interval = Hitch.configuration.device_authorization_interval_seconds
178
+ window_opens = now - interval + [ 0.5, interval / 10.0 ].min
179
+ claimed = where(id: record)
180
+ .where("last_polled_at IS NULL OR last_polled_at <= ?", window_opens)
181
+ .update_all(last_polled_at: now, updated_at: now)
182
+ if claimed == 1
183
+ raise OAuthError.new("authorization_pending", "The authorization request is still pending")
184
+ end
185
+
186
+ raise OAuthError.new("slow_down", "Polling faster than the interval")
187
+ end
188
+
189
+ # Rows are held a day past expiry so the §3.5 answer a polling client
190
+ # hears — expired_token, or access_denied for a denied grant — does
191
+ # not depend on when the host's cleanup job happens to run. Past the
192
+ # floor the row goes: any token issued through it lives on in
193
+ # hitch_access_tokens, which carries the durable audit trail. Same
194
+ # host-scheduled contract as AccessToken.cleanup_expired!.
195
+ def self.cleanup_expired!
196
+ where("expires_at < ?", 1.day.ago).delete_all
197
+ end
198
+
199
+ def self.generate_user_code
200
+ SecureRandom.alphanumeric(USER_CODE_LENGTH, chars: USER_CODE_ALPHABET.chars)
201
+ end
202
+ private_class_method :generate_user_code
203
+
204
+ def self.authentication_method_for(client_id)
205
+ client = Hitch::Client.find_by(client_id: client_id)
206
+ client&.confidential_client? ? "client_secret_basic" : "none"
207
+ end
208
+ private_class_method :authentication_method_for
209
+
210
+ def self.decide!(user_code, decision_column, attributes = {})
211
+ digest = Digest::SHA256.hexdigest(normalize_user_code(user_code))
212
+ now = Time.current
213
+ pending.where(user_code_digest: digest).update_all(
214
+ attributes.merge(decision_column => now, user_code_digest: nil, updated_at: now)
215
+ ) == 1
216
+ end
217
+ private_class_method :decide!
218
+
219
+ # requires_new for the same reason issue! gives: a bare transaction
220
+ # joins any open one, and the consumption must roll back if the token
221
+ # mint fails — a consumed grant with no token is a dead end.
222
+ def self.consume!(record, now:)
223
+ result = nil
224
+ transaction(requires_new: true) do
225
+ consumed = where(id: record.id, consumed_at: nil)
226
+ .where.not(approved_at: nil)
227
+ .update_all(consumed_at: now, device_code_digest: nil, updated_at: now)
228
+ raise ActiveRecord::Rollback unless consumed == 1
229
+
230
+ # The stored, mint-clamped scopes are issued verbatim — consent was
231
+ # evaluated when it was given, and nothing re-litigates it here.
232
+ _, result = Hitch::AccessToken.mint_through_exchange!(
233
+ principal: record.principal,
234
+ client_id: record.client_id,
235
+ client_name: record.client_name.presence || record.client_id,
236
+ resource_uri: record.resource_uri,
237
+ scopes: record.scopes
238
+ )
239
+ end
240
+ result
241
+ end
242
+ private_class_method :consume!
243
+ end
244
+ end
@@ -0,0 +1,68 @@
1
+ <%# Device activation: the decision (RFC 8628 §3.3 / §5.4). The code is
2
+ shown back so the person can confirm it matches what their device
3
+ displays, and the client is named only by what the server verified —
4
+ never by what the client called itself. %>
5
+
6
+ <div class="hitch-rails-activate">
7
+ <h1>Approve this device?</h1>
8
+
9
+ <p class="hitch-rails-activate__code-echo">
10
+ Code: <code><%= @user_code %></code> — check that your device shows
11
+ this same code.
12
+ </p>
13
+
14
+ <% if @activation.unverified? %>
15
+ <p class="hitch-rails-activate__warning">
16
+ <strong>This application could not be verified right now.</strong>
17
+ Its identity document did not resolve, so there is nothing
18
+ trustworthy to show you about who is asking. Approving is disabled;
19
+ the code will expire on its own. If you started this device
20
+ yourself, ask it for a fresh code and try again later.
21
+ </p>
22
+ <% else %>
23
+ <p>
24
+ <strong><%= @activation.display_client_name %></strong>
25
+ <%= "— a client registered by this server's operator —" if @activation.operator_registered? %>
26
+ is asking for access to your <%= @brand_name %> account.
27
+ It will act as you.
28
+ </p>
29
+
30
+ <% if @activation.localhost_only_client? %>
31
+ <p class="hitch-rails-activate__warning">
32
+ <strong>This application runs on someone's own computer.</strong>
33
+ Approve it only if that computer is yours and you started this
34
+ yourself.
35
+ </p>
36
+ <% end %>
37
+
38
+ <% if @activation.scopes.present? %>
39
+ <div class="hitch-rails-activate__scopes">
40
+ <p>It will be granted the following access:</p>
41
+ <ul>
42
+ <% @activation.scopes.each do |scope| %>
43
+ <li><code><%= scope %></code></li>
44
+ <% end %>
45
+ </ul>
46
+ </div>
47
+ <% end %>
48
+
49
+ <p class="hitch-rails-activate__resource">
50
+ Resource: <code><%= @activation.grant.resource_uri %></code>
51
+ </p>
52
+ <% end %>
53
+
54
+ <%= form_with url: activate_path, method: :post, data: { turbo: false } do %>
55
+ <%= hidden_field_tag :user_code, @user_code %>
56
+
57
+ <% unless @activation.unverified? %>
58
+ <button type="submit" name="decision" value="approve"
59
+ class="hitch-rails-activate__approve">
60
+ Approve
61
+ </button>
62
+ <% end %>
63
+ <button type="submit" name="decision" value="deny"
64
+ class="hitch-rails-activate__deny">
65
+ Deny
66
+ </button>
67
+ <% end %>
68
+ </div>
@@ -0,0 +1,18 @@
1
+ <%# Device activation: the outcome. The device learns the same answer on
2
+ its next poll; this page just tells the person they are finished. %>
3
+
4
+ <div class="hitch-rails-activate">
5
+ <% if @decision == :approved %>
6
+ <h1>Device connected</h1>
7
+ <p>
8
+ You can close this page. The device will pick up its access the next
9
+ time it checks in — usually within a few seconds.
10
+ </p>
11
+ <% else %>
12
+ <h1>Request denied</h1>
13
+ <p>
14
+ The device has been told no, and its code no longer works. If this
15
+ was a code someone sent you, you did exactly the right thing.
16
+ </p>
17
+ <% end %>
18
+ </div>
@@ -0,0 +1,36 @@
1
+ <%# Device activation: code entry (RFC 8628 §3.3). Host apps override by
2
+ placing app/views/hitch/activations/new.html.erb in their own tree.
3
+ The host's layout wraps this template automatically.
4
+
5
+ The warning copy below is a security control, not decoration (§5.4):
6
+ the flow's known abuse is a stranger sending someone a code to enter.
7
+ Keep that meaning intact when overriding. %>
8
+
9
+ <div class="hitch-rails-activate">
10
+ <h1>Connect a device</h1>
11
+
12
+ <% if @alert %>
13
+ <p class="hitch-rails-activate__alert"><%= @alert %></p>
14
+ <% end %>
15
+
16
+ <p>
17
+ A device or program you started — a coding agent, a script, a server —
18
+ showed you a short code and pointed you here. Enter the code to see
19
+ what it is asking for.
20
+ </p>
21
+
22
+ <p class="hitch-rails-activate__warning">
23
+ <strong>Only enter a code you asked a device for.</strong>
24
+ If someone sent you this code — in an email, a chat, a phone call —
25
+ stop. Entering it would give <em>their</em> device access to your
26
+ <%= @brand_name %> account.
27
+ </p>
28
+
29
+ <%= form_with url: activate_path, method: :post, data: { turbo: false } do |f| %>
30
+ <%= f.label :user_code, "Code" %>
31
+ <%= f.text_field :user_code, value: @user_code, autocomplete: "off",
32
+ autocapitalize: "characters", spellcheck: false,
33
+ class: "hitch-rails-activate__code" %>
34
+ <button type="submit" class="hitch-rails-activate__continue">Continue</button>
35
+ <% end %>
36
+ </div>
data/config/routes.rb CHANGED
@@ -8,11 +8,20 @@ Hitch::Engine.routes.draw do
8
8
  post "oauth/register", to: "registrations#create", as: :oauth_register
9
9
  post "oauth/revoke", to: "revocations#create", as: :oauth_revoke
10
10
 
11
+ # Device authorization (RFC 8628): the machine leg mints a code pair, the
12
+ # person leg approves it. /activate is deliberately short — a human types
13
+ # it from a screen (§3.3.1). A host route declared before the engine's
14
+ # mount shadows it; the README says so where /activate is documented.
15
+ post "oauth/device_authorization", to: "device_authorizations#create", as: :oauth_device_authorization
16
+ get "activate", to: "activations#new", as: :activate
17
+ post "activate", to: "activations#create"
18
+
11
19
  # Route only real engine endpoints to the dedicated preflight validator.
12
20
  match "oauth/authorize", to: "preflights#show", via: :options, defaults: { target_methods: "GET,POST" }
13
21
  match "oauth/token", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
14
22
  match "oauth/register", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
15
23
  match "oauth/revoke", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
24
+ match "oauth/device_authorization", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
16
25
 
17
26
  # Discovery (RFC 8414 + RFC 9728)
18
27
  get ".well-known/oauth-authorization-server", to: "metadata#show", as: :oauth_authorization_server_metadata
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddHitchRefreshTokens < ActiveRecord::Migration[7.2]
4
+ def change
5
+ change_table :hitch_access_tokens, bulk: true do |t|
6
+ # Same posture as every other secret here: the raw token is returned
7
+ # once and only its SHA256 digest is at rest.
8
+ t.string :refresh_token_digest
9
+ # Idle window. Clamped at mint to the family ceiling, so one comparison
10
+ # answers "still usable" and no caller has to remember the ceiling.
11
+ t.datetime :refresh_expires_at
12
+ # The reuse-detection evidence. A consumed row is what tells a replayed
13
+ # token apart from a first use, so this is the one column cleanup must
14
+ # not collect while the family it belongs to is still live.
15
+ t.datetime :refresh_consumed_at
16
+
17
+ # The lineage. Every rotation inserts a new row carrying the family_id
18
+ # it descended from, so detecting one replay can revoke the whole line
19
+ # in a single statement. Deliberately a grouping key and not a parent
20
+ # pointer: revocation is family-wide, detection is per-digest, and
21
+ # nothing reads the tree. A column is additive later and permanent now.
22
+ t.string :family_id
23
+ # Absolute ceiling, copied unchanged down the lineage. Carried on each
24
+ # row rather than looked up from the root so rotation stays a single
25
+ # write and outlives the root being collected.
26
+ t.datetime :family_expires_at
27
+ end
28
+
29
+ add_index :hitch_access_tokens, :refresh_token_digest,
30
+ unique: true, where: "refresh_token_digest IS NOT NULL"
31
+ add_index :hitch_access_tokens, :family_id, where: "family_id IS NOT NULL"
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateHitchDeviceGrants < ActiveRecord::Migration[7.2]
4
+ def change
5
+ create_table :hitch_device_grants do |t|
6
+ t.string :client_id, null: false
7
+ # Audit only, written at approval. Attacker-controllable in both
8
+ # registration schemes, so nothing displays it as an identity.
9
+ t.string :client_name
10
+
11
+ # Raw codes are returned once at mint; only SHA256 digests are at
12
+ # rest — the same posture as authorization codes. Both nullable on
13
+ # purpose: the user code is erased in the same statement that
14
+ # approves or denies, and the device code in the one that consumes,
15
+ # so only grants still awaiting that step occupy the guessable
16
+ # namespace and its unique index.
17
+ t.string :device_code_digest,
18
+ index: { unique: true, where: "device_code_digest IS NOT NULL" }
19
+ t.string :user_code_digest,
20
+ index: { unique: true, where: "user_code_digest IS NOT NULL" }
21
+
22
+ # Nullable, unlike every other principal column in this schema: a
23
+ # device grant is minted by an unauthenticated machine and owned by
24
+ # nobody until a signed-in person approves it. String-typed for the
25
+ # same lossless UUID/ULID reason as hitch_access_tokens. Unindexed:
26
+ # nothing queries grants by principal — rows live minutes, and the
27
+ # durable per-principal audit trail is hitch_access_tokens.
28
+ t.references :principal, polymorphic: true, type: :string, null: true, index: false
29
+
30
+ t.string :scopes, null: false, default: "mcp"
31
+ t.string :resource_uri, null: false
32
+
33
+ # Indexed for cleanup_expired!, this table's one non-digest query.
34
+ t.datetime :expires_at, null: false, index: true
35
+ # The state machine: pending is all four null. Each is written by a
36
+ # single conditional UPDATE whose WHERE clause is the guard, so a
37
+ # grant can never be half-decided or decided twice.
38
+ t.datetime :approved_at
39
+ t.datetime :denied_at
40
+ t.datetime :consumed_at
41
+ # RFC 8628 §3.5: the poll that claimed the current interval window.
42
+ t.datetime :last_polled_at
43
+
44
+ t.timestamps
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EnforceHitchDeviceGrantState < ActiveRecord::Migration[7.2]
4
+ def up
5
+ # These rows are short-lived, unaudited authorization attempts, and the
6
+ # old schema did not record enough information to reconstruct whether the
7
+ # polling client authenticated. Restarting an in-flight flow is the only
8
+ # fail-closed upgrade; issued access tokens live in their own table.
9
+ execute "DELETE FROM hitch_device_grants"
10
+
11
+ add_column :hitch_device_grants, :token_endpoint_auth_method,
12
+ :string, null: false
13
+
14
+ add_check_constraint :hitch_device_grants,
15
+ "token_endpoint_auth_method IN ('none', 'client_secret_basic')",
16
+ name: "hitch_device_grants_auth_method_check"
17
+ add_check_constraint :hitch_device_grants,
18
+ "NOT (approved_at IS NOT NULL AND denied_at IS NOT NULL)",
19
+ name: "hitch_device_grants_decision_check"
20
+ add_check_constraint :hitch_device_grants, <<~SQL.squish,
21
+ (approved_at IS NULL AND principal_type IS NULL AND principal_id IS NULL)
22
+ OR
23
+ (approved_at IS NOT NULL AND principal_type IS NOT NULL AND principal_id IS NOT NULL)
24
+ SQL
25
+ name: "hitch_device_grants_principal_check"
26
+ add_check_constraint :hitch_device_grants,
27
+ "consumed_at IS NULL OR approved_at IS NOT NULL",
28
+ name: "hitch_device_grants_consumption_check"
29
+ end
30
+
31
+ def down
32
+ remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_consumption_check"
33
+ remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_principal_check"
34
+ remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_decision_check"
35
+ remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_auth_method_check"
36
+ remove_column :hitch_device_grants, :token_endpoint_auth_method
37
+ end
38
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddOperatorRegistrationProvenanceToHitchClients < ActiveRecord::Migration[7.2]
4
+ def up
5
+ # 0.3 stored the token authentication method but not who created the
6
+ # registration, so an existing confidential row might have come from the
7
+ # anonymous DCR endpoint. False is the only honest, fail-closed backfill;
8
+ # the operator can vouch for one by rotating its secret and restating its
9
+ # display name through hitch:clients:rotate_secret.
10
+ add_column :hitch_clients, :operator_registered,
11
+ :boolean, null: false, default: false
12
+ add_check_constraint :hitch_clients,
13
+ "operator_registered = FALSE OR token_endpoint_auth_method = 'client_secret_basic'",
14
+ name: "hitch_clients_operator_registration_check"
15
+ end
16
+
17
+ def down
18
+ remove_check_constraint :hitch_clients, name: "hitch_clients_operator_registration_check"
19
+ remove_column :hitch_clients, :operator_registered
20
+ end
21
+ end