Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
7023a34
denormalize bearer role onto token
ezekg Jul 7, 2026
61b0c6f
refactor bearer role denormalization
ezekg Jul 7, 2026
1763669
assert ownership of denormalized through records
ezekg Jul 7, 2026
2843927
remove unused :with from denormalizable
ezekg Jul 7, 2026
9b8b62e
fix comments
ezekg Jul 7, 2026
9f680d1
refactor owner reflection through path
ezekg Jul 8, 2026
ec20f67
refactor naming for through denormalization
ezekg Jul 8, 2026
fb03e43
defer denormalization job enqueue until after commit
ezekg Jul 8, 2026
3bc140e
add :inverse_of to denormalizes for explicit ownership resolution
ezekg Jul 8, 2026
d29d872
fix sync of loaded records in unpersisted through denormalization
ezekg Jul 8, 2026
88faead
add keyset pagination to bearer role backfill
ezekg Jul 8, 2026
a4f597d
skip null role names in bearer role backfill
ezekg Jul 8, 2026
d121d5c
refactor denormalized attribute through and non-through paths
ezekg Jul 8, 2026
8f0436e
refactor tests for ambiguous denormalization owners
ezekg Jul 8, 2026
0f8cef1
refactor denormalization into a lib
ezekg Jul 8, 2026
e058899
refactor denormalization to separate concerns
ezekg Jul 8, 2026
48f48b8
s/sync/denormalize/
ezekg Jul 8, 2026
c7d2075
s/persisted/async/
ezekg Jul 8, 2026
8564401
remove ambiguous self shadowing
ezekg Jul 8, 2026
a9bf4c1
add scoped errors to denormalizable
ezekg Jul 8, 2026
4debf95
add more tests for denormalizable lib
ezekg Jul 8, 2026
dc9ec3e
add denormalizable initializer
ezekg Jul 8, 2026
15b9c09
reorg top level concern into model concern
ezekg Jul 8, 2026
c6de1a3
s/persisted/async/
ezekg Jul 8, 2026
7485b9d
remove outdated fixme
ezekg Jul 8, 2026
668811a
refactor "async" method names to be clearer
ezekg Jul 8, 2026
730e3f1
refactor denormalizes through association
ezekg Jul 9, 2026
720943d
refactor through into singular and collection classes
ezekg Jul 9, 2026
a5763d9
add polymorphic keyword to clean up reflection assumptions
ezekg Jul 9, 2026
5671bca
fix seed migration assuming pk is sortable
ezekg Jul 9, 2026
9fcfc01
refactor through association builder
ezekg Jul 9, 2026
2ed50c8
reset permission associations via association() in autosave hooks
ezekg Jul 9, 2026
b9f7e6d
require a belongs_to source for :from denormalization
ezekg Jul 9, 2026
5c41e5e
raise when a :from source resolves to a collection at runtime
ezekg Jul 9, 2026
0d1bd49
fix to key :to denormalizations by target and attribute
ezekg Jul 9, 2026
6d529c2
denormalize to singular targets unconditionally
ezekg Jul 9, 2026
cef676a
fall back to a plain write for non-association denormalized columns
ezekg Jul 9, 2026
a8f1f72
update enqueuing of denormalization jobs to use in_batches
ezekg Jul 9, 2026
c35570c
clean up dead code in denormalizable
ezekg Jul 9, 2026
ecae750
fix denormalizable spec coverage
ezekg Jul 9, 2026
1327fcd
fix clickhouse test dependency
ezekg Jul 9, 2026
91937cd
fix in_batches cursor for uuidv4 primary keys
ezekg Jul 9, 2026
bfb023e
fix bulk insert scope reset
ezekg Jul 9, 2026
8d457d1
update ee-specific specs to only run in ee
ezekg Jul 9, 2026
8f77204
move tmp bearer role index to separate migrations
ezekg Jul 9, 2026
affa7be
recheck the seed predicate in outer update
ezekg Jul 10, 2026
a986891
refactor async denormalization to fix desync risk
ezekg Jul 10, 2026
c1a2d4c
add tests to assert denormalization boundaries
ezekg Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 0 additions & 214 deletions app/models/concerns/denormalizable.rb

This file was deleted.

2 changes: 1 addition & 1 deletion app/models/license.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class License < ApplicationRecord
include Envented::Callbacks
include Keygen::PortableClass
include AsyncTouchable
include Denormalizable
include Denormalizable::Model
include Environmental
include Accountable
include Limitable
Expand Down
2 changes: 1 addition & 1 deletion app/models/machine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ResurrectionExpiredError < StandardError; end
include Envented::Callbacks
include Keygen::PortableClass
include AsyncTouchable
include Denormalizable
include Denormalizable::Model
include Environmental
include Accountable
include Limitable
Expand Down
2 changes: 1 addition & 1 deletion app/models/policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class UnsupportedPoolError < StandardError; end
class EmptyPoolError < StandardError; end

include Keygen::PortableClass
include Denormalizable
include Denormalizable::Model
include Environmental
include Accountable
include Limitable
Expand Down
22 changes: 18 additions & 4 deletions app/models/role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class Role < ApplicationRecord
include Keygen::EE::ProtectedMethods[:permissions=, entitlements: %i[permissions]]
include Keygen::PortableClass
include Denormalizable::Model
include Accountable
include Dirtyable

Expand Down Expand Up @@ -39,6 +40,11 @@ def actions = loaded? ? collect(&:action) : super
# NB(ezekg) we're using account over account_id here because it may not be persisted
has_account default: -> { resource&.account }

# NB(ezekg) an explicit :inverse_of is required because Environment#tokens is
# scoped to the environment, not to the environment as a bearer
denormalizes :name,
to: :tokens, through: :resource, inverse_of: :bearer, as: :bearer_role

# FIXME(ezekg) replace with accountable concern i.e. an association
delegate :default_permissions, :default_permission_ids,
:allowed_permissions, :allowed_permission_ids,
Expand Down Expand Up @@ -133,10 +139,16 @@ def pending_permissions
# permission_ids returns an array of the role's permission IDs,
# including pending changes.
def permission_ids
if role_permissions_attributes_assigned?
case
when role_permissions_attributes_assigned?
role_permissions_attributes.collect { it[:permission_id] }
else
when role_permissions.loaded?
role_permissions.collect(&:permission_id)
else
# NB(ezekg) avoid loading the association just to read ids, since a
# loaded through-association will result in n+1 queries on
# subsequent permission reads
role_permissions.pluck(:permission_id)
end
end

Expand Down Expand Up @@ -243,15 +255,17 @@ def autosave_associated_records_for_role_permissions
if role_permissions_attributes.any?
# FIXME(ezekg) Can't use role_permissions.upsert_all at this point, because for
# some reason role_id ends up being nil. Instead, we'll use the
# class method and then call reload.
# class method and then reset the stale associations.
RolePermission.upsert_all(
role_permissions_attributes.map { it.merge(role_id: id) },
record_timestamps: true,
on_duplicate: :skip,
)
end

reload
# reset stale associations after the bulk upsert
role_permissions.reset
permissions.reset
end
end
end
2 changes: 1 addition & 1 deletion app/models/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class Session < ApplicationRecord
MAX_AGE = 2.weeks

include AsyncUpdatable, AsyncDestroyable
include Denormalizable
include Denormalizable::Model
include Environmental
include Accountable

Expand Down
17 changes: 9 additions & 8 deletions app/models/token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Token < ApplicationRecord
include Environmental
include Accountable
include Tokenable
include Denormalizable::Model
include Limitable
include Orderable
include Pageable
Expand Down Expand Up @@ -46,6 +47,9 @@ def actions = loaded? ? collect(&:action) : super
# Default to wildcard permission but allow all
default: %w[*]

denormalizes :name,
from: :role, through: :bearer, as: :bearer_role

accepts_nested_attributes_for :token_permissions
tracks_nested_attributes_for :token_permissions

Expand Down Expand Up @@ -181,12 +185,7 @@ def users = for_bearer_type(:user)
return none if
names.empty?

joins(<<~SQL.squish).where(roles: { name: names })
INNER JOIN roles
ON roles.account_id = tokens.account_id
AND roles.resource_type = tokens.bearer_type
AND roles.resource_id = tokens.bearer_id
SQL
where(bearer_role: names)
}

delegate :role, :role_permissions,
Expand Down Expand Up @@ -468,14 +467,16 @@ def autosave_associated_records_for_token_permissions

# FIXME(ezekg) Can't use token_permissions.upsert_all at this point, because for
# some reason token_id ends up being nil. Instead, we'll use the
# class method and then call reload.
# class method and then reset the stale associations.
TokenPermission.upsert_all(
token_permissions_attributes.map { it.merge(token_id: id) },
record_timestamps: true,
on_duplicate: :skip,
)

reload
# reset stale associations after the bulk upsert
token_permissions.reset
permissions.reset
end
end
end
3 changes: 3 additions & 0 deletions config/initializers/denormalizable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

require_dependency Rails.root / 'lib' / 'denormalizable'
14 changes: 14 additions & 0 deletions db/migrate/20260707121852_add_bearer_role_to_tokens.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class AddBearerRoleToTokens < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
verbose!

def change
add_column :tokens, :bearer_role, :string, if_not_exists: true

add_index :tokens, %i[account_id bearer_role created_at],
algorithm: :concurrently,
if_not_exists: true
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

class AddTemporaryBearerRoleIndexToTokens < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
verbose!

INDEX_NAME = :tmp_idx_tokens_id_bearer_role_null

# NB(ezekg) temporary index supporting the bearer role seed migration
def up
add_index :tokens, :id, name: INDEX_NAME, where: 'bearer_role IS NULL', algorithm: :concurrently, if_not_exists: true
end

def down
remove_index :tokens, name: INDEX_NAME, algorithm: :concurrently, if_exists: true
end
end
Loading
Loading