super_auth 0.4.0 → 0.7.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.
@@ -3,12 +3,26 @@
3
3
  # ByCurrentUser filters queries at the ORM layer; RLS enforces the same rule
4
4
  # inside Postgres, so raw SQL, `unscoped`, and non-Ruby clients are subject
5
5
  # to it too — enforcing apps don't load this gem at all. Identity is
6
- # asserted per transaction by the super_auth_become() SQL function
7
- # (installed by `enable`): it sets transaction-local identity settings plus
8
- # a stamp of the current transaction id, and every policy requires a stamp
9
- # from the current transaction. Identity therefore cannot outlive its
10
- # transaction or leak across pooled connections — a query without a fresh
11
- # assertion sees no rows.
6
+ # asserted per transaction by two SQL functions installed by `enable`:
7
+ #
8
+ # super_auth_become(user_external_id, user_external_type, user_id)
9
+ # asserts a user's identity. Executable by PUBLIC.
10
+ # super_auth_system()
11
+ # asserts system context, which bypasses every policy. EXECUTE is
12
+ # revoked from PUBLIC; `grant_system(role)` hands it to the roles that
13
+ # may bypass.
14
+ #
15
+ # `enable` also grants every role SELECT on the gem's own tables, which the
16
+ # policies and the user models read, so a runtime role needs privileges on
17
+ # the application's tables and nothing else.
18
+ #
19
+ # Both set transaction-local identity settings plus a stamp of the current
20
+ # transaction id, and every policy requires a stamp from the current
21
+ # transaction. Identity therefore cannot outlive its transaction or leak
22
+ # across pooled connections — a query without a fresh assertion sees no
23
+ # rows. Both raise if the calling role is a superuser or has BYPASSRLS:
24
+ # Postgres exempts those roles from every policy, so an identity assertion
25
+ # from one would protect nothing while looking like it does.
12
26
  module SuperAuth
13
27
  module RLS
14
28
  POLICY = "super_auth".freeze
@@ -16,7 +30,7 @@ module SuperAuth
16
30
  class << self
17
31
  # Enable RLS on an app table with a policy mirroring ByCurrentUser:
18
32
  # type-level authorization rows (resource_external_id IS NULL) act as a
19
- # wildcard, per-record rows match on id, and system users bypass.
33
+ # wildcard, per-record rows match on id, and system context bypasses.
20
34
  #
21
35
  # One deliberate divergence: INSERTs are also gated. The policy is
22
36
  # FOR ALL with no WITH CHECK, so Postgres reuses its USING expression
@@ -25,7 +39,8 @@ module SuperAuth
25
39
  # system context.
26
40
  def enable(table, resource_type:, db: SuperAuth.db)
27
41
  postgres!(db)
28
- create_become_function(db)
42
+ create_functions(db)
43
+ grant_runtime_reads(db)
29
44
  t = db.literal(Sequel.identifier(table.to_s))
30
45
  db.run "ALTER TABLE #{t} ENABLE ROW LEVEL SECURITY"
31
46
  # FORCE: apply the policy even when the app connects as the table owner
@@ -54,9 +69,9 @@ module SuperAuth
54
69
  SQL
55
70
  end
56
71
 
57
- # Drops the table's policy; the shared super_auth_become function is
58
- # left in place (other tables may still be protected, and it is
59
- # harmless on its own).
72
+ # Drops the table's policy; the shared functions are left in place
73
+ # (other tables may still be protected, and they are harmless on their
74
+ # own).
60
75
  def disable(table, db: SuperAuth.db)
61
76
  postgres!(db)
62
77
  t = db.literal(Sequel.identifier(table.to_s))
@@ -70,51 +85,166 @@ module SuperAuth
70
85
  # (BEGIN; SELECT super_auth_become(...); queries; COMMIT). Sequel and
71
86
  # ActiveRecord queries inside the block share the transaction's
72
87
  # connection, so the policies see the identity; it dies with the
73
- # transaction. In nested calls the innermost assertion wins for the
74
- # rest of the outer transaction.
75
- def as(user, db: SuperAuth.db)
88
+ # transaction. A user whose `system?` is true asserts system context
89
+ # through super_auth_system() instead, which the connection's role
90
+ # must have been granted EXECUTE on.
91
+ #
92
+ # Inside an enclosing transaction (the caller's, or an outer `as`) it
93
+ # joins that transaction instead of opening one, and it puts the
94
+ # enclosing identity back when the block ends, however it ends: the
95
+ # innermost assertion wins inside the block and nothing else afterwards.
96
+ # This touches only the database settings; SuperAuth.as is the call that
97
+ # also sets SuperAuth.current_user.
98
+ #
99
+ # Transaction options pass through to Sequel's transaction. One matters
100
+ # for a wrapper that exists only to carry an identity:
101
+ # auto_savepoint: true every nested transaction becomes a savepoint
102
+ # (the ActiveRecord bridge turns this into
103
+ # joinable: false), so a save inside the block
104
+ # commits on its own and its after_commit hooks
105
+ # fire then, not at the end of the block.
106
+ # Whether a write survives the block raising is the caller's policy, not
107
+ # this wrapper's: rescue inside the block to keep it, or let the
108
+ # exception out to roll it back.
109
+ def as(user, db: SuperAuth.db, **transaction_options)
76
110
  postgres!(db)
77
- internal_id = external_id = external_type = nil
78
- system = user.respond_to?(:system?) && !!user.system?
79
- if user && internal_user?(user)
80
- internal_id = user.id.to_s
81
- elsif user
82
- external_id = user.id.to_s
83
- external_type = user.class.name
111
+ # Outside a transaction the settings die at COMMIT and there is nothing
112
+ # to restore; inside one, the enclosing identity must survive the block.
113
+ enclosing = db.in_transaction? ? identity(db) : nil
114
+ db.transaction(**transaction_options) do
115
+ assert(user, db: db)
116
+ begin
117
+ yield
118
+ ensure
119
+ restore(enclosing, db) if enclosing
120
+ end
84
121
  end
85
- db.transaction do
86
- db.get(Sequel.function(:super_auth_become, external_id, external_type, internal_id, system))
87
- yield
122
+ end
123
+
124
+ # Assert `user`'s identity in the transaction the caller already holds,
125
+ # without opening one: the SELECT super_auth_become(...) half of the
126
+ # contract, or super_auth_system() for a user whose `system?` is true.
127
+ # For re-asserting mid-transaction, and for code that manages its own
128
+ # transaction and only needs the identity in it. Outside a transaction
129
+ # the settings die with the statement, so it protects nothing there.
130
+ def assert(user, db: SuperAuth.db)
131
+ postgres!(db)
132
+ if user.respond_to?(:system?) && user.system?
133
+ db.get(Sequel.function(:super_auth_system))
134
+ else
135
+ db.get(Sequel.function(:super_auth_become, *become_args(user)))
88
136
  end
89
137
  end
90
138
 
139
+ # Whether `enable` has run on this database: both identity functions
140
+ # exist with their current signatures. One query per call, so a hot
141
+ # path memoises it. False on a non-Postgres database, where RLS cannot
142
+ # be installed.
143
+ def installed?(db: SuperAuth.db)
144
+ return false unless db.database_type == :postgres
145
+ db.get(Sequel.lit("to_regprocedure('super_auth_become(text,text,text)') IS NOT NULL AND to_regprocedure('super_auth_system()') IS NOT NULL"))
146
+ end
147
+
148
+ # Allow `role` to assert system context: SuperAuth.as with a user whose
149
+ # system? is true, or SELECT super_auth_system() directly. enable revokes
150
+ # this from PUBLIC; grant it to the roles that run migrations, seeds and
151
+ # admin jobs, and to nothing else.
152
+ def grant_system(role, db: SuperAuth.db)
153
+ postgres!(db)
154
+ db.run "GRANT EXECUTE ON FUNCTION super_auth_system() TO #{db.literal(Sequel.identifier(role.to_s))}"
155
+ end
156
+
91
157
  private
92
158
 
93
- # One shared function per database; clients assert identity by calling
94
- # it inside their transaction. CREATE OR REPLACE keeps enable
95
- # idempotent.
96
- def create_become_function(db)
159
+ # What any runtime role needs on the gem's own tables: the policies read
160
+ # super_auth_authorizations as the querying role, and the user models'
161
+ # system? reads super_auth_users. Granting PUBLIC makes enable the only
162
+ # setup step; a deployment that wants these tables private can REVOKE
163
+ # from PUBLIC and grant per role.
164
+ def grant_runtime_reads(db)
165
+ db.run "GRANT SELECT ON super_auth_authorizations, super_auth_users TO PUBLIC"
166
+ end
167
+
168
+ # Refuses an identity assertion from a role Postgres exempts from row
169
+ # security: the policies would apply to nobody while everything looked
170
+ # enforced. Checks the effective role, so a superuser session that has
171
+ # SET ROLE to an application role passes.
172
+ SUPERUSER_GUARD = <<~SQL.freeze
173
+ IF (SELECT rolsuper OR rolbypassrls FROM pg_roles WHERE rolname = current_user) THEN
174
+ RAISE EXCEPTION 'super_auth: role % is a superuser or has BYPASSRLS, so row-level security does not apply to it and asserting an identity would protect nothing. Connect as a regular role.', current_user
175
+ USING ERRCODE = 'invalid_authorization_specification';
176
+ END IF;
177
+ SQL
178
+
179
+ # Two shared functions per database; clients assert identity by calling
180
+ # one of them inside their transaction. CREATE OR REPLACE keeps enable
181
+ # idempotent. The pre-0.5 four-argument super_auth_become carried the
182
+ # system bypass as its last parameter; a REPLACE with a different
183
+ # signature would leave that overload in place, so it is dropped.
184
+ def create_functions(db)
185
+ db.run "DROP FUNCTION IF EXISTS super_auth_become(text, text, text, boolean)"
97
186
  db.run <<~SQL
98
187
  CREATE OR REPLACE FUNCTION super_auth_become(
99
188
  user_external_id text DEFAULT NULL,
100
189
  user_external_type text DEFAULT NULL,
101
- user_id text DEFAULT NULL,
102
- system boolean DEFAULT false
190
+ user_id text DEFAULT NULL
103
191
  ) RETURNS void LANGUAGE plpgsql AS $$
104
192
  BEGIN
193
+ #{SUPERUSER_GUARD}
105
194
  PERFORM set_config('super_auth.user_id', COALESCE(user_id, ''), true),
106
195
  set_config('super_auth.user_external_id', COALESCE(user_external_id, ''), true),
107
196
  set_config('super_auth.user_external_type', COALESCE(user_external_type, ''), true),
108
- set_config('super_auth.system', CASE WHEN system THEN 'true' ELSE '' END, true),
197
+ set_config('super_auth.system', '', true),
198
+ set_config('super_auth.xid', pg_current_xact_id()::text, true);
199
+ END
200
+ $$;
201
+ SQL
202
+ db.run <<~SQL
203
+ CREATE OR REPLACE FUNCTION super_auth_system() RETURNS void LANGUAGE plpgsql AS $$
204
+ BEGIN
205
+ #{SUPERUSER_GUARD}
206
+ PERFORM set_config('super_auth.user_id', '', true),
207
+ set_config('super_auth.user_external_id', '', true),
208
+ set_config('super_auth.user_external_type', '', true),
209
+ set_config('super_auth.system', 'true', true),
109
210
  set_config('super_auth.xid', pg_current_xact_id()::text, true);
110
211
  END
111
212
  $$;
112
213
  SQL
214
+ # Bypass is opt-in per role: GRANT EXECUTE ON FUNCTION super_auth_system() TO <role>.
215
+ db.run "REVOKE EXECUTE ON FUNCTION super_auth_system() FROM PUBLIC"
216
+ end
217
+
218
+ # super_auth_become's three arguments for `user`: a SuperAuth user
219
+ # record goes by user_id, any other object by id and class name, nil by
220
+ # nothing, an identity no authorization matches.
221
+ def become_args(user)
222
+ if user.nil?
223
+ [nil, nil, nil]
224
+ elsif SuperAuth.internal_user?(user)
225
+ [nil, nil, user.id.to_s]
226
+ else
227
+ [user.id.to_s, user.class.name, nil]
228
+ end
229
+ end
230
+
231
+ # The five transaction-local settings the policies read, in one order.
232
+ SETTINGS = %w[
233
+ super_auth.user_id super_auth.user_external_id super_auth.user_external_type
234
+ super_auth.system super_auth.xid
235
+ ].freeze
236
+
237
+ def identity(db)
238
+ db.dataset.get(SETTINGS.each_with_index.map { |name, i| Sequel.function(:current_setting, name, true).as(:"s#{i}") })
113
239
  end
114
240
 
115
- def internal_user?(user)
116
- (defined?(SuperAuth::ActiveRecord::User) && user.is_a?(SuperAuth::ActiveRecord::User)) ||
117
- (defined?(SuperAuth::User) && user.is_a?(SuperAuth::User))
241
+ # Writes the settings back directly: the values were read from this same
242
+ # transaction, so the stamp is still the current one, and no role is
243
+ # granted anything it could not already set.
244
+ def restore(values, db)
245
+ db.dataset.get(SETTINGS.zip(values).each_with_index.map { |(name, value), i| Sequel.function(:set_config, name, value.to_s, true).as(:"s#{i}") })
246
+ rescue Sequel::DatabaseError
247
+ # The block aborted the transaction; its rollback discards the settings.
118
248
  end
119
249
 
120
250
  def postgres!(db)
@@ -2,7 +2,9 @@ class SuperAuth::User < Sequel::Model(:super_auth_users)
2
2
  one_to_many :edges
3
3
  one_to_many :resources
4
4
 
5
- def system? = self.class.system == self
5
+ # A read: runtime roles only get SELECT on this table. `.system` creates
6
+ # the row when missing and belongs to migrations, seeds and consoles.
7
+ def system? = self.class.first(name: "system") == self
6
8
  def self.system = find_or_create(name: "system")
7
9
 
8
10
  dataset_module do
@@ -1,3 +1,3 @@
1
1
  module SuperAuth
2
- VERSION = "0.4.0"
2
+ VERSION = "0.7.0"
3
3
  end
data/lib/super_auth.rb CHANGED
@@ -42,6 +42,23 @@ module SuperAuth
42
42
  external_id_type == :string ? String : external_id_type
43
43
  end
44
44
 
45
+ # The human name of the application record behind a node, by convention
46
+ # rather than configuration: a model says it explicitly with
47
+ # `super_auth_label`, otherwise `name` then `title` are tried, and a model
48
+ # with none of them has no label. This is also the name of the column the
49
+ # derivation is stored in, since `label` is a name applications want for
50
+ # themselves. Deliberately never `to_s` — the label sits where the editor
51
+ # otherwise renders `Type#id`, and "#<Claim:0x000055…>" is worse than the id
52
+ # it would replace.
53
+ def self.label_for(record)
54
+ %i[super_auth_label name title].each do |method|
55
+ next unless record.respond_to?(method)
56
+ value = record.public_send(method)
57
+ return value.to_s unless value.nil? || value.to_s.empty?
58
+ end
59
+ nil
60
+ end
61
+
45
62
  def self.load
46
63
  require "super_auth/authorization"
47
64
  require "super_auth/edge"
@@ -95,11 +112,31 @@ module SuperAuth
95
112
  raise Error, "Failed to uninstall migrations: #{e.message}"
96
113
  end
97
114
 
98
- # Run the block with `user`'s identity asserted inside a database
99
- # transaction, so RLS policies (see SuperAuth::RLS) enforce authorization
100
- # for every query in the block. Postgres only.
101
- def self.as(user, db: SuperAuth.db, &block)
102
- SuperAuth::RLS.as(user, db: db, &block)
115
+ # Run the block as `user` in both layers: SuperAuth.current_user, which the
116
+ # ByCurrentUser scope reads, and the database identity the RLS policies read
117
+ # (see SuperAuth::RLS.as). Both are restored on the way out, whether the
118
+ # block returns, raises, or was nested inside another `as`. Passing nil runs
119
+ # the block with no user in either layer. Postgres only, since the database
120
+ # half is. Keyword options (auto_savepoint:, ...) go to SuperAuth::RLS.as.
121
+ # current_user is assigned inside the transaction, after the database
122
+ # identity, so an application that hooks the writer to re-assert does so on
123
+ # the connection that holds the transaction.
124
+ def self.as(user, db: SuperAuth.db, **options)
125
+ previous = current_user
126
+ SuperAuth::RLS.as(user, db: db, **options) do
127
+ self.current_user = user
128
+ yield
129
+ end
130
+ ensure
131
+ self.current_user = previous
132
+ end
133
+
134
+ # Both user models are internal: their id is the user_id that the policies
135
+ # and ByCurrentUser match on. Anything else is an application object,
136
+ # matched by id and class name.
137
+ def self.internal_user?(user)
138
+ (defined?(SuperAuth::ActiveRecord::User) && user.is_a?(SuperAuth::ActiveRecord::User)) ||
139
+ (defined?(SuperAuth::User) && user.is_a?(SuperAuth::User))
103
140
  end
104
141
 
105
142
  def self.current_user=(user)
@@ -6,6 +6,34 @@ namespace :super_auth do
6
6
  puts "Done"
7
7
  end
8
8
 
9
+ namespace :labels do
10
+ desc "Re-derive super_auth_resources.super_auth_label from the application records"
11
+ task backfill: :environment do
12
+ SuperAuth.load
13
+ backfill = lambda do
14
+ changed = 0
15
+ SuperAuth::ActiveRecord::Resource.where.not(external_id: nil).find_each do |resource|
16
+ before = resource.super_auth_label
17
+ resource.refresh_label!
18
+ changed += 1 if resource.super_auth_label != before
19
+ end
20
+ changed
21
+ end
22
+
23
+ # The application tables this reads are the ones RLS protects, so a run
24
+ # with no identity derives nil for exactly the rows that matter most and
25
+ # then reports success. Assert the system identity where RLS is
26
+ # installed; where it is not, there is nothing to assert.
27
+ changed =
28
+ if SuperAuth::RLS.installed?
29
+ SuperAuth.as(SuperAuth::ActiveRecord::User.system) { backfill.call }
30
+ else
31
+ backfill.call
32
+ end
33
+ puts "Labelled #{changed} resources"
34
+ end
35
+ end
36
+
9
37
  task :rollback => :environment do
10
38
  raise "You must define SUPER_AUTH_DATABASE_URL in your environment for this to work" if ENV['SUPER_AUTH_DATABASE_URL'].nil? || ENV['SUPER_AUTH_DATABASE_URL'].empty?
11
39
  SuperAuth.uninstall_migrations
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: super_auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Frias
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -40,7 +40,8 @@ dependencies:
40
40
  description: Simple, yet super powerful authorization for you application
41
41
  email:
42
42
  - jonathan@gofrias.com
43
- executables: []
43
+ executables:
44
+ - super_auth-editor
44
45
  extensions: []
45
46
  extra_rdoc_files: []
46
47
  files:
@@ -53,10 +54,8 @@ files:
53
54
  - README.md
54
55
  - Rakefile
55
56
  - USAGE.md
56
- - VISUALIZATION.md
57
- - app/controllers/super_auth/graph_controller.rb
58
- - app/views/super_auth/graph/index.html.erb
59
57
  - config/routes.rb
58
+ - db/migrate/10_add_super_auth_label_to_resources.rb
60
59
  - db/migrate/1_users.rb
61
60
  - db/migrate/2_groups.rb
62
61
  - db/migrate/3_permissions.rb
@@ -74,7 +73,9 @@ files:
74
73
  - db/migrate_activerecord/20250101000006_create_super_auth_edges.rb
75
74
  - db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb
76
75
  - db/migrate_activerecord/20250101000009_add_by_current_user_index_to_super_auth_authorizations.rb
76
+ - db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb
77
77
  - db/seeds/sample_data.rb
78
+ - exe/super_auth-editor
78
79
  - lib/basic_loader.rb
79
80
  - lib/generators/super_auth/install/install_generator.rb
80
81
  - lib/generators/super_auth/install/templates/README
@@ -93,6 +94,10 @@ files:
93
94
  - lib/super_auth/active_record/user.rb
94
95
  - lib/super_auth/authorization.rb
95
96
  - lib/super_auth/edge.rb
97
+ - lib/super_auth/editor.rb
98
+ - lib/super_auth/editor/cli.rb
99
+ - lib/super_auth/editor/index.html
100
+ - lib/super_auth/editor/seed.rb
96
101
  - lib/super_auth/group.rb
97
102
  - lib/super_auth/nestable.rb
98
103
  - lib/super_auth/permission.rb
@@ -103,8 +108,6 @@ files:
103
108
  - lib/super_auth/user.rb
104
109
  - lib/super_auth/version.rb
105
110
  - lib/tasks/super_auth_tasks.rake
106
- - super_auth.gemspec
107
- - visualization.html
108
111
  homepage: https://github.com/JonathanFrias/super_auth
109
112
  licenses:
110
113
  - GPL-2.0
data/VISUALIZATION.md DELETED
@@ -1,58 +0,0 @@
1
- # SuperAuth Graph Visualization
2
-
3
- SuperAuth includes an interactive graph visualization tool that helps you understand and debug your authorization rules.
4
-
5
- ## Setup
6
-
7
- ### 1. Run the Installer
8
-
9
- Generate the initializer and install migrations:
10
-
11
- ```bash
12
- rails generate super_auth:install
13
- ```
14
-
15
- This will:
16
- - Create `config/initializers/super_auth.rb`
17
- - Install SuperAuth database migrations
18
- - Show you the next steps
19
-
20
- ### 2. Mount the Engine
21
-
22
- Add the following to your `config/routes.rb`:
23
-
24
- ```ruby
25
- Rails.application.routes.draw do
26
- mount SuperAuth::Engine => '/super_auth'
27
-
28
- # Your other routes...
29
- end
30
- ```
31
-
32
- ## Features
33
-
34
- ### Interactive Graph
35
-
36
- - **Nodes**: Color-coded by type (Users, Groups, Roles, Permissions, Resources)
37
- - **Edges**: Solid lines for authorization relationships, dashed for hierarchy
38
- - **Zoom & Pan**: Navigate large graphs easily
39
- - **Click nodes**: View node details
40
-
41
- ### Authorization Query
42
-
43
- 1. Select a user from the dropdown
44
- 2. Select a resource from the dropdown
45
- 3. Click "Find Authorization Paths"
46
- 4. View all paths that grant access
47
- 5. See the first path highlighted on the graph
48
-
49
- ### Statistics Panel
50
-
51
- Real-time counts of:
52
- - Users
53
- - Groups (with hierarchical relationships)
54
- - Roles (with hierarchical relationships)
55
- - Permissions
56
- - Resources
57
- - Authorization edges
58
-