This is what I ended up using. By matching on each individual category AND the counts of categories I am guaranteed that all categories match.
TESTED:
select distinct c.name, l.limit
from tblCustomer c
inner join tblCustomerCategory cc on c.customerId = cc.customerId
inner join (select customerId, count(distinct categoryId) as categories
from tblCustomerCategory
group by customerId) as customerCount
on c.customerId = customerCount.customerId
inner join tblLimit l
inner join tblLimitCategory lc on l.limitId = lc.limitId
inner join (select limitId, count(distinct categoryId) as categories
from tblLimitCategory
group by limitId) as limitCount
on l.limitId = limitCount.limitId
on cc.categoryId = lc.categoryId
and customerCount.categories = limitCount.categories
I'm also testing the following solution which makes use of the new Over() feature to see if it's faster.
UNTESTED:
select distinct c.name, l.limit
from
tblCustomer c,
tblLimit l
where exists(
select cc.categoryId, count(cc.categoryId) over(partition by cc.customerId)
from tblCustomerCategory cc
where cc.customerId = c.customerId
intersect
select l.categoryId, count(l.categoryId) over(partition by l.limitId)
from tblLimitCategory lc
where lc.limitId = l.limitId
)