Skip to content

Commit ee8e7fa

Browse files
committed
denormalize bearer role onto token
1 parent 2db5eb9 commit ee8e7fa

9 files changed

Lines changed: 147 additions & 28 deletions

File tree

app/models/concerns/accountable.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@ def has_account(default: nil, **kwargs)
130130

131131
# Perform asserts on create and update.
132132
validate on: %i[create update] do
133+
# NB(ezekg) supports composite foreign keys, e.g. %i[bearer_type bearer_id]
133134
next unless
134-
account_id_changed? || public_send("#{reflection.foreign_key}_changed?")
135+
account_id_changed? || Array(reflection.foreign_key).any? { public_send("#{it}_changed?") }
135136

136137
association = public_send(reflection.name)
137138
next if

app/models/concerns/denormalizable.rb

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@ module Denormalizable
66
DENORMALIZE_ASSOCIATION_ASYNC_BATCH_SIZE = 1_000
77

88
class_methods do
9-
def denormalizes(*attribute_names, with: nil, from: nil, to: nil, prefix: nil)
9+
def denormalizes(*attribute_names, with: nil, from: nil, to: nil, prefix: nil, as: nil)
1010
raise ArgumentError, 'must provide :from, :to, or :with (but not multiple)' unless
1111
from.present? ^ to.present? ^ with.present?
1212

13+
raise ArgumentError, 'must provide either :prefix or :as (but not both)' if
14+
prefix.present? && as.present?
15+
16+
raise ArgumentError, 'must provide a single attribute when using :as' if
17+
as.present? && attribute_names.many?
18+
1319
case
1420
when from.present?
15-
attribute_names.each { instrument_denormalized_attribute_from(it, from:, prefix:) }
21+
attribute_names.each { instrument_denormalized_attribute_from(it, from:, prefix:, as:) }
1622
when to.present?
17-
attribute_names.each { instrument_denormalized_attribute_to(it, to:, prefix:) }
23+
attribute_names.each { instrument_denormalized_attribute_to(it, to:, prefix:, as:) }
1824
when with.present?
1925
raise NotImplementedError, 'denormalizes :with is not supported yet'
2026
else
@@ -24,14 +30,15 @@ def denormalizes(*attribute_names, with: nil, from: nil, to: nil, prefix: nil)
2430

2531
private
2632

27-
def instrument_denormalized_attribute_from(attribute_name, from:, prefix:)
33+
def instrument_denormalized_attribute_from(attribute_name, from:, prefix:, as: nil)
2834
case from
2935
in Symbol => association_name if reflection = reflect_on_association(association_name)
30-
prefixed_attribute_name = case prefix
31-
when true
36+
prefixed_attribute_name = case
37+
when as.present?
38+
as.to_s
39+
when prefix == true
3240
"#{association_name}_#{attribute_name}"
33-
when Symbol,
34-
String
41+
when (prefix in Symbol | String)
3542
"#{prefix}_#{attribute_name}"
3643
else
3744
attribute_name.to_s
@@ -41,28 +48,32 @@ def instrument_denormalized_attribute_from(attribute_name, from:, prefix:)
4148
raise ArgumentError, "must be a singular association: #{association_name.inspect}"
4249
end
4350

51+
# NB(ezekg) supports composite foreign keys, e.g. %i[bearer_type bearer_id]
52+
association_changed = -> { Array(reflection.foreign_key).any? { send(:"#{it}_changed?") } || send(:"#{reflection.name}_changed?") }
53+
4454
# FIXME(ezekg) after_initialize ignores prepend: false
45-
set_callback :initialize, :after, -> { write_denormalized_attribute_from_schrodingers_record(association_name, attribute_name, prefixed_attribute_name) }, if: -> { send(:"#{reflection.foreign_key}_changed?") || send(:"#{reflection.name}_changed?") }, unless: :persisted?, prepend: false
46-
before_validation -> { write_denormalized_attribute_from_schrodingers_record(association_name, attribute_name, prefixed_attribute_name) }, if: -> { send(:"#{reflection.foreign_key}_changed?") || send(:"#{reflection.name}_changed?") }, on: :create
47-
before_update -> { write_denormalized_attribute_from_persisted_record(association_name, attribute_name, prefixed_attribute_name) }, if: -> { send(:"#{reflection.foreign_key}_changed?") || send(:"#{reflection.name}_changed?") }
55+
set_callback :initialize, :after, -> { write_denormalized_attribute_from_schrodingers_record(association_name, attribute_name, prefixed_attribute_name) }, if: association_changed, unless: :persisted?, prepend: false
56+
before_validation -> { write_denormalized_attribute_from_schrodingers_record(association_name, attribute_name, prefixed_attribute_name) }, if: association_changed, on: :create
57+
before_update -> { write_denormalized_attribute_from_persisted_record(association_name, attribute_name, prefixed_attribute_name) }, if: association_changed
4858

4959
# make sure validation fails if our denormalized column is modified directly
5060
validate -> { validate_denormalized_attribute_from_persisted_record(association_name, attribute_name, prefixed_attribute_name) }, if: :"#{prefixed_attribute_name}_changed?", on: :update
5161

52-
denormalized_attributes << attribute_name
62+
denormalized_attributes << prefixed_attribute_name.to_sym
5363
else
5464
raise ArgumentError, "invalid :from association: #{from.inspect}"
5565
end
5666
end
5767

58-
def instrument_denormalized_attribute_to(attribute_name, to:, prefix:)
68+
def instrument_denormalized_attribute_to(attribute_name, to:, prefix:, as: nil)
5969
case to
6070
in Symbol => association_name if reflection = reflect_on_association(association_name)
61-
prefixed_attribute_name = case prefix
62-
when true
71+
prefixed_attribute_name = case
72+
when as.present?
73+
as.to_s
74+
when prefix == true
6375
"#{association_name}_#{attribute_name}"
64-
when Symbol,
65-
String
76+
when (prefix in Symbol | String)
6677
"#{prefix}_#{attribute_name}"
6778
else
6879
attribute_name.to_s

app/models/concerns/environmental.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,9 @@ def has_environment(default: nil, skip_verify_associations: nil, **kwargs)
154154

155155
# Perform asserts on create and update.
156156
validate on: %i[create update] do
157+
# NB(ezekg) supports composite foreign keys, e.g. %i[bearer_type bearer_id]
157158
next unless
158-
environment_id_changed? || public_send("#{reflection.foreign_key}_changed?")
159+
environment_id_changed? || Array(reflection.foreign_key).any? { public_send("#{it}_changed?") }
159160

160161
association = public_send(reflection.name)
161162
next if

app/models/role.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
class Role < ApplicationRecord
44
include Keygen::EE::ProtectedMethods[:permissions=, entitlements: %i[permissions]]
55
include Keygen::PortableClass
6+
include Denormalizable
67
include Accountable
78
include Dirtyable
89

@@ -36,9 +37,16 @@ class Role < ApplicationRecord
3637
def actions = loaded? ? collect(&:action) : super
3738
end
3839

40+
has_many :tokens,
41+
primary_key: %i[resource_type resource_id],
42+
foreign_key: %i[bearer_type bearer_id]
43+
3944
# NB(ezekg) we're using account over account_id here because it may not be persisted
4045
has_account default: -> { resource&.account }
4146

47+
denormalizes :name,
48+
to: :tokens, as: :bearer_role
49+
4250
# FIXME(ezekg) replace with accountable concern i.e. an association
4351
delegate :default_permissions, :default_permission_ids,
4452
:allowed_permissions, :allowed_permission_ids,

app/models/token.rb

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Token < ApplicationRecord
99
include Environmental
1010
include Accountable
1111
include Tokenable
12+
include Denormalizable
1213
include Limitable
1314
include Orderable
1415
include Pageable
@@ -18,6 +19,10 @@ class Token < ApplicationRecord
1819
belongs_to :bearer,
1920
polymorphic: true
2021

22+
belongs_to :role,
23+
primary_key: %i[resource_type resource_id],
24+
foreign_key: %i[bearer_type bearer_id]
25+
2126
# FIXME(ezekg) sessions must come before permissions otherwise autosave breaks
2227
has_many :sessions,
2328
dependent: :destroy_async,
@@ -46,6 +51,9 @@ def actions = loaded? ? collect(&:action) : super
4651
# Default to wildcard permission but allow all
4752
default: %w[*]
4853

54+
denormalizes :name,
55+
from: :role, as: :bearer_role
56+
4957
accepts_nested_attributes_for :token_permissions
5058
tracks_nested_attributes_for :token_permissions
5159

@@ -181,15 +189,10 @@ def users = for_bearer_type(:user)
181189
return none if
182190
names.empty?
183191

184-
joins(<<~SQL.squish).where(roles: { name: names })
185-
INNER JOIN roles
186-
ON roles.account_id = tokens.account_id
187-
AND roles.resource_type = tokens.bearer_type
188-
AND roles.resource_id = tokens.bearer_id
189-
SQL
192+
where(bearer_role: names)
190193
}
191194

192-
delegate :role, :role_permissions,
195+
delegate :role_permissions,
193196
allow_nil: true,
194197
to: :bearer
195198

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
class AddBearerRoleToTokens < ActiveRecord::Migration[8.1]
4+
disable_ddl_transaction!
5+
verbose!
6+
7+
def change
8+
add_column :tokens, :bearer_role, :string, if_not_exists: true
9+
10+
add_index :tokens, %i[account_id bearer_role created_at],
11+
algorithm: :concurrently,
12+
if_not_exists: true
13+
end
14+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
class SeedBearerRoleForTokens < ActiveRecord::Migration[8.1]
4+
disable_ddl_transaction!
5+
verbose!
6+
7+
BATCH_SIZE = 10_000
8+
9+
def up
10+
update_count = nil
11+
batch_count = 0
12+
13+
until update_count == 0
14+
batch_count += 1
15+
update_count = exec_update(<<~SQL.squish, batch_count:, batch_size: BATCH_SIZE)
16+
WITH batch AS (
17+
SELECT
18+
tokens.id AS token_id,
19+
roles.name AS role_name
20+
FROM
21+
tokens
22+
INNER JOIN
23+
roles ON roles.resource_type = tokens.bearer_type AND
24+
roles.resource_id = tokens.bearer_id
25+
WHERE
26+
tokens.bearer_role IS NULL
27+
LIMIT
28+
:batch_size
29+
)
30+
UPDATE
31+
tokens
32+
SET
33+
bearer_role = batch.role_name
34+
FROM
35+
batch
36+
WHERE
37+
tokens.id = batch.token_id
38+
/* batch=:batch_count */
39+
SQL
40+
end
41+
end
42+
43+
def down
44+
update_count = nil
45+
batch_count = 0
46+
47+
until update_count == 0
48+
batch_count += 1
49+
update_count = exec_update(<<~SQL.squish, batch_count:, batch_size: BATCH_SIZE)
50+
UPDATE
51+
tokens
52+
SET
53+
bearer_role = NULL
54+
WHERE
55+
tokens.id IN (
56+
SELECT
57+
tokens.id
58+
FROM
59+
tokens
60+
WHERE
61+
tokens.bearer_role IS NOT NULL
62+
LIMIT
63+
:batch_size
64+
)
65+
/* batch=:batch_count */
66+
SQL
67+
end
68+
end
69+
70+
private
71+
72+
def exec_update(sql, **binds)
73+
ActiveRecord::Base.connection.exec_update(
74+
ActiveRecord::Base.sanitize_sql([sql, **binds]),
75+
)
76+
end
77+
end

db/schema.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.1].define(version: 2026_05_01_131644) do
13+
ActiveRecord::Schema[8.1].define(version: 2026_07_07_121900) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "btree_gin"
1616
enable_extension "pg_catalog.plpgsql"
@@ -878,6 +878,7 @@
878878
t.uuid "account_id"
879879
t.integer "activations", default: 0
880880
t.uuid "bearer_id"
881+
t.string "bearer_role"
881882
t.string "bearer_type"
882883
t.datetime "created_at", precision: nil, null: false
883884
t.integer "deactivations", default: 0
@@ -888,6 +889,7 @@
888889
t.integer "max_deactivations"
889890
t.string "name"
890891
t.datetime "updated_at", precision: nil, null: false
892+
t.index ["account_id", "bearer_role", "created_at"], name: "index_tokens_on_account_id_and_bearer_role_and_created_at"
891893
t.index ["account_id", "created_at"], name: "index_tokens_on_account_id_and_created_at"
892894
t.index ["bearer_id", "bearer_type", "created_at"], name: "index_tokens_on_bearer_id_and_bearer_type_and_created_at"
893895
t.index ["created_at"], name: "index_tokens_on_created_at", order: :desc

lib/tasks/keygen/permissions.rake

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ namespace :keygen do
1414
record_ids =
1515
args.extras.flatten.partition { Permission::ALL_PERMISSIONS.include?(it) }
1616

17-
records = model.includes(:account, role: { role_permissions: :permission })
17+
# NB(ezekg) :role_permissions is a real association for roleable models, but
18+
# only a delegate for e.g. tokens, so this raises for non-roleables
19+
records = model.includes(:account, role_permissions: :permission)
1820
.where(id: record_ids)
1921

2022
records.find_each(batch_size:) do |record|

0 commit comments

Comments
 (0)