Skip to content

Instantly share code, notes, and snippets.

@ckozus
Created April 1, 2026 12:57
Show Gist options
  • Select an option

  • Save ckozus/619f2ff10e0254e70ad8c23271cb734c to your computer and use it in GitHub Desktop.

Select an option

Save ckozus/619f2ff10e0254e70ad8c23271cb734c to your computer and use it in GitHub Desktop.
IDEA-330: Organization Roles Table - Implementation Plan

Plan: Organization Roles Table (IDEA-330, Phase 1)

Context

KCTCS has a system admin (user 2093824) who needs College Super Admin access across all 16 KCTCS colleges. Currently, users are tied to ONE college via users.college_id or ONE college system via users.college_system_id, with roles in users.role_mask.

This phase creates organization_roles as the new source of truth for org-level role assignments, removes users.college_id and users.college_system_id, and migrates existing data. No UI changes -- data managed via ActiveAdmin.

College context is already handled by branded_host (subdomain). Each college has a unique subdomain, and branded_host_college in ApplicationController resolves it. Multi-college users just access different subdomains -- no switcher needed.


Steps

1. Migration: Create organization_roles table

Create db/migrate/YYYYMMDDHHMMSS_create_organization_roles.rb:

create_table :organization_roles do |t|
  t.references :user, null: false, foreign_key: true
  t.references :organization, polymorphic: true, null: false
  t.string :role, null: false
  t.timestamps
end
add_index :organization_roles, [:user_id, :organization_type, :organization_id, :role],
          unique: true, name: 'idx_org_roles_unique'
add_index :organization_roles, [:organization_type, :organization_id]
add_index :organization_roles, :role

Manually update db/schema.rb per project convention.

2. OrganizationRole Model

Create app/models/organization_role.rb:

  • belongs_to :user, belongs_to :organization, polymorphic: true
  • Validate: role in User::ROLES.keys, organization_type in %w[College CollegeSystem], uniqueness of user+org+role
  • Scopes: for_colleges, for_college_systems, with_role, for_organization
  • Validate role-org consistency (college roles only on College orgs, etc.) using ROLE_CLASSES

3. Add Associations

  • app/models/user.rb: has_many :organization_roles, dependent: :destroy
  • app/models/college.rb: has_many :organization_roles, as: :organization, dependent: :destroy
  • app/models/college_system.rb: has_many :organization_roles, as: :organization, dependent: :destroy

4. Data Migration

Create a migration to populate organization_roles from existing data, then remove old columns:

# Step 1: Populate organization_roles from existing user data
User.where.not(college_id: nil).find_each do |user|
  user.role_mask.to_s.split('|').each do |role|
    role_class = User::ROLE_CLASSES[role]
    if role_class == College
      OrganizationRole.find_or_create_by!(user: user, organization_type: 'College',
                                           organization_id: user.college_id, role: role)
    end
  end
end

User.where.not(college_system_id: nil).find_each do |user|
  user.role_mask.to_s.split('|').each do |role|
    role_class = User::ROLE_CLASSES[role]
    if role_class == CollegeSystem
      OrganizationRole.find_or_create_by!(user: user, organization_type: 'CollegeSystem',
                                           organization_id: user.college_system_id, role: role)
    end
  end
end

# Step 2: Remove columns
remove_column :users, :college_id
remove_column :users, :college_system_id

Note: Roles that map to HighSchool, District, Dean, Instructor, etc. in ROLE_CLASSES stay in role_mask for now -- they aren't org-level college/system roles. Only roles mapping to College or CollegeSystem get migrated.

5. Update User Model

Remove: belongs_to :college, belongs_to :college_system

Remove User#college entirely. The belongs_to :college association goes away with the column. Do NOT add a replacement method -- callers must use branded_host_college (controller context) or user.colleges (list of all). This forces every call site to be explicit about which college they mean.

Remove User#college_system (was belongs_to). Replace with:

def college_system
  org = organization_roles.for_college_systems.first
  CollegeSystem.find_by(id: org&.organization_id)
end

Update User#roles (~line 387) -- merge org roles:

def roles
  @_cached_roles ||= begin
    mask_roles = (role_mask || "").split('|')
    org_roles = organization_roles.loaded? ? organization_roles.map(&:role) : organization_roles.pluck(:role)
    (mask_roles + org_roles).uniq
  end
end

Clear @_cached_roles on reload.

Update User#college? (~line 549):

def college?
  organization_roles.for_colleges.exists?
end

Update User#college_system? (~line 545):

def college_system?
  organization_roles.for_college_systems.exists?
end

Update User#colleges (~line 699):

def colleges
  return [] if admin?
  org_college_ids = organization_roles.for_colleges.pluck(:organization_id)
  colleges_from_orgs = org_college_ids.any? ? College.where(id: org_college_ids).to_a : []
  return colleges_from_orgs if colleges_from_orgs.any?
  return [person.college] if instructor? && person.college.present?
  return [person.college] if person? && person.college?
  (district || high_school || (student || person)&.high_school)&.colleges&.to_a || []
end

has_college_role?, get_college_role, COLLEGE_ROLES -- no changes needed, all derive from roles.

6. Update load_college in ApplicationController

app/controllers/application_controller.rb line 171-175. No current_user.college fallback -- branded_host is the source of truth:

def load_college
  @college ||= College.find(params[:college_id]) if params[:college_id]
  @college ||= branded_host_college
  @college ||= College.find(session[:college_id]) if session[:college_id]
end

Similarly update load_college_system (line 165-168):

def load_college_system
  @college_system ||= CollegeSystem.find(params[:college_system_id]) if params[:college_system_id]
  @college_system ||= branded_host_college_system
  @college_system ||= CollegeSystem.find(session[:college_system_id]) if session[:college_system_id]
end

6b. Replace all current_user.college call sites

All 16 references to current_user.college must be replaced. No fallback to a removed method:

File Current Replacement
application_controller.rb:173 current_user.college Removed (step 6 above)
application_controller.rb:341 current_user.college? Still works (updated in step 5)
colleges/colleges_controller.rb:43 current_user.college == @college current_user.colleges.include?(@college)
instructors/instructor_reviews_controller.rb:12 current_user.college branded_host_college or @college
students/students_controller.rb:232 current_user.college branded_host_college
student_courses_controller.rb:90 current_user.college? Still works
high_schools/high_schools_controller.rb:34 current_user.college == @college current_user.colleges.include?(@college)
concerns/common_filters.rb:131 current_user.college? Still works
concerns/set_system_theme.rb:85-88 current_user.college? / current_user.college.raw_branded_host college? still works; .college.raw_branded_host -> branded_host_college&.raw_branded_host
helpers/application_fields_helper.rb:205,211 current_user.college? Still works
helpers/registration_active_flows_helper.rb:30,129 current_user.college? Still works
helpers/application_helper.rb:170 current_user.college? Still works

7. Update Ability

app/models/ability.rb:

Line 47 -- id computation:

# id now comes from organization_roles
college_org_ids = user.organization_roles.for_colleges.pluck(:organization_id)
system_org_ids = user.organization_roles.for_college_systems.pluck(:organization_id)
id = system_org_ids.first || college_org_ids.first || user.high_school_id || user.district_id || user.student_id
id ||= user.person_ids if user.person?

Lines 51-58 -- ids computation:

ids = case
when user.district?
  user.district.high_schools.pluck(:id)
when user.college_system?
  CollegeSystem.find(system_org_ids.first).college_ids
else
  college_org_ids.presence || [id].compact
end

Audit all user.college_id references in ability.rb (lines 419, 630, 803, 1225):

  • Line 419: user.college_id -> college_org_ids
  • Line 630: user.college_id -> check college_org_ids.include?(...)
  • Line 803: same pattern
  • Line 1225: college_id: user.college_id -> college_id: college_org_ids

Audit all college_id: id (singular) in the college branch and update to college_id: ids where the user could have multiple colleges.

8. Update Other References

12 user.college_id references to update:

File Line Change
app/models/ability.rb 47, 419, 630, 803, 1225 Use college_org_ids (see step 7)
app/models/api_ability.rb 6-7 Use org_roles lookup
app/models/active_flow_step.rb 833 user.college_id -> user.colleges.map(&:id)
app/models/steps/approval_step.rb 58 action_user.college_id -> action_user.colleges.map(&:id).include?(target_object.college.id)
app/models/steps/upload_document_step.rb 64 Same pattern
app/models/steps/review_instructor_and_launch_step.rb 36 Same pattern
app/models/student_de_course_approval_user.rb 50 user.college_id -> user.colleges.map(&:id).include?(college_id)
app/controllers/application_controller.rb 173, 167 See step 6

9. ActiveAdmin

New page app/admin/organization_roles.rb:

  • Index: user name, org type, org name, role (human-readable), created_at
  • Filters: user_id, organization_type, role
  • Form: user_id, organization_type, organization_id, role (select)
  • Show: all attributes with links

Add panels to existing pages:

  • User show page: "Organization Roles" panel showing all org roles for that user
  • College show page (if exists in AA): panel showing users with org roles at that college
  • CollegeSystem show page (if exists): panel showing users with org roles at that system

10. Specs

  • spec/models/organization_role_spec.rb: validations, scopes, associations
  • spec/factories/organization_roles.rb: factory
  • Add to spec/models/user_spec.rb:
    • roles merges role_mask + org roles
    • colleges returns org-role colleges
    • college? / college_system? based on org roles
    • Backward compat for users with non-college roles (hs, district, student)
  • Ability specs: user with org roles at multiple colleges gets correct permissions

11. KCTCS Data Setup

Provide a DEUS script to create organization_roles for the KCTCS user across all 16 colleges with coll_super_admin role.


Critical Files

File Change Type
db/migrate/xxx_create_organization_roles.rb New
db/migrate/xxx_migrate_and_remove_college_columns.rb New
db/schema.rb Manual update
app/models/organization_role.rb New
app/models/user.rb Remove belongs_to :college/:college_system, update roles/colleges/college? methods
app/models/ability.rb Update id/ids computation, all user.college_id refs
app/models/api_ability.rb Update user.college_id/college_system_id refs
app/models/active_flow_step.rb Update user.college_id ref
app/models/steps/approval_step.rb Update action_user.college_id ref
app/models/steps/upload_document_step.rb Update action_user.college_id ref
app/models/steps/review_instructor_and_launch_step.rb Update action_user.college_id ref
app/models/student_de_course_approval_user.rb Update user.college_id ref
app/controllers/application_controller.rb Update load_college, load_college_system
app/models/college.rb Add org_roles association
app/models/college_system.rb Add org_roles association
app/admin/organization_roles.rb New
spec/models/organization_role_spec.rb New
spec/factories/organization_roles.rb New

Verification

  1. bundle exec rspec -- all existing specs pass after changes
  2. Rails console: create OrganizationRole entries, verify user.roles, user.colleges, user.college?
  3. ActiveAdmin: CRUD org roles, verify panels on User/College show pages
  4. Login as a test user with org roles at 2 colleges, verify access via both branded_host subdomains
  5. Verify ability grants correct permissions at each college
  6. Verify workflow steps (approval, upload_document) work for multi-college users
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment