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

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, **transaction_options)
  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(**transaction_options) 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 permission
              edge travels with the permission 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)
  meta = (table, db)
  reach = meta[: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, meta[:wildcard], db),
      null_parent: null_parent,
      orphaned_rows: orphaned_rows(reach, db),
      widening: widening(t, tq, reach, meta[: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.

Returns:

  • (Boolean)


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)
  meta = (table, db)
  reach = meta[: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 meta[: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.

Returns:

  • (Boolean)


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