diff --git a/lib/scenic/adapters/postgres.rb b/lib/scenic/adapters/postgres.rb index 9941832a..83dc4cfd 100644 --- a/lib/scenic/adapters/postgres.rb +++ b/lib/scenic/adapters/postgres.rb @@ -14,7 +14,7 @@ module Scenic module Adapters # An adapter for managing Postgres views. # - # These methods are used interally by Scenic and are not intended for direct + # These methods are used internally by Scenic and are not intended for direct # use. Methods that alter database schema are intended to be called via # {Statements}, while {#refresh_materialized_view} is called via # {Scenic.database}. @@ -55,10 +55,13 @@ def views # # @param name The name of the view to create # @param sql_definition The SQL schema for the view. + # @param security_barrier If we should enable security_barrier + # @param security_invoker If we should enable security_invoker # # @return [void] - def create_view(name, sql_definition) - execute "CREATE VIEW #{quote_table_name(name)} AS #{sql_definition};" + def create_view(name, sql_definition, security_barrier, security_invoker) + with_statement = build_with_statement(security_barrier, security_invoker) + execute "CREATE VIEW #{quote_table_name(name)} #{with_statement} AS #{sql_definition};" end # Updates a view in the database. @@ -75,11 +78,13 @@ def create_view(name, sql_definition) # # @param name The name of the view to update # @param sql_definition The SQL schema for the updated view. + # @param security_barrier If we should enable security_barrier + # @param security_invoker If we should enable security_invoker # # @return [void] - def update_view(name, sql_definition) + def update_view(name, sql_definition, security_barrier, security_invoker) drop_view(name) - create_view(name, sql_definition) + create_view(name, sql_definition, security_barrier, security_invoker) end # Replaces a view in the database using `CREATE OR REPLACE VIEW`. @@ -101,10 +106,13 @@ def update_view(name, sql_definition) # # @param name The name of the view to update # @param sql_definition The SQL schema for the updated view. + # @param security_barrier If we should enable security_barrier + # @param security_invoker If we should enable security_invoker # # @return [void] - def replace_view(name, sql_definition) - execute "CREATE OR REPLACE VIEW #{quote_table_name(name)} AS #{sql_definition};" + def replace_view(name, sql_definition, security_barrier, security_invoker) + with_statement = build_with_statement(security_barrier, security_invoker) + execute "CREATE OR REPLACE VIEW #{quote_table_name(name)} #{with_statement} AS #{sql_definition};" end # Drops the named view from the database @@ -188,7 +196,7 @@ def drop_materialized_view(name) # This is typically called from application code via {Scenic.database}. # # @param name The name of the materialized view to refresh. - # @param concurrently [Boolean] Whether the refreshs hould happen + # @param concurrently [Boolean] Whether the refresh should happen # concurrently or not. A concurrent refresh allows the view to be # refreshed without locking the view for select but requires that the # table have at least one unique index that covers all rows. Attempts to @@ -276,6 +284,18 @@ def refresh_dependencies_for(name, concurrently: false) concurrently: concurrently ) end + + def build_with_statement(security_barrier, security_invoker) + if security_invoker && security_barrier + return "WITH (security_barrier, security_invoker = true)" + elsif security_invoker + return "WITH (security_invoker = true)" + elsif security_barrier + return "WITH (security_barrier)" + end + + return "" + end end end end diff --git a/lib/scenic/adapters/postgres/views.rb b/lib/scenic/adapters/postgres/views.rb index 02b93588..30f0c47f 100644 --- a/lib/scenic/adapters/postgres/views.rb +++ b/lib/scenic/adapters/postgres/views.rb @@ -28,6 +28,7 @@ def views_from_postgres c.relname as viewname, pg_get_viewdef(c.oid) AS definition, c.relkind AS kind, + c.reloptions AS options, n.nspname AS namespace FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace @@ -41,7 +42,17 @@ def views_from_postgres end def to_scenic_view(result) - namespace, viewname = result.values_at "namespace", "viewname" + namespace, viewname, options = result.values_at "namespace", "viewname", "options" + + if options.present? + security_invoker = options.include?("security_invoker=true") + security_barrier = options.include?("security_barrier=true") + end + + options = { + security_invoker:, + security_barrier: + } namespaced_viewname = if namespace != "public" "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}" @@ -52,7 +63,8 @@ def to_scenic_view(result) Scenic::View.new( name: namespaced_viewname, definition: result["definition"].strip, - materialized: result["kind"] == "m" + materialized: result["kind"] == "m", + options: ) end diff --git a/lib/scenic/statements.rb b/lib/scenic/statements.rb index c3caa0ca..e9c74cb3 100644 --- a/lib/scenic/statements.rb +++ b/lib/scenic/statements.rb @@ -12,6 +12,10 @@ module Statements # @param materialized [Boolean, Hash] Set to true to create a materialized # view. Set to { no_data: true } to create materialized view without # loading data. Defaults to false. + # @param security_barrier [Boolean] Set to true to enable the security barrier + # option on the view. Defaults to false. + # @param security_invoker [Boolean] Set to true to enable the security invoker + # option on the view. Defaults to false. # @return The database response from executing the create statement. # # @example Create from `db/views/searches_v02.sql` @@ -22,7 +26,8 @@ module Statements # SELECT * FROM users WHERE users.active = 't' # SQL # - def create_view(name, version: nil, sql_definition: nil, materialized: false) + def create_view(name, version: nil, sql_definition: nil, materialized: false, + security_barrier: false, security_invoker: false) if version.present? && sql_definition.present? raise( ArgumentError, @@ -43,7 +48,7 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) no_data: no_data(materialized) ) else - Scenic.database.create_view(name, sql_definition) + Scenic.database.create_view(name, sql_definition, security_barrier, security_invoker) end end @@ -55,12 +60,16 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) # `version` argument to {#create_view}. # @param materialized [Boolean] Set to true if dropping a meterialized view. # defaults to false. + # @param security_barrier [Boolean] Set to true to enable the security barrier + # option on the view. Defaults to false. + # @param security_invoker [Boolean] Set to true to enable the security invoker + # option on the view. Defaults to false. # @return The database response from executing the drop statement. # # @example Drop a view, rolling back to version 3 on rollback # drop_view(:users_who_recently_logged_in, revert_to_version: 3) # - def drop_view(name, revert_to_version: nil, materialized: false) + def drop_view(name, revert_to_version: nil, materialized: false, security_barrier: false, security_invoker: false) if materialized Scenic.database.drop_materialized_view(name) else @@ -83,12 +92,17 @@ def drop_view(name, revert_to_version: nil, materialized: false) # @param materialized [Boolean, Hash] True if updating a materialized view. # Set to { no_data: true } to update materialized view without loading # data. Defaults to false. + # @param security_barrier [Boolean] Set to true to enable the security barrier + # option on the view. Defaults to false. + # @param security_invoker [Boolean] Set to true to enable the security invoker + # option on the view. Defaults to false. # @return The database response from executing the create statement. # # @example # update_view :engagement_reports, version: 3, revert_to_version: 2 # - def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false) + def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false, + security_barrier: false, security_invoker: false) if version.blank? && sql_definition.blank? raise( ArgumentError, @@ -112,7 +126,7 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, no_data: no_data(materialized) ) else - Scenic.database.update_view(name, sql_definition) + Scenic.database.update_view(name, sql_definition, security_barrier, security_invoker) end end @@ -127,12 +141,17 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, # @param version [Fixnum] The version number of the view. # @param revert_to_version [Fixnum] The version number to rollback to on # `rake db rollback` + # @param security_barrier [Boolean] Set to true to enable the security barrier + # option on the view. Defaults to false. + # @param security_invoker [Boolean] Set to true to enable the security invoker + # option on the view. Defaults to false. # @return The database response from executing the create statement. # # @example # replace_view :engagement_reports, version: 3, revert_to_version: 2 # - def replace_view(name, version: nil, revert_to_version: nil, materialized: false) + def replace_view(name, version: nil, revert_to_version: nil, materialized: false, + security_barrier: false, security_invoker: false) if version.blank? raise ArgumentError, "version is required" end @@ -143,7 +162,7 @@ def replace_view(name, version: nil, revert_to_version: nil, materialized: false sql_definition = definition(name, version) - Scenic.database.replace_view(name, sql_definition) + Scenic.database.replace_view(name, sql_definition, security_barrier, security_invoker) end private diff --git a/lib/scenic/version.rb b/lib/scenic/version.rb index 53e19225..3ca37431 100644 --- a/lib/scenic/version.rb +++ b/lib/scenic/version.rb @@ -1,3 +1,3 @@ module Scenic - VERSION = "1.8.0".freeze + VERSION = "2.0.0".freeze end diff --git a/lib/scenic/view.rb b/lib/scenic/view.rb index 3f39d7ed..f969d331 100644 --- a/lib/scenic/view.rb +++ b/lib/scenic/view.rb @@ -22,30 +22,38 @@ class View # @return [Boolean] attr_reader :materialized + # Options definition for security_invoker and security_barrier + # @return Hash[Symbol, Boolean] + attr_reader :options + # Returns a new instance of View. # # @param name [String] The name of the view. # @param definition [String] The SQL for the query that defines the view. # @param materialized [Boolean] `true` if the view is materialized. - def initialize(name:, definition:, materialized:) + def initialize(name:, definition:, materialized:, options:) @name = name @definition = definition @materialized = materialized + @options = options end # @api private def ==(other) name == other.name && definition == other.definition && - materialized == other.materialized + materialized == other.materialized && + options == other.options end # @api private def to_schema materialized_option = materialized ? "materialized: true, " : "" + security_barrier_option = options[:security_barrier] ? "security_barrier: true, " : "" + security_invoker_option = options[:security_invoker] ? "security_invoker: true, " : "" <<-DEFINITION - create_view #{UnaffixedName.for(name).inspect}, #{materialized_option}sql_definition: <<-\SQL + create_view #{UnaffixedName.for(name).inspect}, #{security_barrier_option}#{security_invoker_option}#{materialized_option}sql_definition: <<-\SQL #{escaped_definition.indent(2)} SQL DEFINITION diff --git a/spec/scenic/statements_spec.rb b/spec/scenic/statements_spec.rb index 39cf1387..1e977a72 100644 --- a/spec/scenic/statements_spec.rb +++ b/spec/scenic/statements_spec.rb @@ -22,7 +22,7 @@ module Scenic end it "creates a view from a text definition" do - sql_definition = "a defintion" + sql_definition = "a definition" connection.create_view(:views, sql_definition: sql_definition) @@ -30,7 +30,7 @@ module Scenic .with(:views, sql_definition) end - it "creates version 1 of the view if neither version nor sql_defintion are provided" do + it "creates version 1 of the view if neither version nor sql_definition are provided" do version = 1 definition_stub = instance_double("Definition", to_sql: "foo") allow(Definition).to receive(:new) @@ -43,9 +43,9 @@ module Scenic .with(:views, definition_stub.to_sql) end - it "raises an error if both version and sql_defintion are provided" do + it "raises an error if both version and sql_definition are provided" do expect do - connection.create_view :foo, version: 1, sql_definition: "a defintion" + connection.create_view :foo, version: 1, sql_definition: "a definition" end.to raise_error ArgumentError end end @@ -108,7 +108,7 @@ module Scenic end it "updates a view from a text definition" do - sql_definition = "a defintion" + sql_definition = "a definition" connection.update_view(:name, sql_definition: sql_definition) @@ -144,19 +144,19 @@ module Scenic .with(:name, definition.to_sql, no_data: true) end - it "raises an error if not supplied a version or sql_defintion" do + it "raises an error if not supplied a version or sql_definition" do expect { connection.update_view :views }.to raise_error( ArgumentError, /sql_definition or version must be specified/ ) end - it "raises an error if both version and sql_defintion are provided" do + it "raises an error if both version and sql_definition are provided" do expect do connection.update_view( :views, version: 1, - sql_definition: "a defintion" + sql_definition: "a definition" ) end.to raise_error ArgumentError, /cannot both be set/ end