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.
- checksums.yaml +4 -4
- data/app/assets/builds/koi/admin.css +1 -0
- data/app/assets/stylesheets/koi/blocks/clipboard.css +43 -16
- data/app/assets/stylesheets/koi/blocks/index.css +1 -0
- data/app/controllers/admin/admin_roles_controller.rb +49 -0
- data/app/controllers/admin/background_jobs_controller.rb +13 -2
- data/app/controllers/admin/device_authorizations_controller.rb +1 -1
- data/app/controllers/admin/recurring_tasks_controller.rb +1 -2
- data/app/controllers/admin/sessions/tokens_controller.rb +45 -0
- data/app/controllers/admin/tokens_controller.rb +25 -26
- data/app/controllers/concerns/koi/controller.rb +22 -0
- data/app/models/admin/device_authorization.rb +66 -18
- data/app/models/admin/role.rb +41 -0
- data/app/models/background_job.rb +10 -1
- data/app/models/koi/current.rb +10 -0
- data/app/models/koi/identity/assertion.rb +78 -0
- data/app/models/koi/identity/member.rb +30 -0
- data/app/models/koi/identity/principal.rb +40 -0
- data/app/models/koi/identity/provider.rb +151 -0
- data/app/models/koi/identity.rb +70 -0
- data/app/models/recurring_task.rb +16 -0
- data/app/views/admin/admin_roles/index.html.erb +14 -0
- data/app/views/admin/admin_roles/show.html.erb +41 -0
- data/app/views/admin/{tokens → sessions/tokens}/create.turbo_stream.erb +2 -2
- data/config/routes.rb +5 -3
- data/db/migrate/20260721000001_create_admin_roles.rb +12 -0
- data/db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb +25 -0
- data/db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb +7 -0
- data/lib/koi/config.rb +32 -0
- data/lib/koi/engine.rb +12 -0
- data/lib/koi/extensions/object_rendering.rb +11 -11
- data/lib/koi/middleware/admin_authentication.rb +2 -2
- data/spec/factories/admin_device_authorizations.rb +18 -0
- data/spec/factories/admin_roles.rb +7 -0
- metadata +33 -5
- data/app/controllers/admin/device_tokens_controller.rb +0 -18
- /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"
|
|
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="
|
|
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
|
-
|
|
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
|
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
|
-
|
|
10
|
-
prefixes = []
|
|
11
|
-
prefix_array = File.dirname(prefix).split("/")
|
|
9
|
+
return object_path unless prefix.include?(?/) && object_path.include?(?/)
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
prefixes = File.dirname(prefix).split("/")
|
|
12
|
+
object_namespace = object_path.split("/")
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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/
|
|
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
|
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.
|
|
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.
|
|
612
|
+
rubygems_version: 4.0.16
|
|
585
613
|
specification_version: 4
|
|
586
614
|
summary: Koi CMS admin framework
|
|
587
615
|
test_files: []
|