omen 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 529ccf31849252011c372776067dfb887267dea3b8e1580a0dbb598bab0afa56
4
- data.tar.gz: 7a9bb4e0ebd22451484b508b1a96e5f2ba85fcc92fcaaf62cd1f433b683b6ef5
3
+ metadata.gz: f96e34cbde627e0781a12488ba4308d704c6896a71fc44b031b7d412512c22eb
4
+ data.tar.gz: 408ad7c0b4a13f8ef3aef1b1c271e81ac116791ff627ffa2d056f5ecb6c45e34
5
5
  SHA512:
6
- metadata.gz: b9d59820ced246e19969a0ff0dbea02b29feb1bc85be637522066ec49d3a15b8e11c83d0aca97397a05235ab1cd7413dc3553bba3b8d17102933fae40c18d549
7
- data.tar.gz: bdcda330b4d7e03ef0b1fa6e2586b8c35dee10101af54600e5cdde47ca1f23b0ed06825e59b3d43ce7559f906aab03de186a2ae75d19396bfae8e481e6670a0e
6
+ metadata.gz: fd53d5b7acb9fea6560bf0edbd5e1fb8f26200d99edaa52dfd9a81c9945dbd154a69514a7ba0028a025f36284bbd04be01e7aa57df61b7c01fa68ceb974638fd
7
+ data.tar.gz: a5b4226127e861f2600ebf62eb1d0c10ae2921886caf8e27b8041b411ac1b904c5e3406a0265f11e1f4c54a80ae9fa33994f396ec9adbc0df74c57b2304946df
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 0.3.1 - 2026-08-24
11
+
12
+ * [Fix] Stop asserting the role attributes only a superuser may set, since `NOSUPERUSER`,
13
+ `NOBYPASSRLS` and `NOREPLICATION` are refused outright by every managed database -- a role is
14
+ created without them anyway, so they are read back and warned about rather than set
15
+ * [Fix] Run each statement of the grant in a savepoint of its own, so one a database refuses no
16
+ longer discards the grants, the revocations and both functions behind it
17
+
10
18
  ## 0.3.0 - 2026-08-24
11
19
 
12
20
  * [Feature] Create a `miles_between(lat1, lng1, lat2, lng2)` function and name it in the prompt,
data/README.md CHANGED
@@ -147,6 +147,10 @@ usually does — the role is made by hand and the revocation with it:
147
147
  REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;
148
148
  ```
149
149
 
150
+ A managed database also refuses `ALTER ROLE ... NOSUPERUSER`, since only a superuser may say it.
151
+ Omen skips that statement and carries on rather than stopping, then reads the role back and says
152
+ so if it holds `SUPERUSER`, `BYPASSRLS` or `REPLICATION` -- which a role it created never does.
153
+
150
154
  A missed table there means Claude is shown the log of every question ever asked.
151
155
 
152
156
  ## License
@@ -0,0 +1,35 @@
1
+ module Omen
2
+ # What the role a statement runs as may and may not be. A role is created with every dangerous
3
+ # attribute already off, so this gem sets only what whoever created it may set, and reads the
4
+ # rest back rather than asserting them: saying `NOSUPERUSER` needs the SUPERUSER attribute, so
5
+ # a managed database refuses the very statement that would have made the role safe.
6
+ module Attributes
7
+ # Set on the role, because whoever may create a role may set these.
8
+ SETTABLE = 'NOLOGIN NOCREATEDB NOCREATEROLE'
9
+
10
+ # Read back instead, since saying no to one of these needs the attribute itself.
11
+ DANGEROUS = { rolsuper: 'SUPERUSER', rolbypassrls: 'BYPASSRLS',
12
+ rolreplication: 'REPLICATION', }
13
+
14
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
15
+ # @return [Boolean] whether the role is there at all to be granted anything.
16
+ def self.exists?(connection) = held(connection).present?
17
+
18
+ # Nil where there is no such role, so that an empty list and a missing role read apart.
19
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
20
+ # @return [Hash, nil] the row of what the role is, or nothing where the role is not.
21
+ def self.held(connection)
22
+ connection.select_one <<~SQL.squish
23
+ SELECT #{DANGEROUS.keys.join ', '} FROM pg_roles
24
+ WHERE rolname = #{connection.quote Omen.config.narrow_role}
25
+ SQL
26
+ end
27
+
28
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
29
+ # @return [Array<String>] what the role holds that no reading should be able to reach.
30
+ def self.dangerous(connection)
31
+ row = held(connection) || {}
32
+ DANGEROUS.filter_map { |column, name| name if row[column.to_s] }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ module Omen
2
+ # Everything said to a database to make the role a statement runs as: what it may be, what it
3
+ # may read, and what it may not. Kept apart from the task that runs them, which is about a
4
+ # database it has to find and a refusal it has to survive rather than about privileges.
5
+ module Grants
6
+ # DDL, which Active Record has no expression for, and not a query.
7
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
8
+ # @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
9
+ # running the task is one of them, and matters in tests, where Rails swaps the reading
10
+ # pool for the writing one and the test connection is the owner.
11
+ # @return [Array<String>] the statements to run, in order.
12
+ def self.statements(connection, members)
13
+ role = connection.quote_table_name Omen.config.narrow_role
14
+ [
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}",
19
+ "GRANT USAGE ON SCHEMA public TO #{role}",
20
+ "GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
21
+ "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
+ *revoked(connection, role),
24
+ *Omen::Eastern.statements(connection),
25
+ *Omen::Distance.statements(connection),
26
+ ]
27
+ end
28
+
29
+ # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
30
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
31
+ # @param role [String] the role to hide this feature's own tables from.
32
+ # @return [Array<String>] one REVOKE per table there is.
33
+ def self.revoked(connection, role)
34
+ (connection.tables & Omen.tables).map do |table|
35
+ "REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/omen/inquirer.rb CHANGED
@@ -3,9 +3,6 @@ module Omen
3
3
  # It needs no database.yml entry of its own: NOLOGIN, it is a privilege container reached
4
4
  # with SET LOCAL ROLE and never connected as.
5
5
  module Inquirer
6
- # What this role may never be: a superuser bypasses GRANT outright.
7
- ATTRIBUTES = 'NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION'
8
-
9
6
  # Whom to ask, since the role a host reads through is one this gem has no name for.
10
7
  WHOEVER = 'SELECT current_user'
11
8
 
@@ -13,6 +10,18 @@ module Omen
13
10
  UNGRANTED = 'No %{role} connection is configured, so nothing was granted to whatever ' \
14
11
  'reads through it. Run this again once config/database.yml names one.'
15
12
 
13
+ # Said where the role could not be made: a managed database never grants CREATEROLE.
14
+ UNMADE = 'Could not make %{role}, so every reading will say this app is misconfigured. Ask ' \
15
+ 'for that role, NOLOGIN, granted SELECT on every table but %{tables}.'
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.
19
+ REFUSED = 'Skipped, refused by the database: %{statement} (%{error})'
20
+
21
+ # Said where a role that already existed is one a reading should not be able to reach through.
22
+ DANGEROUS = '%{role} holds %{held}. This gem cannot take that away without being a superuser ' \
23
+ 'itself, so ask for it to be taken away.'
24
+
16
25
  # Creates the role and the function, on every database this environment prepares.
17
26
  # @return [void]
18
27
  def self.grant
@@ -37,12 +46,26 @@ module Omen
37
46
  read_by = reader
38
47
  warn UNGRANTED % { role: Omen.config.reading_role } unless read_by
39
48
  members = [ read_by, connection.select_value(WHOEVER) ].compact
40
- statements(connection, members).each { |statement| connection.execute statement }
41
- puts "Granted SELECT on #{connection.current_database} to #{Omen.config.narrow_role}"
49
+ Grants.statements(connection, members).each { |statement| attempted connection, statement }
50
+ role = Omen.config.narrow_role
51
+ return warn UNMADE % { role: role, tables: Omen.tables.to_sentence } unless
52
+ Attributes.exists? connection
53
+
54
+ held = Attributes.dangerous connection
55
+ warn DANGEROUS % { role: role, held: held.to_sentence } if held.any?
56
+ puts "Granted SELECT on #{connection.current_database} to #{role}"
57
+ end
58
+
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.
62
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
63
+ # @param statement [String] one of the statements Omen::Grants builds.
64
+ # @return [void]
65
+ def self.attempted(connection, statement)
66
+ connection.transaction(requires_new: true) { connection.execute statement }
42
67
  rescue ActiveRecord::StatementInvalid => error
43
- warn "Could not make #{Omen.config.narrow_role}, so every reading will say this app is " \
44
- 'misconfigured. Ask for that role, NOLOGIN, granted SELECT on every table but ' \
45
- "#{Omen.tables.to_sentence}: #{error.message}"
68
+ warn REFUSED % { statement: statement.squish, error: error.message.lines.first.strip }
46
69
  end
47
70
 
48
71
  # Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
@@ -55,38 +78,5 @@ module Omen
55
78
  rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
56
79
  nil
57
80
  end
58
-
59
- # DDL, which Active Record has no expression for, and not a query.
60
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
61
- # @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
62
- # running the task is one of them, and matters in tests, where Rails swaps the reading
63
- # pool for the writing one and the test connection is the owner.
64
- # @return [Array<String>] the statements to run, in order.
65
- def self.statements(connection, members)
66
- role = connection.quote_table_name Omen.config.narrow_role
67
- [
68
- 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
69
- "#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
70
- 'END IF; END $$',
71
- "ALTER ROLE #{role} WITH NOLOGIN #{ATTRIBUTES}",
72
- "GRANT USAGE ON SCHEMA public TO #{role}",
73
- "GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
74
- "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
75
- *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
76
- *revoked(connection, role),
77
- *Eastern.statements(connection),
78
- *Distance.statements(connection),
79
- ]
80
- end
81
-
82
- # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
83
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
84
- # @param role [String] the role to hide this feature's own tables from.
85
- # @return [Array<String>] one REVOKE per table there is.
86
- def self.revoked(connection, role)
87
- (connection.tables & Omen.tables).map do |table|
88
- "REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
89
- end
90
- end
91
81
  end
92
82
  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.3.0'
3
+ VERSION = '0.3.1'
4
4
  end
data/lib/omen.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  require 'active_job/performs'
2
2
  require 'anthropic'
3
3
 
4
+ require 'omen/attributes'
4
5
  require 'omen/config'
5
6
  require 'omen/distance'
6
7
  require 'omen/eastern'
8
+ require 'omen/grants'
7
9
  require 'omen/inquirer'
8
10
  require 'omen/requirements'
9
11
  require 'omen/version'
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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -97,10 +97,12 @@ files:
97
97
  - lib/generators/omen/install/install_generator.rb
98
98
  - lib/generators/omen/install/templates/omen.rb
99
99
  - lib/omen.rb
100
+ - lib/omen/attributes.rb
100
101
  - lib/omen/config.rb
101
102
  - lib/omen/distance.rb
102
103
  - lib/omen/eastern.rb
103
104
  - lib/omen/engine.rb
105
+ - lib/omen/grants.rb
104
106
  - lib/omen/inquirer.rb
105
107
  - lib/omen/requirements.rb
106
108
  - lib/omen/stubs.rb