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