Module: SuperAuth::RLS
- Defined in:
- lib/super_auth/rls.rb
Constant Summary collapse
- POLICY =
"super_auth".freeze
- POLICY_NAMES =
Every name enable has ever given a policy. Postgres ORs permissive policies, so one left behind under a previous name would keep admitting rows beside the current one; enable drops every name here before it creates POLICY, and the list only grows.
%w[super_auth].freeze
- POLICY_VERSION =
Bumped when the policy template changes and at no other time. Recorded in each policy's comment so current? and stale can tell a table built by an earlier enable from one built by this one; installed? does not read it and keeps meaning only that the identity functions exist.
2- TYPE_FAMILIES =
Column types the policy compares without a cast, by Postgres internal name: a reach column of the protected table against super_auth_authorizations.resource_external_id must be identical or in one of these families.
[%w[int2 int4 int8], %w[text varchar]].freeze
- V1_SHAPE =
The 0.8.0 policy's `resource_external_id IS NULL OR resource_external_id
t.id`, as pg_get_expr deparses it (parenthesised) and as written.
/IS NULL\)? OR/
Class Method Summary collapse
-
.as(user, db: SuperAuth.db, **transaction_options) ⇒ Object
Run the block with
user's identity asserted for one transaction — the Ruby face of the SQL contract (BEGIN; SELECT super_auth_become(...); queries; COMMIT). -
.assert(user, db: SuperAuth.db) ⇒ Object
Assert
user's identity in the transaction the caller already holds, without opening one: the SELECT super_auth_become(...) half of the contract, or super_auth_system() for a user whosesystem?is true. -
.coverage(table, db: SuperAuth.db) ⇒ Object
What the table's compiled rows and nodes look like against its policy — a diagnostic for a host moving tenancy from per-record rows to a parent column, or checking a production dump before it does.
-
.current?(table, resource_type:, parent: nil, wildcard: true, db: SuperAuth.db) ⇒ Boolean
Whether the table carries the policy this enable would build for the same arguments: row security on and forced, the policy's comment equal to the one enable writes (version, reach and wildcard in one canonical JSON string), and its expression free of the 0.8.0 shape, whose
IS NULL ORwas evaluated once per row. -
.disable(table, lock_timeout: "5s", db: SuperAuth.db) ⇒ Object
Drops the table's policy under every name enable ever used; the shared functions are left in place (other tables may still be protected, and they are harmless on their own).
-
.enable(table, resource_type:, parent: nil, wildcard: true, lock_timeout: "5s", db: SuperAuth.db) ⇒ Object
Enable RLS on an app table with one policy mirroring ByCurrentUser.
-
.explain(table, id, db: SuperAuth.db) ⇒ Object
Which compiled rows admit record
idoftablefor the identity currently asserted on the connection, each row a hash of its columns plus :step — :type_level, :id, or the parent column — for the step that admitted it. -
.grant_system(role, db: SuperAuth.db) ⇒ Object
Allow
roleto assert system context: SuperAuth.as with a user whose system? is true, or SELECT super_auth_system() directly. -
.installed?(db: SuperAuth.db) ⇒ Boolean
Whether
enablehas run on this database: both identity functions exist with their current signatures. -
.reach(table, db: SuperAuth.db) ⇒ Object
The reach map the table's policy was built from, read back from its comment, so coverage and explain work with no model loaded.
-
.stale(db: SuperAuth.db) ⇒ Object
Every table carrying a policy of the gem's whose comment is missing or records a version other than POLICY_VERSION: the tables an upgrade has to re-run enable on.
Class Method Details
.as(user, db: SuperAuth.db, **transaction_options) ⇒ Object
Run the block with user's identity asserted for one transaction —
the Ruby face of the SQL contract
(BEGIN; SELECT super_auth_become(...); queries; COMMIT). Sequel and
ActiveRecord queries inside the block share the transaction's
connection, so the policies see the identity; it dies with the
transaction. A user whose system? is true asserts system context
through super_auth_system() instead, which the connection's role
must have been granted EXECUTE on.
Inside an enclosing transaction (the caller's, or an outer as) it
joins that transaction instead of opening one, and it puts the
enclosing identity back when the block ends, however it ends: the
innermost assertion wins inside the block and nothing else afterwards.
This touches only the database settings; SuperAuth.as is the call that
also sets SuperAuth.current_user.
Transaction options pass through to Sequel's transaction. One matters for a wrapper that exists only to carry an identity:
auto_savepoint: true every nested transaction becomes a savepoint
(the ActiveRecord bridge turns this into
joinable: false), so a save inside the block
commits on its own and its after_commit hooks
fire then, not at the end of the block.
Whether a write survives the block raising is the caller's policy, not this wrapper's: rescue inside the block to keep it, or let the exception out to roll it back.
306 307 308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/super_auth/rls.rb', line 306 def as(user, db: SuperAuth.db, **) postgres!(db) # Outside a transaction the settings die at COMMIT and there is nothing # to restore; inside one, the enclosing identity must survive the block. enclosing = db.in_transaction? ? identity(db) : nil db.transaction(**) do assert(user, db: db) begin yield ensure restore(enclosing, db) if enclosing end end end |
.assert(user, db: SuperAuth.db) ⇒ Object
Assert user's identity in the transaction the caller already holds,
without opening one: the SELECT super_auth_become(...) half of the
contract, or super_auth_system() for a user whose system? is true.
For re-asserting mid-transaction, and for code that manages its own
transaction and only needs the identity in it. Outside a transaction
the settings die with the statement, so it protects nothing there.
327 328 329 330 331 332 333 334 |
# File 'lib/super_auth/rls.rb', line 327 def assert(user, db: SuperAuth.db) postgres!(db) if user.respond_to?(:system?) && user.system? db.get(Sequel.function(:super_auth_system)) else db.get(Sequel.function(:super_auth_become, *become_args(user))) end end |
.coverage(table, db: SuperAuth.db) ⇒ Object
What the table's compiled rows and nodes look like against its policy — a diagnostic for a host moving tenancy from per-record rows to a parent column, or checking a production dump before it does. Five buckets, each an Array of small hashes with a :count and up to 20 example :ids, and only the entries whose count is above zero:
loss per holder of a type-level row on one of the table's
own types: the records that holder reaches through
that row and nothing else — no per-record row, no
parent row for the value in any parent column, NULL
parents included. What deleting the type-level row
takes away. ids are the table's.
null_parent per parent column: the records whose column is NULL,
which no parent grant can reach. ids are the table's.
orphaned_rows compiled rows with nothing behind them: the node
they were compiled from is gone, or no longer names
the (type, id) the row copies — a (type, NULL) row
whose wildcard node was deleted keeps admitting every
record of the type until the next compile. Grouped by
type and whether the rows are type-level; ids are
super_auth_resources ids.
widening per holder of a parent-type row: the records that
holder reaches through a parent column and holds no
per-record row for, so the parent step admits them
for the first time. A holder a type-level row already
admits everywhere is left out. ids are the table's.
deletable_nodes per own type: the per-record nodes no user->resource
edge points at, on the node itself or on any
ancestor of it. Access granted through a
edge travels with the and can be replaced
by the parent column; access granted straight to a
user
The holder-to-row match is the SQL the policy runs, with the identity
settings pointed at each holder in turn, so what coverage counts as
reached is exactly what the policy admits. A type-level row on a
parent type is never counted as reaching anything: a column holds an
id, and NULL equals none. Reads run in system context when the role
may assert it, and otherwise as the identity the caller has, which
sees only its own rows (see reading); either way the role needs
SELECT on the table and on super_auth_resources and super_auth_edges,
which enable grants to nobody. explain needs neither of those two:
it reads only the table and super_auth_authorizations, which enable
grants to PUBLIC.
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/super_auth/rls.rb', line 253 def coverage(table, db: SuperAuth.db) postgres!(db) = (table, db) reach = [:reach] parents = Reach.parents(reach) t = Sequel.identifier(table.to_s) tq = db.literal(t) reading(db) do # null_parent is counted first and held in a local: loss and # widening point the identity settings at each holder in turn and # leave them there, so a bucket that samples the table after them # would read it as that last holder rather than as the caller. The # returned Hash keeps the documented order. null_parent = parents.keys.filter_map { |column| entry = sample(t, "#{tq}.#{db.literal(Sequel.identifier(column.to_s))} IS NULL", db) { column: column, **entry } if entry[:count] > 0 } { loss: loss(t, tq, reach, [:wildcard], db), null_parent: null_parent, orphaned_rows: orphaned_rows(reach, db), widening: widening(t, tq, reach, [:wildcard], db), deletable_nodes: deletable_nodes(reach, db), } end end |
.current?(table, resource_type:, parent: nil, wildcard: true, db: SuperAuth.db) ⇒ Boolean
Whether the table carries the policy this enable would build for the
same arguments: row security on and forced, the policy's comment
equal to the one enable writes (version, reach and wildcard in one
canonical JSON string), and its expression free of the 0.8.0 shape,
whose IS NULL OR was evaluated once per row. For a test helper or a
health check after a deploy that changed parent: or upgraded the gem.
False means "not built from these arguments" and nothing worse: the
table has no policy of the gem's yet, row security is off or unforced,
or the reach really does disagree. A policy an earlier version of
enable built raises instead, with the message reach and coverage
give, because the question this asks has no answer there — the
comparison is against a comment written by code that is gone, so a
bare false says "your parent:/wildcard: arguments are wrong" about a
database whose only fault is that nobody re-ran enable after the
upgrade. That is the state a host lands in by running db:migrate and
nothing else, since no migration re-runs enable, and it is the
expensive one to be in unawares: the 0.8.0 policy is still installed
and still correlated per row. stale names every table in it without
raising, and is the call to make first after an upgrade.
149 150 151 152 153 154 155 156 157 158 |
# File 'lib/super_auth/rls.rb', line 149 def current?(table, resource_type:, parent: nil, wildcard: true, db: SuperAuth.db) postgres!(db) reach = Reach.normalize(resource_type: resource_type, parent: parent) row = policy(table, db) return false if row.nil? assert_policy_version!(table, row[:comment]) row[:enabled] && row[:forced] && row[:comment] == comment(reach, wildcard) && !row[:qual].to_s.match?(V1_SHAPE) end |
.disable(table, lock_timeout: "5s", db: SuperAuth.db) ⇒ Object
Drops the table's policy under every name enable ever used; the shared functions are left in place (other tables may still be protected, and they are harmless on their own).
118 119 120 121 122 123 124 125 126 127 |
# File 'lib/super_auth/rls.rb', line 118 def disable(table, lock_timeout: "5s", db: SuperAuth.db) postgres!(db) t = db.literal(Sequel.identifier(table.to_s)) db.transaction do db.run "SET LOCAL lock_timeout = #{db.literal(lock_timeout.to_s)}" POLICY_NAMES.each { |name| db.run "DROP POLICY IF EXISTS #{name} ON #{t}" } db.run "ALTER TABLE #{t} NO FORCE ROW LEVEL SECURITY" db.run "ALTER TABLE #{t} DISABLE ROW LEVEL SECURITY" end end |
.enable(table, resource_type:, parent: nil, wildcard: true, lock_timeout: "5s", db: SuperAuth.db) ⇒ Object
Enable RLS on an app table with one policy mirroring ByCurrentUser. resource_type: names the types whose rows reach a row by its id, and parent: the columns through which other types reach it; both go through SuperAuth::Reach.normalize, as the ORM macro's do, so the two layers cannot drift. The USING expression is the transaction stamp AND (system context OR one step per reach entry): the type-level step admits every row to a holder of a (type, NULL) row, the id step and each column step admit the rows whose column is among the ids the holder's rows name. wildcard: false leaves the type-level step out, for a table whose types are never granted type-level; a (type, NULL) row then admits nothing there.
INSERTs are gated too. The policy is FOR ALL with no WITH CHECK, so Postgres reuses USING for new rows: creating a row needs a type-level row for the table's type, a parent row for the value the new row carries in a parent column, or system context. A NULL parent column equals no id, so a parent grant never admits a row without one.
The DDL runs in one transaction under lock_timeout: DROP and CREATE POLICY take ACCESS EXCLUSIVE, and as separate statements they left a window with no policy on a live table. Inside a transaction the caller already holds — a migration's — it joins that one, and the SET LOCAL lasts until that transaction ends. Every name in POLICY_NAMES is dropped, never altered, and the fresh policy is commented with its version and reach. Idempotent, and re-runnable on a protected table.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/super_auth/rls.rb', line 92 def enable(table, resource_type:, parent: nil, wildcard: true, lock_timeout: "5s", db: SuperAuth.db) postgres!(db) reach = Reach.normalize(resource_type: resource_type, parent: parent) unless [true, false].include?(wildcard) raise SuperAuth::Error, "wildcard: must be true or false, got #{wildcard.inspect}" end preflight!(table, reach, db) create_functions(db) # SuperAuth.as memoises whether they exist; this is what creates them. SuperAuth.rls! grant_runtime_reads(db) t = db.literal(Sequel.identifier(table.to_s)) db.transaction do db.run "SET LOCAL lock_timeout = #{db.literal(lock_timeout.to_s)}" db.run "ALTER TABLE #{t} ENABLE ROW LEVEL SECURITY" # FORCE: apply the policy even when the app connects as the table owner db.run "ALTER TABLE #{t} FORCE ROW LEVEL SECURITY" POLICY_NAMES.each { |name| db.run "DROP POLICY IF EXISTS #{name} ON #{t}" } db.run "CREATE POLICY #{POLICY} ON #{t}\nUSING (\n#{using(t, reach, wildcard, db)}\n)" db.run "COMMENT ON POLICY #{POLICY} ON #{t} IS #{db.literal(comment(reach, wildcard))}" end end |
.explain(table, id, db: SuperAuth.db) ⇒ Object
Which compiled rows admit record id of table for the identity
currently asserted on the connection, each row a hash of its columns
plus :step — :type_level, :id, or the parent column — for the step
that admitted it. Nothing comes back with no identity asserted, under
system context (the system clause admits without a row), or for a
record that does not exist. Reads the table itself for the record's
parent columns, in system context when the role may assert it, so a
role that may not sees a record it is not admitted to as absent (see
reading).
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/super_auth/rls.rb', line 185 def explain(table, id, db: SuperAuth.db) postgres!(db) = (table, db) reach = [:reach] holder = identity(db)[0, 3] reading(db) do as_holder(holder, db) record = db[Sequel.identifier(table.to_s)].where(id: id).select(:id, *Reach.parents(reach).keys).first rows = [] if record rows.concat(tag(:type_level, holdings_of(type_level_where(reach[:id], db), db))) if [:wildcard] reach.each do |column, types| value = record[column] next if value.nil? rows.concat(tag(column, holdings_of("#{per_record_where(types, db)} AND a.resource_external_id = #{db.literal(value)}", db))) end end rows end end |
.grant_system(role, db: SuperAuth.db) ⇒ Object
Allow role to assert system context: SuperAuth.as with a user whose
system? is true, or SELECT super_auth_system() directly. enable revokes
this from PUBLIC; grant it to the roles that run migrations, seeds and
admin jobs, and to nothing else.
349 350 351 352 |
# File 'lib/super_auth/rls.rb', line 349 def grant_system(role, db: SuperAuth.db) postgres!(db) db.run "GRANT EXECUTE ON FUNCTION super_auth_system() TO #{db.literal(Sequel.identifier(role.to_s))}" end |
.installed?(db: SuperAuth.db) ⇒ Boolean
Whether enable has run on this database: both identity functions
exist with their current signatures. One query per call, so a hot
path memoises it. False on a non-Postgres database, where RLS cannot
be installed.
340 341 342 343 |
# File 'lib/super_auth/rls.rb', line 340 def installed?(db: SuperAuth.db) return false unless db.database_type == :postgres db.get(Sequel.lit("to_regprocedure('super_auth_become(text,text,text)') IS NOT NULL AND to_regprocedure('super_auth_system()') IS NOT NULL")) end |
.reach(table, db: SuperAuth.db) ⇒ Object
The reach map the table's policy was built from, read back from its comment, so coverage and explain work with no model loaded. Raises when the table has no policy or one an earlier enable built.
171 172 173 174 |
# File 'lib/super_auth/rls.rb', line 171 def reach(table, db: SuperAuth.db) postgres!(db) (table, db)[:reach] end |
.stale(db: SuperAuth.db) ⇒ Object
Every table carrying a policy of the gem's whose comment is missing or records a version other than POLICY_VERSION: the tables an upgrade has to re-run enable on. Table names as symbols, sorted.
163 164 165 166 |
# File 'lib/super_auth/rls.rb', line 163 def stale(db: SuperAuth.db) postgres!(db) policies(db).reject { |row| version(row[:comment]) == POLICY_VERSION }.map { |row| row[:table].to_sym }.uniq.sort end |