current_scope 0.5.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +377 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +229 -0
- data/app/assets/stylesheets/current_scope/application.css +917 -0
- data/app/controllers/current_scope/application_controller.rb +96 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
- data/app/controllers/current_scope/roles_controller.rb +256 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
- data/app/controllers/current_scope/subjects_controller.rb +75 -0
- data/app/helpers/current_scope/application_helper.rb +261 -0
- data/app/models/concerns/current_scope/storable_keys.rb +101 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +74 -0
- data/app/models/current_scope/event.rb +136 -0
- data/app/models/current_scope/role.rb +106 -0
- data/app/models/current_scope/role_assignment.rb +42 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +98 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +229 -0
- data/app/views/current_scope/roles/index.html.erb +55 -0
- data/app/views/current_scope/roles/members.html.erb +114 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +124 -0
- data/app/views/layouts/current_scope/application.html.erb +56 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
- data/lib/current_scope/configuration.rb +613 -0
- data/lib/current_scope/context.rb +41 -0
- data/lib/current_scope/engine.rb +202 -0
- data/lib/current_scope/gating_reflection.rb +113 -0
- data/lib/current_scope/gating_tripwire.rb +84 -0
- data/lib/current_scope/grant_diagnosis.rb +216 -0
- data/lib/current_scope/guard.rb +750 -0
- data/lib/current_scope/mutation_guard.rb +90 -0
- data/lib/current_scope/parent_chain.rb +397 -0
- data/lib/current_scope/permission_catalog.rb +146 -0
- data/lib/current_scope/permission_grid.rb +132 -0
- data/lib/current_scope/permissions.rb +94 -0
- data/lib/current_scope/resolver.rb +669 -0
- data/lib/current_scope/schema_guard.rb +223 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/sod_preflight.rb +380 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +432 -0
- data/lib/generators/current_scope/install/install_generator.rb +114 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
- data/lib/tasks/current_scope_tasks.rake +454 -0
- metadata +123 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<h1>Subjects</h1>
|
|
2
|
+
<p class="cs-lede">Everyone who can hold a role, with their one org-wide role and
|
|
3
|
+
any per-record scoped roles. Filter to find people; select several to grant a
|
|
4
|
+
scoped role in one go.</p>
|
|
5
|
+
|
|
6
|
+
<div class="cs-toolbar">
|
|
7
|
+
<%= form_with url: subjects_path, method: :get, class: "cs-search", role: "search" do %>
|
|
8
|
+
<input type="search" name="q" value="<%= @query %>" class="cs-filter" data-cs-filter
|
|
9
|
+
placeholder="Search everyone by email or name…" aria-label="Search subjects">
|
|
10
|
+
<%= submit_tag "Search", name: nil, class: "cs-btn" %>
|
|
11
|
+
<% if @query.present? %><%= link_to "Clear", subjects_path, class: "cs-btn" %><% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<span class="cs-spacer"></span>
|
|
14
|
+
</div>
|
|
15
|
+
<% if @query.present? %>
|
|
16
|
+
<p class="cs-hint">Showing matches for “<%= @query %>” across all subjects. Narrow the list below per page; selection applies to the current page.</p>
|
|
17
|
+
<% else %>
|
|
18
|
+
<%# Always shown (not just on multi-page lists): the one input drives TWO
|
|
19
|
+
mechanisms — typing live-filters this page, Search queries every subject —
|
|
20
|
+
and nothing but this line says so. %>
|
|
21
|
+
<p class="cs-hint">Type to filter this page, or Search to find anyone by email or name across every subject.</p>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<div class="cs-bulk" data-cs-bulk hidden>
|
|
25
|
+
<strong><span data-cs-bulk-count>0</span> selected</strong>
|
|
26
|
+
<span class="cs-spacer"></span>
|
|
27
|
+
<%# Org-wide role for the selection. JS injects the checked subjects as
|
|
28
|
+
subject_gids[] on submit; blank role clears it for all. %>
|
|
29
|
+
<%# Setting REPLACES each subject's current org-wide role (blank clears it) —
|
|
30
|
+
as consequential as Remove, which confirms, so this does too. %>
|
|
31
|
+
<% bulk_prompt = "Change the org-wide role for the selected subjects? This replaces their current role." %>
|
|
32
|
+
<%= form_with url: role_assignments_path, method: :post, class: "cs-bulk-org",
|
|
33
|
+
data: { cs_bulk_org: true, cs_confirm: bulk_prompt, turbo_confirm: bulk_prompt } do %>
|
|
34
|
+
<%= select_tag :role_id, options_from_collection_for_select(@roles, :id, :name),
|
|
35
|
+
include_blank: "org role…", aria: { label: "Org-wide role for selected" } %>
|
|
36
|
+
<%= submit_tag "Set for selected", name: nil, class: "cs-btn" %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<%= link_to "Grant scoped role…", "#", class: "cs-btn cs-btn-primary",
|
|
39
|
+
data: { cs_bulk_scoped: true, cs_bulk_url: new_scoped_role_assignment_path } %>
|
|
40
|
+
<button type="button" class="cs-btn" data-cs-bulk-clear>Clear</button>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="cs-card cs-card--flush">
|
|
44
|
+
<table class="cs-table" data-cs-filter-list>
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th class="cs-select-cell">
|
|
48
|
+
<input type="checkbox" data-cs-select-all aria-label="Select all subjects">
|
|
49
|
+
</th>
|
|
50
|
+
<th>Subject</th><th>Org-wide role</th><th>Scoped roles</th>
|
|
51
|
+
</tr>
|
|
52
|
+
</thead>
|
|
53
|
+
<tbody>
|
|
54
|
+
<% @subjects.each do |subject| %>
|
|
55
|
+
<%# Key by what the polymorphic association STORES (the base_class name),
|
|
56
|
+
not subject.class.name — an STI subject (AdminUser < User) is saved as
|
|
57
|
+
"User", and keying on the subclass name would show its roles as
|
|
58
|
+
"— none —" while the resolver happily enforces them. %>
|
|
59
|
+
<% key = [ subject.class.polymorphic_name, subject.id.to_s ] %>
|
|
60
|
+
<% label = current_scope_subject_label(subject) %>
|
|
61
|
+
<%# Filter against a precise text set — subject label, current org role, and
|
|
62
|
+
scoped roles/records — NOT the row's textContent: every row embeds a
|
|
63
|
+
<select> listing all role names, which would match every query. %>
|
|
64
|
+
<% org_role_name = @roles.find { |r| r.id == @assignments[key]&.role_id }&.name %>
|
|
65
|
+
<% filter_text = [ label, org_role_name,
|
|
66
|
+
*(@scoped[key] || []).map { |sra|
|
|
67
|
+
"#{sra.role.name} #{current_scope_holder_resource_label(sra)}"
|
|
68
|
+
} ].compact.join(" ") %>
|
|
69
|
+
<tr data-cs-row data-cs-filter-text="<%= filter_text %>">
|
|
70
|
+
<td class="cs-select-cell">
|
|
71
|
+
<input type="checkbox" data-cs-select value="<%= subject.to_gid %>"
|
|
72
|
+
aria-label="Select <%= label %>">
|
|
73
|
+
</td>
|
|
74
|
+
<td><%= label %></td>
|
|
75
|
+
<td>
|
|
76
|
+
<% set_prompt = "Change the org-wide role for #{label}? This replaces their current role." %>
|
|
77
|
+
<%= form_with url: role_assignments_path, method: :post,
|
|
78
|
+
data: { cs_confirm: set_prompt, turbo_confirm: set_prompt } do %>
|
|
79
|
+
<%= hidden_field_tag :subject_gid, subject.to_gid %>
|
|
80
|
+
<%= select_tag :role_id,
|
|
81
|
+
options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
|
|
82
|
+
include_blank: "— none —",
|
|
83
|
+
aria: { label: "Org-wide role for #{label}" } %>
|
|
84
|
+
<%= submit_tag "Set", name: nil,
|
|
85
|
+
aria: { label: "Set org-wide role for #{label}" } %>
|
|
86
|
+
<% end %>
|
|
87
|
+
</td>
|
|
88
|
+
<td>
|
|
89
|
+
<% (@scoped[key] || []).each do |sra| %>
|
|
90
|
+
<% resource_label = current_scope_holder_resource_label(sra) %>
|
|
91
|
+
<% orphaned = sra.orphaned_resource? %>
|
|
92
|
+
<span class="cs-chip cs-scoped-chip<%= ' cs-chip--inert' if orphaned %>" id="scoped_chip_<%= sra.id %>">
|
|
93
|
+
<span class="cs-chip-label">
|
|
94
|
+
<%= sra.role.name %> of <%= resource_label %>
|
|
95
|
+
<% if orphaned %><span class="cs-inert-badge">inert</span><% end %>
|
|
96
|
+
</span>
|
|
97
|
+
<% revoke_prompt = orphaned ?
|
|
98
|
+
"Remove inert scoped grant (#{resource_label})?" :
|
|
99
|
+
"Revoke #{sra.role.name} on #{resource_label}?" %>
|
|
100
|
+
<%= button_to "×", scoped_role_assignment_path(sra), method: :delete,
|
|
101
|
+
class: "cs-chip-remove",
|
|
102
|
+
id: "scoped_chip_revoke_#{sra.id}",
|
|
103
|
+
title: (orphaned ? "Remove inert scoped grant" : "Revoke this scoped role"),
|
|
104
|
+
form: { data: { cs_confirm: revoke_prompt } },
|
|
105
|
+
aria: { label: (orphaned ? "Remove inert #{sra.role.name} grant on #{resource_label}" : "Revoke #{sra.role.name} on #{resource_label}") },
|
|
106
|
+
data: { turbo_confirm: revoke_prompt } %>
|
|
107
|
+
</span>
|
|
108
|
+
<% end %>
|
|
109
|
+
<%= link_to "+ scoped role", new_scoped_role_assignment_path(subject_gid: subject.to_gid),
|
|
110
|
+
class: "cs-add-scoped" %>
|
|
111
|
+
</td>
|
|
112
|
+
</tr>
|
|
113
|
+
<% end %>
|
|
114
|
+
</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<p class="cs-filter-empty" data-cs-filter-empty hidden>No subjects on this page match your filter.</p>
|
|
119
|
+
|
|
120
|
+
<nav class="cs-pagination" aria-label="Subjects pagination">
|
|
121
|
+
<% if @page > 1 %><%= link_to "← Previous", subjects_path(page: @page - 1, q: @query.presence) %><% end %>
|
|
122
|
+
<span>Page <%= @page %></span>
|
|
123
|
+
<% if @has_next_page %><%= link_to "Next →", subjects_path(page: @page + 1, q: @query.presence) %><% end %>
|
|
124
|
+
</nav>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<% cs_theme = %w[light dark].include?(cookies[:current_scope_theme]) ? cookies[:current_scope_theme] : nil %>
|
|
2
|
+
<% active = controller_name %>
|
|
3
|
+
<!DOCTYPE html>
|
|
4
|
+
<html lang="en"<%= " data-cs-theme=\"#{cs_theme}\"".html_safe if cs_theme %>>
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
<title>CurrentScope</title>
|
|
9
|
+
<%= csrf_meta_tags %>
|
|
10
|
+
<%= csp_meta_tag %>
|
|
11
|
+
|
|
12
|
+
<%= yield :head %>
|
|
13
|
+
|
|
14
|
+
<%= stylesheet_link_tag "current_scope/application", media: "all" %>
|
|
15
|
+
<%= javascript_include_tag "current_scope/application", defer: true %>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<%# WCAG 2.4.1: every page repeats the sidebar + topbar before its content —
|
|
19
|
+
keyboard users get a bypass. Visually hidden until focused. %>
|
|
20
|
+
<a class="cs-skip-link" href="#cs-main-content">Skip to content</a>
|
|
21
|
+
<div class="cs-shell">
|
|
22
|
+
<aside class="cs-sidebar">
|
|
23
|
+
<div class="cs-brand"><span class="cs-brand-mark" aria-hidden="true"></span> CurrentScope</div>
|
|
24
|
+
<nav class="cs-nav" aria-label="Management">
|
|
25
|
+
<%= link_to "Roles", current_scope.roles_path,
|
|
26
|
+
"aria-current": ("page" if active == "roles") %>
|
|
27
|
+
<%= link_to "Subjects", current_scope.subjects_path,
|
|
28
|
+
"aria-current": ("page" if %w[subjects scoped_role_assignments].include?(active)) %>
|
|
29
|
+
<%= link_to "Events", current_scope.events_path,
|
|
30
|
+
"aria-current": ("page" if active == "events") %>
|
|
31
|
+
</nav>
|
|
32
|
+
<div class="cs-sidebar-foot">Authorization console</div>
|
|
33
|
+
</aside>
|
|
34
|
+
|
|
35
|
+
<div class="cs-main">
|
|
36
|
+
<div class="cs-topbar">
|
|
37
|
+
<span class="cs-topbar-title">Authorization</span>
|
|
38
|
+
<span class="cs-topbar-spacer"></span>
|
|
39
|
+
<span class="cs-badge">full access</span>
|
|
40
|
+
<button type="button" class="cs-theme-toggle" data-cs-theme-toggle
|
|
41
|
+
aria-label="Toggle dark mode" aria-pressed="<%= cs_theme == "dark" %>">
|
|
42
|
+
<span class="cs-icon-light" aria-hidden="true">☾</span>
|
|
43
|
+
<span class="cs-icon-dark" aria-hidden="true">☀</span>
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<% if notice.present? %><p class="cs-flash cs-flash--notice" role="status"><%= notice %></p><% end %>
|
|
48
|
+
<% if alert.present? %><p class="cs-flash cs-flash--alert" role="alert"><%= alert %></p><% end %>
|
|
49
|
+
|
|
50
|
+
<main class="cs-content" id="cs-main-content" tabindex="-1">
|
|
51
|
+
<%= yield %>
|
|
52
|
+
</main>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
CurrentScope::Engine.routes.draw do
|
|
2
|
+
root to: "roles#index"
|
|
3
|
+
|
|
4
|
+
resources :roles, except: :show do
|
|
5
|
+
member { get :members }
|
|
6
|
+
end
|
|
7
|
+
resources :subjects, only: :index
|
|
8
|
+
resources :events, only: :index
|
|
9
|
+
# create is subject-keyed (no id); destroy removes one assignment by id (the
|
|
10
|
+
# members page's cleanup path for an orphan whose subject was deleted).
|
|
11
|
+
resources :role_assignments, only: [ :create, :destroy ]
|
|
12
|
+
resources :scoped_role_assignments, only: [ :new, :create, :destroy ]
|
|
13
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class CreateCurrentScopeTables < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :current_scope_roles do |t|
|
|
4
|
+
t.string :name, null: false, index: { unique: true }
|
|
5
|
+
t.boolean :full_access, null: false, default: false
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
create_table :current_scope_role_permissions do |t|
|
|
10
|
+
t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
|
|
11
|
+
t.string :permission_key, null: false
|
|
12
|
+
t.index [ :role_id, :permission_key ], unique: true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
create_table :current_scope_role_assignments do |t|
|
|
16
|
+
t.references :subject, polymorphic: true, null: false,
|
|
17
|
+
index: { unique: true, name: "index_current_scope_one_role_per_subject" }
|
|
18
|
+
t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
create_table :current_scope_scoped_role_assignments do |t|
|
|
23
|
+
t.references :subject, polymorphic: true, null: false
|
|
24
|
+
t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
|
|
25
|
+
t.references :resource, polymorphic: true, null: false
|
|
26
|
+
t.timestamps
|
|
27
|
+
t.index [ :subject_type, :subject_id, :resource_type, :resource_id, :role_id ],
|
|
28
|
+
unique: true, name: "index_current_scope_unique_scoped_assignment"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class CreateCurrentScopeEvents < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Append-only authorization event ledger. This migration ships to arbitrary
|
|
3
|
+
# hosts and is NEVER renamed after release, so the schema shape is FROZEN.
|
|
4
|
+
#
|
|
5
|
+
# NORMATIVE target mapping (mirrored in the Event model header):
|
|
6
|
+
# - assignment events (org_role.*, scoped_role.*): target = the GRANTEE
|
|
7
|
+
# (the subject being granted). The role/resource ride in `details`.
|
|
8
|
+
# - role.* events: target = the role itself.
|
|
9
|
+
def change
|
|
10
|
+
create_table :current_scope_events do |t|
|
|
11
|
+
t.string :event, null: false # namespaced past-tense name (role.created, permission.granted, ...)
|
|
12
|
+
t.string :actor, null: false # GlobalID of the REAL actor (never nil: record! raises first)
|
|
13
|
+
t.string :subject, null: false # GlobalID of the effective subject; ALWAYS set (== actor unless impersonating)
|
|
14
|
+
t.string :target, null: false # GlobalID of the thing acted on (see normative mapping above)
|
|
15
|
+
t.string :target_label, null: false # denormalized human label so history survives target deletion
|
|
16
|
+
# json (NOT jsonb): an opaque, append-only payload the engine never
|
|
17
|
+
# queries, so it needs no index/operator support — and plain json is the
|
|
18
|
+
# portable type across SQLite / PostgreSQL / MySQL.
|
|
19
|
+
t.json :details
|
|
20
|
+
t.string :request_id # correlation-only, not evidence (client-suppliable via X-Request-Id)
|
|
21
|
+
# created_at only — declared explicitly, NOT via t.timestamps. There is
|
|
22
|
+
# deliberately no updated_at: its absence documents append-only intent.
|
|
23
|
+
t.datetime :created_at, null: false
|
|
24
|
+
|
|
25
|
+
# Append-only + host-side retention => unbounded growth, so unindexed
|
|
26
|
+
# scans are forever. Newest-first reads use order(id: :desc) (append-only
|
|
27
|
+
# => id order == commit order), so no created_at index is needed.
|
|
28
|
+
t.index :target
|
|
29
|
+
t.index :actor
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# #151. `subject_id` and `resource_id` were integer columns, so a host whose
|
|
2
|
+
# models use UUID (or any non-numeric) primary keys had those ids cast by
|
|
3
|
+
# String#to_i on write: "7f00aaaa-…" and "7f00bbbb-…" both stored as 7. Two
|
|
4
|
+
# subjects became one identity and one inherited the other's roles.
|
|
5
|
+
#
|
|
6
|
+
# Widening to string supports BOTH shapes with no configuration to get wrong: an
|
|
7
|
+
# integer key stores as "1", a UUID stores whole. Equality is the only comparison
|
|
8
|
+
# these columns ever see (the resolver never orders or ranges on them), so string
|
|
9
|
+
# storage costs nothing but index width.
|
|
10
|
+
#
|
|
11
|
+
# COLLATION IS FORCED BINARY ON MYSQL. Its default (utf8mb4_0900_ai_ci) is case
|
|
12
|
+
# AND accent insensitive, so "ABC" and "abc" — or "jose" and "josé" — compare
|
|
13
|
+
# equal. A grant to one would then match the other, which is #151 all over again
|
|
14
|
+
# with a different mechanism. A record's primary key is an identifier, not prose;
|
|
15
|
+
# it must compare byte for byte. PostgreSQL and SQLite already do.
|
|
16
|
+
#
|
|
17
|
+
# LENGTH IS BOUNDED AT 64 ON PURPOSE. These columns sit in a five-column unique
|
|
18
|
+
# index, and MySQL caps an index at 3072 bytes; four unbounded varchar(255)
|
|
19
|
+
# columns at utf8mb4 exceed that and the table will not create at all. 64 holds a
|
|
20
|
+
# UUID (36), a ULID (26), and any integer key with room to spare.
|
|
21
|
+
#
|
|
22
|
+
# THIS MIGRATION CANNOT REPAIR ALREADY-COLLAPSED ROWS. Once "7f00aaaa-…" was
|
|
23
|
+
# written as 7 the original value is gone. A host that ran 0.2 to 0.4 with
|
|
24
|
+
# non-integer keys must re-grant those roles; UPGRADING.md carries the audit
|
|
25
|
+
# query that lists them.
|
|
26
|
+
# Bracketed [7.1] like every other migration this engine ships. The bracket pins
|
|
27
|
+
# generation-time schema defaults, not the gem's minimum Rails, and the three
|
|
28
|
+
# existing migrations all use it — a mechanical bump to match the Rails floor is
|
|
29
|
+
# exactly what this repo's readiness plan says not to do.
|
|
30
|
+
class WidenCurrentScopePolymorphicIds < ActiveRecord::Migration[7.1]
|
|
31
|
+
KEY_LIMIT = 64
|
|
32
|
+
# utf8mb4_bin is binary but PAD SPACE, so it still compares "abc" equal to
|
|
33
|
+
# "abc " — a trailing space would make two keys one identity, the same
|
|
34
|
+
# escalation the case-folding fix closed. utf8mb4_0900_bin is binary AND NO
|
|
35
|
+
# PAD (MySQL 8.0.17+). Prefer it; fall back where the server is older, since a
|
|
36
|
+
# case-sensitive comparison is still far better than the ai_ci default.
|
|
37
|
+
PREFERRED_COLLATION = "utf8mb4_0900_bin".freeze
|
|
38
|
+
FALLBACK_COLLATION = "utf8mb4_bin".freeze
|
|
39
|
+
|
|
40
|
+
COLUMNS = {
|
|
41
|
+
current_scope_role_assignments: [ :subject_id ],
|
|
42
|
+
current_scope_scoped_role_assignments: [ :subject_id, :resource_id ]
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# The TYPE columns pair with the id in every grant predicate and in the unique
|
|
46
|
+
# index, so folding them is the same escalation by another column: on MySQL's
|
|
47
|
+
# default collation a grant on `Foo#5` would match a check for `FOO#5`. They are
|
|
48
|
+
# already varchar; only the collation changes, and only on MySQL.
|
|
49
|
+
TYPE_COLUMNS = {
|
|
50
|
+
current_scope_role_assignments: [ :subject_type ],
|
|
51
|
+
current_scope_scoped_role_assignments: [ :subject_type, :resource_type ]
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def up
|
|
55
|
+
COLUMNS.each do |table, columns|
|
|
56
|
+
columns.each { |column| refuse_if_any_key_too_long(table, column) }
|
|
57
|
+
end
|
|
58
|
+
COLUMNS.each do |table, columns|
|
|
59
|
+
columns.each { |column| widen(table, column) }
|
|
60
|
+
end
|
|
61
|
+
return unless mysql?
|
|
62
|
+
|
|
63
|
+
TYPE_COLUMNS.each do |table, columns|
|
|
64
|
+
columns.each { |column| binary_collate(table, column) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deliberately irreversible. Going back to integer would silently truncate
|
|
69
|
+
# every UUID it now holds — the exact data loss this migration exists to stop.
|
|
70
|
+
def down
|
|
71
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
72
|
+
"Narrowing subject_id/resource_id back to integer would truncate any " \
|
|
73
|
+
"non-integer key stored since the upgrade (#151). Restore from a backup " \
|
|
74
|
+
"if you need the previous schema."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def mysql? = connection.adapter_name.match?(/mysql|trilogy|maria/i)
|
|
80
|
+
|
|
81
|
+
# Asked once per run, not per column: it is a catalog query.
|
|
82
|
+
def binary_collation
|
|
83
|
+
@binary_collation ||=
|
|
84
|
+
if connection.select_value(
|
|
85
|
+
"SELECT 1 FROM information_schema.collations " \
|
|
86
|
+
"WHERE collation_name = #{connection.quote(PREFERRED_COLLATION)}"
|
|
87
|
+
)
|
|
88
|
+
PREFERRED_COLLATION
|
|
89
|
+
else
|
|
90
|
+
FALLBACK_COLLATION
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Look BEFORE narrowing. Coming from the integer column this migration was
|
|
95
|
+
# written for, nothing can be too long — but a host arriving from a wider
|
|
96
|
+
# string column is a different story, and MySQL outside strict mode truncates
|
|
97
|
+
# silently rather than refusing. A truncated key names the wrong record, which
|
|
98
|
+
# is the whole of #151. Stop with the count and the column named, so the
|
|
99
|
+
# operator decides, rather than discovering it afterwards.
|
|
100
|
+
def refuse_if_any_key_too_long(table, column)
|
|
101
|
+
existing = connection.columns(table).find { |c| c.name == column.to_s }
|
|
102
|
+
return if existing.nil? || !existing.type.in?([ :string, :text ])
|
|
103
|
+
|
|
104
|
+
quoted = "#{connection.quote_table_name(table)}.#{connection.quote_column_name(column)}"
|
|
105
|
+
# CHAR_LENGTH on MySQL, where LENGTH counts BYTES and would refuse a
|
|
106
|
+
# perfectly short key that happens to be multi-byte. SQLite has no
|
|
107
|
+
# CHAR_LENGTH at all, and its LENGTH already counts characters; so does
|
|
108
|
+
# PostgreSQL's.
|
|
109
|
+
length_fn = mysql? ? "CHAR_LENGTH" : "LENGTH"
|
|
110
|
+
too_long = connection.select_value(
|
|
111
|
+
"SELECT COUNT(*) FROM #{connection.quote_table_name(table)} " \
|
|
112
|
+
"WHERE #{length_fn}(#{quoted}) > #{KEY_LIMIT}"
|
|
113
|
+
).to_i
|
|
114
|
+
return if too_long.zero?
|
|
115
|
+
|
|
116
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
117
|
+
"#{table}.#{column} holds #{too_long} value(s) longer than #{KEY_LIMIT} characters. " \
|
|
118
|
+
"Narrowing the column would truncate them, and a truncated key names the WRONG " \
|
|
119
|
+
"record (#151). Shorten or remove those grants first."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def already_correct?(table, column)
|
|
123
|
+
existing = connection.columns(table).find { |c| c.name == column.to_s }
|
|
124
|
+
return false if existing.nil?
|
|
125
|
+
return false unless existing.type == :string && existing.limit == KEY_LIMIT && !existing.null
|
|
126
|
+
|
|
127
|
+
!mysql? || existing.collation == binary_collation
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Collation only — the type columns are already the right type and width.
|
|
131
|
+
def binary_collate(table, column)
|
|
132
|
+
existing = connection.columns(table).find { |c| c.name == column.to_s }
|
|
133
|
+
return if existing.nil? || existing.collation == binary_collation
|
|
134
|
+
|
|
135
|
+
change_column table, column, :string, limit: existing.limit,
|
|
136
|
+
null: false, collation: binary_collation
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def widen(table, column)
|
|
140
|
+
# Idempotent, but NOT satisfied by type and width alone: a database built from
|
|
141
|
+
# schema.rb already has varchar(64) while carrying the server's default
|
|
142
|
+
# collation, which on MySQL is case-insensitive. Skipping there would leave the
|
|
143
|
+
# #151 collision live on every freshly-loaded schema.
|
|
144
|
+
return if already_correct?(table, column)
|
|
145
|
+
|
|
146
|
+
if connection.adapter_name.match?(/postg/i)
|
|
147
|
+
# Postgres will not cast integer to varchar implicitly in ALTER COLUMN; it
|
|
148
|
+
# needs USING. change_column does not emit one, so write it directly.
|
|
149
|
+
connection.execute(<<~SQL.squish)
|
|
150
|
+
ALTER TABLE #{connection.quote_table_name(table)}
|
|
151
|
+
ALTER COLUMN #{connection.quote_column_name(column)} TYPE character varying(#{KEY_LIMIT})
|
|
152
|
+
USING #{connection.quote_column_name(column)}::character varying
|
|
153
|
+
SQL
|
|
154
|
+
elsif mysql?
|
|
155
|
+
change_column table, column, :string, limit: KEY_LIMIT,
|
|
156
|
+
null: false, collation: binary_collation
|
|
157
|
+
return
|
|
158
|
+
else
|
|
159
|
+
# SQLite rebuilds the table and compares BINARY by default.
|
|
160
|
+
change_column table, column, :string, limit: KEY_LIMIT
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
change_column_null table, column, false
|
|
164
|
+
end
|
|
165
|
+
end
|