Skip to content

Add organization isolation for personal snippets

What does this MR do and why?

Add organization isolation for personal snippets to ensure users can only access snippets within their organization while maintaining backward compatibility for project snippets.

Why this change is needed:

  • Personal snippets need to be isolated by organization for data security
  • Users should only see and access snippets from their own organization
  • Addresses organization isolation requirements for snippets

What changed:

  • SnippetsFinder: Add by_organization filter that restricts personal snippets to user's organization while allowing all project snippets
  • PersonalSnippetPolicy: Enforce organization-based access control with exceptions for authors and admins (using can_admin_all_resources?)
  • Snippets::CreateService: Auto-assign organization_id from current user when not explicitly provided
  • Snippets::UpdateService: Prevent organization_id changes after creation to maintain data integrity
  • Snippet model: Add in_organization and for_user_organization scopes

Key behaviors:

  • Personal snippets are filtered by organization_id
  • Project snippets bypass organization filtering (remain accessible)
  • Authors can always access their own snippets
  • Admins can access all snippets when in admin mode
  • Anonymous users can still access public snippets
  • Organization_id is immutable after snippet creation

Database constraint ensures exactly one of project_id or organization_id is set (enforced by existing CHECK constraint).

References

  • Closes #570399 (Your work - snippets - Organization isolation)
  • Related to #565221 (Organization isolation for snippets)
  • Related to #460827 (closed) (Organization ID constraint)

Screenshots or screen recordings

No UI changes - this is a backend implementation for organization isolation.

How to set up and validate locally

  1. Setup test data in Rails console:
   # Create two organizations
   org1 = Organizations::Organization.create!(name: 'Organization 1', path: 'org1')
   org2 = Organizations::Organization.create!(name: 'Organization 2', path: 'org2')

   # Create users with proper attributes
   user1 = FactoryBot.create(:user, organization: org1)
   user2 = FactoryBot.create(:user, organization: org2)
   
   # Create personal snippets
   snippet1 = PersonalSnippet.create!(
     title: 'Org1 Snippet', 
     content: 'Content from org1',
     file_name: 'test.rb',
     author: user1, 
     organization: org1,
     visibility_level: Snippet::PUBLIC
   )
   
   snippet2 = PersonalSnippet.create!(
     title: 'Org2 Snippet',
     content: 'Content from org2', 
     file_name: 'test.rb',
     author: user2,
     organization: org2,
     visibility_level: Snippet::PUBLIC
   )
  1. Test organization isolation:
   # User1 should only see org1 snippets
   SnippetsFinder.new(user1, only_personal: true).execute.pluck(:title)
   # => ["Org1 Snippet"]
   
   # User2 should only see org2 snippets
   SnippetsFinder.new(user2, only_personal: true).execute.pluck(:title)
   # => ["Org2 Snippet"]
  1. Test policy enforcement:
   # User1 cannot access org2 snippets
   Ability.allowed?(user1, :read_snippet, snippet2)
   # => false
   
   # But can access their own org snippets
   Ability.allowed?(user1, :read_snippet, snippet1)
   # => true
  1. Test organization_id immutability:
   # Try to update organization_id
   Snippets::UpdateService.new(
     project: nil,
     current_user: user1,
     params: { organization_id: org2.id }
   ).execute(snippet1)
   
   snippet1.reload.organization_id
   # => Still org1.id (unchanged)
  1. Test admin access (requires admin user):
   admin = User.find_by(admin: true)
   
   # Admin can see all snippets with all_available flag
   SnippetsFinder.new(admin, all_available: true).execute.pluck(:title)
   # => ["Org1 Snippet", "Org2 Snippet"]

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Related to #570399

Edited by Chen Zhang

Merge request reports

Loading