panda-core 1.1.1 → 1.2.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/panda/core/shared/header_component.html.erb +18 -7
  3. data/app/components/panda/core/shared/header_component.rb +37 -0
  4. data/app/controllers/panda/core/admin/sessions_controller.rb +2 -1
  5. data/app/helpers/panda/core/asset_helper.rb +55 -10
  6. data/app/helpers/panda/core/sessions_helper.rb +3 -1
  7. data/app/javascript/panda/core/application.js +3 -0
  8. data/app/javascript/panda/core/fontawesome-config.js +23 -0
  9. data/app/models/panda/core/user.rb +70 -0
  10. data/db/migrate/20260202171614_create_panda_core_file_categories.rb +35 -1
  11. data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +27 -6
  12. data/db/migrate/20260825120000_normalise_polymorphic_tenant_id_columns.rb +41 -0
  13. data/lib/generators/panda/core/templates/panda.rb +16 -1
  14. data/lib/panda/core/configuration.rb +15 -1
  15. data/lib/panda/core/engine/omniauth_config.rb +45 -20
  16. data/lib/panda/core/module_registry.rb +56 -9
  17. data/lib/panda/core/oauth_providers.rb +12 -0
  18. data/lib/panda/core/shared/generator_config.rb +8 -0
  19. data/lib/panda/core/version.rb +1 -1
  20. data/lib/tasks/panda/core/schema_check.rake +105 -0
  21. data/public/panda-core-assets/es-module-shims.js +2 -0
  22. data/public/panda-core-assets/fontawesome/css/all.min.css +9 -0
  23. data/public/panda-core-assets/fontawesome/webfonts/fa-brands-400.woff2 +0 -0
  24. data/public/panda-core-assets/fontawesome/webfonts/fa-regular-400.woff2 +0 -0
  25. data/public/panda-core-assets/fontawesome/webfonts/fa-solid-900.woff2 +0 -0
  26. data/public/panda-core-assets/fontawesome/webfonts/fa-v4compatibility.woff2 +0 -0
  27. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37a6c53ce5d4b5656603f5bec9ef7de364b6e94452eb4f635d96811bfdcfd3cd
4
- data.tar.gz: 5641f056c4876c0fb00cc1bab8063ccfbeb84bc94835d4df06da6a99ba4d0ef8
3
+ metadata.gz: d3e74c09fd81106220b436b5fd9f2290fd3c33c4b648d011a9ee637f7da06af1
4
+ data.tar.gz: 4979e5db64c94a2a48256561fb4dacf8d8970c3621aae32f00fe47d6734b9aea
5
5
  SHA512:
6
- metadata.gz: 80cb0f2e79923c41c05a67bf3f7a62b37ae6bf5a6dc74d4ed938e58530358f71c0bb1b542a00da04db30f34116e3b557de61a7840dd765c205e832d4a5bfc8d9
7
- data.tar.gz: efc50d077137b2e6ef993318ba548fc3dc957560800a291697407f74903443d44491992ba08cc1068a2295cd4027a6cb2974d93d023ad6e08ca4f1430ac20e91
6
+ metadata.gz: 0da5c19054a4279007f0e82cb77834fec89a42d6f7ec52aa5175991f85008a4e75568e5366c05fa4b6d6de3f44918c53ad1f87b39fa0f742dd431c001de27a85
7
+ data.tar.gz: bd7540345f539cdfb5e32b2e38f8416bc0a84d6c1d9475f055cc73a5c8b185c2450dda6060a8058643b39b5a66420ea7f1787a832a3152925dbdaf682c0d177d
@@ -1,10 +1,20 @@
1
1
  <!DOCTYPE html>
2
- <html data-theme="<%= Panda::Core::Current&.user&.current_theme || Panda::Core.config.default_theme %>" class="<%= @html_class %>">
2
+ <html lang="<%= html_lang %>" data-theme="<%= Panda::Core::Current&.user&.current_theme || Panda::Core.config.default_theme %>" class="<%= @html_class %>">
3
3
  <head>
4
+ <%# Must stay inside the first 1024 bytes of the document: the HTML spec %>
5
+ <%# ignores a charset declaration that lands any later, and only the %>
6
+ <%# component can guarantee that (additional_head_content is thousands of %>
7
+ <%# bytes in). Without it the encoding rests on the Content-Type header %>
8
+ <%# alone, which a saved copy or a stripping proxy does not carry. %>
9
+ <meta charset="utf-8">
4
10
  <title><%= helpers.content_for?(:title) ? helpers.content_for(:title) : (Panda::Core.config.admin_title || "Panda Admin") %></title>
5
11
  <%= helpers.csrf_meta_tags %>
6
12
  <%= helpers.csp_meta_tag %>
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7.1.0/css/all.min.css">
13
+ <%# Font Awesome is vendored rather than loaded from a CDN: a strict %>
14
+ <%# style-src 'self' refuses the stylesheet and font-src 'self' refuses the %>
15
+ <%# webfonts behind it. The upstream CSS is unmodified — its ../webfonts/ %>
16
+ <%# URLs resolve against the css/ directory it is served from. %>
17
+ <link rel="stylesheet" href="/panda-core-assets/fontawesome/css/all.min.css">
8
18
  <%= helpers.panda_core_stylesheet %>
9
19
  <link rel="stylesheet" href="/panda-core-assets/vanilla-calendar.css">
10
20
 
@@ -13,8 +23,11 @@
13
23
  <script src="/panda-core-assets/chartkick/chartkick.js"></script>
14
24
  <% end %>
15
25
 
16
- <!-- ES Module Shims polyfill - must load BEFORE importmap -->
17
- <script async src="https://ga.jspm.io/npm:es-module-shims@2.6.2/dist/es-module-shims.js"></script>
26
+ <%# ES Module Shims polyfill - must load BEFORE the importmap, and must not %>
27
+ <%# be async: the shim has to merge importmaps before any module script %>
28
+ <%# evaluates, which async does not guarantee. Vendored so script-src 'self' %>
29
+ <%# does not refuse it. %>
30
+ <script src="/panda-core-assets/es-module-shims.js"></script>
18
31
 
19
32
  <!-- Panda JavaScript (Core + all registered modules via ModuleRegistry) -->
20
33
  <%= helpers.panda_core_javascript %>
@@ -23,9 +36,7 @@
23
36
  <%= render "panda/cms/shared/favicons" %>
24
37
  <% end %>
25
38
 
26
- <% if Panda::Core.config.additional_head_content.respond_to?(:call) %>
27
- <%= Panda::Core.config.additional_head_content.call.html_safe %>
28
- <% end %>
39
+ <%= additional_head_content %>
29
40
 
30
41
  <%= helpers.content_for :head %>
31
42
  </head>
@@ -13,6 +13,43 @@ module Panda
13
13
  end
14
14
 
15
15
  attr_reader :html_class, :body_class
16
+
17
+ # Language for the <html lang> attribute.
18
+ #
19
+ # Screen readers use it to pick a pronunciation, so a page without one is
20
+ # read in whatever the reader's default happens to be.
21
+ def html_lang
22
+ I18n.locale.to_s.presence || "en"
23
+ end
24
+
25
+ # Render Panda::Core.config.additional_head_content with a view context.
26
+ #
27
+ # Called with no receiver and no arguments, a host app's lambda has no
28
+ # view and no request, so content_security_policy_nonce is nil inside it
29
+ # and anything it injects (javascript_importmap_tags included) comes out
30
+ # un-nonced and is dropped by a strict script-src.
31
+ #
32
+ # Two shapes are supported, so existing configuration keeps working:
33
+ #
34
+ # # Arity 0 - evaluated against the view context, so view helpers and
35
+ # # content_security_policy_nonce resolve directly.
36
+ # config.additional_head_content = -> { javascript_importmap_tags(nonce: content_security_policy_nonce) }
37
+ #
38
+ # # Arity 1 - the view context is yielded as an argument.
39
+ # config.additional_head_content = ->(view) { view.javascript_importmap_tags(nonce: view.content_security_policy_nonce) }
40
+ def additional_head_content
41
+ content = Panda::Core.config.additional_head_content
42
+ return nil unless content.respond_to?(:call)
43
+
44
+ result =
45
+ if content.arity == 0
46
+ helpers.instance_exec(&content)
47
+ else
48
+ content.call(helpers)
49
+ end
50
+
51
+ result&.to_s&.html_safe
52
+ end
16
53
  end
17
54
  end
18
55
  end
@@ -16,7 +16,8 @@ module Panda
16
16
  if key.to_sym == :developer
17
17
  Rails.env.development?
18
18
  else
19
- config[:client_id].present? && config[:client_secret].present?
19
+ symbol = Engine::OmniauthConfig::PROVIDER_REGISTRY[key.to_s] || key.to_sym
20
+ Engine::OmniauthConfig.credentials_configured?(symbol, config)
20
21
  end
21
22
  end.keys
22
23
  end
@@ -15,16 +15,38 @@ module Panda
15
15
  # via ModuleRegistry.
16
16
  #
17
17
  # Note: Always uses importmap (Rails 8 approach), regardless of asset source
18
+ #
19
+ # The tags are built by hand rather than through +javascript_importmap_tags+
20
+ # because the importmap is assembled from ModuleRegistry rather than from a
21
+ # Rails importmap. That means the nonce has to be applied by hand too: under
22
+ # a Content Security Policy of +script-src 'self'+ an un-nonced inline
23
+ # <script> is dropped silently, which takes Turbo and every Stimulus
24
+ # controller with it and looks like nothing at all.
18
25
  def panda_core_javascript
19
- # Use ModuleRegistry to combine all Panda module importmaps
20
- imports = Panda::Core::ModuleRegistry.combined_importmap
26
+ panda_core_importmap_tag + panda_core_script_tags
27
+ end
21
28
 
29
+ # The <script type="importmap"> tag on its own.
30
+ #
31
+ # Split out from the entry points because a host app that emits its own
32
+ # importmap needs exactly one: browsers with native import-map support
33
+ # honour only the first one in the document and ignore the rest. Such an
34
+ # app should merge Panda's pins (Panda::Core::ModuleRegistry.combined_importmap)
35
+ # into its own importmap and call panda_core_script_tags instead of
36
+ # panda_core_javascript.
37
+ def panda_core_importmap_tag
38
+ imports = Panda::Core::ModuleRegistry.combined_importmap
22
39
  importmap_json = JSON.generate({"imports" => imports})
23
40
 
24
- # Generate entry point script tags for all registered modules (including Core)
41
+ %(<script type="importmap"#{panda_core_nonce_attribute}>#{importmap_json}</script>).html_safe
42
+ end
43
+
44
+ # The module entry-point <script> tags for Core and every registered module,
45
+ # without an importmap. Safe to call alongside a host app's own importmap.
46
+ def panda_core_script_tags
47
+ nonce_attr = panda_core_nonce_attribute
25
48
  entry_points = []
26
49
 
27
- # Add entry points for each registered module
28
50
  Panda::Core::ModuleRegistry.modules.each do |gem_name, info|
29
51
  # Extract module namespace from gem name (e.g., "panda-cms" -> "cms")
30
52
  module_slug = gem_name.sub(/^panda-/, "")
@@ -33,14 +55,11 @@ module Panda
33
55
  module_name = info[:engine].sub(/::Engine$/, "")
34
56
  next unless Object.const_defined?(module_name)
35
57
 
36
- entry_points << %(<script type="module">import "panda/#{module_slug}/application"</script>)
37
- entry_points << %(<script type="module">import "panda/#{module_slug}/controllers/index"</script>)
58
+ entry_points << %(<script type="module"#{nonce_attr}>import "panda/#{module_slug}/application"</script>)
59
+ entry_points << %(<script type="module"#{nonce_attr}>import "panda/#{module_slug}/controllers/index"</script>)
38
60
  end
39
61
 
40
- <<~HTML.html_safe
41
- <script type="importmap">#{importmap_json}</script>
42
- #{entry_points.join("\n")}
43
- HTML
62
+ entry_points.join("\n").html_safe
44
63
  end
45
64
 
46
65
  # Include only Core CSS
@@ -50,6 +69,32 @@ module Panda
50
69
 
51
70
  stylesheet_link_tag(css_url)
52
71
  end
72
+
73
+ private
74
+
75
+ # A ready-to-interpolate ` nonce="..."` attribute, or "" when there is no
76
+ # nonce to emit.
77
+ #
78
+ # Guarded on presence because an app with no CSP configured gets nil back,
79
+ # and emitting nonce="" there is worse than emitting nothing: an empty
80
+ # nonce never matches a policy, so it would break the very apps that have
81
+ # no CSP problem to begin with.
82
+ def panda_core_nonce_attribute
83
+ nonce = panda_core_csp_nonce
84
+ return "" if nonce.blank?
85
+
86
+ %( nonce="#{ERB::Util.html_escape(nonce)}")
87
+ end
88
+
89
+ def panda_core_csp_nonce
90
+ return nil unless respond_to?(:content_security_policy_nonce)
91
+
92
+ content_security_policy_nonce
93
+ rescue
94
+ # No request in scope (e.g. rendering outside a controller), so there is
95
+ # no per-request nonce to read.
96
+ nil
97
+ end
53
98
  end
54
99
  end
55
100
  end
@@ -7,7 +7,8 @@ module Panda
7
7
  PROVIDER_ICON_MAP = {
8
8
  google_oauth2: "google",
9
9
  microsoft_graph: "microsoft",
10
- github: "github"
10
+ github: "github",
11
+ apple: "apple"
11
12
  }.freeze
12
13
 
13
14
  # Map of providers that don't use fa-brands (use fa-solid instead)
@@ -20,6 +21,7 @@ module Panda
20
21
  google_oauth2: "Google",
21
22
  microsoft_graph: "Microsoft",
22
23
  github: "GitHub",
24
+ apple: "Apple",
23
25
  developer: "Developer"
24
26
  }.freeze
25
27
 
@@ -1,4 +1,7 @@
1
1
  import { Application } from '@hotwired/stimulus'
2
+ // Must precede the Font Awesome import: it disables the SVG watcher, whose
3
+ // un-nonced <style> injection is dropped by a strict style-src. See the module.
4
+ import './fontawesome-config.js'
2
5
  import '@fortawesome/fontawesome-free'
3
6
 
4
7
  const application = Application.start()
@@ -0,0 +1,23 @@
1
+ // Font Awesome's JavaScript renders icons by replacing every
2
+ // <i class="fa-solid ..."> with an inline <svg>, and sizes the result with a
3
+ // <style> element it injects through document.head.insertBefore. That style
4
+ // carries no nonce, and Font Awesome 7.2.0 has no nonce option at all — grep the
5
+ // vendored build and there is not one occurrence. Under an enforced
6
+ // style-src 'self' the browser drops it silently and every replaced icon renders
7
+ // at raw viewBox size.
8
+ //
9
+ // Panda serves the Font Awesome webfont CSS (see the <link> in
10
+ // Shared::HeaderComponent), which draws the same fa-solid / fa-brands glyphs
11
+ // from a ::before rule with no JavaScript involved. The SVG watcher is therefore
12
+ // redundant as well as CSP-hostile, so it is switched off here — the icons
13
+ // consumers write keep resolving, through the CSS instead.
14
+ //
15
+ // This lives in its own module rather than at the top of application.js because
16
+ // ES module dependencies are evaluated before the importing module's own body
17
+ // runs: an assignment inside application.js would land *after* Font Awesome had
18
+ // already read its config. A module imported ahead of it does not.
19
+ window.FontAwesomeConfig = {
20
+ autoReplaceSvg: false,
21
+ autoAddCss: false,
22
+ observeMutations: false
23
+ }
@@ -49,6 +49,20 @@ module Panda
49
49
  }
50
50
 
51
51
  def self.find_or_create_from_auth_hash(auth_hash)
52
+ # Email is the cross-provider identity key, so we must only trust it
53
+ # when the identity provider actually attests the user owns it.
54
+ # Otherwise a provider that lets a user assert an arbitrary email
55
+ # (a generic OAuth2/OIDC/SAML strategy, or a misconfigured one) would
56
+ # allow matching into an existing account — an account-takeover vector.
57
+ unless email_verified_for_auth?(auth_hash)
58
+ user = new(
59
+ email: auth_hash.info.email.to_s.downcase,
60
+ name: auth_hash.info.name || "Unknown User"
61
+ )
62
+ user.errors.add(:base, "Your email address could not be verified with the identity provider. Please contact your administrator.")
63
+ return user
64
+ end
65
+
52
66
  user = find_by(email: auth_hash.info.email.downcase)
53
67
 
54
68
  avatar_url = auth_hash.info.image
@@ -102,6 +116,62 @@ module Panda
102
116
  user
103
117
  end
104
118
 
119
+ # Whether the provider's asserted email may be trusted as proof of
120
+ # ownership for this authentication.
121
+ #
122
+ # Precedence:
123
+ # 1. An explicit `email_verified` signal from the provider (checked in
124
+ # both `info` and `extra.raw_info`) is authoritative — truthy means
125
+ # verified, an explicit false means NOT verified (no fallback).
126
+ # 2. When the provider returns no such signal, fall back to the
127
+ # `trusted_email_providers` allow-list so operators must opt a
128
+ # provider in. The dev-only `developer` strategy is trusted only in
129
+ # the development environment.
130
+ def self.email_verified_for_auth?(auth_hash)
131
+ signal = extract_email_verified_signal(auth_hash)
132
+ return truthy_verification?(signal) unless signal.nil?
133
+
134
+ provider = indifferent_lookup(auth_hash, :provider).to_s
135
+ return false if provider.empty?
136
+ return true if provider == "developer" && Rails.env.development?
137
+
138
+ trusted = Panda::Core.config.trusted_email_providers || []
139
+ trusted.map(&:to_s).include?(provider)
140
+ end
141
+
142
+ # Pull an `email_verified` value from the standard OmniAuth locations.
143
+ # Returns nil when the provider asserts nothing (so the caller can fall
144
+ # back to the trust policy), and preserves an explicit `false`.
145
+ def self.extract_email_verified_signal(auth_hash)
146
+ info = indifferent_lookup(auth_hash, :info)
147
+ info_signal = indifferent_lookup(info, :email_verified)
148
+ return info_signal unless info_signal.nil?
149
+
150
+ extra = indifferent_lookup(auth_hash, :extra)
151
+ raw_info = indifferent_lookup(extra, :raw_info)
152
+ indifferent_lookup(raw_info, :email_verified)
153
+ end
154
+
155
+ # OmniAuth strategies variously return booleans or strings ("true").
156
+ def self.truthy_verification?(value)
157
+ value == true || value.to_s.strip.casecmp("true").zero?
158
+ end
159
+
160
+ # Fetch a key from an OmniAuth::AuthHash (Hashie::Mash) or a plain Hash
161
+ # without losing an explicit `false` value. Returns nil when absent.
162
+ def self.indifferent_lookup(hash, key)
163
+ return nil unless hash.respond_to?(:key?)
164
+
165
+ if hash.key?(key.to_s)
166
+ hash[key.to_s]
167
+ elsif hash.key?(key.to_sym)
168
+ hash[key.to_sym]
169
+ end
170
+ end
171
+
172
+ private_class_method :email_verified_for_auth?, :extract_email_verified_signal,
173
+ :truthy_verification?, :indifferent_lookup
174
+
105
175
  # Admin status check
106
176
  def admin?
107
177
  ActiveRecord::Type::Boolean.new.cast(admin)
@@ -18,7 +18,7 @@ class CreatePandaCoreFileCategories < ActiveRecord::Migration[7.1]
18
18
  create_table :panda_core_file_categorizations, id: :uuid do |t|
19
19
  t.references :file_category, null: false, type: :uuid,
20
20
  foreign_key: {to_table: :panda_core_file_categories}
21
- t.bigint :blob_id, null: false
21
+ t.column :blob_id, active_storage_blob_key_type, null: false
22
22
  t.timestamps
23
23
  end
24
24
 
@@ -27,4 +27,38 @@ class CreatePandaCoreFileCategories < ActiveRecord::Migration[7.1]
27
27
  add_index :panda_core_file_categorizations, :blob_id
28
28
  add_foreign_key :panda_core_file_categorizations, :active_storage_blobs, column: :blob_id
29
29
  end
30
+
31
+ private
32
+
33
+ # The type of active_storage_blobs.id is decided by the *host application*, not
34
+ # by this gem: Rails' own CreateActiveStorageTables reads
35
+ # Rails.configuration.generators[:active_record][:primary_key_type] at migration
36
+ # time, so an app that sets `primary_key_type: :uuid` gets uuid blob ids while an
37
+ # app that leaves it alone gets bigint.
38
+ #
39
+ # Hardcoding either one makes this migration fail on half of all host apps, and
40
+ # makes `db:migrate` and `db:schema:load` build structurally different databases
41
+ # in the same app. Read the type that is actually there instead.
42
+ def active_storage_blob_key_type
43
+ unless table_exists?(:active_storage_blobs)
44
+ raise <<~MESSAGE
45
+ panda-core's file categorisation tables reference active_storage_blobs, but that
46
+ table does not exist yet. Install and run Active Storage's migrations first:
47
+
48
+ bin/rails active_storage:install
49
+ bin/rails db:migrate
50
+ MESSAGE
51
+ end
52
+
53
+ column = columns(:active_storage_blobs).find { |c| c.name == "id" }
54
+
55
+ case column.type
56
+ when :integer
57
+ # PostgreSQL bigserial reports limit 8; SQLite's INTEGER primary key reports
58
+ # no limit and is 64-bit. Only a genuinely narrow integer stays :integer.
59
+ (column.limit && column.limit < 8) ? :integer : :bigint
60
+ else
61
+ column.type
62
+ end
63
+ end
30
64
  end
@@ -5,19 +5,25 @@ class FixActiveStorageAttachmentsRecordIdType < ActiveRecord::Migration[8.1]
5
5
  return unless table_exists?(:active_storage_attachments)
6
6
 
7
7
  column = columns(:active_storage_attachments).find { |c| c.name == "record_id" }
8
- return if column.nil? || column.sql_type == "character varying"
8
+ return if column.nil? || column.type == :string
9
9
 
10
- # Remove orphaned attachments where UUID was silently cast to 0
11
- execute <<~SQL
12
- DELETE FROM active_storage_attachments WHERE record_id = 0
13
- SQL
10
+ # Remove orphaned attachments where a UUID was silently cast to 0. This only
11
+ # ever happened on an integer column: on a uuid record_id (a host app with
12
+ # `primary_key_type: :uuid`) the comparison is not even well typed.
13
+ if column.type == :integer
14
+ execute <<~SQL
15
+ DELETE FROM active_storage_attachments WHERE record_id = 0
16
+ SQL
17
+ end
14
18
 
15
19
  # Drop the unique index before changing the column type
16
20
  remove_index :active_storage_attachments,
17
21
  name: "index_active_storage_attachments_uniqueness",
18
22
  if_exists: true
19
23
 
20
- change_column :active_storage_attachments, :record_id, :string, null: false
24
+ change_column :active_storage_attachments, :record_id, :string,
25
+ null: false,
26
+ **cast_to_string_options
21
27
 
22
28
  add_index :active_storage_attachments,
23
29
  [:record_type, :record_id, :name, :blob_id],
@@ -41,4 +47,19 @@ class FixActiveStorageAttachmentsRecordIdType < ActiveRecord::Migration[8.1]
41
47
  unique: true,
42
48
  name: "index_active_storage_attachments_uniqueness"
43
49
  end
50
+
51
+ private
52
+
53
+ # PostgreSQL has no assignment cast from uuid to text, so ALTER TABLE needs an
54
+ # explicit USING clause when the host app created record_id as a uuid. Other
55
+ # adapters (and integer columns) neither need nor accept one.
56
+ def cast_to_string_options
57
+ return {} unless adapter_is_postgresql?
58
+
59
+ {using: "record_id::text"}
60
+ end
61
+
62
+ def adapter_is_postgresql?
63
+ connection.adapter_name.match?(/postgres/i)
64
+ end
44
65
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # panda_core_tags.tenant_id and panda_core_import_sessions.tenant_id back
4
+ # `belongs_to :tenant, polymorphic: true`. A polymorphic id has to be able to hold
5
+ # whatever primary key the host app's tenant model uses, which for every current
6
+ # panda host is a uuid — so the column has to be a string.
7
+ #
8
+ # #150 established that for panda_core_tags and #151 fixed it by editing the
9
+ # already-released CreatePandaCoreTags migration in place. That fixed what a fresh
10
+ # `db:migrate` builds and nothing else: every database that had already run the
11
+ # migration kept its bigint column, and `db:schema:load` kept building bigint too,
12
+ # because spec/dummy/db/schema.rb was never regenerated. panda_core_import_sessions
13
+ # was never fixed at all.
14
+ #
15
+ # This converts the columns wherever they are still integers, and leaves string and
16
+ # uuid columns (a host app that typed them itself) alone.
17
+ class NormalisePolymorphicTenantIdColumns < ActiveRecord::Migration[8.1]
18
+ TABLES = %i[panda_core_tags panda_core_import_sessions].freeze
19
+
20
+ def up
21
+ TABLES.each do |table|
22
+ next unless integer_tenant_id?(table)
23
+
24
+ change_column table, :tenant_id, :string
25
+ end
26
+ end
27
+
28
+ def down
29
+ raise ActiveRecord::IrreversibleMigration,
30
+ "tenant_id holds host tenant primary keys, which are not necessarily integers"
31
+ end
32
+
33
+ private
34
+
35
+ def integer_tenant_id?(table)
36
+ return false unless table_exists?(table)
37
+
38
+ column = columns(table).find { |c| c.name == "tenant_id" }
39
+ column&.type == :integer
40
+ end
41
+ end
@@ -12,7 +12,22 @@ Panda::Core.configure do |config|
12
12
 
13
13
  # Authentication providers
14
14
  # Add the corresponding OmniAuth gem for each provider you enable
15
- # (e.g. omniauth-google-oauth2, omniauth-github, omniauth-microsoft_graph).
15
+ # (e.g. omniauth-google-oauth2, omniauth-github, omniauth-microsoft_graph,
16
+ # omniauth-apple).
17
+ #
18
+ # Sign in with Apple uses JWT options instead of a static client_secret:
19
+ #
20
+ # apple: {
21
+ # enabled: true,
22
+ # name: "Apple",
23
+ # client_id: Rails.application.credentials.dig(:apple, :client_id),
24
+ # options: {
25
+ # scope: "email name",
26
+ # team_id: Rails.application.credentials.dig(:apple, :team_id),
27
+ # key_id: Rails.application.credentials.dig(:apple, :key_id),
28
+ # pem: Rails.application.credentials.dig(:apple, :pem)
29
+ # }
30
+ # }
16
31
  #
17
32
  # In development, GitHub OAuth works out of the box with shared dev credentials
18
33
  # (restricted to localhost:3000). For production, add your own credentials via
@@ -50,7 +50,8 @@ module Panda
50
50
  :restrict_user_creation,
51
51
  :after_user_invited,
52
52
  :invite_form_content,
53
- :post_authentication_redirect
53
+ :post_authentication_redirect,
54
+ :trusted_email_providers
54
55
 
55
56
  def initialize
56
57
  @user_class = "Panda::Core::User"
@@ -67,6 +68,19 @@ module Panda
67
68
  @authentication_provider_resolver = nil
68
69
  @authentication_provider_gate = nil
69
70
  @authentication_validator = nil
71
+ # OAuth providers whose asserted email address may be trusted as proof
72
+ # of ownership when the provider does NOT return an explicit
73
+ # `email_verified` signal. Providers that DO return the signal (e.g.
74
+ # Google/OIDC) are honoured regardless of this list. Operators must
75
+ # opt a provider in here; anything not listed (and without a verified
76
+ # signal) is treated as unverified to avoid account-takeover via a
77
+ # provider that lets users assert arbitrary emails.
78
+ #
79
+ # Defaults to the first-party providers this engine ships support for,
80
+ # all of which verify email ownership (Google also returns an explicit
81
+ # signal). A generic/self-hosted OAuth2/OIDC/SAML provider an operator
82
+ # adds is NOT trusted by default and must be listed here explicitly.
83
+ @trusted_email_providers = %i[google_oauth2 github microsoft_graph apple]
70
84
  @admin_path = "/admin"
71
85
  @default_theme = "default"
72
86
 
@@ -22,10 +22,47 @@ module Panda
22
22
  "github" => :github,
23
23
  "gh" => :github,
24
24
 
25
+ # Apple (Sign in with Apple)
26
+ "apple" => :apple,
27
+
25
28
  # Developer
26
29
  "developer" => :developer
27
30
  }.freeze
28
31
 
32
+ # Whether a provider has enough credentials to register / show on login.
33
+ # Apple uses a JWT client secret derived from team_id / key_id / pem
34
+ # (no static client_secret).
35
+ def self.credentials_configured?(symbol, settings)
36
+ case symbol.to_sym
37
+ when :developer
38
+ true
39
+ when :apple
40
+ apple_credentials_configured?(settings)
41
+ else
42
+ settings[:client_id].present? && settings[:client_secret].present?
43
+ end
44
+ end
45
+
46
+ def self.apple_credentials_configured?(settings)
47
+ settings[:client_id].present? &&
48
+ settings.dig(:options, :team_id).present? &&
49
+ settings.dig(:options, :key_id).present? &&
50
+ settings.dig(:options, :pem).present?
51
+ end
52
+
53
+ # Register a provider on an OmniAuth::Builder.
54
+ # Apple's strategy ignores the static secret and signs a JWT from options.
55
+ def self.register_provider(builder, symbol, settings, options)
56
+ case symbol.to_sym
57
+ when :apple
58
+ builder.provider :apple, settings[:client_id], "", options
59
+ when :developer
60
+ builder.provider :developer, options
61
+ else
62
+ builder.provider symbol, settings[:client_id], settings[:client_secret], options
63
+ end
64
+ end
65
+
29
66
  class_methods do
30
67
  # Load YAML provider overrides during engine definition (before middleware setup)
31
68
  def load_yaml_provider_overrides_early!
@@ -51,8 +88,8 @@ module Panda
51
88
  # causes "Forbidden" errors when submitting the admin login form.
52
89
  #
53
90
  # Disabling OmniAuth's request_validation_phase is safe because:
54
- # - Production OAuth providers (Google, GitHub, Microsoft) are
55
- # protected by the OAuth state parameter in the callback phase
91
+ # - Production OAuth providers (Google, GitHub, Microsoft, Apple)
92
+ # are protected by the OAuth state parameter in the callback phase
56
93
  # - The developer provider only runs in development/test
57
94
  # - The login form still requires POST (allowed_request_methods)
58
95
  c.request_validation_phase = nil
@@ -87,21 +124,15 @@ module Panda
87
124
 
88
125
  return if symbol == :developer && !Rails.env.development?
89
126
 
90
- # Skip providers without credentials (except developer which doesn't need them)
91
- has_credentials = settings[:client_id].present? && settings[:client_secret].present?
92
- if symbol != :developer && !has_credentials
93
- Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing client_id or client_secret") if defined?(Rails.logger)
127
+ unless OmniauthConfig.credentials_configured?(symbol, settings)
128
+ Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing credentials") if defined?(Rails.logger)
94
129
  return
95
130
  end
96
131
 
97
132
  options = (settings[:options] || {}).dup
98
133
  options[:name] = settings[:path_name] if settings[:path_name].present?
99
134
 
100
- if settings[:client_id] && settings[:client_secret]
101
- builder.provider symbol, settings[:client_id], settings[:client_secret], options
102
- else
103
- builder.provider symbol, options
104
- end
135
+ OmniauthConfig.register_provider(builder, symbol, settings, options)
105
136
  end
106
137
 
107
138
  included do
@@ -144,10 +175,8 @@ module Panda
144
175
 
145
176
  next if symbol == :developer && !Rails.env.development?
146
177
 
147
- # Skip providers without credentials (except developer which doesn't need them)
148
- has_credentials = settings[:client_id].present? && settings[:client_secret].present?
149
- if symbol != :developer && !has_credentials
150
- Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing client_id or client_secret") if defined?(Rails.logger)
178
+ unless OmniauthConfig.credentials_configured?(symbol, settings)
179
+ Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing credentials") if defined?(Rails.logger)
151
180
  next
152
181
  end
153
182
 
@@ -166,11 +195,7 @@ module Panda
166
195
  }
167
196
  end
168
197
 
169
- if settings[:client_id] && settings[:client_secret]
170
- builder.provider symbol, settings[:client_id], settings[:client_secret], options
171
- else
172
- builder.provider symbol, options
173
- end
198
+ OmniauthConfig.register_provider(builder, symbol, settings, options)
174
199
  end
175
200
  end
176
201
  end