katalyst-koi 5.8.3 → 5.9.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/koi/admin.css +1 -0
  3. data/app/assets/stylesheets/koi/blocks/clipboard.css +43 -16
  4. data/app/assets/stylesheets/koi/blocks/index.css +1 -0
  5. data/app/controllers/admin/admin_roles_controller.rb +49 -0
  6. data/app/controllers/admin/background_jobs_controller.rb +13 -2
  7. data/app/controllers/admin/device_authorizations_controller.rb +1 -1
  8. data/app/controllers/admin/recurring_tasks_controller.rb +1 -2
  9. data/app/controllers/admin/sessions/tokens_controller.rb +45 -0
  10. data/app/controllers/admin/tokens_controller.rb +25 -26
  11. data/app/controllers/concerns/koi/controller.rb +22 -0
  12. data/app/models/admin/device_authorization.rb +66 -18
  13. data/app/models/admin/role.rb +41 -0
  14. data/app/models/background_job.rb +10 -1
  15. data/app/models/koi/current.rb +10 -0
  16. data/app/models/koi/identity/assertion.rb +78 -0
  17. data/app/models/koi/identity/member.rb +30 -0
  18. data/app/models/koi/identity/principal.rb +40 -0
  19. data/app/models/koi/identity/provider.rb +151 -0
  20. data/app/models/koi/identity.rb +70 -0
  21. data/app/models/recurring_task.rb +16 -0
  22. data/app/views/admin/admin_roles/index.html.erb +14 -0
  23. data/app/views/admin/admin_roles/show.html.erb +41 -0
  24. data/app/views/admin/{tokens → sessions/tokens}/create.turbo_stream.erb +2 -2
  25. data/config/routes.rb +5 -3
  26. data/db/migrate/20260721000001_create_admin_roles.rb +12 -0
  27. data/db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb +25 -0
  28. data/db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb +7 -0
  29. data/lib/koi/config.rb +32 -0
  30. data/lib/koi/engine.rb +12 -0
  31. data/lib/koi/extensions/object_rendering.rb +11 -11
  32. data/lib/koi/middleware/admin_authentication.rb +2 -2
  33. data/spec/factories/admin_device_authorizations.rb +18 -0
  34. data/spec/factories/admin_roles.rb +7 -0
  35. metadata +33 -5
  36. data/app/controllers/admin/device_tokens_controller.rb +0 -18
  37. /data/app/views/admin/{tokens → sessions/tokens}/show.html.erb +0 -0
@@ -47,11 +47,23 @@ module Admin
47
47
 
48
48
  def render_state(state)
49
49
  states = BackgroundJob.states
50
- @collection = Collection.with_params(params).apply(states.fetch(state).strict_loading)
50
+ collection = Collection.new(sorting: default_sort(state))
51
+ @collection = collection.with_params(params).apply(states.fetch(state).strict_loading)
51
52
 
52
53
  render locals: { collection:, state_counts: states.transform_values(&:count) }
53
54
  end
54
55
 
56
+ def default_sort(state)
57
+ case state
58
+ when :completed
59
+ "finished_at desc"
60
+ when :failed
61
+ "updated_at desc"
62
+ else
63
+ "scheduled_at desc"
64
+ end
65
+ end
66
+
55
67
  def set_background_job
56
68
  job = SolidQueue::Job.find_by!(active_job_id: params.expect(:active_job_id))
57
69
  @background_job = BackgroundJob.new(job)
@@ -62,7 +74,6 @@ module Admin
62
74
 
63
75
  attribute :class_name, :string
64
76
  attribute :queue_name, :string
65
- attribute :scheduled_at, :date
66
77
  end
67
78
  end
68
79
  end
@@ -19,7 +19,7 @@ module Admin
19
19
  end
20
20
 
21
21
  def create
22
- device_authorization, device_code = Admin::DeviceAuthorization.issue!(
22
+ device_authorization, device_code = Admin::DeviceAuthorization.create_request!(
23
23
  requested_ip: request.remote_ip,
24
24
  user_agent: request.user_agent,
25
25
  )
@@ -7,7 +7,7 @@ module Admin
7
7
  attr_reader :recurring_task
8
8
 
9
9
  def index
10
- collection = Collection.with_params(params).apply(SolidQueue::RecurringTask.all)
10
+ collection = Collection.with_params(params).apply(RecurringTask.scope)
11
11
 
12
12
  render locals: { collection: }
13
13
  end
@@ -43,7 +43,6 @@ module Admin
43
43
 
44
44
  attribute :key, :string
45
45
  attribute :schedule, :string
46
- attribute :last_enqueued_time, :date
47
46
  end
48
47
 
49
48
  class JobsCollection < Admin::Collection
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module Sessions
5
+ class TokensController < ApplicationController
6
+ include Koi::Controller::RecordsAuthentication
7
+
8
+ before_action :set_admin_user, only: %i[create]
9
+
10
+ attr_reader :admin_user
11
+
12
+ def show
13
+ if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
14
+ render locals: { admin_user:, token: params[:token] }
15
+ else
16
+ redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
17
+ end
18
+ end
19
+
20
+ def create
21
+ render locals: { token: admin_user.generate_token_for(:password_reset) }
22
+ end
23
+
24
+ def update
25
+ if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
26
+ create_admin_session!(admin_user)
27
+
28
+ if admin_user.credentials.any?
29
+ redirect_to(admin_root_path, status: :see_other)
30
+ else
31
+ redirect_to(new_admin_profile_credential_path, status: :see_other)
32
+ end
33
+ else
34
+ redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def set_admin_user
41
+ @admin_user = Admin::User.find(params.expect(:admin_user_id))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,42 +2,41 @@
2
2
 
3
3
  module Admin
4
4
  class TokensController < ApplicationController
5
- include Koi::Controller::RecordsAuthentication
5
+ DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device-code"
6
+ JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
6
7
 
7
- before_action :set_admin_user, only: %i[create]
8
+ rate_limit to: 20, within: 1.minute, only: :create
9
+ skip_before_action :verify_authenticity_token, only: :create
8
10
 
9
- attr_reader :admin_user
10
-
11
- def show
12
- if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
13
- render locals: { admin_user:, token: params[:token] }
11
+ def create
12
+ case params[:grant_type]
13
+ when DEVICE_CODE_GRANT_TYPE
14
+ authorize_device_code
15
+ when JWT_BEARER_GRANT_TYPE
16
+ authorize_bearer_token
14
17
  else
15
- redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
18
+ render(json: { error: "invalid_request" }, status: :bad_request)
16
19
  end
17
20
  end
18
21
 
19
- def create
20
- render locals: { token: admin_user.generate_token_for(:password_reset) }
21
- end
22
-
23
- def update
24
- if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
25
- create_admin_session!(admin_user)
22
+ private
26
23
 
27
- if admin_user.credentials.any?
28
- redirect_to(admin_root_path, status: :see_other)
29
- else
30
- redirect_to(new_admin_profile_credential_path, status: :see_other)
31
- end
32
- else
33
- redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
34
- end
24
+ def authorize_device_code
25
+ render json: Admin::DeviceAuthorization.consume_request!(device_code: params[:device_code])
26
+ rescue Admin::DeviceAuthorization::TokenError => e
27
+ render json: { error: e.code }, status: :bad_request
35
28
  end
36
29
 
37
- private
30
+ def authorize_bearer_token
31
+ assertion = Koi::Identity.authorize_bearer_token!(params[:assertion], audience: "#{request.base_url}/admin")
38
32
 
39
- def set_admin_user
40
- @admin_user = Admin::User.find(params.expect(:admin_user_id))
33
+ render json: Admin::DeviceAuthorization.issue_token!(
34
+ principal: assertion.principal,
35
+ requested_ip: request.remote_ip,
36
+ user_agent: request.user_agent,
37
+ )
38
+ rescue JWT::DecodeError
39
+ render json: { error: "invalid_grant" }, status: :bad_request
41
40
  end
42
41
  end
43
42
  end
@@ -39,6 +39,8 @@ module Koi
39
39
 
40
40
  protect_from_forgery with: :exception
41
41
  skip_forgery_protection if: :bearer_token_request?
42
+
43
+ after_action :set_release_headers
42
44
  end
43
45
 
44
46
  private
@@ -46,5 +48,25 @@ module Koi
46
48
  def bearer_token_request?
47
49
  request.authorization.to_s.match?(/\ABearer .+\z/)
48
50
  end
51
+
52
+ # HTML responses carry the release in meta tags (see Koi::Release.meta_tags);
53
+ # JSON consumers get the same information as response headers.
54
+ def set_release_headers
55
+ return unless response.media_type == "application/json"
56
+
57
+ response.set_header("X-Application-Version", Koi::Release.version)
58
+ response.set_header("X-Application-Revision", Koi::Release.revision)
59
+ end
60
+
61
+ # Surface the grant's stored principal on the request's process_action
62
+ # payload so structured loggers attribute machine requests to the
63
+ # verified identity that minted the token.
64
+ def append_info_to_payload(payload)
65
+ super
66
+
67
+ if (principal = Koi::Current.principal)
68
+ payload[:principal] = { provider: principal.provider, subject: principal.subject }
69
+ end
70
+ end
49
71
  end
50
72
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Admin
4
4
  class DeviceAuthorization < ApplicationRecord
5
- EXPIRES_IN = 10.minutes
5
+ REQUEST_EXPIRES_IN = 10.minutes
6
+ TOKEN_EXPIRES_IN = 1.hour
6
7
 
7
8
  class TokenError < StandardError
8
9
  attr_reader :code
@@ -17,12 +18,17 @@ module Admin
17
18
 
18
19
  enum :status, %w[pending approved denied consumed].index_with(&:to_s)
19
20
 
20
- generates_token_for(:api_access, expires_in: 12.hours) { admin_user&.last_sign_in_at }
21
+ generates_token_for(:api_access, expires_in: TOKEN_EXPIRES_IN) do
22
+ admin_user&.last_sign_in_at || admin_role&.tokens_revoked_at
23
+ end
21
24
 
22
- validates :device_code_digest, presence: true, uniqueness: true
23
- validates :request_expires_at, presence: true
24
25
  validates :status, presence: true, inclusion: { in: statuses.values }
25
- validates :user_code, presence: true, uniqueness: true
26
+
27
+ with_options(if: :pending?) do
28
+ validates :device_code_digest, presence: true, uniqueness: true
29
+ validates :user_code, presence: true, uniqueness: true
30
+ validates :request_expires_at, presence: true
31
+ end
26
32
 
27
33
  belongs_to :admin_user,
28
34
  class_name: "Admin::User",
@@ -30,13 +36,23 @@ module Admin
30
36
  inverse_of: :device_authorizations,
31
37
  optional: true
32
38
 
33
- def self.issue!(requested_ip:, user_agent:)
39
+ belongs_to :admin_role,
40
+ class_name: "Admin::Role",
41
+ inverse_of: :device_authorizations,
42
+ optional: true
43
+
44
+ # Snapshot of the verified principal, as captured by `issue_token!`.
45
+ serialize :principal, coder: Koi::Identity::Principal
46
+ attr_readonly :principal
47
+
48
+ # Creates a new un-approved request.
49
+ def self.create_request!(requested_ip:, user_agent:)
34
50
  device_code = SecureRandom.urlsafe_base64(32)
35
51
 
36
52
  device_authorization = create!(
37
53
  device_code_digest: digest(device_code),
38
54
  user_code: generate_user_code,
39
- request_expires_at: EXPIRES_IN.from_now,
55
+ request_expires_at: REQUEST_EXPIRES_IN.from_now,
40
56
  requested_ip:,
41
57
  user_agent:,
42
58
  )
@@ -52,7 +68,8 @@ module Admin
52
68
  "#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}"
53
69
  end
54
70
 
55
- def self.issue_access_token!(device_code:, token_expires_in: 12.hours)
71
+ # Consume an approved request, returns the issued token payload.
72
+ def self.consume_request!(device_code:)
56
73
  device_authorization = find_by(device_code_digest: digest(device_code.to_s))
57
74
  raise TokenError.new("invalid_grant") unless device_authorization
58
75
 
@@ -63,19 +80,38 @@ module Admin
63
80
  raise TokenError.new(error)
64
81
  end
65
82
 
66
- access_token = device_authorization.generate_token_for(:api_access)
67
- device_authorization.consume!(token_expires_in:)
83
+ device_authorization.consume!
84
+ end
85
+ end
68
86
 
69
- {
70
- access_token:,
71
- token_type: "Bearer",
72
- expires_in: token_expires_in.to_i,
73
- }
87
+ # Issue a new token directly from a validated assertion, returns the issued token payload.
88
+ def self.issue_token!(principal:, **)
89
+ case principal.scope
90
+ when "admin/user"
91
+ admin_user = Admin::User.find_by(principal.attributes_for_find)
92
+
93
+ unless admin_user
94
+ Rails.logger.warn("Koi::Identity rejected #{principal}: no matching admin user")
95
+ raise JWT::VerificationError, "unknown user for #{principal}"
96
+ end
97
+
98
+ new(admin_user:, principal:, **).consume!
99
+ when %r{\Aadmin/role/(?<slug>[a-z0-9_]+)\z}
100
+ admin_role = Admin::Role.materialize(Regexp.last_match(:slug))
101
+
102
+ new(admin_role:, principal:, **).consume!
103
+ else
104
+ raise ArgumentError, "unknown scope #{principal.scope.inspect}"
74
105
  end
75
106
  end
76
107
 
108
+ # @return [Admin::User, Admin::Role, nil]
109
+ def actor
110
+ admin_user || admin_role
111
+ end
112
+
77
113
  def expired?
78
- request_expires_at <= Time.current
114
+ request_expires_at.present? && request_expires_at <= Time.current
79
115
  end
80
116
 
81
117
  def issuable?
@@ -89,12 +125,14 @@ module Admin
89
125
  "invalid_grant" if consumed? || expired?
90
126
  end
91
127
 
92
- def consume!(token_expires_in:)
128
+ def consume!
93
129
  update!(
94
130
  status: "consumed",
95
131
  consumed_at: Time.current,
96
- token_expires_at: token_expires_in.from_now,
132
+ token_expires_at: TOKEN_EXPIRES_IN.from_now,
97
133
  )
134
+
135
+ token_payload
98
136
  end
99
137
 
100
138
  def approve!(admin_user:)
@@ -115,5 +153,15 @@ module Admin
115
153
  def actionable?
116
154
  pending? && !expired?
117
155
  end
156
+
157
+ private
158
+
159
+ def token_payload(access_token = generate_token_for(:api_access))
160
+ {
161
+ access_token:,
162
+ token_type: "Bearer",
163
+ expires_in: (token_expires_at - Time.current).round,
164
+ }
165
+ end
118
166
  end
119
167
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ # A machine actor, assumable only via an identity member's grant — nothing
5
+ # signs in as a role. Config declares roles (a role exists when a member's
6
+ # scope names it); the row is the stable identity that grants and audit
7
+ # records reference, materialized on first use and never deleted.
8
+ class Role < ApplicationRecord
9
+ self.table_name = :admin_roles
10
+
11
+ validates :slug, presence: true
12
+
13
+ has_many :device_authorizations,
14
+ class_name: "Admin::DeviceAuthorization",
15
+ foreign_key: :admin_role_id,
16
+ inverse_of: :admin_role,
17
+ dependent: :destroy
18
+
19
+ def self.materialize(slug)
20
+ create_or_find_by!(slug:)
21
+ end
22
+
23
+ # Config is authoritative for grants: a row whose slug no longer appears
24
+ # in any member's scope is not assumable, but remains for attribution.
25
+ def orphaned?
26
+ Koi::Identity.role_slugs.exclude?(slug)
27
+ end
28
+
29
+ # Members granting this role in the current trust config.
30
+ def members
31
+ Koi::Identity.members.select { |member| member.role_slug == slug }
32
+ end
33
+
34
+ # Roles authenticate by exchanging an assertion for a token; requests
35
+ # made with the token never touch this row, so issuance is the freshest
36
+ # signal available.
37
+ def last_authenticated_at
38
+ device_authorizations.maximum(:consumed_at)
39
+ end
40
+ end
41
+ end
@@ -3,6 +3,15 @@
3
3
  class BackgroundJob
4
4
  include ActiveModel::Model
5
5
 
6
+ module Scopes
7
+ def admin_search(query)
8
+ where(
9
+ "solid_queue_jobs.class_name LIKE :query OR solid_queue_jobs.queue_name LIKE :query",
10
+ query: "%#{query}%",
11
+ )
12
+ end
13
+ end
14
+
6
15
  # @return [SolidQueue::Job]
7
16
  attr_reader :job
8
17
 
@@ -18,7 +27,7 @@ class BackgroundJob
18
27
  blocked: SolidQueue::Job.where.associated(:blocked_execution),
19
28
  failed: SolidQueue::Job.failed,
20
29
  completed: SolidQueue::Job.finished,
21
- }
30
+ }.transform_values { |jobs| jobs.extending(Scopes) }
22
31
  end
23
32
 
24
33
  # @param [SolidQueue::Job] job
@@ -8,6 +8,16 @@ module Koi
8
8
  # @return [Admin::Session, nil]
9
9
  attribute :session
10
10
 
11
+ # @return [Admin::User, Admin::Role, nil]
12
+ def actor
13
+ device_authorization&.actor || session&.admin
14
+ end
15
+
16
+ # @return [Koi::Identity::Principal, nil]
17
+ def principal
18
+ device_authorization&.principal
19
+ end
20
+
11
21
  # @return [Admin::User, nil]
12
22
  def admin_user
13
23
  device_authorization&.admin_user || session&.admin
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Identity
5
+ class Assertion
6
+ # @return String
7
+ attr_reader :token
8
+
9
+ # @return Principal
10
+ attr_reader :principal
11
+
12
+ def initialize(token)
13
+ @token = token
14
+ @claims, @header = JWT.decode(token, nil, false)
15
+ @state = :unverified
16
+ end
17
+
18
+ def verified?
19
+ @state == :verified
20
+ end
21
+
22
+ def claims
23
+ @claims
24
+ end
25
+
26
+ def header
27
+ @header
28
+ end
29
+
30
+ def verify!(provider)
31
+ # The library validates required_claims after verify_jti, but
32
+ # consume_jti derives its cache TTL from exp — so require it first.
33
+ raise JWT::MissingRequiredClaim, "missing required claim exp" if claims["exp"].nil?
34
+
35
+ JWT.decode(
36
+ @token, nil, true,
37
+ algorithms: provider.algorithms,
38
+ jwks: provider.method(:key_set),
39
+ aud: provider.audience,
40
+ leeway: provider.leeway.to_i,
41
+ required_claims: %w[exp],
42
+ verify_aud: true,
43
+ verify_jti: provider.method(:consume_jti)
44
+ )
45
+
46
+ if claims["exp"].to_i > provider.max_expiry.from_now.to_i
47
+ raise JWT::InvalidPayload, "assertion expiry is more than #{provider.max_expiry.inspect} away"
48
+ end
49
+
50
+ # ensure that we can map the claim to a valid principal using the claim's subject
51
+ @principal = Identity.principal_for(provider, self)
52
+
53
+ if principal.blank? || principal.subject.blank?
54
+ raise(JWT::InvalidSubError, "unknown subject #{subject} for provider #{provider.name}")
55
+ end
56
+
57
+ @state = :verified
58
+
59
+ self
60
+ end
61
+
62
+ def iss
63
+ @claims["iss"]
64
+ end
65
+ alias_method :issuer, :iss
66
+
67
+ def sub
68
+ @claims["sub"]
69
+ end
70
+ alias_method :subject, :sub
71
+
72
+ def inspect
73
+ "<#{self.class.name} iss=#{iss.inspect} sub=#{sub.inspect}>"
74
+ end
75
+ alias :to_s :inspect
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Identity
5
+ # A named trust rule from config: pairs a provider and an exact subject
6
+ # with the scope a verified assertion may act as.
7
+ class Member
8
+ include ActiveModel::Model
9
+ include ActiveModel::Attributes
10
+
11
+ SCOPES = %r{\Aadmin/user\z|\Aadmin/role/(?<slug>[a-z0-9_]+)\z}
12
+
13
+ attribute :name, :string
14
+ attribute :provider, :string
15
+ attribute :scope, :string
16
+ attribute :subject, :string
17
+
18
+ # Provider names declared in config, for cross-checking references.
19
+ attr_accessor :provider_names
20
+
21
+ validates :provider, :scope, :subject, presence: true
22
+ validates :provider, inclusion: { in: ->(member) { member.provider_names || [] }, allow_blank: true }
23
+ validates :scope, format: { with: SCOPES, allow_blank: true }
24
+
25
+ def role_slug
26
+ scope&.[](SCOPES, :slug)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Identity
5
+ class Principal
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ def self.dump(principal)
10
+ principal&.attributes&.to_json
11
+ end
12
+
13
+ def self.load(json)
14
+ return if json.blank?
15
+
16
+ Principal.new(**JSON.parse(json).slice(*attribute_names))
17
+ end
18
+
19
+ # Required attributes
20
+ attribute :provider, :string
21
+ attribute :subject, :string
22
+ attribute :scope, :string
23
+
24
+ # Optional extensions, required for user authentication
25
+ attribute :name, :string
26
+ attribute :email, :string
27
+
28
+ def attributes_for_find
29
+ { email: }
30
+ end
31
+
32
+ def inspect
33
+ "<#{self.class.name} provider=#{provider.inspect} scope=#{scope.inspect} subject=#{subject.inspect} " \
34
+ "name=#{name.inspect} email=#{email.inspect}>"
35
+ end
36
+
37
+ alias :to_s :inspect
38
+ end
39
+ end
40
+ end