omen 0.2.2 → 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: 7bff8c4c873f1cbc8923e3dbc7cebe42e282126624a56cab8d2461b8c879fb0d
4
- data.tar.gz: e8bd747cf8234259e52e7bf5d1ab24e607dc5ba6ba09deef01f037692bd3a6f7
3
+ metadata.gz: f96e34cbde627e0781a12488ba4308d704c6896a71fc44b031b7d412512c22eb
4
+ data.tar.gz: 408ad7c0b4a13f8ef3aef1b1c271e81ac116791ff627ffa2d056f5ecb6c45e34
5
5
  SHA512:
6
- metadata.gz: 1c82de79367f41a2712f44bdf21d6ca715c263fbc097dae9d7dcd032ba28a152ebf4c1976b02b37f62992781f98981fab99fd49173a4925bea3664ba668cbff1
7
- data.tar.gz: 0a2a09480ee44f60283db5db8976d6c97d6373fca2af7f6e1b2526de51052e5aba96b171ba989af70ef7bf5e9acd1fb01bbd6933cb9ec1b6cf00a10be80f0a8e
6
+ metadata.gz: fd53d5b7acb9fea6560bf0edbd5e1fb8f26200d99edaa52dfd9a81c9945dbd154a69514a7ba0028a025f36284bbd04be01e7aa57df61b7c01fa68ceb974638fd
7
+ data.tar.gz: a5b4226127e861f2600ebf62eb1d0c10ae2921886caf8e27b8041b411ac1b904c5e3406a0265f11e1f4c54a80ae9fa33994f396ec9adbc0df74c57b2304946df
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ 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
+
18
+ ## 0.3.0 - 2026-08-24
19
+
20
+ * [Feature] Create a `miles_between(lat1, lng1, lat2, lng2)` function and name it in the prompt,
21
+ so a radius is four arguments rather than a dozen nested trigonometric calls that a reply
22
+ balances by hand -- and gets wrong
23
+ * [Fix] Tell Claude that text is single-quoted and an apostrophe inside it is doubled, since a
24
+ double-quoted literal is read as a column name and refuses the whole statement
25
+ * [Fix] Tell Claude to declare a `combine` where an encrypted value belongs inside a sentence,
26
+ rather than returning the pieces as columns for the page to draw apart
27
+
10
28
  ## 0.2.2 - 2026-08-24
11
29
 
12
30
  * [Fix] Run a load hook as each model loads, so a host declares `broadcasts_refreshes` and the
data/README.md CHANGED
@@ -50,6 +50,21 @@ never crosses a breaking change.
50
50
  question ever asked.
51
51
  </details>
52
52
 
53
+ **Two database functions**
54
+
55
+ <details>
56
+ <summary>A timestamp and a distance are read through a function, never an expression. </summary>
57
+ `db:omen:grant` creates both. `eastern()` hands a stored timestamp back in the zone the company
58
+ works in, so every date in the prompt means the same whole days, and
59
+ `miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in miles. The prompt
60
+ names each and forbids writing either by hand: a conversion assembled per query drifts, and a
61
+ great-circle expression runs to a dozen nested calls that a reply balances by hand and gets
62
+ wrong. Both are `LANGUAGE sql IMMUTABLE` and executable by anyone, so neither needs a grant.
63
+ An app in another zone renames the first; an app whose tables carry no coordinates simply never
64
+ calls the second. Neither can be a migration: Rails' `:ruby` schema format dumps no functions,
65
+ so `db:schema:load` would drop one a migration had made.
66
+ </details>
67
+
53
68
  ## Configuration
54
69
 
55
70
  Installing by adding to your Gemfile and running three commands in your terminal:
@@ -132,6 +147,10 @@ usually does — the role is made by hand and the revocation with it:
132
147
  REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;
133
148
  ```
134
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
+
135
154
  A missed table there means Claude is shown the log of every question ever asked.
136
155
 
137
156
  ## License
@@ -24,6 +24,11 @@ comes back however you write it. A `WITH` clause is fine.
24
24
  Prefer `count(*)`, `group by` and aggregates over returning raw rows: a question about how many
25
25
  or about which is most is answered better by ten rows than by a thousand.
26
26
 
27
+ Text goes in single quotes, and an apostrophe inside it is written twice: `'it''s'`. Double
28
+ quotes name a column, so `"it's here"` is read as the name of a column, no such column is found
29
+ and the whole statement is refused. It is the one mistake that still looks like text after you
30
+ have made it, and switching quote style to avoid an apostrophe is how you make it.
31
+
27
32
  ## Reading the schema
28
33
 
29
34
  Timestamps are stored in UTC, and `%{eastern}()` is the one way to read one: it hands the same
@@ -36,6 +41,12 @@ Today is %{today}. Resolve every relative date yourself; the query has no idea w
36
41
  month" means, and it must never ask the database what time it is -- `now()` and
37
42
  `current_timestamp` are the clock of the machine, not the date above.
38
43
 
44
+ Where a table carries coordinates, the distance between two points in miles is
45
+ `%{miles}(lat1, lng1, lat2, lng2)`, so a radius reads
46
+ `WHERE %{miles}(l.lat, l.lng, u.lat, u.lng) <= 2`. Never write the trigonometry yourself: a
47
+ great-circle expression built by hand runs to a dozen nested calls, and one bracket out of place
48
+ either refuses the statement or, worse, measures something else and says nothing about it.
49
+
39
50
  Every type named in a `create_enum` line at the top of the schema is a Postgres enum, and the
40
51
  values it may take are listed on that line. Compare one as text, for example
41
52
  `WHERE status::text = 'fulfilled'`.
@@ -55,6 +66,11 @@ decrypting, and the page draws one column under the name you gave, in place of i
55
66
  entry of `parts` has to be a header your query really returns. Where nothing needs joining,
56
67
  `combine` is `[]` -- and never explain a join in `note` instead of declaring it.
57
68
 
69
+ Declare it the same way where the value belongs inside a sentence you are building: return the
70
+ text before it, the column itself, and the text after it as three columns, and join them with
71
+ `"separator": ""`. What you must not do is leave the parts as separate columns and let the page
72
+ draw them apart, which is what happens when you work around the ciphertext instead of saying so.
73
+
58
74
  These columns are stored encrypted as well, and no page ever reads one back -- either the name
59
75
  reads as a credential's, or the value is encrypted in a way no two writes of it agree on -- so
60
76
  do not select them: %{refused}.
@@ -6,6 +6,9 @@ class Omen::Instructions
6
6
  # The database function a stored timestamp is read through, created by the gem's rake task.
7
7
  EASTERN = 'eastern'
8
8
 
9
+ # The database function a distance in miles is measured with, created by the same task.
10
+ MILES = 'miles_between'
11
+
9
12
  # The one shape a reply may take: both keys required, and no others admitted.
10
13
  ANSWER = {
11
14
  type: 'object', additionalProperties: false, required: %w[ sql note combine ],
@@ -33,7 +36,7 @@ class Omen::Instructions
33
36
  # Today's date is said out loud because "last month" is Claude's to resolve, and it has no clock.
34
37
  # @return [String] the prose, with the schema, the subclasses and the host's notes filled in.
35
38
  def text
36
- format File.read(PROSE), today: Date.current.to_fs(:long), eastern: EASTERN,
39
+ format File.read(PROSE), today: Date.current.to_fs(:long), eastern: EASTERN, miles: MILES,
37
40
  schema: schema, types: types, readable: readable, refused: refused,
38
41
  notes: Omen.config.notes
39
42
  end
@@ -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,31 @@
1
+ module Omen
2
+ # The database function a reading measures a distance with. Created by the rake task rather
3
+ # than by a migration, for the reason Omen::Eastern is: Rails' :ruby schema format dumps no
4
+ # functions, so db:schema:load would drop one a migration had made.
5
+ module Distance
6
+ # The earth's mean radius in miles, which is what makes the answer miles.
7
+ RADIUS = 3958.7613
8
+
9
+ # DDL, which Active Record has no expression for, and not a query.
10
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
11
+ # @return [Array<String>] the statements to run, in order.
12
+ def self.statements(connection) = [ haversine(connection) ]
13
+
14
+ # Declared over double precision, which numeric, real and integer all cast to implicitly,
15
+ # so a host's own column type does not have to be guessed at. Any argument NULL and the
16
+ # answer is NULL, the way a distance to nowhere ought to read.
17
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
18
+ # @return [String] the statement declaring the function.
19
+ def self.haversine(connection)
20
+ "CREATE OR REPLACE FUNCTION #{name connection}(lat1 double precision, " \
21
+ 'lng1 double precision, lat2 double precision, lng2 double precision) ' \
22
+ "RETURNS double precision AS $$ SELECT 2 * #{RADIUS} * asin(sqrt(" \
23
+ 'power(sin(radians(lat2 - lat1) / 2), 2) + cos(radians(lat1)) * cos(radians(lat2)) ' \
24
+ '* power(sin(radians(lng2 - lng1) / 2), 2))) $$ LANGUAGE sql IMMUTABLE'
25
+ end
26
+
27
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
28
+ # @return [String] the function's name, quoted.
29
+ def self.name(connection) = connection.quote_table_name Omen::Instructions::MILES
30
+ end
31
+ 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,37 +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
- ]
79
- end
80
-
81
- # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
82
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
83
- # @param role [String] the role to hide this feature's own tables from.
84
- # @return [Array<String>] one REVOKE per table there is.
85
- def self.revoked(connection, role)
86
- (connection.tables & Omen.tables).map do |table|
87
- "REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
88
- end
89
- end
90
81
  end
91
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.2.2'
3
+ VERSION = '0.3.1'
4
4
  end
data/lib/omen.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  require 'active_job/performs'
2
2
  require 'anthropic'
3
3
 
4
+ require 'omen/attributes'
4
5
  require 'omen/config'
6
+ require 'omen/distance'
5
7
  require 'omen/eastern'
8
+ require 'omen/grants'
6
9
  require 'omen/inquirer'
7
10
  require 'omen/requirements'
8
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.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -97,9 +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
102
+ - lib/omen/distance.rb
101
103
  - lib/omen/eastern.rb
102
104
  - lib/omen/engine.rb
105
+ - lib/omen/grants.rb
103
106
  - lib/omen/inquirer.rb
104
107
  - lib/omen/requirements.rb
105
108
  - lib/omen/stubs.rb