omen 0.4.0 → 0.6.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.
data/lib/omen/grants.rb CHANGED
@@ -3,6 +3,12 @@ module Omen
3
3
  # may read, and what it may not. Kept apart from the task that runs them, which is about a
4
4
  # database it has to find and a refusal it has to survive rather than about privileges.
5
5
  module Grants
6
+ # Membership without inheritance, so a role that enters a narrowed one with SET LOCAL ROLE
7
+ # is not itself held back by its policies. Postgres 16 and later; below that the grant is
8
+ # refused and the task says so, which leaves a reading misconfigured rather than narrowing
9
+ # somebody nobody meant to narrow.
10
+ APART = 'GRANT %{role} TO %{member} WITH INHERIT FALSE'
11
+
6
12
  # DDL, which Active Record has no expression for, and not a query.
7
13
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
8
14
  # @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
@@ -12,14 +18,11 @@ module Omen
12
18
  def self.statements(connection, members)
13
19
  role = connection.quote_table_name Omen.config.narrow_role
14
20
  [
15
- 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
16
- "#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
17
- 'END IF; END $$',
18
- "ALTER ROLE #{role} WITH #{Attributes::SETTABLE}",
21
+ *made(connection, Omen.config.narrow_role),
22
+ *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
19
23
  "GRANT USAGE ON SCHEMA public TO #{role}",
20
24
  "GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
21
25
  "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
22
- *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
23
26
  *revoked(connection, role),
24
27
  *Omen::Renamed.statements,
25
28
  *Omen::TimeZone.statements(connection),
@@ -27,6 +30,54 @@ module Omen
27
30
  ]
28
31
  end
29
32
 
33
+ # The same role, made for one audience rather than for every table: it is granted nothing
34
+ # here, since a narrowing names the tables and the columns of each itself.
35
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
36
+ # @param name [String] the role to make.
37
+ # @param members [Array<String>] the roles that may enter it, none of them inheriting it.
38
+ # @return [Array<String>] the statements to run, in order.
39
+ def self.narrowed(connection, name, members)
40
+ role = connection.quote_table_name name
41
+ [
42
+ *made(connection, name),
43
+ *members.map { |member| apart role, connection.quote_table_name(member) },
44
+ "GRANT USAGE ON SCHEMA public TO #{role}",
45
+ ]
46
+ end
47
+
48
+ # @param role [String] the role, quoted.
49
+ # @param member [String] the role that may enter it, quoted.
50
+ # @return [String] the statement that lets it in without handing it what the role holds.
51
+ def self.apart(role, member) = APART % { role: role, member: member }
52
+
53
+ # Named one by one rather than granted whole: a column added to hold a secret, or to count
54
+ # what belongs to everybody, is one a role has to be given before it can read it. Taken away
55
+ # first, since a grant only ever adds: without the revoke, a column dropped from the list
56
+ # stays readable by whoever was granted it the last time this ran.
57
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
58
+ # @param name [String] the role to grant them to.
59
+ # @param table [String] the table whose columns are being granted.
60
+ # @param refused [Regexp] the columns of it that role may not read.
61
+ # @return [Array<String>] what it may read of that table, said over again.
62
+ def self.granted(connection, name, table, refused)
63
+ role = connection.quote_table_name name
64
+ quoted = connection.quote_table_name table
65
+ columns = connection.columns(table).map(&:name).grep_v(refused)
66
+ .map { |column| connection.quote_column_name column }
67
+ [ "REVOKE ALL ON #{quoted} FROM #{role}",
68
+ "GRANT SELECT (#{columns.join ', '}) ON #{quoted} TO #{role}", ]
69
+ end
70
+
71
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
72
+ # @param name [String] the role to make, where the database has not got it.
73
+ # @return [Array<String>] the statements that make it and say what it may be.
74
+ def self.made(connection, name)
75
+ role = connection.quote_table_name name
76
+ [ 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
77
+ "#{connection.quote name}) THEN CREATE ROLE #{role} NOLOGIN; END IF; END $$",
78
+ "ALTER ROLE #{role} WITH #{Attributes::SETTABLE}", ]
79
+ end
80
+
30
81
  # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
31
82
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
32
83
  # @param role [String] the role to hide this feature's own tables from.
data/lib/omen/inquirer.rb CHANGED
@@ -14,30 +14,30 @@ module Omen
14
14
  UNMADE = 'Could not make %{role}, so every reading will say this app is misconfigured. Ask ' \
15
15
  'for that role, NOLOGIN, granted SELECT on every table but %{tables}.'
16
16
 
17
- # Said per statement, since the grants, the revocations and the functions need nothing from
18
- # one another and one refusal should not discard the rest.
17
+ # Said per group, since the grants, the revocations and the functions need nothing from one
18
+ # another and one refusal should not discard the rest.
19
19
  REFUSED = 'Skipped, refused by the database: %{statement} (%{error})'
20
20
 
21
21
  # Said where a role that already existed is one a reading should not be able to reach through.
22
22
  DANGEROUS = '%{role} holds %{held}. This gem cannot take that away without being a superuser ' \
23
23
  'itself, so ask for it to be taken away.'
24
24
 
25
- # Creates the role and the function, on every database this environment prepares.
25
+ # Creates the role and the functions, and writes every narrowing a host declared, on every
26
+ # database this environment prepares.
26
27
  # @return [void]
27
- def self.grant
28
- environments.each do |environment|
29
- config = ActiveRecord::Base.configurations.configs_for env_name: environment,
30
- name: 'primary'
31
- next unless config
32
- ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection config do |connection|
33
- grant_on connection
28
+ def self.grant = Omen.each_database { |connection| grant_on connection }
29
+
30
+ # Takes every narrowing back off, leaving each table read the way it was read before one.
31
+ # @return [void]
32
+ def self.widen
33
+ Omen.each_database do |connection|
34
+ Omen::Reading.narrowings.each do |narrowing|
35
+ narrowing.widening(connection).each { |group| attempted connection, *group }
36
+ puts "Widened #{connection.current_database} back out of #{narrowing.role}"
34
37
  end
35
38
  end
36
39
  end
37
40
 
38
- # @return [Array<String>] the environments whose databases this run should cover.
39
- def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
40
-
41
41
  # Warns rather than raises: a managed database never grants CREATEROLE, and a deploy that
42
42
  # cannot make the role must still finish, having said what has to be made by hand.
43
43
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
@@ -54,18 +54,34 @@ module Omen
54
54
  held = Attributes.dangerous connection
55
55
  warn DANGEROUS % { role: role, held: held.to_sentence } if held.any?
56
56
  puts "Granted SELECT on #{connection.current_database} to #{role}"
57
+ narrow connection, members
57
58
  end
58
59
 
59
- # One statement at a time, so a database that refuses one still runs the others -- and each
60
- # inside a savepoint of its own, since a refusal inside a transaction refuses everything
61
- # after it too, and this task is as likely to be run from a console as from a deploy.
60
+ # Every audience a host declared, written into the database its readings are answered from.
62
61
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
63
- # @param statement [String] one of the statements Omen::Grants builds.
62
+ # @param members [Array<String>] the roles that may enter a narrowed one.
64
63
  # @return [void]
65
- def self.attempted(connection, statement)
66
- connection.transaction(requires_new: true) { connection.execute statement }
64
+ def self.narrow(connection, members)
65
+ Omen::Reading.narrowings.each do |narrowing|
66
+ narrowing.statements(connection, members).each { |group| attempted connection, *group }
67
+ puts "Narrowed #{narrowing.role} to the rows #{narrowing.setting} names"
68
+ end
69
+ end
70
+
71
+ # A group at a time, so a database that refuses one still runs the others -- and each inside
72
+ # a savepoint of its own, since a refusal inside a transaction refuses everything after it
73
+ # too, and this task is as likely to be run from a console as from a deploy. What arrives
74
+ # together is applied together: a table is never left with row level security on and no
75
+ # policy under it.
76
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
77
+ # @param statements [Array<String>] what Omen::Grants or a narrowing built.
78
+ # @return [void]
79
+ def self.attempted(connection, *statements)
80
+ connection.transaction(requires_new: true) do
81
+ statements.each { |statement| connection.execute statement }
82
+ end
67
83
  rescue ActiveRecord::StatementInvalid => error
68
- warn REFUSED % { statement: statement.squish, error: error.message.lines.first.strip }
84
+ warn REFUSED % { statement: statements.first.squish, error: error.message.lines.first.strip }
69
85
  end
70
86
 
71
87
  # Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
@@ -0,0 +1,87 @@
1
+ module Omen
2
+ # What one audience of a reading may read: the role its statement runs as, the rows each
3
+ # table admits it, and the tables it reads whole because nothing in them is anybody's.
4
+ class Narrowing
5
+ # The policy every role holds, so that turning row level security on takes nothing away
6
+ # from whoever could already read -- a role made after this ran included. The narrowed role
7
+ # is held back by the restrictive policy beside it, which is ANDed with this one and
8
+ # applies to nobody else.
9
+ EVERYBODY = 'omen_everybody'
10
+
11
+ # @return [String] the Postgres role a reading of this audience runs as.
12
+ attr_reader :role
13
+
14
+ # @return [Symbol] the column of the reading that says whose rows these are.
15
+ attr_reader :by
16
+
17
+ # @param role [String] the Postgres role a reading of this audience runs as.
18
+ # @param by [Symbol] the column of the reading that says whose rows these are.
19
+ # @param own [Hash] every table it reads rows of, to what makes a row its own. A table is
20
+ # named either way, since a host writing one of these writes symbols or strings by habit.
21
+ # @param whole [Array<String>] the tables it reads whole, nobody's in particular.
22
+ # @param except [Regexp, nil] the columns of those it may not read, a counter over everybody
23
+ # being the case this exists for: it counts every owner rather than this one.
24
+ def initialize(role:, by:, own:, whole: [], except: nil)
25
+ @role = role
26
+ @by = by
27
+ @own = own.transform_keys(&:to_s)
28
+ @whole = whole.map(&:to_s)
29
+ @except = except
30
+ end
31
+
32
+ # @return [String] the setting a policy reads to know whose rows it is looking at.
33
+ def setting = "omen.#{@by}"
34
+
35
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
36
+ # @param members [Array<String>] the roles that may enter this one.
37
+ # @return [Array<Array<String>>] the statements, grouped: what arrives together is applied
38
+ # together, so no table is left with row level security on and no policy under it.
39
+ def statements(connection, members)
40
+ [ Grants.narrowed(connection, @role, members),
41
+ *@whole.map { |table| granted connection, table },
42
+ *@own.map { |table, own| narrowed connection, table, own }, ]
43
+ end
44
+
45
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
46
+ # @return [Array<Array<String>>] the statements that take it back off, table by table.
47
+ def widening(connection)
48
+ @own.keys.map { |table| widened connection, table }
49
+ end
50
+
51
+ private
52
+
53
+ # The permissive policy first and the table turned on last, so a group that stops short
54
+ # leaves a table nobody has been narrowed on rather than one nobody can read.
55
+ def narrowed(connection, table, own)
56
+ quoted = connection.quote_table_name table
57
+ [ *granted(connection, table),
58
+ *policy(connection, quoted, EVERYBODY, 'FOR ALL TO PUBLIC USING (true) WITH CHECK (true)'),
59
+ *policy(connection, quoted, "#{table}_#{@role}",
60
+ "AS RESTRICTIVE FOR SELECT TO #{connection.quote_table_name @role} " \
61
+ "USING (#{format own, owner: owner, setting: setting})"),
62
+ "ALTER TABLE #{quoted} ENABLE ROW LEVEL SECURITY", ]
63
+ end
64
+
65
+ # Dropped first, so that running this again says what it said the first time.
66
+ def policy(connection, quoted, name, rule)
67
+ named = connection.quote_table_name name
68
+ [ "DROP POLICY IF EXISTS #{named} ON #{quoted}",
69
+ "CREATE POLICY #{named} ON #{quoted} #{rule}", ]
70
+ end
71
+
72
+ def widened(connection, table)
73
+ quoted = connection.quote_table_name table
74
+ [ "DROP POLICY IF EXISTS #{connection.quote_table_name "#{table}_#{@role}"} ON #{quoted}",
75
+ "DROP POLICY IF EXISTS #{connection.quote_table_name EVERYBODY} ON #{quoted}",
76
+ "ALTER TABLE #{quoted} DISABLE ROW LEVEL SECURITY", ]
77
+ end
78
+
79
+ # Empty where the reading names nobody, and no row is nobody's, so nobody reads nothing.
80
+ def owner = "NULLIF(current_setting('#{setting}', true), '')::bigint"
81
+
82
+ def granted(connection, table)
83
+ refused = [ Omen::Column::CREDENTIALS, (@except if @whole.include? table) ].compact
84
+ Grants.granted connection, @role, table, Regexp.union(refused)
85
+ end
86
+ end
87
+ end
data/lib/omen/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Omen
2
2
  # The version of this gem, as RubyGems knows it.
3
- VERSION = '0.4.0'
3
+ VERSION = '0.6.0'
4
4
  end
data/lib/omen.rb CHANGED
@@ -7,6 +7,8 @@ require 'omen/distance'
7
7
  require 'omen/grants'
8
8
  require 'omen/renamed'
9
9
  require 'omen/time_zone'
10
+ require 'omen/bound'
11
+ require 'omen/narrowing'
10
12
  require 'omen/inquirer'
11
13
  require 'omen/requirements'
12
14
  require 'omen/version'
@@ -24,4 +26,19 @@ module Omen
24
26
  # Yields the configuration, so a host states its own facts in one initializer.
25
27
  # @return [void]
26
28
  def self.configure = yield config
29
+
30
+ # @return [Array<String>] the environments whose databases a grant should cover.
31
+ def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
32
+
33
+ # @yield [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing connection to each
34
+ # database this environment prepares.
35
+ # @return [void]
36
+ def self.each_database
37
+ environments.each do |environment|
38
+ config = ActiveRecord::Base.configurations.configs_for env_name: environment,
39
+ name: 'primary'
40
+ next unless config
41
+ ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(config) { |it| yield it }
42
+ end
43
+ end
27
44
  end
data/lib/tasks/omen.rake CHANGED
@@ -1,9 +1,23 @@
1
1
  namespace :db do
2
2
  namespace :omen do
3
- desc 'Create the role a reading runs its statement as, and the function it reads a ' \
4
- 'timestamp through'
3
+ desc 'Create the role a reading runs its statement as, the function it reads a ' \
4
+ 'timestamp through, and the policies every narrowing a host declared asks for'
5
5
  task grant: :environment do
6
6
  Omen::Inquirer.grant
7
7
  end
8
+
9
+ desc 'Take every narrowing back off, leaving each table read the way it was read before'
10
+ task widen: :environment do
11
+ Omen::Inquirer.widen
12
+ end
13
+
14
+ desc 'Say which roles the policies of a narrowing hold back, membership included'
15
+ task narrowed: :environment do
16
+ Omen.config.record.with_connection do |connection|
17
+ Omen::Bound.roles(connection).each { |row| puts row.join ' ' }
18
+ surprises = Omen::Bound.surprises connection
19
+ abort "Narrowed without being asked for: #{surprises.inspect}" if surprises.any?
20
+ end
21
+ end
8
22
  end
9
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -73,6 +73,7 @@ extensions: []
73
73
  extra_rdoc_files: []
74
74
  files:
75
75
  - CHANGELOG.md
76
+ - INSTRUCTIONS.md
76
77
  - LICENSE.txt
77
78
  - README.md
78
79
  - app/models/omen/answer.rb
@@ -83,9 +84,12 @@ files:
83
84
  - app/models/omen/executed.rb
84
85
  - app/models/omen/instructions.md
85
86
  - app/models/omen/instructions.rb
87
+ - app/models/omen/narrowed.rb
86
88
  - app/models/omen/query.rb
87
89
  - app/models/omen/question.rb
88
90
  - app/models/omen/reading.rb
91
+ - app/models/omen/refusal.rb
92
+ - app/models/omen/reply.rb
89
93
  - app/models/omen/revealed.rb
90
94
  - app/models/omen/role.rb
91
95
  - app/models/omen/schema.rb
@@ -94,15 +98,25 @@ files:
94
98
  - db/migrate/20260821120000_create_omen_readings.rb
95
99
  - db/migrate/20260821120001_create_omen_questions.rb
96
100
  - db/migrate/20260821120002_create_omen_answers.rb
101
+ - db/migrate/20260912000000_add_type_to_omen_readings.rb
97
102
  - lib/generators/omen/install/install_generator.rb
98
103
  - lib/generators/omen/install/templates/omen.rb
104
+ - lib/generators/omen/pages/USAGE
105
+ - lib/generators/omen/pages/pages_generator.rb
106
+ - lib/generators/omen/pages/templates/_form.html.erb.tt
107
+ - lib/generators/omen/pages/templates/controller.rb.tt
108
+ - lib/generators/omen/pages/templates/index.html.erb.tt
109
+ - lib/generators/omen/pages/templates/model.rb.tt
110
+ - lib/generators/omen/pages/templates/show.html.erb.tt
99
111
  - lib/omen.rb
100
112
  - lib/omen/attributes.rb
113
+ - lib/omen/bound.rb
101
114
  - lib/omen/config.rb
102
115
  - lib/omen/distance.rb
103
116
  - lib/omen/engine.rb
104
117
  - lib/omen/grants.rb
105
118
  - lib/omen/inquirer.rb
119
+ - lib/omen/narrowing.rb
106
120
  - lib/omen/renamed.rb
107
121
  - lib/omen/requirements.rb
108
122
  - lib/omen/stubs.rb