super_auth 0.8.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 +4 -4
- data/CHANGELOG.md +68 -1
- data/Gemfile.lock +1 -1
- data/README.md +409 -28
- data/USAGE.md +66 -25
- data/db/migrate/12_add_resource_indexes.rb +116 -0
- data/db/migrate/13_add_resource_tree_guard.rb +28 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
- data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
- data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
- data/lib/generators/super_auth/install/templates/README +4 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +11 -7
- data/lib/super_auth/active_record/by_current_user.rb +136 -29
- data/lib/super_auth/active_record/group.rb +3 -0
- data/lib/super_auth/active_record/nested.rb +43 -0
- data/lib/super_auth/active_record/resource.rb +21 -1
- data/lib/super_auth/active_record/role.rb +3 -0
- data/lib/super_auth/active_record.rb +30 -2
- data/lib/super_auth/authorization.rb +62 -21
- data/lib/super_auth/edge.rb +26 -7
- data/lib/super_auth/editor/index.html +3 -4
- data/lib/super_auth/editor.rb +12 -3
- data/lib/super_auth/nestable.rb +89 -1
- data/lib/super_auth/railtie.rb +0 -9
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +41 -27
- data/lib/super_auth/rls.rb +576 -44
- data/lib/super_auth/tree_guard.rb +115 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +55 -33
- metadata +8 -1
data/lib/super_auth/railtie.rb
CHANGED
|
@@ -16,15 +16,6 @@ module SuperAuth
|
|
|
16
16
|
load "tasks/super_auth_tasks.rake"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# Rails 7.1+ keeps one deprecator per library and applies
|
|
20
|
-
# config.active_support.deprecation / report_deprecations to each of
|
|
21
|
-
# them in the active_support.deprecation_behavior initializer, which
|
|
22
|
-
# runs after load_environment_config — so registration has to come
|
|
23
|
-
# before that, as Rails' own railties do.
|
|
24
|
-
initializer "super_auth.deprecator", before: :load_environment_config do |app|
|
|
25
|
-
app.deprecators[:super_auth] = SuperAuth.deprecator if app.respond_to?(:deprecators)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
19
|
initializer "super_auth.initialize" do
|
|
29
20
|
if defined?(ActiveRecord) && defined?(ActiveRecord::Base)
|
|
30
21
|
SuperAuth.db
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# The reach map: which authorization rows admit a row of a protected table.
|
|
2
|
+
#
|
|
3
|
+
# Both layers take the same two keywords, resource_type: and parent:, and
|
|
4
|
+
# both ask the same question of every row: does the current user hold a
|
|
5
|
+
# compiled authorization that reaches it? A row is reached through its own
|
|
6
|
+
# id (a per-record grant, or a type-level grant on the class's own type) or
|
|
7
|
+
# through a column holding another record's id (a parent grant: the row's
|
|
8
|
+
# tenancy, read off the row itself). The reach map is that question in one
|
|
9
|
+
# shape, an ordered Hash from column to the types whose rows admit through
|
|
10
|
+
# it, with :id always the first step:
|
|
11
|
+
#
|
|
12
|
+
# SuperAuth::Reach.normalize(
|
|
13
|
+
# resource_type: "Claim",
|
|
14
|
+
# parent: { column: :organization_id, resource_type: %w[Organization::Member Organization::Admin] })
|
|
15
|
+
# # => { id: ["Claim"], organization_id: ["Organization::Member", "Organization::Admin"] }
|
|
16
|
+
#
|
|
17
|
+
# The RLS policy and the ByCurrentUser scope each emit one step per entry,
|
|
18
|
+
# and both build from this map rather than from the raw keywords so they
|
|
19
|
+
# cannot drift: an argument shape one layer accepted and the other rejected,
|
|
20
|
+
# or a column one saw and the other did not, is a row the ORM shows and the
|
|
21
|
+
# database hides, or the reverse, which is the dangerous direction. Every
|
|
22
|
+
# entry is a list because RLS must never be narrower than any tier's ORM
|
|
23
|
+
# scope over the same table: a table whose readers key on
|
|
24
|
+
# Organization::Member and whose writers on Organization::CaseWriter names
|
|
25
|
+
# both under the column, so the holder of one without the other is still
|
|
26
|
+
# admitted at the database.
|
|
27
|
+
#
|
|
28
|
+
# :id is refused as a parent column since it is the per-record step, already
|
|
29
|
+
# declared by resource_type:. A column declared twice is refused because the
|
|
30
|
+
# second entry would silently shadow the first; every type a column admits
|
|
31
|
+
# goes in one list.
|
|
32
|
+
module SuperAuth
|
|
33
|
+
module Reach
|
|
34
|
+
class << self
|
|
35
|
+
def normalize(resource_type:, parent: nil)
|
|
36
|
+
reach = { id: types(resource_type, "resource_type:") }
|
|
37
|
+
entries(parent).each do |entry|
|
|
38
|
+
unless entry.is_a?(Hash) && (entry.keys - %i[column resource_type]).empty?
|
|
39
|
+
raise Error, "parent: must be a Hash {column:, resource_type:} or an Array of them, got #{entry.inspect}"
|
|
40
|
+
end
|
|
41
|
+
column = column_name(entry[:column])
|
|
42
|
+
if reach.key?(column)
|
|
43
|
+
raise Error, "parent: column #{column.inspect} is declared twice; list every type it admits under one entry"
|
|
44
|
+
end
|
|
45
|
+
reach[column] = types(entry[:resource_type], "parent: #{column} resource_type:")
|
|
46
|
+
end
|
|
47
|
+
reach.freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The column steps alone, for the layer emitting one per parent and for
|
|
51
|
+
# recording what a policy was built from.
|
|
52
|
+
def parents(reach)
|
|
53
|
+
reach.reject { |column, _| column == :id }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def entries(parent)
|
|
59
|
+
case parent
|
|
60
|
+
when nil then []
|
|
61
|
+
when Hash then [parent]
|
|
62
|
+
when Array then parent
|
|
63
|
+
else raise Error, "parent: must be a Hash {column:, resource_type:} or an Array of them, got #{parent.inspect}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def column_name(column)
|
|
68
|
+
name = column.to_s if column.is_a?(Symbol) || column.is_a?(String)
|
|
69
|
+
if name.nil? || name.empty?
|
|
70
|
+
raise Error, "parent: column: must be a Symbol or String naming a column, got #{column.inspect}"
|
|
71
|
+
end
|
|
72
|
+
if name == "id"
|
|
73
|
+
raise Error, "parent: column: :id is the per-record step, which resource_type: already declares; a parent is a column holding another record's id"
|
|
74
|
+
end
|
|
75
|
+
name.to_sym
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Frozen copies rather than freezing the caller's strings in place.
|
|
79
|
+
def types(value, label)
|
|
80
|
+
list = value.is_a?(Array) ? value : [value]
|
|
81
|
+
unless !list.empty? && list.all? { |type| type.is_a?(String) && !type.empty? }
|
|
82
|
+
raise Error, "#{label} must be a String or a non-empty Array of Strings, got #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
list.uniq.map { |type| -type }.freeze
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/super_auth/resource.rb
CHANGED
|
@@ -12,21 +12,52 @@ class SuperAuth::Resource < Sequel::Model(:super_auth_resources)
|
|
|
12
12
|
# (wildcard) node: at runtime it means every record of that type, present
|
|
13
13
|
# and future (the ByCurrentUser type_level branch, the policy's
|
|
14
14
|
# `resource_external_id IS NULL OR` clause). A node with neither is a
|
|
15
|
-
# container.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# container. The type-level grant is a supported, permanent primitive —
|
|
16
|
+
# "this principal may act on every record of a type" has no cheaper
|
|
17
|
+
# spelling — and it is flat: what it may not do is join the tree.
|
|
18
18
|
def wildcards
|
|
19
19
|
exclude(external_type: nil).where(external_id: nil)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# The node registered for one record: the pair every host helper looks
|
|
23
|
+
# up before it grants, revokes or labels. It refuses a nil id rather than
|
|
24
|
+
# answering, because where(external_type: type, external_id: nil) is not
|
|
25
|
+
# "no node" — it IS the type-level node for that type, and a helper called
|
|
26
|
+
# with an unset foreign key would otherwise act on the grant that covers
|
|
27
|
+
# every record of the type.
|
|
28
|
+
def record(type, id)
|
|
29
|
+
if id.nil?
|
|
30
|
+
raise SuperAuth::Error, "SuperAuth::Resource.record(#{type.to_s.inspect}, nil): the id is nil. " \
|
|
31
|
+
"A node with an external_type and no external_id is the type-level node for every #{type} record, " \
|
|
32
|
+
"not the node for one of them; pass the record's id, or use wildcards for the type-level node."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
first(external_type: type.to_s, external_id: id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The recursive step of descendant_pairs stops at a type-level node. A
|
|
39
|
+
# per-record node nested under one would otherwise receive the
|
|
40
|
+
# type-level node's grants: one accidental parent_id, and a grant on
|
|
41
|
+
# "every Claim" also compiled a row for every claim node beneath it, on
|
|
42
|
+
# every path that reads the walk — including a host that reads
|
|
43
|
+
# Edge.authorizations directly and never calls compile!, where
|
|
44
|
+
# assert_compilable! does not run. The walk still anchors on the node a
|
|
45
|
+
# grant names, so a granted type-level node yields its own (type, NULL)
|
|
46
|
+
# row and nothing else; join_resource_subtree drops the other direction,
|
|
47
|
+
# a type-level node reached as a descendant.
|
|
48
|
+
def descend_from(parent)
|
|
49
|
+
Sequel.|({ Sequel[parent][:external_type] => nil }, Sequel.~(Sequel[parent][:external_id] => nil))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# "Type-level nodes are flat." compile! calls this before touching the
|
|
23
53
|
# compiled table, so a refused compile leaves the previous rows in place.
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# have a parent, or that some node names as its
|
|
54
|
+
# The walk and the join above make the shape harmless; this makes it
|
|
55
|
+
# loud, because nobody means it. A type-level node with a parent looks
|
|
56
|
+
# like a container grant that reaches every record of a type, and one
|
|
57
|
+
# with children looks like a container whose children can only be
|
|
58
|
+
# reached through a grant that already covers them. One query: the
|
|
59
|
+
# type-level nodes that have a parent, or that some node names as its
|
|
60
|
+
# parent.
|
|
30
61
|
def assert_compilable!
|
|
31
62
|
parents = dataset.exclude(parent_id: nil).select(:parent_id)
|
|
32
63
|
nested = wildcards.where(Sequel.|(Sequel.~(parent_id: nil), { id: parents })).select_order_map(:id)
|
|
@@ -38,22 +69,5 @@ class SuperAuth::Resource < Sequel::Model(:super_auth_resources)
|
|
|
38
69
|
"reaches every record of its type through the tree. Move each to the root with no children, or give " \
|
|
39
70
|
"it an external_id."
|
|
40
71
|
end
|
|
41
|
-
|
|
42
|
-
# One warning per compile, naming what exists, through SuperAuth.deprecator
|
|
43
|
-
# so a Rails host's deprecation config (notify, raise, silence) applies.
|
|
44
|
-
# A no-op when there are none, which is the common case.
|
|
45
|
-
def warn_deprecated_wildcards
|
|
46
|
-
rows = wildcards.order(:id).select_map([:id, :name])
|
|
47
|
-
return if rows.empty?
|
|
48
|
-
|
|
49
|
-
listed = rows.first(10).map { |id, name| "#{name} (#{id})" }
|
|
50
|
-
listed << "..." if rows.size > 10
|
|
51
|
-
SuperAuth.deprecator.warn(
|
|
52
|
-
"#{rows.size} type-level (wildcard) resource node#{'s' if rows.size > 1} " \
|
|
53
|
-
"(external_type set, external_id NULL): #{listed.join(', ')}. Wildcard nodes are deprecated. " \
|
|
54
|
-
"They still work, and they remain the only way to authorize INSERT under row-level security; " \
|
|
55
|
-
"the successor is a grant on a parent record. See the CHANGELOG."
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
72
|
end
|
|
59
73
|
end
|