omen 0.4.0 → 0.5.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/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.5.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.5.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