nquery 0.1.0 → 0.1.2
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/CHANGELOG.md +32 -0
- data/README.md +263 -24
- data/app/assets/builds/nquery/application.css +61 -4
- data/app/assets/builds/nquery/application.js +107 -12
- data/app/controllers/concerns/nquery/breadcrumbs.rb +5 -3
- data/app/controllers/concerns/nquery/chart_actions.rb +1 -0
- data/app/controllers/concerns/nquery/chart_results.rb +17 -3
- data/app/controllers/nquery/admin/data_sources_controller.rb +49 -2
- data/app/controllers/nquery/admin/groups_controller.rb +8 -2
- data/app/controllers/nquery/admin/users_controller.rb +6 -1
- data/app/controllers/nquery/application_controller.rb +45 -28
- data/app/controllers/nquery/dashboards_controller.rb +30 -0
- data/app/controllers/nquery/embed/charts_controller.rb +1 -0
- data/app/controllers/nquery/embed/dashboards_controller.rb +1 -0
- data/app/controllers/nquery/errors_controller.rb +11 -0
- data/app/controllers/nquery/home_controller.rb +1 -0
- data/app/controllers/nquery/onboarding/admins_controller.rb +42 -0
- data/app/controllers/nquery/onboarding/base_controller.rb +27 -0
- data/app/controllers/nquery/onboarding/companies_controller.rb +30 -0
- data/app/controllers/nquery/onboarding/confirmations_controller.rb +48 -0
- data/app/controllers/nquery/onboarding/congrats_controller.rb +15 -0
- data/app/controllers/nquery/queries_controller.rb +0 -1
- data/app/controllers/nquery/sessions_controller.rb +14 -17
- data/app/helpers/nquery/data_sources_helper.rb +13 -0
- data/app/helpers/nquery/groups_helper.rb +19 -0
- data/app/javascript/nquery/controllers/data_source_form_controller.js +25 -0
- data/app/mailers/nquery/application_mailer.rb +7 -0
- data/app/mailers/nquery/devise_mailer.rb +9 -0
- data/app/models/nquery/audit.rb +2 -2
- data/app/models/nquery/collection.rb +1 -1
- data/app/models/nquery/dashboard.rb +1 -1
- data/app/models/nquery/data_source.rb +139 -4
- data/app/models/nquery/organization.rb +38 -0
- data/app/models/nquery/query.rb +8 -0
- data/app/models/nquery/user.rb +45 -10
- data/app/views/nquery/admin/data_sources/_form.html.erb +121 -0
- data/app/views/nquery/admin/data_sources/edit.html.erb +6 -8
- data/app/views/nquery/admin/data_sources/new.html.erb +6 -7
- data/app/views/nquery/admin/groups/_form.html.erb +47 -0
- data/app/views/nquery/admin/groups/edit.html.erb +15 -6
- data/app/views/nquery/admin/groups/index.html.erb +25 -20
- data/app/views/nquery/admin/groups/new.html.erb +6 -6
- data/app/views/nquery/admin/groups/show.html.erb +98 -19
- data/app/views/nquery/admin/logs/index.html.erb +26 -30
- data/app/views/nquery/charts/_builder_form.html.erb +6 -4
- data/app/views/nquery/charts/edit.html.erb +2 -1
- data/app/views/nquery/dashboards/index.html.erb +6 -0
- data/app/views/nquery/dashboards/new.html.erb +34 -0
- data/app/views/nquery/devise_mailer/confirmation_instructions.html.erb +5 -0
- data/app/views/nquery/devise_mailer/confirmation_instructions.text.erb +5 -0
- data/app/views/nquery/errors/internal_server_error.html.erb +10 -0
- data/app/views/nquery/errors/not_found.html.erb +10 -0
- data/app/views/nquery/home/index.html.erb +7 -15
- data/app/views/nquery/onboarding/admins/new.html.erb +21 -0
- data/app/views/nquery/onboarding/companies/new.html.erb +25 -0
- data/app/views/nquery/onboarding/confirmations/show.html.erb +17 -0
- data/app/views/nquery/onboarding/congrats/show.html.erb +11 -0
- data/app/views/nquery/sessions/new.html.erb +8 -9
- data/app/views/nquery/shared/_auth_shell.html.erb +1 -1
- data/app/views/nquery/shared/_topbar.html.erb +0 -4
- data/config/routes.rb +26 -7
- data/db/migrate/20260721000001_create_nquery_tables.rb +1 -1
- data/db/migrate/20260722000001_add_key_to_nquery_data_sources.rb +8 -0
- data/db/migrate/20260722000002_create_nquery_organizations.rb +11 -0
- data/db/migrate/20260722000003_add_devise_to_nquery_users.rb +35 -0
- data/db/migrate/20260722000004_add_onboarding_completed_at_to_nquery_organizations.rb +7 -0
- data/lib/generators/nquery/install_generator.rb +9 -17
- data/lib/nquery/configuration.rb +7 -23
- data/lib/nquery/csv_importer.rb +3 -3
- data/lib/nquery/data_sources/adapter.rb +4 -0
- data/lib/nquery/data_sources/postgresql_adapter.rb +15 -1
- data/lib/nquery/data_sources/rails_adapter.rb +5 -0
- data/lib/nquery/data_sources/syncer.rb +50 -0
- data/lib/nquery/encryption.rb +50 -0
- data/lib/nquery/engine.rb +42 -3
- data/lib/nquery/onboarding/admin_provisioner.rb +40 -0
- data/lib/nquery/onboarding/password_confirmation.rb +46 -0
- data/lib/nquery/onboarding.rb +40 -0
- data/lib/nquery/permissions/resolver.rb +14 -4
- data/lib/nquery/query_runner.rb +5 -9
- data/lib/nquery/readonly_sql.rb +25 -0
- data/lib/nquery/seeder.rb +25 -34
- data/lib/nquery/setup.rb +75 -0
- data/lib/nquery/version.rb +1 -1
- data/lib/nquery.rb +9 -0
- data/lib/tasks/nquery.rake +6 -0
- data/nquery.gemspec +11 -5
- metadata +135 -26
- data/app/controllers/nquery/registrations_controller.rb +0 -31
- data/app/views/nquery/registrations/new.html.erb +0 -34
|
@@ -1,37 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators/base"
|
|
4
|
-
require "rails/generators/migration"
|
|
5
4
|
|
|
6
5
|
module Nquery
|
|
7
6
|
module Generators
|
|
8
7
|
class InstallGenerator < Rails::Generators::Base
|
|
9
|
-
include Rails::Generators::Migration
|
|
10
8
|
source_root File.expand_path("templates", __dir__)
|
|
11
9
|
|
|
12
|
-
desc "Install nquery: copies
|
|
13
|
-
|
|
14
|
-
def copy_migration
|
|
15
|
-
migration_template "install.rb.tt", "db/migrate/install_nquery.rb", migration_version: migration_version
|
|
16
|
-
end
|
|
10
|
+
desc "Install nquery: copies initializer and prints setup instructions"
|
|
17
11
|
|
|
18
12
|
def copy_initializer
|
|
19
13
|
template "nquery.rb.tt", "config/initializers/nquery.rb"
|
|
20
14
|
end
|
|
21
15
|
|
|
22
16
|
def show_route_snippet
|
|
23
|
-
say "\
|
|
17
|
+
say "\nNext steps:\n", :green
|
|
18
|
+
say " 1. rails active_storage:install # required for organization logo/cover uploads"
|
|
19
|
+
say " 2. rails db:migrate # engine migrations load from the gem automatically"
|
|
20
|
+
say " 3. rails nquery:setup"
|
|
21
|
+
say " 4. Add to config/routes.rb:\n"
|
|
24
22
|
say <<~ROUTE
|
|
25
|
-
|
|
26
|
-
mount Nquery::Engine, at: "/nquery"
|
|
27
|
-
end
|
|
23
|
+
mount Nquery::Engine, at: "/nquery"
|
|
28
24
|
ROUTE
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def migration_version
|
|
34
|
-
"[#{ActiveRecord::Migration.current_version}]"
|
|
25
|
+
say " 5. Set mailer_sender + smtp in config/initializers/nquery.rb"
|
|
26
|
+
say " 6. Visit /nquery and complete the onboarding wizard\n"
|
|
35
27
|
end
|
|
36
28
|
end
|
|
37
29
|
end
|
data/lib/nquery/configuration.rb
CHANGED
|
@@ -2,39 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module Nquery
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :authentication_mode
|
|
6
|
-
attr_accessor :current_user_method
|
|
7
5
|
attr_accessor :parent_controller
|
|
8
6
|
attr_accessor :data_sources
|
|
7
|
+
attr_accessor :default_data_source
|
|
8
|
+
attr_accessor :mailer_sender
|
|
9
|
+
attr_accessor :smtp
|
|
9
10
|
attr_accessor :query_timeout
|
|
10
11
|
attr_accessor :query_row_limit
|
|
11
12
|
attr_accessor :embed_secret
|
|
12
13
|
|
|
13
14
|
def initialize
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
-
@
|
|
15
|
+
@data_sources = { main: { adapter: :rails, name: "Application database" } }
|
|
16
|
+
@default_data_source = :main
|
|
17
|
+
@mailer_sender = nil
|
|
18
|
+
@smtp = {}
|
|
17
19
|
@query_timeout = 15
|
|
18
20
|
@query_row_limit = 10_000
|
|
19
21
|
@embed_secret = nil
|
|
20
|
-
@authenticate_with = nil
|
|
21
|
-
@resolve_nquery_user = nil
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def authenticate_with(&block)
|
|
25
|
-
@authenticate_with = block
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def authenticate
|
|
29
|
-
@authenticate_with
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def resolve_nquery_user(&block)
|
|
33
|
-
@resolve_nquery_user = block
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def resolve_user
|
|
37
|
-
@resolve_nquery_user
|
|
38
22
|
end
|
|
39
23
|
|
|
40
24
|
def embed_signing_key
|
data/lib/nquery/csv_importer.rb
CHANGED
|
@@ -31,10 +31,10 @@ module Nquery
|
|
|
31
31
|
create_import_table(connection, table_name, columns)
|
|
32
32
|
insert_rows(connection, table_name, rows, columns)
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
DataSource.create!(
|
|
35
35
|
name: @name,
|
|
36
|
-
adapter:
|
|
37
|
-
|
|
36
|
+
adapter: "rails",
|
|
37
|
+
connection_config_hash: { "import_table" => table_name }
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
upload.update!(status: "completed", column_mapping: columns)
|
|
@@ -13,6 +13,11 @@ module Nquery
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def test_connection
|
|
17
|
+
with_connection { |conn| conn.exec_query("SELECT 1") }
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
def execute_readonly(statement, timeout: 15, row_limit: 10_000)
|
|
17
22
|
with_connection do |conn|
|
|
18
23
|
rows = []
|
|
@@ -35,12 +40,21 @@ module Nquery
|
|
|
35
40
|
|
|
36
41
|
def with_connection
|
|
37
42
|
config = @data_source.connection_config_hash
|
|
38
|
-
klass =
|
|
43
|
+
klass = ephemeral_connection_class
|
|
39
44
|
klass.establish_connection(config)
|
|
40
45
|
yield klass.connection
|
|
41
46
|
ensure
|
|
42
47
|
klass.remove_connection if defined?(klass) && klass.connected?
|
|
43
48
|
end
|
|
49
|
+
|
|
50
|
+
def ephemeral_connection_class
|
|
51
|
+
class_name = "Nquery::DataSources::EphemeralConnection#{@data_source.id || "new"}#{object_id}"
|
|
52
|
+
Class.new(ActiveRecord::Base) do
|
|
53
|
+
self.abstract_class = true
|
|
54
|
+
|
|
55
|
+
define_singleton_method(:name) { class_name }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
44
58
|
end
|
|
45
59
|
end
|
|
46
60
|
end
|
|
@@ -13,6 +13,11 @@ module Nquery
|
|
|
13
13
|
connection.columns(table_name).map { |c| { name: c.name, type: c.type.to_s } }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def test_connection
|
|
17
|
+
connection.exec_query("SELECT 1")
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
def execute_readonly(statement, timeout: 15, row_limit: 10_000)
|
|
17
22
|
rows = []
|
|
18
23
|
columns = []
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module DataSources
|
|
5
|
+
class Syncer
|
|
6
|
+
CREDENTIAL_KEYS = %i[host port database username password database_path sslmode].freeze
|
|
7
|
+
|
|
8
|
+
def self.call
|
|
9
|
+
new.call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
Nquery.configuration.data_sources.each do |key, definition|
|
|
14
|
+
sync_data_source!(key, definition)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def sync_data_source!(key, definition)
|
|
21
|
+
attrs = normalize_definition(definition, key)
|
|
22
|
+
data_source = DataSource.find_or_initialize_by(key: key.to_s)
|
|
23
|
+
data_source.name = attrs.fetch(:name)
|
|
24
|
+
data_source.adapter = attrs.fetch(:adapter).to_s
|
|
25
|
+
data_source.connection_config_hash = {} if data_source.adapter == "rails"
|
|
26
|
+
data_source.save!
|
|
27
|
+
data_source
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def normalize_definition(definition, key)
|
|
31
|
+
if definition.is_a?(Hash)
|
|
32
|
+
symbolized = definition.deep_symbolize_keys
|
|
33
|
+
{
|
|
34
|
+
adapter: symbolized.fetch(:adapter),
|
|
35
|
+
name: symbolized.fetch(:name)
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
{
|
|
39
|
+
adapter: definition,
|
|
40
|
+
name: default_name_for(key)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_name_for(key)
|
|
46
|
+
key.to_s.humanize
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Nquery
|
|
6
|
+
module Encryption
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def configure!(app)
|
|
10
|
+
encryption = app.config.active_record.encryption
|
|
11
|
+
return if encryption.primary_key.present?
|
|
12
|
+
|
|
13
|
+
unless derive_keys_allowed?(app)
|
|
14
|
+
warn_missing_keys(app)
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
secret = app.secret_key_base
|
|
19
|
+
encryption.primary_key = derived_key(secret, "primary")
|
|
20
|
+
encryption.deterministic_key = derived_key(secret, "deterministic")
|
|
21
|
+
encryption.key_derivation_salt = derived_key(secret, "salt")
|
|
22
|
+
encryption.support_unencrypted_data = support_unencrypted_data if encryption.support_unencrypted_data.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def derive_keys_allowed?(app)
|
|
26
|
+
app.config.consider_all_requests_local
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def support_unencrypted_data
|
|
30
|
+
if ENV.key?("NQUERY_SUPPORT_UNENCRYPTED_DATA")
|
|
31
|
+
return ActiveModel::Type::Boolean.new.cast(ENV.fetch("NQUERY_SUPPORT_UNENCRYPTED_DATA"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def derived_key(secret, label)
|
|
38
|
+
Digest::SHA256.hexdigest("nquery-#{label}-#{secret}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def warn_missing_keys(app)
|
|
42
|
+
return unless defined?(Rails) && Rails.logger
|
|
43
|
+
|
|
44
|
+
Rails.logger.warn(
|
|
45
|
+
"[nquery] Active Record encryption keys are not configured. " \
|
|
46
|
+
"Set active_record_encryption in Rails credentials before deploying to production."
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/nquery/engine.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "devise"
|
|
4
|
+
|
|
3
5
|
module Nquery
|
|
4
6
|
class Engine < ::Rails::Engine
|
|
5
7
|
isolate_namespace Nquery
|
|
@@ -36,12 +38,49 @@ module Nquery
|
|
|
36
38
|
app.config.paths["db/migrate"] << migration_path unless app.config.paths["db/migrate"].include?(migration_path)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
initializer "nquery.
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
initializer "nquery.schema_dumper" do
|
|
42
|
+
ActiveSupport.on_load(:active_record) do
|
|
43
|
+
ActiveRecord::SchemaDumper.ignore_tables |= Nquery::SampleData::Ecommerce::REQUIRED_TABLES
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
initializer "nquery.encryption", before: "active_record_encryption" do |app|
|
|
48
|
+
Nquery::Encryption.configure!(app)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
initializer "nquery.filter_parameters" do |app|
|
|
52
|
+
app.config.filter_parameters += %i[
|
|
53
|
+
password
|
|
54
|
+
password_confirmation
|
|
55
|
+
connection_config
|
|
56
|
+
username
|
|
57
|
+
database
|
|
58
|
+
database_path
|
|
59
|
+
host
|
|
60
|
+
port
|
|
61
|
+
sslmode
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
initializer "nquery.mailer" do
|
|
66
|
+
config.to_prepare do
|
|
67
|
+
mailer_sender = Nquery.configuration.mailer_sender
|
|
68
|
+
smtp_settings = Nquery.configuration.smtp&.deep_symbolize_keys
|
|
69
|
+
|
|
70
|
+
[Nquery::ApplicationMailer, Nquery::DeviseMailer].each do |mailer_class|
|
|
71
|
+
mailer_class.default from: mailer_sender if mailer_sender.present?
|
|
72
|
+
mailer_class.smtp_settings = smtp_settings if smtp_settings.present?
|
|
73
|
+
end
|
|
42
74
|
end
|
|
43
75
|
end
|
|
44
76
|
|
|
77
|
+
# Devise mapping options (router_name, sign_out_via) live on devise_for in
|
|
78
|
+
# config/routes.rb. Mail is sent via User#send_devise_notification.
|
|
79
|
+
# Do not mutate global Devise settings (parent_controller, Warden failure_app,
|
|
80
|
+
# navigational_formats, mailer) — host apps that also use Devise must keep
|
|
81
|
+
# their own settings. Stock Devise::FailureApp + router_name: :nquery preserves
|
|
82
|
+
# the engine mount prefix on unauthenticated redirects.
|
|
83
|
+
|
|
45
84
|
initializer "nquery.components" do
|
|
46
85
|
ActiveSupport.on_load(:action_controller) do
|
|
47
86
|
append_view_path Nquery::Engine.root.join("app/components")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Onboarding
|
|
5
|
+
class AdminProvisioner
|
|
6
|
+
Result = Data.define(:user, :mail_delivered?)
|
|
7
|
+
|
|
8
|
+
def self.call(user)
|
|
9
|
+
new(user).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(user)
|
|
13
|
+
@user = user
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
@user.skip_confirmation_notification!
|
|
18
|
+
|
|
19
|
+
ActiveRecord::Base.transaction do
|
|
20
|
+
@user.save!
|
|
21
|
+
administrators_group.group_memberships.find_or_create_by!(user: @user)
|
|
22
|
+
@user.ensure_all_users_membership!
|
|
23
|
+
@user.ensure_personal_collection!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Result.new(user: @user, mail_delivered?: deliver_confirmation_email)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def deliver_confirmation_email
|
|
32
|
+
@user.send_confirmation_instructions != false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def administrators_group
|
|
36
|
+
Group.find_by!(system_group: "administrators")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Onboarding
|
|
5
|
+
class PasswordConfirmation
|
|
6
|
+
Result = Data.define(:success?, :user)
|
|
7
|
+
|
|
8
|
+
def self.call(user:, password:, password_confirmation:)
|
|
9
|
+
new(user:, password:, password_confirmation:).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(user:, password:, password_confirmation:)
|
|
13
|
+
@user = user
|
|
14
|
+
@password = password
|
|
15
|
+
@password_confirmation = password_confirmation
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return failure unless user
|
|
20
|
+
|
|
21
|
+
user.assign_attributes(password: password, password_confirmation: password_confirmation)
|
|
22
|
+
return failure unless user.save
|
|
23
|
+
|
|
24
|
+
user.confirm unless user.confirmed?
|
|
25
|
+
latch_onboarding_completion!
|
|
26
|
+
success
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :user, :password, :password_confirmation
|
|
32
|
+
|
|
33
|
+
def latch_onboarding_completion!
|
|
34
|
+
Organization.first&.update!(onboarding_completed_at: Time.current)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def success
|
|
38
|
+
Result.new(success?: true, user: user)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failure
|
|
42
|
+
Result.new(success?: false, user: user)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Onboarding
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def required?
|
|
8
|
+
!complete?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def complete?
|
|
12
|
+
Organization.exists? && (onboarding_latched? || confirmed_admin_exists?)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def pending_admin_confirmation?
|
|
16
|
+
return false if complete?
|
|
17
|
+
return false unless Organization.exists?
|
|
18
|
+
|
|
19
|
+
unconfirmed_admin_exists?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def onboarding_latched?
|
|
23
|
+
Organization.where.not(onboarding_completed_at: nil).exists?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def confirmed_admin_exists?
|
|
27
|
+
administrators = Group.find_by(system_group: "administrators")
|
|
28
|
+
return false unless administrators
|
|
29
|
+
|
|
30
|
+
administrators.users.active.where.not(confirmed_at: nil).exists?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def unconfirmed_admin_exists?
|
|
34
|
+
administrators = Group.find_by(system_group: "administrators")
|
|
35
|
+
return false unless administrators
|
|
36
|
+
|
|
37
|
+
administrators.users.active.where(confirmed_at: nil).exists?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -20,10 +20,7 @@ module Nquery
|
|
|
20
20
|
def collection_access(collection)
|
|
21
21
|
return :curate if admin?
|
|
22
22
|
|
|
23
|
-
levels =
|
|
24
|
-
perm = Nquery::CollectionPermission.find_by(group: group, collection: collection)
|
|
25
|
-
perm&.access_level
|
|
26
|
-
end
|
|
23
|
+
levels = collection_access_levels_by_id[collection.id] || []
|
|
27
24
|
max_level(levels, COLLECTION_LEVELS)&.to_sym || :no_access
|
|
28
25
|
end
|
|
29
26
|
|
|
@@ -62,6 +59,19 @@ module Nquery
|
|
|
62
59
|
|
|
63
60
|
private
|
|
64
61
|
|
|
62
|
+
def collection_access_levels_by_id
|
|
63
|
+
@collection_access_levels_by_id ||= load_collection_access_levels_by_id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_collection_access_levels_by_id
|
|
67
|
+
return {} if @groups.empty?
|
|
68
|
+
|
|
69
|
+
Nquery::CollectionPermission
|
|
70
|
+
.where(group_id: @groups.map(&:id))
|
|
71
|
+
.group_by(&:collection_id)
|
|
72
|
+
.transform_values { |permissions| permissions.map(&:access_level) }
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
def max_level(levels, map)
|
|
66
76
|
levels.max_by { |l| map[l] || -1 }
|
|
67
77
|
end
|
data/lib/nquery/query_runner.rb
CHANGED
|
@@ -5,8 +5,6 @@ module Nquery
|
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
class PermissionError < Error; end
|
|
7
7
|
|
|
8
|
-
FORBIDDEN_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|INTO)\b/i
|
|
9
|
-
|
|
10
8
|
def initialize(data_source:, statement:, user: nil, query: nil)
|
|
11
9
|
@data_source = data_source
|
|
12
10
|
@statement = statement.to_s.strip
|
|
@@ -14,7 +12,7 @@ module Nquery
|
|
|
14
12
|
@query = query
|
|
15
13
|
end
|
|
16
14
|
|
|
17
|
-
def run
|
|
15
|
+
def run(audit: true)
|
|
18
16
|
validate_statement!
|
|
19
17
|
check_permissions!
|
|
20
18
|
|
|
@@ -25,22 +23,20 @@ module Nquery
|
|
|
25
23
|
row_limit: Nquery.configuration.query_row_limit
|
|
26
24
|
)
|
|
27
25
|
|
|
28
|
-
record_audit!(result, "success")
|
|
26
|
+
record_audit!(result, "success") if audit
|
|
29
27
|
result
|
|
30
28
|
rescue PermissionError
|
|
31
29
|
raise
|
|
32
30
|
rescue StandardError => e
|
|
33
|
-
record_audit!({}, "error", e.message)
|
|
31
|
+
record_audit!({}, "error", e.message) if audit
|
|
34
32
|
raise Error, e.message
|
|
35
33
|
end
|
|
36
34
|
|
|
37
35
|
private
|
|
38
36
|
|
|
39
37
|
def validate_statement!
|
|
40
|
-
|
|
41
|
-
raise Error,
|
|
42
|
-
raise Error, "Query must start with SELECT or WITH" unless @statement.match?(/\A\s*(SELECT|WITH)\b/i)
|
|
43
|
-
raise Error, "Only SELECT queries are allowed" if @statement.match?(FORBIDDEN_KEYWORDS)
|
|
38
|
+
message = ReadonlySql.error_message(@statement)
|
|
39
|
+
raise Error, message if message
|
|
44
40
|
end
|
|
45
41
|
|
|
46
42
|
def check_permissions!
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
# Validates that a SQL statement is read-only (SELECT / WITH … SELECT).
|
|
5
|
+
module ReadonlySql
|
|
6
|
+
FORBIDDEN_KEYWORDS = /\b(
|
|
7
|
+
INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|
|
|
8
|
+
GRANT|REVOKE|INTO|MERGE|REPLACE|CALL|EXEC|EXECUTE|
|
|
9
|
+
COPY|ATTACH|DETACH|VACUUM|PRAGMA|REINDEX|RENAME
|
|
10
|
+
)\b/ix
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def error_message(statement, allow_blank: false)
|
|
15
|
+
sql = statement.to_s.strip
|
|
16
|
+
|
|
17
|
+
return "Query cannot be blank" if sql.blank? && !allow_blank
|
|
18
|
+
return if sql.blank?
|
|
19
|
+
|
|
20
|
+
return "Multi-statement queries are not allowed" if sql.include?(";")
|
|
21
|
+
return "Query must start with SELECT or WITH" unless sql.match?(/\A\s*(SELECT|WITH)\b/i)
|
|
22
|
+
"Only SELECT queries are allowed" if sql.match?(FORBIDDEN_KEYWORDS)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/nquery/seeder.rb
CHANGED
|
@@ -7,10 +7,15 @@ module Nquery
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def run!
|
|
10
|
+
Setup.run!
|
|
11
|
+
|
|
12
|
+
organization = Organization.find_or_create_by!(name: "Nquery Demo") do |org|
|
|
13
|
+
org.website = "https://nquery.dev"
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
seed_sample_data!
|
|
11
17
|
|
|
12
|
-
admin_group =
|
|
13
|
-
all_users_group = ensure_group!("All Users", "all_users")
|
|
18
|
+
admin_group = Group.find_by!(system_group: "administrators")
|
|
14
19
|
engineering = ensure_group!("Engineering", "custom", "Engineering team")
|
|
15
20
|
|
|
16
21
|
admin = ensure_user!("admin@nquery.dev", "Admin", "User", "password123")
|
|
@@ -20,15 +25,12 @@ module Nquery
|
|
|
20
25
|
admin_group.group_memberships.find_or_create_by!(user: admin)
|
|
21
26
|
engineering.group_memberships.find_or_create_by!(user: analyst)
|
|
22
27
|
|
|
23
|
-
data_source = DataSource.
|
|
24
|
-
ds.adapter = "rails"
|
|
25
|
-
ds.connection_config = {}.to_json
|
|
26
|
-
end
|
|
28
|
+
data_source = DataSource.find_by(key: "main") || DataSource.find_by!(name: "Application database")
|
|
27
29
|
|
|
28
|
-
root_collection = Collection.find_or_create_by!(name:
|
|
30
|
+
root_collection = Collection.find_or_create_by!(name: Setup::ROOT_COLLECTION_NAME, kind: "root")
|
|
29
31
|
admin.ensure_personal_collection!
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
seed_demo_permissions!(engineering, root_collection, data_source)
|
|
32
34
|
|
|
33
35
|
sample_tables = SampleData::Ecommerce::TABLES
|
|
34
36
|
query = Query.find_or_initialize_by(name: "Monthly revenue")
|
|
@@ -74,11 +76,7 @@ module Nquery
|
|
|
74
76
|
expires_at: 1.year.from_now
|
|
75
77
|
) unless EmbedToken.exists?(resource_type: "Nquery::Chart", resource_id: chart.id)
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
ApplicationPermission.find_or_create_by!(group: admin_group, feature: feature) do |p|
|
|
79
|
-
p.access_level = "yes"
|
|
80
|
-
end
|
|
81
|
-
end
|
|
79
|
+
organization.update!(onboarding_completed_at: Time.current) if organization.onboarding_completed_at.blank?
|
|
82
80
|
end
|
|
83
81
|
|
|
84
82
|
private
|
|
@@ -97,34 +95,27 @@ module Nquery
|
|
|
97
95
|
end
|
|
98
96
|
|
|
99
97
|
def ensure_user!(email, first_name, last_name, password)
|
|
100
|
-
User.find_or_create_by!(email: email) do |
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
User.find_or_create_by!(email: email) do |user|
|
|
99
|
+
user.first_name = first_name
|
|
100
|
+
user.last_name = last_name
|
|
101
|
+
user.password = password
|
|
102
|
+
user.password_confirmation = password
|
|
103
|
+
user.confirmed_at = Time.current
|
|
104
|
+
end.tap do |user|
|
|
105
|
+
user.update!(confirmed_at: Time.current) if user.confirmed_at.blank?
|
|
104
106
|
end
|
|
105
107
|
end
|
|
106
108
|
|
|
107
|
-
def
|
|
108
|
-
CollectionPermission.find_or_create_by!(group: admin_group, collection: root_collection) do |p|
|
|
109
|
-
p.access_level = "curate"
|
|
110
|
-
end
|
|
111
|
-
CollectionPermission.find_or_create_by!(group: all_users_group, collection: root_collection) do |p|
|
|
112
|
-
p.access_level = "view"
|
|
113
|
-
end
|
|
109
|
+
def seed_demo_permissions!(engineering, root_collection, data_source)
|
|
114
110
|
CollectionPermission.find_or_create_by!(group: engineering, collection: root_collection) do |p|
|
|
115
111
|
p.access_level = "view"
|
|
116
112
|
end
|
|
117
113
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
p.access_level = level
|
|
124
|
-
end
|
|
125
|
-
DataPermission.find_or_create_by!(group: group, data_source: data_source, permission_type: "create_queries", resource_path: nil) do |p|
|
|
126
|
-
p.access_level = create_level
|
|
127
|
-
end
|
|
114
|
+
DataPermission.find_or_create_by!(group: engineering, data_source: data_source, permission_type: "view_data", resource_path: nil) do |p|
|
|
115
|
+
p.access_level = "blocked"
|
|
116
|
+
end
|
|
117
|
+
DataPermission.find_or_create_by!(group: engineering, data_source: data_source, permission_type: "create_queries", resource_path: nil) do |p|
|
|
118
|
+
p.access_level = "no"
|
|
128
119
|
end
|
|
129
120
|
end
|
|
130
121
|
end
|