super_auth 0.4.0 → 0.8.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 +90 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +132 -49
- data/USAGE.md +98 -31
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/seeds/sample_data.rb +1 -0
- data/exe/super_auth-editor +9 -0
- data/lib/generators/super_auth/install/templates/README +18 -11
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +7 -0
- data/lib/super_auth/active_record/by_current_user.rb +1 -1
- data/lib/super_auth/active_record/resource.rb +45 -0
- data/lib/super_auth/active_record/user.rb +3 -1
- data/lib/super_auth/authorization.rb +24 -0
- data/lib/super_auth/edge.rb +54 -18
- data/lib/super_auth/editor/cli.rb +91 -0
- data/lib/super_auth/editor/index.html +430 -0
- data/lib/super_auth/editor/seed.rb +176 -0
- data/lib/super_auth/editor.rb +288 -0
- data/lib/super_auth/nestable.rb +16 -3
- data/lib/super_auth/railtie.rb +9 -2
- data/lib/super_auth/resource.rb +57 -0
- data/lib/super_auth/rls.rb +164 -34
- data/lib/super_auth/user.rb +3 -1
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +73 -5
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +13 -8
- data/VISUALIZATION.md +0 -58
- data/app/controllers/super_auth/graph_controller.rb +0 -654
- data/app/views/super_auth/graph/index.html.erb +0 -1408
- data/super_auth.gemspec +0 -35
- data/visualization.html +0 -747
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require "super_auth"
|
|
2
|
+
|
|
3
|
+
module SuperAuth
|
|
4
|
+
class Editor
|
|
5
|
+
# Sample graph for the editor. "Acme Cloud": three departments that are
|
|
6
|
+
# deliberately DISJOINT. Engineering, Finance, and Support each have their
|
|
7
|
+
# own users, roles, permissions, and resources with no shared nodes, so
|
|
8
|
+
# traversal is obvious: click anyone in Engineering and only Engineering
|
|
9
|
+
# lights up.
|
|
10
|
+
#
|
|
11
|
+
# The hierarchy matches how grants inherit:
|
|
12
|
+
# - The shared "Developer" role is attached to the Engineering PARENT
|
|
13
|
+
# group, so every engineer (Backend + Frontend) inherits it, while
|
|
14
|
+
# Backend and Frontend each also hold a child-group grant the other
|
|
15
|
+
# does not.
|
|
16
|
+
# - "Support Lead" is the PARENT role of "Support Agent": a lead inherits
|
|
17
|
+
# the agent's abilities plus refunds; an agent does not get refunds.
|
|
18
|
+
# - "clusters" is a CONTAINER resource holding production_cluster and
|
|
19
|
+
# staging_cluster. deploy is granted on the container and reaches both
|
|
20
|
+
# clusters through the tree; restart_server and Morgan's direct grant
|
|
21
|
+
# name production_cluster alone, so the tree shows a container grant
|
|
22
|
+
# and a leaf grant side by side.
|
|
23
|
+
#
|
|
24
|
+
# Special people:
|
|
25
|
+
# - Riley (Auditor): read-only into BOTH Finance and Support
|
|
26
|
+
# - Morgan (Admin): direct user->resource access across departments
|
|
27
|
+
# - Nina (New Hire): no access at all
|
|
28
|
+
#
|
|
29
|
+
# Destructive: run! replaces the whole graph, including the compiled
|
|
30
|
+
# authorizations. Only ever runs on request (super_auth-editor --seed).
|
|
31
|
+
module Seed
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Returns the row counts per table.
|
|
35
|
+
def run!
|
|
36
|
+
SuperAuth.db.transaction do
|
|
37
|
+
clear!
|
|
38
|
+
|
|
39
|
+
grp = SuperAuth::Group
|
|
40
|
+
rol = SuperAuth::Role
|
|
41
|
+
usr = SuperAuth::User
|
|
42
|
+
perm_m = SuperAuth::Permission
|
|
43
|
+
res_m = SuperAuth::Resource
|
|
44
|
+
edg = SuperAuth::Edge
|
|
45
|
+
|
|
46
|
+
# ===== GROUPS (Engineering is a parent of Backend + Frontend) =====
|
|
47
|
+
engineering = grp.create(name: "Engineering")
|
|
48
|
+
backend = grp.create(name: "Backend", parent_id: engineering.id)
|
|
49
|
+
frontend = grp.create(name: "Frontend", parent_id: engineering.id)
|
|
50
|
+
finance = grp.create(name: "Finance")
|
|
51
|
+
support = grp.create(name: "Customer Support")
|
|
52
|
+
|
|
53
|
+
# ===== ROLES (Support Lead is the parent of Support Agent) =====
|
|
54
|
+
developer = rol.create(name: "Developer")
|
|
55
|
+
sre = rol.create(name: "SRE")
|
|
56
|
+
accountant = rol.create(name: "Accountant")
|
|
57
|
+
support_lead = rol.create(name: "Support Lead")
|
|
58
|
+
support_agent = rol.create(name: "Support Agent", parent_id: support_lead.id)
|
|
59
|
+
|
|
60
|
+
# ===== PERMISSIONS (disjoint per department) =====
|
|
61
|
+
merge_code = perm_m.create(name: "merge_code")
|
|
62
|
+
read_repo = perm_m.create(name: "read_repo")
|
|
63
|
+
deploy = perm_m.create(name: "deploy")
|
|
64
|
+
run_migrations = perm_m.create(name: "run_migrations") # Backend-only
|
|
65
|
+
publish_site = perm_m.create(name: "publish_site") # Frontend-only
|
|
66
|
+
restart_server = perm_m.create(name: "restart_server") # SRE-only
|
|
67
|
+
view_ledger = perm_m.create(name: "view_ledger")
|
|
68
|
+
issue_invoice = perm_m.create(name: "issue_invoice")
|
|
69
|
+
run_payroll = perm_m.create(name: "run_payroll")
|
|
70
|
+
view_ticket = perm_m.create(name: "view_ticket")
|
|
71
|
+
close_ticket = perm_m.create(name: "close_ticket")
|
|
72
|
+
issue_refund = perm_m.create(name: "issue_refund") # Support Lead-only
|
|
73
|
+
|
|
74
|
+
# ===== RESOURCES (disjoint per department) =====
|
|
75
|
+
source_repo = res_m.create(name: "source_repo")
|
|
76
|
+
clusters = res_m.create(name: "clusters") # container
|
|
77
|
+
production_cluster = res_m.create(name: "production_cluster", parent_id: clusters.id)
|
|
78
|
+
res_m.create(name: "staging_cluster", parent_id: clusters.id) # reached only through clusters
|
|
79
|
+
app_database = res_m.create(name: "app_database") # Backend
|
|
80
|
+
marketing_site = res_m.create(name: "marketing_site") # Frontend
|
|
81
|
+
general_ledger = res_m.create(name: "general_ledger")
|
|
82
|
+
invoices = res_m.create(name: "invoices")
|
|
83
|
+
support_tickets = res_m.create(name: "support_tickets")
|
|
84
|
+
customer_accounts = res_m.create(name: "customer_accounts")
|
|
85
|
+
|
|
86
|
+
# ===== USERS =====
|
|
87
|
+
alice = usr.create(name: "Alice") # Backend dev
|
|
88
|
+
bob = usr.create(name: "Bob") # Frontend dev
|
|
89
|
+
sam = usr.create(name: "Sam") # SRE
|
|
90
|
+
carol = usr.create(name: "Carol") # Accountant
|
|
91
|
+
dave = usr.create(name: "Dave") # Accountant
|
|
92
|
+
erin = usr.create(name: "Erin") # Support agent
|
|
93
|
+
frank = usr.create(name: "Frank") # Support lead
|
|
94
|
+
riley = usr.create(name: "Riley") # Auditor (cross-department, read-only)
|
|
95
|
+
morgan = usr.create(name: "Morgan") # Admin (direct resource access)
|
|
96
|
+
usr.create(name: "Nina") # New hire, no access yet
|
|
97
|
+
|
|
98
|
+
# ===== ENGINEERING =====
|
|
99
|
+
edg.create(user_id: alice.id, group_id: backend.id)
|
|
100
|
+
edg.create(user_id: bob.id, group_id: frontend.id)
|
|
101
|
+
# Shared Developer role on the PARENT group: both Alice and Bob inherit it
|
|
102
|
+
edg.create(group_id: engineering.id, role_id: developer.id)
|
|
103
|
+
edg.create(role_id: developer.id, permission_id: merge_code.id)
|
|
104
|
+
edg.create(role_id: developer.id, permission_id: read_repo.id)
|
|
105
|
+
edg.create(role_id: developer.id, permission_id: deploy.id)
|
|
106
|
+
edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
|
|
107
|
+
edg.create(permission_id: read_repo.id, resource_id: source_repo.id)
|
|
108
|
+
# One grant on the container reaches both clusters.
|
|
109
|
+
edg.create(permission_id: deploy.id, resource_id: clusters.id)
|
|
110
|
+
# Child-group-specific grants (Alice gets one, Bob the other)
|
|
111
|
+
edg.create(group_id: backend.id, permission_id: run_migrations.id)
|
|
112
|
+
edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
|
|
113
|
+
edg.create(group_id: frontend.id, permission_id: publish_site.id)
|
|
114
|
+
edg.create(permission_id: publish_site.id, resource_id: marketing_site.id)
|
|
115
|
+
# Sam is an SRE via a direct role assignment
|
|
116
|
+
edg.create(user_id: sam.id, role_id: sre.id)
|
|
117
|
+
edg.create(role_id: sre.id, permission_id: restart_server.id)
|
|
118
|
+
edg.create(role_id: sre.id, permission_id: deploy.id)
|
|
119
|
+
edg.create(permission_id: restart_server.id, resource_id: production_cluster.id)
|
|
120
|
+
|
|
121
|
+
# ===== FINANCE =====
|
|
122
|
+
edg.create(user_id: carol.id, group_id: finance.id)
|
|
123
|
+
edg.create(user_id: dave.id, group_id: finance.id)
|
|
124
|
+
edg.create(group_id: finance.id, role_id: accountant.id)
|
|
125
|
+
edg.create(role_id: accountant.id, permission_id: view_ledger.id)
|
|
126
|
+
edg.create(role_id: accountant.id, permission_id: issue_invoice.id)
|
|
127
|
+
edg.create(role_id: accountant.id, permission_id: run_payroll.id)
|
|
128
|
+
edg.create(permission_id: view_ledger.id, resource_id: general_ledger.id)
|
|
129
|
+
edg.create(permission_id: issue_invoice.id, resource_id: invoices.id)
|
|
130
|
+
edg.create(permission_id: run_payroll.id, resource_id: general_ledger.id)
|
|
131
|
+
|
|
132
|
+
# ===== SUPPORT (Lead inherits Agent's abilities via the role hierarchy) =====
|
|
133
|
+
edg.create(user_id: erin.id, group_id: support.id)
|
|
134
|
+
edg.create(user_id: frank.id, group_id: support.id)
|
|
135
|
+
edg.create(user_id: frank.id, role_id: support_lead.id) # Frank is a lead
|
|
136
|
+
edg.create(group_id: support.id, role_id: support_agent.id) # everyone is at least an agent
|
|
137
|
+
edg.create(role_id: support_agent.id, permission_id: view_ticket.id)
|
|
138
|
+
edg.create(role_id: support_agent.id, permission_id: close_ticket.id)
|
|
139
|
+
edg.create(role_id: support_lead.id, permission_id: issue_refund.id)
|
|
140
|
+
edg.create(permission_id: view_ticket.id, resource_id: support_tickets.id)
|
|
141
|
+
edg.create(permission_id: close_ticket.id, resource_id: support_tickets.id)
|
|
142
|
+
edg.create(permission_id: issue_refund.id, resource_id: customer_accounts.id)
|
|
143
|
+
|
|
144
|
+
# ===== CROSS-CUTTERS =====
|
|
145
|
+
# Riley audits both the ledger and tickets (direct permission grants).
|
|
146
|
+
edg.create(user_id: riley.id, permission_id: view_ledger.id)
|
|
147
|
+
edg.create(user_id: riley.id, permission_id: view_ticket.id)
|
|
148
|
+
# Morgan has direct resource access across departments (simplest path).
|
|
149
|
+
edg.create(user_id: morgan.id, resource_id: production_cluster.id)
|
|
150
|
+
edg.create(user_id: morgan.id, resource_id: general_ledger.id)
|
|
151
|
+
edg.create(user_id: morgan.id, resource_id: support_tickets.id)
|
|
152
|
+
|
|
153
|
+
counts
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Empties the graph and the compiled table. Parents are detached first:
|
|
158
|
+
# MySQL checks the self-referencing key row by row.
|
|
159
|
+
def clear!
|
|
160
|
+
SuperAuth::Edge.dataset.delete
|
|
161
|
+
SuperAuth::Authorization.dataset.delete
|
|
162
|
+
[SuperAuth::Group, SuperAuth::Role, SuperAuth::Resource].each { |m| m.dataset.update(parent_id: nil) }
|
|
163
|
+
[SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
|
|
164
|
+
m.dataset.delete
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def counts
|
|
169
|
+
{
|
|
170
|
+
groups: SuperAuth::Group.count, roles: SuperAuth::Role.count, users: SuperAuth::User.count,
|
|
171
|
+
permissions: SuperAuth::Permission.count, resources: SuperAuth::Resource.count, edges: SuperAuth::Edge.count,
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "super_auth"
|
|
3
|
+
|
|
4
|
+
module SuperAuth
|
|
5
|
+
# A small Rack application that edits the authorization graph: five boxes of
|
|
6
|
+
# records (groups, roles and resources drawn as trees), client-side
|
|
7
|
+
# traversal, node and edge CRUD, and a Recompile button.
|
|
8
|
+
# Rails-free; it needs only SuperAuth.db to be connected and the tables to
|
|
9
|
+
# exist. Mount it as `run SuperAuth::Editor` (Rack) or
|
|
10
|
+
# `mount SuperAuth::Editor => "/super_auth/editor"` (Rails), or run
|
|
11
|
+
# `super_auth-editor`, which serves it on loopback.
|
|
12
|
+
#
|
|
13
|
+
# It has no authentication of its own. Anyone who can reach it can rewrite
|
|
14
|
+
# the graph, so the host must put its own authentication in front of the
|
|
15
|
+
# mount. Two stdlib-only guards remain: writes must be application/json (a
|
|
16
|
+
# cross-origin browser cannot send that without a CORS preflight, which is
|
|
17
|
+
# never answered) and cross-site fetches are refused; `hosts:` additionally
|
|
18
|
+
# rejects any other Host header, the DNS-rebinding defence the executable
|
|
19
|
+
# turns on for loopback.
|
|
20
|
+
#
|
|
21
|
+
# Edits change the graph, not runtime access: ByCurrentUser and the RLS
|
|
22
|
+
# policies read the compiled super_auth_authorizations table, so the UI
|
|
23
|
+
# shows its row count and offers POST /api/compile. A compile the models
|
|
24
|
+
# refuse (SuperAuth::Error, the wildcard guard) comes back as a 422 with
|
|
25
|
+
# the model's own message, like any other rejected write.
|
|
26
|
+
class Editor
|
|
27
|
+
TYPES = {
|
|
28
|
+
"user" => :User, "group" => :Group, "role" => :Role,
|
|
29
|
+
"permission" => :Permission, "resource" => :Resource,
|
|
30
|
+
}.freeze
|
|
31
|
+
COLUMNS = {
|
|
32
|
+
"user" => :user_id, "group" => :group_id, "role" => :role_id,
|
|
33
|
+
"permission" => :permission_id, "resource" => :resource_id,
|
|
34
|
+
}.freeze
|
|
35
|
+
NESTED = %w[group role resource].freeze
|
|
36
|
+
# The pairs the path strategies read (see Edge.authorizations), unordered.
|
|
37
|
+
# The models also accept group->resource and role->resource rows, but no
|
|
38
|
+
# strategy reads them, so they would grant nothing.
|
|
39
|
+
ALLOWED_PAIRS = [
|
|
40
|
+
%w[user group], %w[user role], %w[user permission], %w[user resource],
|
|
41
|
+
%w[group role], %w[group permission], %w[role permission], %w[permission resource],
|
|
42
|
+
].map(&:sort).freeze
|
|
43
|
+
EMPTY_EDGE = { user_id: nil, group_id: nil, role_id: nil, permission_id: nil, resource_id: nil }.freeze
|
|
44
|
+
INDEX_HTML = File.read(File.join(__dir__, "editor", "index.html")).freeze
|
|
45
|
+
MAX_BODY = 64 * 1024
|
|
46
|
+
ID = /\A\d+\z/
|
|
47
|
+
NAME_MAX = 255
|
|
48
|
+
|
|
49
|
+
def self.call(env)
|
|
50
|
+
(@default ||= new).call(env)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# hosts: host names (port ignored) this app answers to; nil disables the check.
|
|
54
|
+
def initialize(hosts: nil)
|
|
55
|
+
@hosts = hosts && hosts.map { |h| h.to_s.downcase }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call(env)
|
|
59
|
+
return forbidden("host not allowed") if @hosts && !@hosts.include?(host_of(env))
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
SuperAuth.load unless defined?(SuperAuth::User)
|
|
63
|
+
rescue Sequel::DatabaseError
|
|
64
|
+
return json(503, error: "super_auth tables not found; run the migrations (super_auth-editor --migrate, or your application's)")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
method = env["REQUEST_METHOD"]
|
|
68
|
+
path = env["PATH_INFO"].to_s
|
|
69
|
+
path = "/" if path.empty?
|
|
70
|
+
if %w[POST DELETE].include?(method) && env["HTTP_SEC_FETCH_SITE"] == "cross-site"
|
|
71
|
+
return forbidden("cross-site requests are not accepted")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
route(method, path, env)
|
|
75
|
+
rescue SuperAuth::Error => e
|
|
76
|
+
json(422, error: e.message)
|
|
77
|
+
rescue Sequel::Error
|
|
78
|
+
json(422, error: "the database rejected the change")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def route(method, path, env)
|
|
84
|
+
if method == "GET" && path == "/"
|
|
85
|
+
html
|
|
86
|
+
elsif method == "GET" && path == "/api/graph"
|
|
87
|
+
json(200, graph)
|
|
88
|
+
elsif method == "POST" && path == "/api/compile"
|
|
89
|
+
json(200, count: SuperAuth::Authorization.compile!)
|
|
90
|
+
elsif method == "POST" && path == "/api/edges"
|
|
91
|
+
with_body(env) { |body| create_edge(body) }
|
|
92
|
+
elsif method == "DELETE" && (m = path.match(%r{\A/api/edges/([^/]+)\z}))
|
|
93
|
+
delete_edge(m[1])
|
|
94
|
+
elsif method == "POST" && (m = path.match(%r{\A/api/nodes/([^/]+)\z}))
|
|
95
|
+
with_body(env) { |body| create_node(m[1], body) }
|
|
96
|
+
elsif method == "DELETE" && (m = path.match(%r{\A/api/nodes/([^/]+)/([^/]+)\z}))
|
|
97
|
+
delete_node(m[1], m[2])
|
|
98
|
+
else
|
|
99
|
+
json(404, error: "not found")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ---- reads ----
|
|
104
|
+
|
|
105
|
+
def graph
|
|
106
|
+
{
|
|
107
|
+
groups: nodes(:Group, :parent_id),
|
|
108
|
+
roles: nodes(:Role, :parent_id),
|
|
109
|
+
users: nodes(:User, :external_id, :external_type),
|
|
110
|
+
permissions: nodes(:Permission),
|
|
111
|
+
resources: nodes(:Resource, :parent_id, :external_id, :external_type, :super_auth_label),
|
|
112
|
+
edges: SuperAuth::Edge.order(:id).map { |e| edge_json(e) },
|
|
113
|
+
authorizations_count: SuperAuth::Authorization.count,
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def nodes(model, *extra)
|
|
118
|
+
rows = SuperAuth.const_get(model).order(:name, :id).map do |n|
|
|
119
|
+
row = { id: n.id, name: n.name }
|
|
120
|
+
extra.each { |column| row[column] = n[column] }
|
|
121
|
+
row
|
|
122
|
+
end
|
|
123
|
+
extra.include?(:parent_id) ? tree_order(rows) : rows
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The nested types render as a flat list that fakes the tree with
|
|
127
|
+
# indentation, so a child has to arrive immediately after its parent or
|
|
128
|
+
# it reads as nested under whatever happens to sort above it — which is
|
|
129
|
+
# the one question an auditor opens this editor to answer. Sorting by the
|
|
130
|
+
# ancestors' [name, label, id] triples, outermost first, puts every child
|
|
131
|
+
# under its own parent and leaves siblings alphabetical; the label only
|
|
132
|
+
# separates same-named siblings, which synced resources are (one "Claim"
|
|
133
|
+
# per record), and is absent from groups and roles. All three node sets
|
|
134
|
+
# are small enough to order in Ruby, and the client's depthOf is
|
|
135
|
+
# unaffected.
|
|
136
|
+
#
|
|
137
|
+
# The key is total, so the order stays defined for broken trees: a row
|
|
138
|
+
# whose parent_id names a missing row sorts as a root, and a parent cycle
|
|
139
|
+
# stops at the first repeated id rather than walking forever.
|
|
140
|
+
def tree_order(rows)
|
|
141
|
+
by_id = rows.each_with_object({}) { |row, index| index[row[:id]] = row }
|
|
142
|
+
rows.sort_by do |row|
|
|
143
|
+
path = []
|
|
144
|
+
seen = {}
|
|
145
|
+
node = row
|
|
146
|
+
while node && !seen[node[:id]]
|
|
147
|
+
seen[node[:id]] = true
|
|
148
|
+
path.unshift([node[:name].to_s, node[:super_auth_label].to_s, node[:id]])
|
|
149
|
+
node = by_id[node[:parent_id]]
|
|
150
|
+
end
|
|
151
|
+
path
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ---- writes ----
|
|
156
|
+
|
|
157
|
+
def create_node(type, body)
|
|
158
|
+
model = model_for(type) or return json(404, error: "unknown node type")
|
|
159
|
+
name = body["name"].to_s.strip
|
|
160
|
+
return json(422, error: "name is required") if name.empty?
|
|
161
|
+
return json(422, error: "name is too long (#{NAME_MAX} characters max)") if name.length > NAME_MAX
|
|
162
|
+
return json(422, error: "the name \"system\" is reserved") if type == "user" && name == "system"
|
|
163
|
+
|
|
164
|
+
attrs = { name: name }
|
|
165
|
+
parent = body["parent_id"]
|
|
166
|
+
unless parent.nil?
|
|
167
|
+
return json(422, error: "#{type} records cannot have a parent") unless NESTED.include?(type)
|
|
168
|
+
return json(422, error: "parent_id must be an integer") unless integer_id?(parent)
|
|
169
|
+
parent_node = model[parent.to_i]
|
|
170
|
+
return json(422, error: "parent not found") unless parent_node
|
|
171
|
+
# "Wildcard nodes are flat": compile! refuses a tree with a type-level
|
|
172
|
+
# node in it, so refuse the shape at the door with the reason instead.
|
|
173
|
+
if type == "resource" && parent_node.external_type && parent_node.external_id.nil?
|
|
174
|
+
return json(422, error: "type-level (wildcard) resources are deprecated and cannot contain other resources; " \
|
|
175
|
+
"make a container (a resource with no external type) instead")
|
|
176
|
+
end
|
|
177
|
+
attrs[:parent_id] = parent.to_i
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
json(201, node_json(model.create(attrs)))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def delete_node(type, id)
|
|
184
|
+
model = model_for(type) or return json(404, error: "unknown node type")
|
|
185
|
+
record = integer_id?(id) && model[id.to_i]
|
|
186
|
+
return json(404, error: "not found") unless record
|
|
187
|
+
|
|
188
|
+
SuperAuth.db.transaction do
|
|
189
|
+
SuperAuth::Edge.where(COLUMNS[type] => record.id).delete
|
|
190
|
+
# Children become roots: the deny-safe choice, and required before the
|
|
191
|
+
# delete on MySQL, which checks the self-referencing key row by row.
|
|
192
|
+
model.where(parent_id: record.id).update(parent_id: nil) if NESTED.include?(type)
|
|
193
|
+
model.where(id: record.id).delete
|
|
194
|
+
end
|
|
195
|
+
json(200, ok: true)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def create_edge(body)
|
|
199
|
+
a_type = body["a_type"].to_s
|
|
200
|
+
b_type = body["b_type"].to_s
|
|
201
|
+
return json(422, error: "unknown node type") unless COLUMNS[a_type] && COLUMNS[b_type]
|
|
202
|
+
return json(422, error: "an edge links two different node types") if a_type == b_type
|
|
203
|
+
unless ALLOWED_PAIRS.include?([a_type, b_type].sort)
|
|
204
|
+
return json(422, error: "no path strategy reads #{a_type} -> #{b_type} edges; it would grant nothing")
|
|
205
|
+
end
|
|
206
|
+
return json(422, error: "ids must be integers") unless integer_id?(body["a_id"]) && integer_id?(body["b_id"])
|
|
207
|
+
|
|
208
|
+
a_id = body["a_id"].to_i
|
|
209
|
+
b_id = body["b_id"].to_i
|
|
210
|
+
return json(404, error: "#{a_type} #{a_id} not found") unless model_for(a_type)[a_id]
|
|
211
|
+
return json(404, error: "#{b_type} #{b_id} not found") unless model_for(b_type)[b_id]
|
|
212
|
+
|
|
213
|
+
# The full five-column hash, so a row created here always has exactly two ids.
|
|
214
|
+
attrs = EMPTY_EDGE.merge(COLUMNS[a_type] => a_id, COLUMNS[b_type] => b_id)
|
|
215
|
+
if (edge = SuperAuth::Edge.where(attrs).first)
|
|
216
|
+
json(200, edge_json(edge))
|
|
217
|
+
else
|
|
218
|
+
json(201, edge_json(SuperAuth::Edge.create(attrs)))
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def delete_edge(id)
|
|
223
|
+
edge = integer_id?(id) && SuperAuth::Edge[id.to_i]
|
|
224
|
+
return json(404, error: "not found") unless edge
|
|
225
|
+
|
|
226
|
+
edge.delete
|
|
227
|
+
json(200, ok: true)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# ---- helpers ----
|
|
231
|
+
|
|
232
|
+
def model_for(type)
|
|
233
|
+
TYPES[type] && SuperAuth.const_get(TYPES[type])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def integer_id?(value)
|
|
237
|
+
(value.is_a?(Integer) && value >= 0) || (value.is_a?(String) && value.match?(ID))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def node_json(record)
|
|
241
|
+
{
|
|
242
|
+
id: record.id,
|
|
243
|
+
name: record.name,
|
|
244
|
+
parent_id: record.respond_to?(:parent_id) ? record.parent_id : nil,
|
|
245
|
+
external_id: record.respond_to?(:external_id) ? record.external_id : nil,
|
|
246
|
+
external_type: record.respond_to?(:external_type) ? record.external_type : nil,
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def edge_json(edge)
|
|
251
|
+
{ id: edge.id, user_id: edge.user_id, group_id: edge.group_id, role_id: edge.role_id,
|
|
252
|
+
permission_id: edge.permission_id, resource_id: edge.resource_id }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def with_body(env)
|
|
256
|
+
media_type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
|
|
257
|
+
return json(415, error: "send application/json") unless media_type == "application/json"
|
|
258
|
+
|
|
259
|
+
input = env["rack.input"]
|
|
260
|
+
raw = input ? input.read(MAX_BODY + 1).to_s : ""
|
|
261
|
+
return json(413, error: "body too large") if raw.bytesize > MAX_BODY
|
|
262
|
+
|
|
263
|
+
body = raw.empty? ? nil : JSON.parse(raw)
|
|
264
|
+
return json(400, error: "body must be a JSON object") unless body.is_a?(Hash)
|
|
265
|
+
|
|
266
|
+
yield body
|
|
267
|
+
rescue JSON::ParserError
|
|
268
|
+
json(400, error: "body must be a JSON object")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def host_of(env)
|
|
272
|
+
host = env["HTTP_HOST"].to_s.downcase
|
|
273
|
+
host.start_with?("[") ? host[/\A\[[^\]]*\]/].to_s : host.split(":").first.to_s
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def json(status, payload)
|
|
277
|
+
[status, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [JSON.generate(payload)]]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def html
|
|
281
|
+
[200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store", "x-frame-options" => "DENY" }, [INDEX_HTML]]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def forbidden(message)
|
|
285
|
+
json(403, error: message)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
data/lib/super_auth/nestable.rb
CHANGED
|
@@ -53,24 +53,37 @@ module SuperAuth::Nestable
|
|
|
53
53
|
# pairs on equality; matching ids inside the comma-separated path strings
|
|
54
54
|
# with LIKE forced a nested loop no planner could index, and compile time
|
|
55
55
|
# grew roughly cubically with the graph.
|
|
56
|
+
#
|
|
57
|
+
# Both pair CTEs recurse with UNION rather than UNION ALL. The pair
|
|
58
|
+
# relation is finite (at most n² rows), so UNION stops as soon as a step
|
|
59
|
+
# produces nothing new, which on a parent_id cycle is the first time round;
|
|
60
|
+
# UNION ALL re-derives the same pairs forever and compile! never returns.
|
|
61
|
+
# On a valid tree no step repeats a pair, so the output is the same.
|
|
56
62
|
def ancestor_pairs
|
|
57
63
|
table = pluralize
|
|
58
64
|
name = :"#{singularize}_ancestor_pairs"
|
|
59
65
|
anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
|
|
60
66
|
step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
|
|
61
67
|
select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
|
|
62
|
-
db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
|
|
68
|
+
db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
# Every node paired with itself and each of its descendants, as
|
|
66
72
|
# (ancestor_id, descendant_id). Granting a role grants its whole subtree.
|
|
67
|
-
|
|
73
|
+
#
|
|
74
|
+
# `of:` (a dataset or an array of ids) restricts the anchor to those nodes,
|
|
75
|
+
# so only their subtrees are walked. Groups and roles are few and the
|
|
76
|
+
# whole table is cheap; resources are one row per protected record, and an
|
|
77
|
+
# unanchored CTE materialises every pair of the whole table once per
|
|
78
|
+
# strategy that joins it.
|
|
79
|
+
def descendant_pairs(of: nil)
|
|
68
80
|
table = pluralize
|
|
69
81
|
name = :"#{singularize}_descendant_pairs"
|
|
70
82
|
anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
|
|
83
|
+
anchor = anchor.where(id: of) unless of.nil?
|
|
71
84
|
step = db[name].join(table, parent_id: :descendant_id).
|
|
72
85
|
select(Sequel[name][:ancestor_id], Sequel[table][:id])
|
|
73
|
-
db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id])
|
|
86
|
+
db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
|
|
74
87
|
end
|
|
75
88
|
|
|
76
89
|
def cte(id = nil, direction = :desc)
|
data/lib/super_auth/railtie.rb
CHANGED
|
@@ -3,8 +3,6 @@ module SuperAuth
|
|
|
3
3
|
class Engine < Rails::Engine
|
|
4
4
|
isolate_namespace SuperAuth
|
|
5
5
|
|
|
6
|
-
config.paths.add 'app/controllers', eager_load: true
|
|
7
|
-
|
|
8
6
|
# Use ActiveRecord migrations when in a Rails environment
|
|
9
7
|
if defined?(ActiveRecord)
|
|
10
8
|
config.paths['db/migrate'] = 'db/migrate_activerecord'
|
|
@@ -18,6 +16,15 @@ module SuperAuth
|
|
|
18
16
|
load "tasks/super_auth_tasks.rake"
|
|
19
17
|
end
|
|
20
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
|
+
|
|
21
28
|
initializer "super_auth.initialize" do
|
|
22
29
|
if defined?(ActiveRecord) && defined?(ActiveRecord::Base)
|
|
23
30
|
SuperAuth.db
|
data/lib/super_auth/resource.rb
CHANGED
|
@@ -1,2 +1,59 @@
|
|
|
1
1
|
class SuperAuth::Resource < Sequel::Model(:super_auth_resources)
|
|
2
|
+
# Resources nest like groups and roles: a grant on a node reaches the node
|
|
3
|
+
# and every node under it (SuperAuth::Edge.join_resource_subtree). No
|
|
4
|
+
# unrestrict_primary_key, unlike those two: it exists for the ActiveRecord
|
|
5
|
+
# twins' descendants_dataset, which builds a Sequel model with an id, and
|
|
6
|
+
# the ActiveRecord Resource deliberately has none — nothing at runtime
|
|
7
|
+
# walks a resource tree.
|
|
8
|
+
include SuperAuth::Nestable
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# A node with an external_type and no external_id is a type-level
|
|
12
|
+
# (wildcard) node: at runtime it means every record of that type, present
|
|
13
|
+
# and future (the ByCurrentUser type_level branch, the policy's
|
|
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.
|
|
18
|
+
def wildcards
|
|
19
|
+
exclude(external_type: nil).where(external_id: nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# "Wildcard nodes are flat." compile! calls this before touching the
|
|
23
|
+
# 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.
|
|
30
|
+
def assert_compilable!
|
|
31
|
+
parents = dataset.exclude(parent_id: nil).select(:parent_id)
|
|
32
|
+
nested = wildcards.where(Sequel.|(Sequel.~(parent_id: nil), { id: parents })).select_order_map(:id)
|
|
33
|
+
return if nested.empty?
|
|
34
|
+
|
|
35
|
+
raise SuperAuth::Error, "Wildcard resource nodes must be flat, but wildcard node(s) #{nested.join(', ')} " \
|
|
36
|
+
"have a parent or children. A resource node with an external_type and no external_id is a wildcard " \
|
|
37
|
+
"for every record of that type, not a container: nested in the tree it would compile to a row that " \
|
|
38
|
+
"reaches every record of its type through the tree. Move each to the root with no children, or give " \
|
|
39
|
+
"it an external_id."
|
|
40
|
+
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
|
+
end
|
|
2
59
|
end
|