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.
- checksums.yaml +4 -4
- data/app/components/panda/core/shared/header_component.html.erb +18 -7
- data/app/components/panda/core/shared/header_component.rb +37 -0
- data/app/controllers/panda/core/admin/sessions_controller.rb +2 -1
- data/app/helpers/panda/core/asset_helper.rb +55 -10
- data/app/helpers/panda/core/sessions_helper.rb +3 -1
- data/app/javascript/panda/core/application.js +3 -0
- data/app/javascript/panda/core/fontawesome-config.js +23 -0
- data/app/models/panda/core/user.rb +70 -0
- data/db/migrate/20260202171614_create_panda_core_file_categories.rb +35 -1
- data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +27 -6
- data/db/migrate/20260825120000_normalise_polymorphic_tenant_id_columns.rb +41 -0
- data/lib/generators/panda/core/templates/panda.rb +16 -1
- data/lib/panda/core/configuration.rb +15 -1
- data/lib/panda/core/engine/omniauth_config.rb +45 -20
- data/lib/panda/core/module_registry.rb +56 -9
- data/lib/panda/core/oauth_providers.rb +12 -0
- data/lib/panda/core/shared/generator_config.rb +8 -0
- data/lib/panda/core/version.rb +1 -1
- data/lib/tasks/panda/core/schema_check.rake +105 -0
- data/public/panda-core-assets/es-module-shims.js +2 -0
- data/public/panda-core-assets/fontawesome/css/all.min.css +9 -0
- data/public/panda-core-assets/fontawesome/webfonts/fa-brands-400.woff2 +0 -0
- data/public/panda-core-assets/fontawesome/webfonts/fa-regular-400.woff2 +0 -0
- data/public/panda-core-assets/fontawesome/webfonts/fa-solid-900.woff2 +0 -0
- data/public/panda-core-assets/fontawesome/webfonts/fa-v4compatibility.woff2 +0 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3e74c09fd81106220b436b5fd9f2290fd3c33c4b648d011a9ee637f7da06af1
|
|
4
|
+
data.tar.gz: 4979e5db64c94a2a48256561fb4dacf8d8970c3621aae32f00fe47d6734b9aea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
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.
|
|
8
|
+
return if column.nil? || column.type == :string
|
|
9
9
|
|
|
10
|
-
# Remove orphaned attachments where UUID was silently cast to 0
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|