omen 0.7.0 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77023f5984f5bf7b9d121db8f2b233f1c69dcb0ae4580f31d78794f3c136c77c
4
- data.tar.gz: d2b51269750ab2b89cac43fb90e8382508abc80dd7ed9c4ff2577f65aadeec12
3
+ metadata.gz: c51ab71a4d3ccce3f1bdcfa1e6f4b6a8196e2778f55368e7dea902e220f367d4
4
+ data.tar.gz: '003328f8dcba05fe6654e1852b8c9c18ef5e305d8c0ba95456b266c01880c546'
5
5
  SHA512:
6
- metadata.gz: 16057a3aefe593c75889520a214e3697a61c3c72fd8115374eb62d8236dd51c1123c0169c5ff89ea6b38a1e6d0c262eaef9a9a0bfc9f39870c3d009ccd167bcc
7
- data.tar.gz: 9bc6b5f7967966b99b8f666a135fc627d063109f0d228666b221dd4e1f18894554338c455cb56a46a0421c1fc1f2fe6f3d07408e9db614359f97c86f5d7aaeb8
6
+ metadata.gz: 89a995c7beebd10a587d370ba7e8762f4e92a05252985045666101e64af81f10e5b7100631d670bd12ef89fe527fd4fc744f56918d26500268a39667311ee941
7
+ data.tar.gz: cf1a91b08d3e40e10d4a43a12872f2befad7cdefea114bbfe0e0e4eb78a8eb55076201ac8bf55e0bd2cd22d9a7ae3a4525367880d0a9622aa05a3d6f11e9062f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com) and
6
6
  [Vandamme](http://tech-angels.github.io/vandamme).
7
7
 
8
+ ## 0.9.0 - 2026-09-18
9
+
10
+ * [Fix] An answer landing touches the reading it belongs to, so a page watching one refreshes
11
+ on the answer rather than on whichever status change the run happens to make around it. A
12
+ question already touched its reading, which is why a question appeared and its answer did not.
13
+ * [Fix] Making an audience's role and letting members into it are two transactions, not one.
14
+ A managed database refuses to make a role at all, and a refusal takes its whole transaction
15
+ with it -- so the membership a role already there only needed was rolled back with the
16
+ refusal, and every statement run as it went on saying the app was misconfigured.
17
+ * [Change] An audience whose Postgres role could not be made says so once and narrows nothing,
18
+ rather than failing table by table and then reporting the audience narrowed. A managed
19
+ database refuses `CREATE ROLE` to the app's own user, and the refusal used to arrive as a
20
+ screenful of `role does not exist` followed by a success line.
21
+
22
+ ## 0.8.0 - 2026-09-16
23
+
24
+ * [Feature] Make how a Postgres role is made safely public, so a host builds the read-only role
25
+ Omen requires with the care Omen builds its own: `Omen::Grants.made` takes `login:`,
26
+ `Omen::Attributes.dangerous` and `.settable` take the role they are about, and `Omen.attempted`
27
+ and `Omen.each_database` are the two a host's rake task calls. A host had to hand-write all of
28
+ it, and the copy in Fountain asserted `NOSUPERUSER` -- which only a superuser may say, so the
29
+ grant died at its second statement, warned, and left the deploy green for a year
30
+
31
+ * [Breaking change] `Omen::Attributes.exists?`, `.held` and `.dangerous` take the role to ask
32
+ about rather than reading `Omen.config.narrow_role`, since a host asks about its own
33
+
34
+ * [Breaking change] `Omen::Attributes::SETTABLE` no longer carries `NOLOGIN`, which is now the
35
+ default of `Omen::Attributes.settable login:` -- a role a host connects as needs `LOGIN`
36
+
37
+ * [Breaking change] `Omen::Inquirer.attempted` is `Omen.attempted` and `Omen::Inquirer::REFUSED`
38
+ is `Omen::REFUSED`. Neither was ever about the inquirer, and a host calling `Omen::Inquirer`
39
+ to build its reader read as though the two roles were one
40
+
8
41
  ## 0.7.0 - 2026-09-12
9
42
 
10
43
  * [Bugfix] Keep the columns of an answer in the order the statement asked for them. They were
data/INSTRUCTIONS.md CHANGED
@@ -104,8 +104,10 @@ end
104
104
  - **A read-only connection role.** `connects_to database: { writing: :primary, reading: :reader }`
105
105
  on the record class, with the `reading` entry logging in as a Postgres role granted `SELECT`
106
106
  and nothing else. Omen raises rather than falling back to a role that could write, which is
107
- the point. Creating that role is the app's own business — Omen has no name for it, and
108
- discovers it when granting.
107
+ the point. Naming that role is the app's own business — Omen has none for it, and discovers
108
+ it when granting — but making one is not, since a managed database refuses half the DDL it
109
+ takes. `Omen::Grants.made`, `Omen::Attributes.dangerous`, `Omen.attempted` and
110
+ `Omen.each_database` are public for that, and the README shows the task they add up to.
109
111
  - **Active Record Encryption keys.** Without them an encrypted column reads back as the
110
112
  placeholder rather than as the value, quietly.
111
113
  - **An `ApplicationJob`.** A reading is answered outside the request, and the job descends
data/README.md CHANGED
@@ -41,6 +41,32 @@ None of it is Omen's to create, and each is checked rather than assumed:
41
41
  - **A read-only connection role**: `connects_to database: { writing: :primary, reading: :reader }`
42
42
  on `ApplicationRecord`, where `reader` logs in as a Postgres role granted `SELECT` and nothing
43
43
  else. Omen raises rather than falling back to a role that could write, which is the point.
44
+ Naming that role is yours; making one safely is not, since a managed database refuses half the
45
+ DDL it takes — so `Omen::Grants.made`, `Omen::Attributes.dangerous`, `Omen.attempted` and
46
+ `Omen.each_database` are public, and a host's own task is the grants it wants and little else:
47
+
48
+ module Reader
49
+ ROLE = 'your_app_reader'
50
+
51
+ def self.grant = Omen.each_database { |connection| grant_on connection }
52
+
53
+ def self.grant_on(connection)
54
+ statements(connection).each { |it| Omen.attempted connection, it }
55
+ held = Omen::Attributes.dangerous connection, ROLE
56
+ warn "#{ROLE} holds #{held.to_sentence}" if held.any?
57
+ end
58
+
59
+ def self.statements(connection)
60
+ role = connection.quote_table_name ROLE
61
+ Omen::Grants.made(connection, ROLE, login: true) +
62
+ [ "GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}", ... ]
63
+ end
64
+ end
65
+
66
+ `Omen.attempted` runs each statement in a savepoint and warns rather than raising, so one
67
+ refusal discards neither the deploy nor the statements behind it. `Omen::Grants.made` asserts
68
+ only what whoever may create a role may set: saying `NOSUPERUSER` needs the `SUPERUSER`
69
+ attribute, so it is read back with `Omen::Attributes.dangerous` and warned about instead.
44
70
  - **`ANTHROPIC_API_KEY`**, or a key named in the initializer.
45
71
  - **Active Record Encryption keys**, without which an encrypted column reads back as a
46
72
  placeholder rather than as its value, quietly.
@@ -5,7 +5,10 @@ class Omen::Answer < Omen.config.record
5
5
  # A \uXXXX escape that outlived JSON.parse, because Claude escaped the backslash of its own.
6
6
  UNICODE_ESCAPE = /\\u([0-9a-fA-F]{4})/
7
7
 
8
- belongs_to :question
8
+ # Touched, so that a reading whose answer has just landed says so: the reading is what
9
+ # broadcasts, and the row it broadcasts for is this one. Without it a page waits on the
10
+ # status the run happens to change around the answer rather than on the answer itself.
11
+ belongs_to :question, touch: true
9
12
 
10
13
  # @return [String] the side of the conversation this was said on, in the words the API uses.
11
14
  def role = 'assistant'
@@ -1,35 +1,42 @@
1
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.
2
+ # What a Postgres role may and may not be. A role is created with every dangerous attribute
3
+ # already off, so this gem sets only what whoever created it may set, and reads the rest back
4
+ # rather than asserting them: saying `NOSUPERUSER` needs the SUPERUSER attribute, so a managed
5
+ # database refuses the very statement that would have made the role safe.
6
6
  module Attributes
7
7
  # Set on the role, because whoever may create a role may set these.
8
- SETTABLE = 'NOLOGIN NOCREATEDB NOCREATEROLE'
8
+ SETTABLE = 'NOCREATEDB NOCREATEROLE'
9
9
 
10
10
  # Read back instead, since saying no to one of these needs the attribute itself.
11
11
  DANGEROUS = { rolsuper: 'SUPERUSER', rolbypassrls: 'BYPASSRLS',
12
12
  rolreplication: 'REPLICATION', }
13
13
 
14
+ # @param login [Boolean] whether anything connects as the role.
15
+ # @return [String] what an ALTER ROLE may assert against any database, managed or not.
16
+ def self.settable(login: false) = "#{login ? 'LOGIN' : 'NOLOGIN'} #{SETTABLE}"
17
+
14
18
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
19
+ # @param name [String] the role to ask about.
15
20
  # @return [Boolean] whether the role is there at all to be granted anything.
16
- def self.exists?(connection) = held(connection).present?
21
+ def self.exists?(connection, name) = held(connection, name).present?
17
22
 
18
23
  # Nil where there is no such role, so that an empty list and a missing role read apart.
19
24
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
25
+ # @param name [String] the role to ask about.
20
26
  # @return [Hash, nil] the row of what the role is, or nothing where the role is not.
21
- def self.held(connection)
27
+ def self.held(connection, name)
22
28
  connection.select_one <<~SQL.squish
23
29
  SELECT #{DANGEROUS.keys.join ', '} FROM pg_roles
24
- WHERE rolname = #{connection.quote Omen.config.narrow_role}
30
+ WHERE rolname = #{connection.quote name}
25
31
  SQL
26
32
  end
27
33
 
28
34
  # @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] }
35
+ # @param name [String] the role to ask about.
36
+ # @return [Array<String>] what the role holds that no read-only request should reach.
37
+ def self.dangerous(connection, name)
38
+ row = held(connection, name) || {}
39
+ DANGEROUS.filter_map { |column, attribute| attribute if row[column.to_s] }
33
40
  end
34
41
  end
35
42
  end
data/lib/omen/grants.rb CHANGED
@@ -30,8 +30,10 @@ module Omen
30
30
  ]
31
31
  end
32
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.
33
+ # Who may enter the role, and the schema it reads in. Kept apart from making the role: a
34
+ # managed database refuses to make one at all, and a refusal takes its whole transaction
35
+ # with it -- so membership granted in the same breath would be lost to a role that was
36
+ # already there and only needed letting into.
35
37
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
36
38
  # @param name [String] the role to make.
37
39
  # @param members [Array<String>] the roles that may enter it, none of them inheriting it.
@@ -39,7 +41,6 @@ module Omen
39
41
  def self.narrowed(connection, name, members)
40
42
  role = connection.quote_table_name name
41
43
  [
42
- *made(connection, name),
43
44
  *members.map { |member| apart role, connection.quote_table_name(member) },
44
45
  "GRANT USAGE ON SCHEMA public TO #{role}",
45
46
  ]
@@ -70,12 +71,14 @@ module Omen
70
71
 
71
72
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
72
73
  # @param name [String] the role to make, where the database has not got it.
74
+ # @param login [Boolean] whether anything connects as it, as a host's reading role does.
73
75
  # @return [Array<String>] the statements that make it and say what it may be.
74
- def self.made(connection, name)
76
+ def self.made(connection, name, login: false)
75
77
  role = connection.quote_table_name name
78
+ entered = login ? 'LOGIN' : 'NOLOGIN'
76
79
  [ '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}", ]
80
+ "#{connection.quote name}) THEN CREATE ROLE #{role} #{entered}; END IF; END $$",
81
+ "ALTER ROLE #{role} WITH #{Attributes.settable login: login}", ]
79
82
  end
80
83
 
81
84
  # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
data/lib/omen/inquirer.rb CHANGED
@@ -14,9 +14,12 @@ 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 group, since the grants, the revocations and the functions need nothing from one
18
- # another and one refusal should not discard the rest.
19
- REFUSED = 'Skipped, refused by the database: %{statement} (%{error})'
17
+ # Said where an audience's own role could not be made. Its tables are then granted to
18
+ # nobody and no policy stands over them, so every reading of that audience says the app is
19
+ # misconfigured -- and every statement after the refusal fails for the same reason, which
20
+ # is one message worth saying and a screenful worth not.
21
+ UNNARROWED = 'Could not make %{role}, so nothing was narrowed to it and every reading of ' \
22
+ 'that audience will say this app is misconfigured. Ask for that role, NOLOGIN.'
20
23
 
21
24
  # Said where a role that already existed is one a reading should not be able to reach through.
22
25
  DANGEROUS = '%{role} holds %{held}. This gem cannot take that away without being a superuser ' \
@@ -32,7 +35,7 @@ module Omen
32
35
  def self.widen
33
36
  Omen.each_database do |connection|
34
37
  Omen::Reading.narrowings.each do |narrowing|
35
- narrowing.widening(connection).each { |group| attempted connection, *group }
38
+ narrowing.widening(connection).each { |group| Omen.attempted connection, *group }
36
39
  puts "Widened #{connection.current_database} back out of #{narrowing.role}"
37
40
  end
38
41
  end
@@ -46,12 +49,12 @@ module Omen
46
49
  read_by = reader
47
50
  warn UNGRANTED % { role: Omen.config.reading_role } unless read_by
48
51
  members = [ read_by, connection.select_value(WHOEVER) ].compact
49
- Grants.statements(connection, members).each { |statement| attempted connection, statement }
52
+ Grants.statements(connection, members).each { |it| Omen.attempted connection, it }
50
53
  role = Omen.config.narrow_role
51
54
  return warn UNMADE % { role: role, tables: Omen.tables.to_sentence } unless
52
- Attributes.exists? connection
55
+ Attributes.exists? connection, role
53
56
 
54
- held = Attributes.dangerous connection
57
+ held = Attributes.dangerous connection, role
55
58
  warn DANGEROUS % { role: role, held: held.to_sentence } if held.any?
56
59
  puts "Granted SELECT on #{connection.current_database} to #{role}"
57
60
  narrow connection, members
@@ -62,26 +65,24 @@ module Omen
62
65
  # @param members [Array<String>] the roles that may enter a narrowed one.
63
66
  # @return [void]
64
67
  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
68
+ Omen::Reading.narrowings.each { |narrowing| narrowed connection, narrowing, members }
69
69
  end
70
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.
71
+ # One audience: its role made first and asked for straight after, since everything else
72
+ # here names that role and a database that would not make it refuses the lot. Nothing is
73
+ # said to have been narrowed where nothing was.
76
74
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
77
- # @param statements [Array<String>] what Omen::Grants or a narrowing built.
75
+ # @param narrowing [Omen::Narrowing] the audience to write.
76
+ # @param members [Array<String>] the roles that may enter its role.
78
77
  # @return [void]
79
- def self.attempted(connection, *statements)
80
- connection.transaction(requires_new: true) do
81
- statements.each { |statement| connection.execute statement }
82
- end
83
- rescue ActiveRecord::StatementInvalid => error
84
- warn REFUSED % { statement: statements.first.squish, error: error.message.lines.first.strip }
78
+ def self.narrowed(connection, narrowing, members)
79
+ making, *holding = narrowing.statements(connection, members)
80
+ Omen.attempted connection, *making
81
+ return warn UNNARROWED % { role: narrowing.role } unless
82
+ Attributes.exists? connection, narrowing.role
83
+
84
+ holding.each { |group| Omen.attempted connection, *group }
85
+ puts "Narrowed #{narrowing.role} to the rows #{narrowing.setting} names"
85
86
  end
86
87
 
87
88
  # Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
@@ -37,7 +37,8 @@ module Omen
37
37
  # @return [Array<Array<String>>] the statements, grouped: what arrives together is applied
38
38
  # together, so no table is left with row level security on and no policy under it.
39
39
  def statements(connection, members)
40
- [ Grants.narrowed(connection, @role, members),
40
+ [ Grants.made(connection, @role),
41
+ Grants.narrowed(connection, @role, members),
41
42
  *@whole.map { |table| granted connection, table },
42
43
  *@own.map { |table, own| narrowed connection, table, own }, ]
43
44
  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.7.0'
3
+ VERSION = '0.9.0'
4
4
  end
data/lib/omen.rb CHANGED
@@ -27,9 +27,27 @@ module Omen
27
27
  # @return [void]
28
28
  def self.configure = yield config
29
29
 
30
+ # Said where a database refuses one group, since the groups need nothing from one another.
31
+ REFUSED = 'Skipped, refused by the database: %{statement} (%{error})'
32
+
30
33
  # @return [Array<String>] the environments whose databases a grant should cover.
31
34
  def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
32
35
 
36
+ # A savepoint each and a warning rather than a raise, so a statement a managed database
37
+ # refuses discards neither the deploy nor the statements behind it -- and what arrives
38
+ # together is applied together, since a table is never left with row level security on and
39
+ # no policy under it.
40
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
41
+ # @param statements [Array<String>] what to run, all of it or none of it.
42
+ # @return [void]
43
+ def self.attempted(connection, *statements)
44
+ connection.transaction(requires_new: true) do
45
+ statements.each { |statement| connection.execute statement }
46
+ end
47
+ rescue ActiveRecord::StatementInvalid => error
48
+ warn REFUSED % { statement: statements.first.squish, error: error.message.lines.first.strip }
49
+ end
50
+
33
51
  # @yield [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing connection to each
34
52
  # database this environment prepares.
35
53
  # @return [void]
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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo