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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/Gemfile.lock +1 -1
  4. data/README.md +409 -28
  5. data/USAGE.md +66 -25
  6. data/db/migrate/12_add_resource_indexes.rb +116 -0
  7. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  8. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  9. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  10. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  11. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  12. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  13. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  14. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  15. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  16. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  17. data/lib/generators/super_auth/install/templates/README +4 -2
  18. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  19. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  20. data/lib/super_auth/active_record/authorization.rb +11 -7
  21. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  22. data/lib/super_auth/active_record/group.rb +3 -0
  23. data/lib/super_auth/active_record/nested.rb +43 -0
  24. data/lib/super_auth/active_record/resource.rb +21 -1
  25. data/lib/super_auth/active_record/role.rb +3 -0
  26. data/lib/super_auth/active_record.rb +30 -2
  27. data/lib/super_auth/authorization.rb +62 -21
  28. data/lib/super_auth/edge.rb +26 -7
  29. data/lib/super_auth/editor/index.html +3 -4
  30. data/lib/super_auth/editor.rb +12 -3
  31. data/lib/super_auth/nestable.rb +89 -1
  32. data/lib/super_auth/railtie.rb +0 -9
  33. data/lib/super_auth/reach.rb +88 -0
  34. data/lib/super_auth/resource.rb +41 -27
  35. data/lib/super_auth/rls.rb +576 -44
  36. data/lib/super_auth/tree_guard.rb +115 -0
  37. data/lib/super_auth/version.rb +1 -1
  38. data/lib/super_auth.rb +55 -33
  39. metadata +8 -1
@@ -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
@@ -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. Wildcards are deprecated (see warn_deprecated_wildcards) but
16
- # still the only way to authorize INSERT under row-level security, so they
17
- # stay; what they may not do is join the tree.
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
- # "Wildcard nodes are flat." compile! calls this before touching the
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
- # A wildcard with a parent would compile to a (type, NULL) row reachable
25
- # through every ancestor's grants one edge to a container silently
26
- # granting every record of a type and a wildcard with children would
27
- # make the children unreachable except through a grant that already covers
28
- # them; neither is a shape anyone means. One query: the wildcards that
29
- # have a parent, or that some node names as its parent.
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