Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for security barrier and invoker #433

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions lib/scenic/adapters/postgres.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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.
Expand All @@ -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`.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 14 additions & 2 deletions lib/scenic/adapters/postgres/views.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)}"
Expand All @@ -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

Expand Down
33 changes: 26 additions & 7 deletions lib/scenic/statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/scenic/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Scenic
VERSION = "1.8.0".freeze
VERSION = "2.0.0".freeze
end
14 changes: 11 additions & 3 deletions lib/scenic/view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions spec/scenic/statements_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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)

expect(Scenic.database).to have_received(:create_view)
.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)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down