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
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Identity
5
+ class Provider
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ # Upper bound on how long a key removed from the issuer's JWKS remains
10
+ # trusted. Newly rotated-in keys are picked up immediately, as an
11
+ # unknown kid invalidates the cache.
12
+ KEY_SET_TTL = 1.hour
13
+
14
+ # A cached key set younger than this cannot be invalidated, so a stream
15
+ # of unknown-kid assertions cannot force a discovery refetch per
16
+ # request. A rotated-in key may take this long to be honoured.
17
+ INVALIDATION_GRACE = 5.minutes
18
+
19
+ attribute :name, :string
20
+ attribute :issuer, :string
21
+ attribute :keys, :string
22
+ attribute :audience, :string
23
+
24
+ # Acceptable signature algorithms: asymmetric families only, so a
25
+ # provider's public key can never be replayed as an HMAC secret and
26
+ # unsigned (none) tokens are rejected before key lookup.
27
+ attribute :algorithms, default: -> { %w[ES256 ES384 ES512 RS256 RS384 RS512 PS256 PS384 PS512].freeze }
28
+
29
+ # Allowed clock drift for verification
30
+ attribute :leeway, default: -> { 15.seconds }
31
+
32
+ # Upper bound on assertion lifetime. Exchange is immediate, so a
33
+ # distant expiry indicates an integration minting long-lived
34
+ # credentials; require assertions to be short-lived instead
35
+ # (RFC 7523 §3).
36
+ attribute :max_expiry, default: -> { 1.hour }
37
+
38
+ validates :keys, inclusion: { in: %w[env discover] }
39
+ validate :pinned_keys_parse, if: -> { keys == "env" }
40
+
41
+ # This provider's JWKS: pinned from ENV or fetched via OIDC discovery
42
+ # and cached. Passed to JWT.decode as its jwks loader, which retries
43
+ # with invalidate: true when a presented kid is missing from the set.
44
+ def key_set(options = {})
45
+ invalidate_key_set if options[:invalidate]
46
+
47
+ JWT::JWK::Set.new(jwks)
48
+ end
49
+
50
+ # Trusted-keys summary for the roles page: RFC 7638 thumbprints, plus
51
+ # when a discovered set was cached. Viewing may prime the discovery
52
+ # cache — the same fail-closed path verification uses — and an
53
+ # unreachable issuer reports itself rather than raising.
54
+ def key_status
55
+ case keys
56
+ when "env"
57
+ { fingerprints: fingerprints(jwks) }
58
+ when "discover"
59
+ cached = cached_discovery
60
+
61
+ { fingerprints: fingerprints(cached.fetch("jwks")),
62
+ fetched_at: Time.zone.at(cached.fetch("fetched_at")) }
63
+ end
64
+ rescue JWT::JWKError => e
65
+ { error: e.message }
66
+ end
67
+
68
+ # Identity attributes are issuer-specific: AWS issuers carry
69
+ # admin-controlled principal tags; other issuers assert no identity
70
+ # beyond their subject. Keyed by the verified issuer — never by claim
71
+ # shape, which any trusted signer could imitate.
72
+ def identity_attributes(claims)
73
+ case URI.parse(issuer.to_s).host
74
+ when /\.sts\.global\.api\.aws\z/
75
+ claims.dig("https://sts.amazonaws.com/", "principal_tags")&.slice("name", "email") || {}
76
+ else
77
+ {}
78
+ end
79
+ end
80
+
81
+ # Atomically claims an assertion's jti for its replayable lifetime:
82
+ # the first presentation writes the key, a replay finds it taken and
83
+ # is rejected. jti uniqueness is only promised within an issuer
84
+ # (RFC 7519), so entries are scoped per provider. Requires a cache
85
+ # store shared by all app processes.
86
+ def consume_jti(jti, claims)
87
+ jti.present? &&
88
+ Rails.cache.write("koi/identity/jti/#{name}/#{jti}", true,
89
+ unless_exist: true,
90
+ expires_in: Time.zone.at(claims["exp"].to_i) + leeway - Time.current)
91
+ end
92
+
93
+ private
94
+
95
+ def pinned_keys_parse
96
+ JWT::JWK::Set.new(JSON.parse(ENV.fetch(env_name)))
97
+ rescue KeyError, JSON::ParserError, JWT::JWKError => e
98
+ errors.add(:keys, "ENV #{env_name} is unavailable or invalid (#{e.message})")
99
+ end
100
+
101
+ def fingerprints(jwks)
102
+ JWT::JWK::Set.new(jwks).map { |jwk| JWT::JWK::Thumbprint.new(jwk).generate }
103
+ end
104
+
105
+ def env_name
106
+ "KOI_API_JWKS_#{name.upcase}"
107
+ end
108
+
109
+ def jwks
110
+ case keys
111
+ when "env"
112
+ JSON.parse(ENV.fetch(env_name))
113
+ when "discover"
114
+ cached_discovery.fetch("jwks")
115
+ end
116
+ end
117
+
118
+ def cached_discovery
119
+ Rails.cache.fetch(key_set_cache_key, expires_in: KEY_SET_TTL) do
120
+ { "fetched_at" => Time.current.to_i, "jwks" => discover_jwks }
121
+ end
122
+ end
123
+
124
+ def discover_jwks
125
+ discovery = JSON.parse(Net::HTTP.get(URI.parse("#{issuer}/.well-known/openid-configuration")))
126
+
127
+ # Mix-up defence: only the configured issuer's own keys are trusted.
128
+ unless discovery["issuer"] == issuer
129
+ raise JWT::JWKError, "#{name} discovery names issuer #{discovery['issuer'].inspect}, expected #{issuer}"
130
+ end
131
+
132
+ JSON.parse(Net::HTTP.get(URI.parse(discovery.fetch("jwks_uri"))))
133
+ rescue JWT::JWKError
134
+ raise
135
+ rescue StandardError => e
136
+ raise JWT::JWKError, "key discovery for #{name} failed: #{e.message} (#{e.class})"
137
+ end
138
+
139
+ def invalidate_key_set
140
+ cached = Rails.cache.read(key_set_cache_key)
141
+ return if cached && cached.fetch("fetched_at") > INVALIDATION_GRACE.ago.to_i
142
+
143
+ Rails.cache.delete(key_set_cache_key)
144
+ end
145
+
146
+ def key_set_cache_key
147
+ "koi/identity/jwks/#{name}"
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+ require "net/http"
5
+
6
+ module Koi
7
+ module Identity
8
+ module_function
9
+
10
+ def authorize_bearer_token!(token, audience:)
11
+ assertion = Assertion.new(token)
12
+ provider = provider_for(assertion)
13
+
14
+ provider.audience ||= audience
15
+ raise JWT::InvalidAudError, "no expected audience" if provider.audience.blank?
16
+
17
+ assertion.verify!(provider)
18
+ rescue JWT::DecodeError => e
19
+ Rails.logger.warn("#{e.class}: Koi::Identity rejected assertion #{assertion.inspect}: #{e.message}")
20
+ raise
21
+ end
22
+
23
+ def provider_for(assertion)
24
+ providers.find { |provider| provider.issuer == assertion.issuer } ||
25
+ raise(JWT::InvalidIssuerError, "unknown issuer #{assertion.issuer}")
26
+ end
27
+
28
+ # Resolves the member matching the verified (provider, subject) into a
29
+ # principal, carrying whatever identity attributes the issuer publishes.
30
+ def principal_for(provider, assertion)
31
+ member = members.find { |m| m.provider == provider.name && m.subject == assertion.subject }
32
+
33
+ return if member.nil?
34
+
35
+ Principal.new(provider: provider.name,
36
+ scope: member.scope,
37
+ subject: assertion.subject,
38
+ **provider.identity_attributes(assertion.claims))
39
+ end
40
+
41
+ def providers
42
+ Koi.config.identity.providers.map do |name, config|
43
+ provider = Provider.new(name:, **config)
44
+ unless provider.valid?
45
+ raise ArgumentError, "Invalid identity provider #{name}: #{provider.errors.full_messages.to_sentence}"
46
+ end
47
+
48
+ provider
49
+ end
50
+ end
51
+
52
+ def members
53
+ provider_names = Koi.config.identity.providers.keys.map(&:to_s)
54
+
55
+ Koi.config.identity.members.map do |name, config|
56
+ member = Member.new(name:, provider_names:, **config)
57
+ unless member.valid?
58
+ raise ArgumentError, "Invalid identity member #{name}: #{member.errors.full_messages.to_sentence}"
59
+ end
60
+
61
+ member
62
+ end
63
+ end
64
+
65
+ # Role slugs granted by config; roles outside this set are not assumable.
66
+ def role_slugs
67
+ members.filter_map(&:role_slug)
68
+ end
69
+ end
70
+ end
@@ -3,6 +3,22 @@
3
3
  class RecurringTask
4
4
  include ActiveModel::Model
5
5
 
6
+ module Scopes
7
+ def admin_search(query)
8
+ where(
9
+ <<~SQL.squish,
10
+ solid_queue_recurring_tasks.key LIKE :query
11
+ OR solid_queue_recurring_tasks.class_name LIKE :query
12
+ SQL
13
+ query: "%#{query}%",
14
+ )
15
+ end
16
+ end
17
+
18
+ def self.scope
19
+ SolidQueue::RecurringTask.extending(Scopes)
20
+ end
21
+
6
22
  # @return [SolidQueue::RecurringTask]
7
23
  attr_reader :task
8
24
 
@@ -0,0 +1,14 @@
1
+ <%# locals: (collection:) %>
2
+
3
+ <% content_for(:header) do %>
4
+ <h1>Admin roles</h1>
5
+ <% end %>
6
+
7
+ <%= table_query_with(collection:) %>
8
+
9
+ <%= table_with(collection:) do |row| %>
10
+ <% row.link(:slug, url: :admin_admin_role_path) %>
11
+ <% row.date(:created_at, label: "Materialized") %>
12
+ <% row.datetime(:last_authenticated_at, label: "Last authenticated") %>
13
+ <% row.boolean(:orphaned?, label: "Orphaned") %>
14
+ <% end %>
@@ -0,0 +1,41 @@
1
+ <%# locals: (role:, members:, providers:) %>
2
+
3
+ <% content_for(:header) do %>
4
+ <%= breadcrumb_list do %>
5
+ <li><%= link_to("Admin roles", admin_admin_roles_path) %></li>
6
+ <% end %>
7
+
8
+ <h1><%= role.slug %></h1>
9
+ <% end %>
10
+
11
+ <%= summary_table_with(model: role) do |row| %>
12
+ <% row.text(:slug) %>
13
+ <% row.date(:created_at, label: "Materialized") %>
14
+ <% row.datetime(:last_authenticated_at, label: "Last authenticated") %>
15
+ <% row.boolean(:orphaned?) %>
16
+ <% row.datetime(:tokens_revoked_at) %>
17
+ <% end %>
18
+
19
+ <h2>Trust</h2>
20
+
21
+ <%= table_with(collection: members) do |row| %>
22
+ <% row.text(:name, label: "Member") %>
23
+ <% row.text(:subject) %>
24
+ <% row.text(:provider) %>
25
+ <% end %>
26
+
27
+ <%= table_with(collection: providers) do |row, provider| %>
28
+ <% row.text(:name, label: "Provider") %>
29
+ <% row.text(:issuer) %>
30
+ <% row.text(:keys) do %>
31
+ <% status = provider.key_status %>
32
+ <% if status[:fingerprints] %>
33
+ <%= "#{provider.keys}: #{status[:fingerprints].join(', ')}" %>
34
+ <% if status[:fetched_at] %>
35
+ (fetched <%= l(status[:fetched_at], format: :short) %>)
36
+ <% end %>
37
+ <% else %>
38
+ <%= "#{provider.keys}: #{status[:error]}" %>
39
+ <% end %>
40
+ <% end %>
41
+ <% end %>
@@ -3,9 +3,9 @@
3
3
  <%= turbo_stream.replace "invite" do %>
4
4
  <div class="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
5
5
  <%= text_field_tag :invite_link, admin_session_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
6
- <button class="govuk-input__suffix clipboard-button" aria-hidden="true" data-action="clipboard#copy">
6
+ <button class="govuk-input__suffix clipboard-button" data-action="clipboard#copy">
7
7
  Copy link
8
8
  </button>
9
- <div class="copy-to-clipboard-feedback" role="alert"></div>
9
+ <div class="copy-to-clipboard-feedback" role="status">Link copied to your clipboard!</div>
10
10
  </div>
11
11
  <% end %>
data/config/routes.rb CHANGED
@@ -7,14 +7,16 @@ Rails.application.routes.draw do
7
7
  put :archive, on: :collection
8
8
  put :restore, on: :collection
9
9
 
10
- resources :tokens, only: %i[create]
10
+ resources :tokens, only: %i[create], module: :sessions
11
11
  end
12
12
 
13
+ resources :admin_roles, only: %i[index show]
14
+
13
15
  resource :cache, only: %i[destroy]
14
16
  resource :dashboard, only: %i[show]
15
17
 
16
18
  resources :device_authorizations, param: :user_code, only: %i[create show update]
17
- resources :device_tokens, only: %i[create]
19
+ post "/tokens", to: "tokens#create", constraints: { format: :json }
18
20
 
19
21
  scope :active_storage, module: :active_storage do
20
22
  post :direct_uploads, to: "direct_uploads#create"
@@ -27,7 +29,7 @@ Rails.application.routes.draw do
27
29
 
28
30
  resource :session, only: %i[new create destroy] do
29
31
  # JWT tokens contain periods
30
- resources :tokens, param: :token, only: %i[show update], token: /[^\/]+/
32
+ resources :tokens, param: :token, only: %i[show update], token: /[^\/]+/, module: :sessions
31
33
  end
32
34
 
33
35
  resources :background_jobs, only: %i[index show], param: :active_job_id do
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdminRoles < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :admin_roles do |t|
6
+ t.string :slug, null: false, index: { unique: true }
7
+ t.datetime :tokens_revoked_at
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAdminRoleToAdminDeviceAuthorizations < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_reference :admin_device_authorizations, :admin_role, null: true, foreign_key: true
6
+
7
+ add_check_constraint :admin_device_authorizations,
8
+ "admin_user_id IS NULL OR admin_role_id IS NULL",
9
+ name: "admin_device_authorizations_single_actor"
10
+
11
+ # allow device authorizations to be created without the request/approval flow
12
+ change_column_null :admin_device_authorizations, :device_code_digest, true
13
+ change_column_null :admin_device_authorizations, :request_expires_at, true
14
+ change_column_null :admin_device_authorizations, :user_code, true
15
+
16
+ # regenerate the unique indexes to ignore nulls
17
+ remove_index :admin_device_authorizations, :device_code_digest, unique: true
18
+ add_index :admin_device_authorizations, :device_code_digest,
19
+ unique: true, where: "device_code_digest IS NOT NULL"
20
+
21
+ remove_index :admin_device_authorizations, :user_code, unique: true
22
+ add_index :admin_device_authorizations, :user_code,
23
+ unique: true, where: "user_code IS NOT NULL"
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPrincipalToAdminDeviceAuthorizations < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :admin_device_authorizations, :principal, :text
6
+ end
7
+ end
data/lib/koi/config.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/hash/deep_merge"
3
4
  require "active_support/core_ext/numeric"
5
+ require "active_support/ordered_options"
4
6
 
5
7
  module Koi
6
8
  class Config
@@ -28,5 +30,35 @@ module Koi
28
30
  @image_mime_types = %w[image/png image/gif image/jpeg image/webp].freeze
29
31
  @image_size_limit = 10.megabytes
30
32
  end
33
+
34
+ # Identity & access settings for OIDC authentication.
35
+ def identity
36
+ @identity ||= ActiveSupport::OrderedOptions.new.merge!(providers: {}, members: {})
37
+ end
38
+
39
+ def identity=(options)
40
+ identity.deep_merge!(options.to_h)
41
+ end
42
+
43
+ # Load config/koi.yml, if present
44
+ def load(app)
45
+ app.config_for(:koi).each do |attribute, value|
46
+ public_send("#{attribute}=", value)
47
+ end
48
+
49
+ self
50
+ rescue RuntimeError => e
51
+ raise unless e.message.start_with?("Could not load configuration")
52
+
53
+ self
54
+ end
55
+
56
+ private
57
+
58
+ def method_missing(name, *) # rubocop:disable Style/MissingRespondToMissing
59
+ return super unless name.to_s.end_with?("=")
60
+
61
+ raise ArgumentError, "Unknown Koi config setting: #{name.to_s.chomp('=')}"
62
+ end
31
63
  end
32
64
  end
data/lib/koi/engine.rb CHANGED
@@ -5,6 +5,7 @@ require "katalyst/content"
5
5
  require "katalyst-govuk-formbuilder"
6
6
  require "katalyst/navigation"
7
7
  require "katalyst/tables"
8
+ require "jwt"
8
9
  require "lexxy"
9
10
  require "pagy"
10
11
  require "rotp"
@@ -62,6 +63,17 @@ module Koi
62
63
  end
63
64
  end
64
65
 
66
+ initializer "koi.config" do |app|
67
+ Koi.config.load(app)
68
+
69
+ # Constructing providers and members validates the trust config, so a
70
+ # config error fails the deploy rather than a partner's exchange.
71
+ app.config.to_prepare do
72
+ Koi::Identity.providers
73
+ Koi::Identity.members
74
+ end
75
+ end
76
+
65
77
  initializer "koi.content" do
66
78
  Katalyst::Content.config.base_controller = "Admin::ApplicationController"
67
79
  Katalyst::Content.config.errors_component = "Koi::Content::Editor::ErrorsComponent"
@@ -6,20 +6,20 @@ module Koi
6
6
  # Workaround for de-duplicating nested module paths for admin controllers
7
7
  # See https://github.com/rails/rails/issues/50916
8
8
  def merge_prefix_into_object_path(prefix, object_path)
9
- if prefix.include?(?/) && object_path.include?(?/)
10
- prefixes = []
11
- prefix_array = File.dirname(prefix).split("/")
9
+ return object_path unless prefix.include?(?/) && object_path.include?(?/)
12
10
 
13
- prefix_array.each_with_index do |dir, index|
14
- break if object_path.start_with?(prefix_array[index..].join("/"))
11
+ prefixes = File.dirname(prefix).split("/")
12
+ object_namespace = object_path.split("/")
15
13
 
16
- prefixes << dir
17
- end
18
-
19
- (prefixes << object_path).join("/")
20
- else
21
- object_path
14
+ # Drop the longest run of trailing prefix segments that also appears at
15
+ # the head of the object namespace, merging overlapping namespaces
16
+ # rather than repeating them.
17
+ max_overlap = [prefixes.length, object_namespace.length].min
18
+ overlap = max_overlap.downto(0).find do |length|
19
+ prefixes.last(length) == object_namespace.first(length)
22
20
  end
21
+
22
+ (prefixes.first(prefixes.length - overlap) << object_path).join("/")
23
23
  end
24
24
  end
25
25
  end
@@ -48,7 +48,7 @@ module Koi
48
48
  end
49
49
 
50
50
  def authenticated?
51
- Koi::Current.admin_user.present?
51
+ Koi::Current.actor.present?
52
52
  end
53
53
 
54
54
  def find_device_authentication(token:)
@@ -75,7 +75,7 @@ module Koi
75
75
  end
76
76
 
77
77
  def device_flow_request?(request:)
78
- request.post? && %w[/admin/device_authorizations /admin/device_tokens].include?(request.path)
78
+ request.post? && %w[/admin/device_authorizations /admin/tokens].include?(request.path)
79
79
  end
80
80
 
81
81
  def bearer_token(request:)
@@ -25,5 +25,23 @@ FactoryBot.define do
25
25
  consumed_at { Time.current }
26
26
  admin_user
27
27
  end
28
+
29
+ # An authorization issued for an admin role
30
+ trait :admin_role do
31
+ admin_role
32
+ principal do
33
+ Koi::Identity::Principal.new(
34
+ provider: "komet",
35
+ scope: "admin/role/#{admin_role.slug}",
36
+ subject: "komet-production",
37
+ )
38
+ end
39
+ status { :consumed }
40
+ consumed_at { Time.current }
41
+ token_expires_at { consumed_at + 1.hour }
42
+ device_code_digest { nil }
43
+ user_code { nil }
44
+ request_expires_at { nil }
45
+ end
28
46
  end
29
47
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :admin_role, class: "Admin::Role" do
5
+ sequence(:slug) { |n| "role_#{n}" }
6
+ end
7
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-koi
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.8.3
4
+ version: 5.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: jwt
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: rotp
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -265,6 +279,7 @@ files:
265
279
  - app/assets/builds/katalyst/koi.js
266
280
  - app/assets/builds/katalyst/koi.min.js
267
281
  - app/assets/builds/katalyst/koi.min.js.map
282
+ - app/assets/builds/koi/admin.css
268
283
  - app/assets/config/koi.js
269
284
  - app/assets/fonts/koi/inconsolata-license.txt
270
285
  - app/assets/fonts/koi/inconsolata-v37-latin-regular.woff2
@@ -373,6 +388,7 @@ files:
373
388
  - app/components/koi/tables/cells/link_component.rb
374
389
  - app/components/koi/tables/table_component.rb
375
390
  - app/controllers/admin/active_storage/direct_uploads_controller.rb
391
+ - app/controllers/admin/admin_roles_controller.rb
376
392
  - app/controllers/admin/admin_users_controller.rb
377
393
  - app/controllers/admin/application_controller.rb
378
394
  - app/controllers/admin/background_jobs_controller.rb
@@ -380,11 +396,11 @@ files:
380
396
  - app/controllers/admin/credentials_controller.rb
381
397
  - app/controllers/admin/dashboards_controller.rb
382
398
  - app/controllers/admin/device_authorizations_controller.rb
383
- - app/controllers/admin/device_tokens_controller.rb
384
399
  - app/controllers/admin/feature_flags_controller.rb
385
400
  - app/controllers/admin/otps_controller.rb
386
401
  - app/controllers/admin/profiles_controller.rb
387
402
  - app/controllers/admin/recurring_tasks_controller.rb
403
+ - app/controllers/admin/sessions/tokens_controller.rb
388
404
  - app/controllers/admin/sessions_controller.rb
389
405
  - app/controllers/admin/tokens_controller.rb
390
406
  - app/controllers/admin/url_rewrites_controller.rb
@@ -425,6 +441,7 @@ files:
425
441
  - app/models/admin/collection.rb
426
442
  - app/models/admin/credential.rb
427
443
  - app/models/admin/device_authorization.rb
444
+ - app/models/admin/role.rb
428
445
  - app/models/admin/session.rb
429
446
  - app/models/admin/user.rb
430
447
  - app/models/application_record.rb
@@ -433,9 +450,16 @@ files:
433
450
  - app/models/concerns/koi/model/otp.rb
434
451
  - app/models/feature_flag.rb
435
452
  - app/models/koi/current.rb
453
+ - app/models/koi/identity.rb
454
+ - app/models/koi/identity/assertion.rb
455
+ - app/models/koi/identity/member.rb
456
+ - app/models/koi/identity/principal.rb
457
+ - app/models/koi/identity/provider.rb
436
458
  - app/models/recurring_task.rb
437
459
  - app/models/url_rewrite.rb
438
460
  - app/models/well_known.rb
461
+ - app/views/admin/admin_roles/index.html.erb
462
+ - app/views/admin/admin_roles/show.html.erb
439
463
  - app/views/admin/admin_users/_form.html.erb
440
464
  - app/views/admin/admin_users/archived.html.erb
441
465
  - app/views/admin/admin_users/edit.html.erb
@@ -469,12 +493,12 @@ files:
469
493
  - app/views/admin/sessions/new.html.erb
470
494
  - app/views/admin/sessions/otp.html.erb
471
495
  - app/views/admin/sessions/password.html.erb
496
+ - app/views/admin/sessions/tokens/create.turbo_stream.erb
497
+ - app/views/admin/sessions/tokens/show.html.erb
472
498
  - app/views/admin/shared/icons/_close.html.erb
473
499
  - app/views/admin/shared/icons/_cross.html.erb
474
500
  - app/views/admin/shared/icons/_menu.html.erb
475
501
  - app/views/admin/shared/icons/_refresh.html.erb
476
- - app/views/admin/tokens/create.turbo_stream.erb
477
- - app/views/admin/tokens/show.html.erb
478
502
  - app/views/admin/url_rewrites/_form.html.erb
479
503
  - app/views/admin/url_rewrites/edit.html.erb
480
504
  - app/views/admin/url_rewrites/index.html.erb
@@ -521,6 +545,9 @@ files:
521
545
  - db/migrate/20260501000000_add_last_sign_out_at_to_admin_users.rb
522
546
  - db/migrate/20260525041029_create_admin_sessions.rb
523
547
  - db/migrate/20260525111759_clean_up_admin_user_timestamps.rb
548
+ - db/migrate/20260721000001_create_admin_roles.rb
549
+ - db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb
550
+ - db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb
524
551
  - db/seeds.rb
525
552
  - lib/generators/koi/admin/USAGE
526
553
  - lib/generators/koi/admin/admin_generator.rb
@@ -558,6 +585,7 @@ files:
558
585
  - lib/koi/middleware/url_redirect.rb
559
586
  - lib/koi/release.rb
560
587
  - spec/factories/admin_device_authorizations.rb
588
+ - spec/factories/admin_roles.rb
561
589
  - spec/factories/admin_sessions.rb
562
590
  - spec/factories/admins.rb
563
591
  - spec/factories/url_rewrites.rb
@@ -581,7 +609,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
581
609
  - !ruby/object:Gem::Version
582
610
  version: '0'
583
611
  requirements: []
584
- rubygems_version: 4.0.10
612
+ rubygems_version: 4.0.16
585
613
  specification_version: 4
586
614
  summary: Koi CMS admin framework
587
615
  test_files: []