super_auth 0.7.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 +100 -0
- data/Gemfile.lock +1 -1
- data/README.md +440 -42
- data/USAGE.md +127 -21
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- 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/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- 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/db/seeds/sample_data.rb +1 -0
- data/lib/generators/super_auth/install/templates/README +6 -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 +14 -3
- 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 +28 -4
- 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 +63 -10
- data/lib/super_auth/edge.rb +73 -18
- data/lib/super_auth/editor/index.html +16 -10
- data/lib/super_auth/editor/seed.rb +11 -5
- data/lib/super_auth/editor.rb +35 -11
- data/lib/super_auth/nestable.rb +105 -4
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +71 -0
- 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 -2
- metadata +10 -1
data/lib/super_auth/edge.rb
CHANGED
|
@@ -7,6 +7,19 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
7
7
|
many_to_one :role
|
|
8
8
|
many_to_one :resource
|
|
9
9
|
|
|
10
|
+
# The columns of `authorizations`, in its order. compile! inserts the union
|
|
11
|
+
# straight into super_auth_authorizations under this list, so it is the
|
|
12
|
+
# contract between the five SELECTs below and the table: MySQL and Postgres
|
|
13
|
+
# both need the column list, and the table has columns the union does not
|
|
14
|
+
# fill (its own timestamps, the ActiveRecord migration's id).
|
|
15
|
+
AUTHORIZATION_COLUMNS = %i[
|
|
16
|
+
user_id user_name user_external_id user_external_type user_created_at user_updated_at
|
|
17
|
+
group_id group_name group_path group_name_path group_parent_id group_created_at group_updated_at
|
|
18
|
+
role_id role_name role_path role_name_path role_parent_id role_created_at role_updated_at
|
|
19
|
+
permission_id permission_name permission_created_at permission_updated_at
|
|
20
|
+
resource_id resource_name resource_external_id resource_external_type
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
10
23
|
class << self
|
|
11
24
|
# The five strategies are UNIONed positionally. A column that is a real
|
|
12
25
|
# text column in one strategy and CAST(NULL AS ...) in another must be cast
|
|
@@ -39,6 +52,51 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
39
52
|
.union(users_resources)
|
|
40
53
|
end
|
|
41
54
|
|
|
55
|
+
# Every resource node a grant reaches: each granted node paired with
|
|
56
|
+
# itself and each of its descendants, as (ancestor_id, descendant_id).
|
|
57
|
+
# Anchored on the ids that appear in edges rather than on the whole table.
|
|
58
|
+
# Groups and roles are few, but resources are one row per protected
|
|
59
|
+
# record, and the unanchored CTE materialises every pair of the table once
|
|
60
|
+
# per strategy — 27s per strategy on MySQL at 300k resources, 0.025s
|
|
61
|
+
# anchored — so the walk is sized by the grants, not by the table. On a
|
|
62
|
+
# flat graph it is the identity relation and the compiled rows are exactly
|
|
63
|
+
# what the previous pk join produced. The walk never descends from a
|
|
64
|
+
# type-level node (Resource.descend_from): a granted (type, NULL) node
|
|
65
|
+
# pairs with itself and nothing beneath it, on every path that reads this
|
|
66
|
+
# relation, compile! or not.
|
|
67
|
+
def resource_subtrees
|
|
68
|
+
granted = db[:super_auth_edges].exclude(resource_id: nil).select(:resource_id)
|
|
69
|
+
SuperAuth::Resource.descendant_pairs(of: granted)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The last hop of every strategy: from the resource id on an edge to the
|
|
73
|
+
# node it names and each node under it. The compiled row copies the
|
|
74
|
+
# descendant's own columns, so containment is not inheritance — a node
|
|
75
|
+
# keeps its own external_type — and the type-level tricks in ByCurrentUser
|
|
76
|
+
# still hold. No resource path columns, unlike groups and roles: nothing at
|
|
77
|
+
# runtime reads one, and super_auth_authorizations gains no columns.
|
|
78
|
+
def join_resource_subtree(ds, resource_id_column)
|
|
79
|
+
ds.
|
|
80
|
+
join(resource_subtrees.as(:resource_descendants), ancestor_id: resource_id_column).
|
|
81
|
+
join(Sequel[:super_auth_resources], id: Sequel[:resource_descendants][:descendant_id]).
|
|
82
|
+
# A (type, NULL) row — a type-level node, every record of its type —
|
|
83
|
+
# is only ever the node the grant named, never one reached through
|
|
84
|
+
# the tree; the walk itself refuses the other direction, a node
|
|
85
|
+
# reached through a type-level parent. Resource.assert_compilable!
|
|
86
|
+
# refuses both shapes loudly, but it is a separate statement from this
|
|
87
|
+
# one: under READ COMMITTED a write that nests a type-level node can
|
|
88
|
+
# land between the two, and the compiled table must not widen a
|
|
89
|
+
# container grant to a whole type, or a type-level grant to the nodes
|
|
90
|
+
# under it, because of it.
|
|
91
|
+
where(
|
|
92
|
+
Sequel.|(
|
|
93
|
+
{ Sequel[:resource_descendants][:ancestor_id] => Sequel[:resource_descendants][:descendant_id] },
|
|
94
|
+
{ Sequel[:super_auth_resources][:external_type] => nil },
|
|
95
|
+
Sequel.~(Sequel[:super_auth_resources][:external_id] => nil)
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
42
100
|
def users_groups_roles_permissions_resources
|
|
43
101
|
cast_type = string_cast_type
|
|
44
102
|
# Join users to their group via edges. group_ancestors pairs that group with itself and
|
|
@@ -46,7 +104,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
46
104
|
# expands the granted role to its whole subtree. Each step is correlated to the previous
|
|
47
105
|
# one, so a role held by one group never reaches members of an unrelated group. The tree
|
|
48
106
|
# CTEs (user_groups, granted_roles) are joined by id only to supply the path columns.
|
|
49
|
-
SuperAuth::User.db[:super_auth_users].
|
|
107
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
50
108
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
51
109
|
join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
|
|
52
110
|
join(Sequel[:super_auth_edges].as(:group_role_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
|
|
@@ -56,8 +114,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
56
114
|
join(SuperAuth::Role.from(SuperAuth::Role.trees).as(:granted_roles), Sequel[:granted_roles][:id] => Sequel[:role_descendants][:descendant_id]).
|
|
57
115
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
|
|
58
116
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
59
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
60
|
-
|
|
117
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
118
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).
|
|
61
119
|
select(
|
|
62
120
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
63
121
|
Sequel[:super_auth_users][:name].as(:user_name),
|
|
@@ -100,14 +158,14 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
100
158
|
# Join users to their group via edges. group_ancestors pairs that group with itself and
|
|
101
159
|
# every ancestor, so a group -> permission edge on any of them applies. user_groups (the
|
|
102
160
|
# tree) is joined by id only to supply the path columns.
|
|
103
|
-
SuperAuth::User.db[:super_auth_users].
|
|
161
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
104
162
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
105
163
|
join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
|
|
106
164
|
join(Sequel[:super_auth_edges].as(:group_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
|
|
107
165
|
join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:user_groups), Sequel[:user_groups][:id] => Sequel[:user_edges][:group_id]).
|
|
108
166
|
join(Sequel[:super_auth_permissions], id: Sequel[:group_edges][:permission_id]).
|
|
109
|
-
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
110
|
-
|
|
167
|
+
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
168
|
+
join_resource_subtree(ds, Sequel[:permission_edges][:resource_id]).
|
|
111
169
|
select(
|
|
112
170
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
113
171
|
Sequel[:super_auth_users][:name].as(:user_name),
|
|
@@ -150,7 +208,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
150
208
|
|
|
151
209
|
# Join users to the roles they hold directly. role_descendants expands each held role to
|
|
152
210
|
# its whole subtree; granted_roles (the tree) is joined by id only to supply the path columns.
|
|
153
|
-
SuperAuth::User.db[:super_auth_users].
|
|
211
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
154
212
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
155
213
|
where(Sequel.~(Sequel[:user_edges][:role_id] => nil)).
|
|
156
214
|
join(SuperAuth::Role.descendant_pairs.as(:role_descendants), ancestor_id: Sequel[:user_edges][:role_id]).
|
|
@@ -192,14 +250,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
192
250
|
# Join permission and resource edges on the expanded role
|
|
193
251
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
|
|
194
252
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
195
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
196
|
-
|
|
197
|
-
distinct
|
|
253
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
254
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
|
|
198
255
|
end
|
|
199
256
|
|
|
200
257
|
def users_permissions_resources
|
|
201
258
|
cast_type = string_cast_type
|
|
202
|
-
SuperAuth::User.
|
|
259
|
+
ds = SuperAuth::User.
|
|
203
260
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
204
261
|
select(
|
|
205
262
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
@@ -237,14 +294,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
237
294
|
).
|
|
238
295
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:user_id] => Sequel[:super_auth_users][:id]).
|
|
239
296
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
240
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
241
|
-
|
|
242
|
-
distinct
|
|
297
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
298
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
|
|
243
299
|
end
|
|
244
300
|
|
|
245
301
|
def users_resources
|
|
246
302
|
cast_type = string_cast_type
|
|
247
|
-
SuperAuth::User.
|
|
303
|
+
ds = SuperAuth::User.
|
|
248
304
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
249
305
|
select(
|
|
250
306
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
@@ -279,9 +335,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
279
335
|
Sequel[:super_auth_resources][:name].as(:resource_name),
|
|
280
336
|
Sequel[:super_auth_resources][:external_id].as(:resource_external_id),
|
|
281
337
|
Sequel[:super_auth_resources][:external_type].as(:resource_external_type)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
distinct
|
|
338
|
+
)
|
|
339
|
+
join_resource_subtree(ds, Sequel[:user_edges][:resource_id]).distinct
|
|
285
340
|
end
|
|
286
341
|
end
|
|
287
342
|
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
.region{display:grid;gap:10px;min-height:0}
|
|
54
54
|
.region.top{flex:1.15;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;
|
|
55
55
|
grid-template-areas:"group role" "user perm"}
|
|
56
|
-
.region.bottom{flex:.85;grid-template-columns:1fr
|
|
57
|
-
grid-template-areas:"resource
|
|
56
|
+
.region.bottom{flex:.85;grid-template-columns:1fr;grid-template-rows:1fr;
|
|
57
|
+
grid-template-areas:"resource"}
|
|
58
58
|
|
|
59
59
|
/* ---- box ---- */
|
|
60
60
|
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
|
@@ -63,7 +63,6 @@
|
|
|
63
63
|
.box[data-area=user]{grid-area:user;--accent:var(--c-user)}
|
|
64
64
|
.box[data-area=permission]{grid-area:perm;--accent:var(--c-perm)}
|
|
65
65
|
.box[data-area=resource]{grid-area:resource;--accent:var(--c-resource)}
|
|
66
|
-
.box[data-area=user2]{grid-area:user2;--accent:var(--c-user)}
|
|
67
66
|
.box-head{display:flex;align-items:center;gap:9px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
|
|
68
67
|
.box-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none;box-shadow:0 0 10px -2px var(--accent)}
|
|
69
68
|
.box-head .title{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.02em}
|
|
@@ -122,7 +121,6 @@
|
|
|
122
121
|
</section>
|
|
123
122
|
<section class="region bottom">
|
|
124
123
|
<div class="box" data-area="resource" data-type="resource"></div>
|
|
125
|
-
<div class="box" data-area="user2" data-type="user"></div>
|
|
126
124
|
</section>
|
|
127
125
|
</main>
|
|
128
126
|
|
|
@@ -130,7 +128,7 @@
|
|
|
130
128
|
// The app may be mounted under a prefix; every API call is relative to this page.
|
|
131
129
|
const API = location.pathname.replace(/\/$/, "");
|
|
132
130
|
const TITLES = {group:"Groups", role:"Roles", user:"Users", permission:"Permissions", resource:"Resources"};
|
|
133
|
-
const NESTED = new Set(["group","role"]);
|
|
131
|
+
const NESTED = new Set(["group","role","resource"]);
|
|
134
132
|
|
|
135
133
|
let GRAPH = null; // {groups,roles,users,permissions,resources,edges,authorizations_count}
|
|
136
134
|
let selection = null; // {type,id}
|
|
@@ -157,8 +155,8 @@ async function load(){
|
|
|
157
155
|
// Authorization flows user → group → role → permission → resource. From the
|
|
158
156
|
// selected node we collect everything DOWNSTREAM (what it can reach) and
|
|
159
157
|
// everything UPSTREAM (what can reach it). So a user shows what they can access;
|
|
160
|
-
// a resource shows who can access it.
|
|
161
|
-
//
|
|
158
|
+
// a resource shows who can access it. The group, role and resource trees are
|
|
159
|
+
// each folded in as one more directed hop (see buildDirected).
|
|
162
160
|
const RANK = {user:0, group:1, role:2, permission:3, resource:4};
|
|
163
161
|
|
|
164
162
|
function buildDirected(){
|
|
@@ -184,9 +182,13 @@ function buildDirected(){
|
|
|
184
182
|
// inherits ANCESTOR grants → a child group is upstream of its parent.
|
|
185
183
|
// • Role grants flow to DESCENDANT roles, so holding a parent role includes
|
|
186
184
|
// its children → a parent role is upstream of its child.
|
|
185
|
+
// • Resource grants reach DESCENDANT resources: a grant on a container
|
|
186
|
+
// covers everything under it → a parent resource is upstream of its
|
|
187
|
+
// children, the same direction as roles and the opposite of groups.
|
|
187
188
|
// (Selecting a child group therefore reaches its ancestors, never its siblings.)
|
|
188
189
|
for(const g of GRAPH.groups) if(g.parent_id) dir(key("group",g.id), key("group",g.parent_id));
|
|
189
190
|
for(const r of GRAPH.roles) if(r.parent_id) dir(key("role",r.parent_id), key("role",r.id));
|
|
191
|
+
for(const r of GRAPH.resources) if(r.parent_id) dir(key("resource",r.parent_id), key("resource",r.id));
|
|
190
192
|
return {fwd,bwd};
|
|
191
193
|
}
|
|
192
194
|
|
|
@@ -248,11 +250,15 @@ function render(){
|
|
|
248
250
|
const name = n.name ?? "";
|
|
249
251
|
// The external record occupies one slot: its label when the graph
|
|
250
252
|
// stored one, otherwise the Type#id that used to be the only
|
|
251
|
-
// rendering. Either way the other half is the tooltip.
|
|
253
|
+
// rendering. Either way the other half is the tooltip. A type with no
|
|
254
|
+
// id is the type-level (wildcard) node — every record of the type —
|
|
255
|
+
// and its tooltip says so, since Type#* alone reads as a typo.
|
|
256
|
+
const wildcard = type === "resource" && !!n.external_type && n.external_id == null;
|
|
252
257
|
const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
|
|
258
|
+
const kind = wildcard ? "type-level grant" : "external record";
|
|
253
259
|
const ext = !ref ? "" : (n.super_auth_label
|
|
254
|
-
? `<span class="ext" title="${ref}">${escapeHtml(n.super_auth_label)}</span>`
|
|
255
|
-
: `<span class="ext" title="
|
|
260
|
+
? `<span class="ext" title="${wildcard ? `${ref} · ${kind}` : ref}">${escapeHtml(n.super_auth_label)}</span>`
|
|
261
|
+
: `<span class="ext" title="${kind}">${ref}</span>`);
|
|
256
262
|
return `<div class="item ${isSel?'selected':''} ${isCF?'connect-first':''}" data-id="${n.id}" ${pad}>
|
|
257
263
|
<span class="tick"></span>
|
|
258
264
|
${arrow}<span class="name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>${ext}
|
|
@@ -15,6 +15,11 @@ module SuperAuth
|
|
|
15
15
|
# does not.
|
|
16
16
|
# - "Support Lead" is the PARENT role of "Support Agent": a lead inherits
|
|
17
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.
|
|
18
23
|
#
|
|
19
24
|
# Special people:
|
|
20
25
|
# - Riley (Auditor): read-only into BOTH Finance and Support
|
|
@@ -68,8 +73,9 @@ module SuperAuth
|
|
|
68
73
|
|
|
69
74
|
# ===== RESOURCES (disjoint per department) =====
|
|
70
75
|
source_repo = res_m.create(name: "source_repo")
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
73
79
|
app_database = res_m.create(name: "app_database") # Backend
|
|
74
80
|
marketing_site = res_m.create(name: "marketing_site") # Frontend
|
|
75
81
|
general_ledger = res_m.create(name: "general_ledger")
|
|
@@ -99,8 +105,8 @@ module SuperAuth
|
|
|
99
105
|
edg.create(role_id: developer.id, permission_id: deploy.id)
|
|
100
106
|
edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
|
|
101
107
|
edg.create(permission_id: read_repo.id, resource_id: source_repo.id)
|
|
102
|
-
|
|
103
|
-
edg.create(permission_id: deploy.id, resource_id:
|
|
108
|
+
# One grant on the container reaches both clusters.
|
|
109
|
+
edg.create(permission_id: deploy.id, resource_id: clusters.id)
|
|
104
110
|
# Child-group-specific grants (Alice gets one, Bob the other)
|
|
105
111
|
edg.create(group_id: backend.id, permission_id: run_migrations.id)
|
|
106
112
|
edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
|
|
@@ -153,7 +159,7 @@ module SuperAuth
|
|
|
153
159
|
def clear!
|
|
154
160
|
SuperAuth::Edge.dataset.delete
|
|
155
161
|
SuperAuth::Authorization.dataset.delete
|
|
156
|
-
[SuperAuth::Group, SuperAuth::Role].each { |m| m.dataset.update(parent_id: nil) }
|
|
162
|
+
[SuperAuth::Group, SuperAuth::Role, SuperAuth::Resource].each { |m| m.dataset.update(parent_id: nil) }
|
|
157
163
|
[SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
|
|
158
164
|
m.dataset.delete
|
|
159
165
|
end
|
data/lib/super_auth/editor.rb
CHANGED
|
@@ -3,7 +3,8 @@ require "super_auth"
|
|
|
3
3
|
|
|
4
4
|
module SuperAuth
|
|
5
5
|
# A small Rack application that edits the authorization graph: five boxes of
|
|
6
|
-
# records
|
|
6
|
+
# records (groups, roles and resources drawn as trees), client-side
|
|
7
|
+
# traversal, node and edge CRUD, and a Recompile button.
|
|
7
8
|
# Rails-free; it needs only SuperAuth.db to be connected and the tables to
|
|
8
9
|
# exist. Mount it as `run SuperAuth::Editor` (Rack) or
|
|
9
10
|
# `mount SuperAuth::Editor => "/super_auth/editor"` (Rails), or run
|
|
@@ -19,7 +20,9 @@ module SuperAuth
|
|
|
19
20
|
#
|
|
20
21
|
# Edits change the graph, not runtime access: ByCurrentUser and the RLS
|
|
21
22
|
# policies read the compiled super_auth_authorizations table, so the UI
|
|
22
|
-
# shows its row count and offers POST /api/compile.
|
|
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.
|
|
23
26
|
class Editor
|
|
24
27
|
TYPES = {
|
|
25
28
|
"user" => :User, "group" => :Group, "role" => :Role,
|
|
@@ -29,7 +32,7 @@ module SuperAuth
|
|
|
29
32
|
"user" => :user_id, "group" => :group_id, "role" => :role_id,
|
|
30
33
|
"permission" => :permission_id, "resource" => :resource_id,
|
|
31
34
|
}.freeze
|
|
32
|
-
NESTED = %w[group role].freeze
|
|
35
|
+
NESTED = %w[group role resource].freeze
|
|
33
36
|
# The pairs the path strategies read (see Edge.authorizations), unordered.
|
|
34
37
|
# The models also accept group->resource and role->resource rows, but no
|
|
35
38
|
# strategy reads them, so they would grant nothing.
|
|
@@ -69,6 +72,8 @@ module SuperAuth
|
|
|
69
72
|
end
|
|
70
73
|
|
|
71
74
|
route(method, path, env)
|
|
75
|
+
rescue SuperAuth::Error => e
|
|
76
|
+
json(422, error: e.message)
|
|
72
77
|
rescue Sequel::Error
|
|
73
78
|
json(422, error: "the database rejected the change")
|
|
74
79
|
end
|
|
@@ -103,7 +108,7 @@ module SuperAuth
|
|
|
103
108
|
roles: nodes(:Role, :parent_id),
|
|
104
109
|
users: nodes(:User, :external_id, :external_type),
|
|
105
110
|
permissions: nodes(:Permission),
|
|
106
|
-
resources: nodes(:Resource, :external_id, :external_type, :super_auth_label),
|
|
111
|
+
resources: nodes(:Resource, :parent_id, :external_id, :external_type, :super_auth_label),
|
|
107
112
|
edges: SuperAuth::Edge.order(:id).map { |e| edge_json(e) },
|
|
108
113
|
authorizations_count: SuperAuth::Authorization.count,
|
|
109
114
|
}
|
|
@@ -122,9 +127,12 @@ module SuperAuth
|
|
|
122
127
|
# indentation, so a child has to arrive immediately after its parent or
|
|
123
128
|
# it reads as nested under whatever happens to sort above it — which is
|
|
124
129
|
# the one question an auditor opens this editor to answer. Sorting by the
|
|
125
|
-
# ancestors' [name, id]
|
|
126
|
-
# own parent and leaves siblings alphabetical
|
|
127
|
-
#
|
|
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.
|
|
128
136
|
#
|
|
129
137
|
# The key is total, so the order stays defined for broken trees: a row
|
|
130
138
|
# whose parent_id names a missing row sorts as a root, and a parent cycle
|
|
@@ -137,7 +145,7 @@ module SuperAuth
|
|
|
137
145
|
node = row
|
|
138
146
|
while node && !seen[node[:id]]
|
|
139
147
|
seen[node[:id]] = true
|
|
140
|
-
path.unshift([node[:name].to_s, node[:id]])
|
|
148
|
+
path.unshift([node[:name].to_s, node[:super_auth_label].to_s, node[:id]])
|
|
141
149
|
node = by_id[node[:parent_id]]
|
|
142
150
|
end
|
|
143
151
|
path
|
|
@@ -158,7 +166,14 @@ module SuperAuth
|
|
|
158
166
|
unless parent.nil?
|
|
159
167
|
return json(422, error: "#{type} records cannot have a parent") unless NESTED.include?(type)
|
|
160
168
|
return json(422, error: "parent_id must be an integer") unless integer_id?(parent)
|
|
161
|
-
|
|
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 flat and cannot contain other resources; " \
|
|
175
|
+
"make a container (a resource with no external type) instead")
|
|
176
|
+
end
|
|
162
177
|
attrs[:parent_id] = parent.to_i
|
|
163
178
|
end
|
|
164
179
|
|
|
@@ -172,8 +187,17 @@ module SuperAuth
|
|
|
172
187
|
|
|
173
188
|
SuperAuth.db.transaction do
|
|
174
189
|
SuperAuth::Edge.where(COLUMNS[type] => record.id).delete
|
|
175
|
-
#
|
|
176
|
-
#
|
|
190
|
+
# The node's own compiled rows go with it: runtime reads only that
|
|
191
|
+
# table, and a row naming a node that no longer exists would keep
|
|
192
|
+
# granting until the next compile. Rows compiled through it for its
|
|
193
|
+
# descendants stay until one runs, as after any other revocation.
|
|
194
|
+
SuperAuth::Authorization.where(COLUMNS[type] => record.id).delete
|
|
195
|
+
# Children become roots, deliberately, and the client's confirm says
|
|
196
|
+
# so: not the grandparent's children, whose grants would then reach
|
|
197
|
+
# them, and not deleted with the node, which is not what "delete this
|
|
198
|
+
# container" asks. A root grants nothing by itself, so it is the
|
|
199
|
+
# deny-safe choice. Also required before the delete on MySQL, which
|
|
200
|
+
# checks the self-referencing key row by row.
|
|
177
201
|
model.where(parent_id: record.id).update(parent_id: nil) if NESTED.include?(type)
|
|
178
202
|
model.where(id: record.id).delete
|
|
179
203
|
end
|
data/lib/super_auth/nestable.rb
CHANGED
|
@@ -24,6 +24,44 @@ module SuperAuth::Nestable
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# A node may not be its own parent, nor sit under one of its own
|
|
28
|
+
# descendants: either closes a parent_id cycle. The pair CTEs terminate on
|
|
29
|
+
# one (UNION), so a cycle does not hang a compile; it does something
|
|
30
|
+
# quieter and worse. Every node in a cycle is an ancestor of every other,
|
|
31
|
+
# so a grant on any of them reaches all of their subtrees — a container
|
|
32
|
+
# pointed at one of its own children turned a single per-record read into
|
|
33
|
+
# the container's whole membership, with nothing raised anywhere. Checked
|
|
34
|
+
# only when parent_id changes, by walking UP from the new parent: that is
|
|
35
|
+
# one row per level however large the subtree, and unlike the descendant
|
|
36
|
+
# walk it does not stop at a type-level node
|
|
37
|
+
# (SuperAuth::Resource.descend_from), so a cycle through one is caught too.
|
|
38
|
+
# assert_acyclic! covers writes that bypass the model.
|
|
39
|
+
def validate
|
|
40
|
+
super
|
|
41
|
+
return if parent_id.nil? || !changed_columns.include?(:parent_id)
|
|
42
|
+
|
|
43
|
+
if parent_id == id
|
|
44
|
+
errors.add(:parent_id, "cannot be the node itself")
|
|
45
|
+
elsif !new? && model.ancestor_pairs(of: [parent_id]).where(ancestor_id: id).count > 0
|
|
46
|
+
errors.add(:parent_id, "is inside the node's own subtree, which would close a cycle")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A deleted node takes its compiled rows and its edges with it, in the
|
|
51
|
+
# transaction that deletes the row: runtime reads only the compiled table,
|
|
52
|
+
# and a row naming a node that no longer exists would keep granting until
|
|
53
|
+
# the next compile. Children are not touched. The foreign key refuses to
|
|
54
|
+
# orphan them, and whether they are re-rooted or deleted is the caller's
|
|
55
|
+
# decision (the editor re-roots them, deliberately). Rows compiled through
|
|
56
|
+
# this node for its descendants stay until the next compile, as after any
|
|
57
|
+
# other revocation.
|
|
58
|
+
def before_destroy
|
|
59
|
+
super
|
|
60
|
+
column = :"#{model.singularize}_id"
|
|
61
|
+
SuperAuth::Authorization.where(column => id).delete
|
|
62
|
+
SuperAuth::Edge.where(column => id).delete
|
|
63
|
+
end
|
|
64
|
+
|
|
27
65
|
module ClassMethods
|
|
28
66
|
# Helper method to get the appropriate string cast type for the database
|
|
29
67
|
def string_cast_type
|
|
@@ -53,24 +91,87 @@ module SuperAuth::Nestable
|
|
|
53
91
|
# pairs on equality; matching ids inside the comma-separated path strings
|
|
54
92
|
# with LIKE forced a nested loop no planner could index, and compile time
|
|
55
93
|
# grew roughly cubically with the graph.
|
|
56
|
-
|
|
94
|
+
#
|
|
95
|
+
# Both pair CTEs recurse with UNION rather than UNION ALL. The pair
|
|
96
|
+
# relation is finite (at most n² rows), so UNION stops as soon as a step
|
|
97
|
+
# produces nothing new, which on a parent_id cycle is the first time round;
|
|
98
|
+
# UNION ALL re-derives the same pairs forever and compile! never returns.
|
|
99
|
+
# On a valid tree no step repeats a pair, so the output is the same.
|
|
100
|
+
#
|
|
101
|
+
# `of:` (a dataset or an array of ids) restricts the anchor to those
|
|
102
|
+
# nodes, so only their ancestor chains are walked: one row per level.
|
|
103
|
+
def ancestor_pairs(of: nil)
|
|
57
104
|
table = pluralize
|
|
58
105
|
name = :"#{singularize}_ancestor_pairs"
|
|
59
106
|
anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
|
|
107
|
+
anchor = anchor.where(id: of) unless of.nil?
|
|
60
108
|
step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
|
|
61
109
|
select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
|
|
62
|
-
db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
|
|
110
|
+
db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
|
|
63
111
|
end
|
|
64
112
|
|
|
65
113
|
# Every node paired with itself and each of its descendants, as
|
|
66
114
|
# (ancestor_id, descendant_id). Granting a role grants its whole subtree.
|
|
67
|
-
|
|
115
|
+
#
|
|
116
|
+
# `of:` (a dataset or an array of ids) restricts the anchor to those nodes,
|
|
117
|
+
# so only their subtrees are walked. Groups and roles are few and the
|
|
118
|
+
# whole table is cheap; resources are one row per protected record, and an
|
|
119
|
+
# unanchored CTE materialises every pair of the whole table once per
|
|
120
|
+
# strategy that joins it.
|
|
121
|
+
#
|
|
122
|
+
# The recursive step joins the parent row only when the model puts a
|
|
123
|
+
# condition on it (descend_from); the pair CTE itself never carries more
|
|
124
|
+
# than the two ids.
|
|
125
|
+
def descendant_pairs(of: nil)
|
|
68
126
|
table = pluralize
|
|
69
127
|
name = :"#{singularize}_descendant_pairs"
|
|
128
|
+
parent = :"#{singularize}_parent"
|
|
70
129
|
anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
|
|
130
|
+
anchor = anchor.where(id: of) unless of.nil?
|
|
71
131
|
step = db[name].join(table, parent_id: :descendant_id).
|
|
72
132
|
select(Sequel[name][:ancestor_id], Sequel[table][:id])
|
|
73
|
-
|
|
133
|
+
if (condition = descend_from(parent))
|
|
134
|
+
step = step.join(Sequel[table].as(parent), id: Sequel[name][:descendant_id]).where(condition)
|
|
135
|
+
end
|
|
136
|
+
db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Whether descendant_pairs continues below a node: a Sequel condition on
|
|
140
|
+
# the parent row, addressed through the alias `parent`, or nil to descend
|
|
141
|
+
# from every node. Groups and roles descend from everything.
|
|
142
|
+
# SuperAuth::Resource stops at a type-level node, so a grant on one yields
|
|
143
|
+
# its own row and nothing beneath it on every path that reads the walk.
|
|
144
|
+
def descend_from(parent)
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Every node some root reaches, walking parent_id downward from the rows
|
|
149
|
+
# that have none. On a valid forest that is the whole table; what it
|
|
150
|
+
# misses is exactly the nodes on or under a parent_id cycle (and, where
|
|
151
|
+
# no foreign key stands, a node whose parent is missing). No path
|
|
152
|
+
# columns, unlike trees: this runs over the whole resources table before
|
|
153
|
+
# every compile, and needs only the ids.
|
|
154
|
+
def rooted
|
|
155
|
+
table = pluralize
|
|
156
|
+
name = :"rooted_#{table}"
|
|
157
|
+
anchor = db[table].where(parent_id: nil).select(:id)
|
|
158
|
+
step = db[name].join(table, parent_id: :id).select(Sequel[table][:id])
|
|
159
|
+
db.from(name).with_recursive(name, anchor, step, args: [:id], union_all: false)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Refuses a table with a parent_id cycle in it, naming the nodes no root
|
|
163
|
+
# reaches. compile! calls this for groups, roles and resources before
|
|
164
|
+
# touching the compiled table, because a cycle does not fail a compile:
|
|
165
|
+
# the walks terminate, and every node in the cycle is an ancestor of
|
|
166
|
+
# every other, so a grant on any of them silently reaches all of their
|
|
167
|
+
# subtrees. validate refuses the shape at the model; this catches it
|
|
168
|
+
# after a write that went around the model.
|
|
169
|
+
def assert_acyclic!
|
|
170
|
+
unreachable = dataset.exclude(id: rooted).select_order_map(:id)
|
|
171
|
+
return if unreachable.empty?
|
|
172
|
+
|
|
173
|
+
raise SuperAuth::Error, "#{pluralize} has a parent_id cycle: node(s) #{unreachable.join(', ')} " \
|
|
174
|
+
"cannot be reached from any root. Point one of them at a root, or at no parent, and recompile."
|
|
74
175
|
end
|
|
75
176
|
|
|
76
177
|
def cte(id = nil, direction = :desc)
|
|
@@ -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
|