omen 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +26 -1
- data/INSTRUCTIONS.md +5 -3
- data/README.md +26 -0
- data/db/migrate/20260821120002_create_omen_answers.rb +1 -1
- data/db/migrate/20260912100000_keep_the_column_order_of_an_answer.rb +5 -0
- data/lib/generators/omen/install/templates/omen.rb +1 -1
- data/lib/omen/attributes.rb +19 -12
- data/lib/omen/config.rb +1 -1
- data/lib/omen/grants.rb +5 -3
- data/lib/omen/inquirer.rb +5 -25
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +18 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50e0ccd4a4b0c3fa358e235be39a5e79b53fabbcb4e05c09424508cab6be99f9
|
|
4
|
+
data.tar.gz: 4c4de5e474629c685455a599469c33caff9ae1e646dd331752313bf8c2e00020
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5158631649bc16dbeffbd630ba07303880657e30dd0c15ccba1ebde0a66317985caff827dfa423259d71091f74bf835634fa029050cd554917427784f9ef766e
|
|
7
|
+
data.tar.gz: 91910028f557c76263c24b7c664aca12e6e89a8f5c009e605e4fd3563f1c39a8bbd35672dc24c3529c800a689b5c62a09a572e3e5e01f5072232ee8acf98db03
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,32 @@ 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
|
-
##
|
|
8
|
+
## 0.8.0 - 2026-09-16
|
|
9
|
+
|
|
10
|
+
* [Feature] Make how a Postgres role is made safely public, so a host builds the read-only role
|
|
11
|
+
Omen requires with the care Omen builds its own: `Omen::Grants.made` takes `login:`,
|
|
12
|
+
`Omen::Attributes.dangerous` and `.settable` take the role they are about, and `Omen.attempted`
|
|
13
|
+
and `Omen.each_database` are the two a host's rake task calls. A host had to hand-write all of
|
|
14
|
+
it, and the copy in Fountain asserted `NOSUPERUSER` -- which only a superuser may say, so the
|
|
15
|
+
grant died at its second statement, warned, and left the deploy green for a year
|
|
16
|
+
|
|
17
|
+
* [Breaking change] `Omen::Attributes.exists?`, `.held` and `.dangerous` take the role to ask
|
|
18
|
+
about rather than reading `Omen.config.narrow_role`, since a host asks about its own
|
|
19
|
+
|
|
20
|
+
* [Breaking change] `Omen::Attributes::SETTABLE` no longer carries `NOLOGIN`, which is now the
|
|
21
|
+
default of `Omen::Attributes.settable login:` -- a role a host connects as needs `LOGIN`
|
|
22
|
+
|
|
23
|
+
* [Breaking change] `Omen::Inquirer.attempted` is `Omen.attempted` and `Omen::Inquirer::REFUSED`
|
|
24
|
+
is `Omen::REFUSED`. Neither was ever about the inquirer, and a host calling `Omen::Inquirer`
|
|
25
|
+
to build its reader read as though the two roles were one
|
|
26
|
+
|
|
27
|
+
## 0.7.0 - 2026-09-12
|
|
28
|
+
|
|
29
|
+
* [Bugfix] Keep the columns of an answer in the order the statement asked for them. They were
|
|
30
|
+
held as `jsonb`, which sorts a row's keys by length, so a page drew them scrambled
|
|
31
|
+
|
|
32
|
+
* [Breaking change] Carry five turns of a thread rather than twenty, which is what a question
|
|
33
|
+
usually leans on and what a thread nobody clears can afford to send again every time
|
|
9
34
|
|
|
10
35
|
## 0.6.0 - 2026-09-11
|
|
11
36
|
|
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.
|
|
108
|
-
|
|
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
|
|
@@ -121,7 +123,7 @@ writes it with each line commented out, as the list of what there is to say.
|
|
|
121
123
|
| Setting | Default |
|
|
122
124
|
|---|---|
|
|
123
125
|
| `narrow_role` | `'omen_inquirer'` |
|
|
124
|
-
| `remembered` | `
|
|
126
|
+
| `remembered` | `5`, the turns of a thread that travel with the next question |
|
|
125
127
|
| `notes` | none, so the prompt says nothing about this app beyond its schema |
|
|
126
128
|
|
|
127
129
|
## What a host builds on top
|
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.
|
|
@@ -2,7 +2,7 @@ class CreateOmenAnswers < ActiveRecord::Migration[8.1]
|
|
|
2
2
|
def change
|
|
3
3
|
create_table :omen_answers do |t|
|
|
4
4
|
t.jsonb :content, default: [], null: false
|
|
5
|
-
t.
|
|
5
|
+
t.json :result, default: [], null: false
|
|
6
6
|
t.jsonb :provenance, default: {}, null: false
|
|
7
7
|
t.integer :input_usage, default: 0, null: false
|
|
8
8
|
t.integer :output_usage, default: 0, null: false
|
|
@@ -25,7 +25,7 @@ Omen.configure do |config|
|
|
|
25
25
|
|
|
26
26
|
# How many turns of a thread travel with the next question. A thread nobody clears would
|
|
27
27
|
# otherwise carry its whole history into each one, and be charged for it again each time.
|
|
28
|
-
# config.remembered =
|
|
28
|
+
# config.remembered = 5
|
|
29
29
|
|
|
30
30
|
# Left unset, the Anthropic SDK resolves ANTHROPIC_API_KEY and its own wider chain.
|
|
31
31
|
# config.api_key = Rails.application.credentials.dig :anthropic, :api_key
|
data/lib/omen/attributes.rb
CHANGED
|
@@ -1,35 +1,42 @@
|
|
|
1
1
|
module Omen
|
|
2
|
-
# What
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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 = '
|
|
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
|
|
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
|
-
# @
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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/config.rb
CHANGED
data/lib/omen/grants.rb
CHANGED
|
@@ -70,12 +70,14 @@ module Omen
|
|
|
70
70
|
|
|
71
71
|
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
|
|
72
72
|
# @param name [String] the role to make, where the database has not got it.
|
|
73
|
+
# @param login [Boolean] whether anything connects as it, as a host's reading role does.
|
|
73
74
|
# @return [Array<String>] the statements that make it and say what it may be.
|
|
74
|
-
def self.made(connection, name)
|
|
75
|
+
def self.made(connection, name, login: false)
|
|
75
76
|
role = connection.quote_table_name name
|
|
77
|
+
entered = login ? 'LOGIN' : 'NOLOGIN'
|
|
76
78
|
[ 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
|
|
77
|
-
"#{connection.quote name}) THEN CREATE ROLE #{role}
|
|
78
|
-
"ALTER ROLE #{role} WITH #{Attributes
|
|
79
|
+
"#{connection.quote name}) THEN CREATE ROLE #{role} #{entered}; END IF; END $$",
|
|
80
|
+
"ALTER ROLE #{role} WITH #{Attributes.settable login: login}", ]
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
# Intersected, so a bare db:create with no table yet to revoke on is not a failure.
|
data/lib/omen/inquirer.rb
CHANGED
|
@@ -14,10 +14,6 @@ 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})'
|
|
20
|
-
|
|
21
17
|
# Said where a role that already existed is one a reading should not be able to reach through.
|
|
22
18
|
DANGEROUS = '%{role} holds %{held}. This gem cannot take that away without being a superuser ' \
|
|
23
19
|
'itself, so ask for it to be taken away.'
|
|
@@ -32,7 +28,7 @@ module Omen
|
|
|
32
28
|
def self.widen
|
|
33
29
|
Omen.each_database do |connection|
|
|
34
30
|
Omen::Reading.narrowings.each do |narrowing|
|
|
35
|
-
narrowing.widening(connection).each { |group| attempted connection, *group }
|
|
31
|
+
narrowing.widening(connection).each { |group| Omen.attempted connection, *group }
|
|
36
32
|
puts "Widened #{connection.current_database} back out of #{narrowing.role}"
|
|
37
33
|
end
|
|
38
34
|
end
|
|
@@ -46,12 +42,12 @@ module Omen
|
|
|
46
42
|
read_by = reader
|
|
47
43
|
warn UNGRANTED % { role: Omen.config.reading_role } unless read_by
|
|
48
44
|
members = [ read_by, connection.select_value(WHOEVER) ].compact
|
|
49
|
-
Grants.statements(connection, members).each { |
|
|
45
|
+
Grants.statements(connection, members).each { |it| Omen.attempted connection, it }
|
|
50
46
|
role = Omen.config.narrow_role
|
|
51
47
|
return warn UNMADE % { role: role, tables: Omen.tables.to_sentence } unless
|
|
52
|
-
Attributes.exists? connection
|
|
48
|
+
Attributes.exists? connection, role
|
|
53
49
|
|
|
54
|
-
held = Attributes.dangerous connection
|
|
50
|
+
held = Attributes.dangerous connection, role
|
|
55
51
|
warn DANGEROUS % { role: role, held: held.to_sentence } if held.any?
|
|
56
52
|
puts "Granted SELECT on #{connection.current_database} to #{role}"
|
|
57
53
|
narrow connection, members
|
|
@@ -63,27 +59,11 @@ module Omen
|
|
|
63
59
|
# @return [void]
|
|
64
60
|
def self.narrow(connection, members)
|
|
65
61
|
Omen::Reading.narrowings.each do |narrowing|
|
|
66
|
-
narrowing.statements(connection, members).each { |group| attempted connection, *group }
|
|
62
|
+
narrowing.statements(connection, members).each { |group| Omen.attempted connection, *group }
|
|
67
63
|
puts "Narrowed #{narrowing.role} to the rows #{narrowing.setting} names"
|
|
68
64
|
end
|
|
69
65
|
end
|
|
70
66
|
|
|
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
|
|
83
|
-
rescue ActiveRecord::StatementInvalid => error
|
|
84
|
-
warn REFUSED % { statement: statements.first.squish, error: error.message.lines.first.strip }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
67
|
# Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
|
|
88
68
|
# this one, and the role a host's reading connection logs in as is the host's own business.
|
|
89
69
|
# @return [String, nil] the Postgres user a reading is read through, where there is one.
|
data/lib/omen/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Claudio Baccigalupo
|
|
@@ -99,6 +99,7 @@ files:
|
|
|
99
99
|
- db/migrate/20260821120001_create_omen_questions.rb
|
|
100
100
|
- db/migrate/20260821120002_create_omen_answers.rb
|
|
101
101
|
- db/migrate/20260912000000_add_type_to_omen_readings.rb
|
|
102
|
+
- db/migrate/20260912100000_keep_the_column_order_of_an_answer.rb
|
|
102
103
|
- lib/generators/omen/install/install_generator.rb
|
|
103
104
|
- lib/generators/omen/install/templates/omen.rb
|
|
104
105
|
- lib/generators/omen/pages/USAGE
|