diff --git a/coaster/sqlalchemy/mixins.py b/coaster/sqlalchemy/mixins.py
index bd2edb3..df9a7a2 100644
--- a/coaster/sqlalchemy/mixins.py
+++ b/coaster/sqlalchemy/mixins.py
@@ -201,7 +201,7 @@ class UrlForMixin(object):
     """
     Provides a :meth:`url_for` method used by BaseMixin-derived classes
     """
-    #: Mapping of {app: {action: (endpoint, {param: attr})}}, where attr is a string or tuple of strings.
+    #: Mapping of {app: {action: (endpoint, {param: attr}, _external)}}, where attr is a string or tuple of strings.
     #: The same action can point to different endpoints in different apps. The app may also be None as fallback.
     #: Each subclass will get its own dictionary. This particular dictionary is only used as an inherited fallback.
     url_for_endpoints = {None: {}}
@@ -221,7 +221,14 @@ class UrlForMixin(object):
         params = {}
         for param, attr in list(paramattrs.items()):
             if isinstance(attr, tuple):
-                item = self
+                # attr is a tuple containing:
+                # 1. ('parent', 'name') --> self.parent.name
+                # 2. ('**entity', 'name') --> kwargs['entity'].name
+                if attr[0].startswith('**'):
+                    item = kwargs.pop(attr[0][2:])
+                    attr = attr[1:]
+                else:
+                    item = self
                 for subattr in attr:
                     item = getattr(item, subattr)
                 params[param] = item
diff --git a/coaster/views/classview.py b/coaster/views/classview.py
index 6e71f1e..8fd5d89 100644
--- a/coaster/views/classview.py
+++ b/coaster/views/classview.py
@@ -17,6 +17,7 @@ from werkzeug.routing import parse_rule
 from werkzeug.local import LocalProxy
 from flask import _request_ctx_stack, has_request_context, request, redirect, make_response, Blueprint
 from ..auth import current_auth, add_auth_attribute
+from ..utils import InspectableSet
 
 __all__ = [
     'rulejoin', 'current_view',  # Functions
@@ -507,9 +508,18 @@ class ModelView(ClassView):
     def after_loader(self):
         # Determine permissions available on the object for the current actor,
         # but only if the view method has a requires_permission decorator
-        if (hasattr(self.current_handler.wrapped_func, 'requires_permission') and
-                hasattr(self.obj, 'current_permissions')):
-            perms = self.obj.current_permissions
+        if hasattr(self.current_handler.wrapped_func, 'requires_permission'):
+            if isinstance(self.obj, tuple):
+                perms = None
+                for subobj in self.obj:
+                    if hasattr(subobj, 'permissions'):
+                        perms = subobj.permissions(current_auth.actor, perms)
+                perms = InspectableSet(perms or set())
+            elif hasattr(self.obj, 'current_permissions'):
+                # current_permissions always returns an InspectableSet
+                perms = self.obj.current_permissions
+            else:
+                perms = InspectableSet()
             if hasattr(current_auth, 'permissions'):
                 perms = perms | current_auth.permissions
             add_auth_attribute('permissions', perms)

MultiModelView required
In a URL structure like `/<parent>/<child>`, ModelView is designed to load the child joined with the parent. However, sometimes we have the relationship inverted in the model design. Funnel's `Commentset`, for instance, has no parent relationship. This means in a URL like `/path/to/<proposal>/comments/<comment>`, we can't load the comment without loading the proposal (or other "parent") first, nor can we discover permissions on the comment without asking the proposal for assistance.

The older `load_models` loader solved this by running a chain of queries and adding up permissions. We will have to rebuild this functionality in ModelView. Proposal:

1. `loader` can return a tuple (or namedtuple) of results that is placed in `self.obj`.
2. `ModelView.after_loader` should recognise the presence of a tuple and iterate through them to find permissions, using the `inherited` parameter.

Unfortunately, there is no solution for `UrlForView` since the model is still blind to its parentage. We can hack this by sticking the parent as an attribute into the loaded instance. Or we can write code in the model to discover parentage, thereby making this ticket irrelevant for this use case.
diff --git a/tests/test_url_for.py b/tests/test_url_for.py
index f770de3..b5a7e3c 100644
--- a/tests/test_url_for.py
+++ b/tests/test_url_for.py
@@ -40,6 +40,15 @@ def doc_upper(doc):
     return u'{} {}'.format('upper', doc)
 
 
+# The unusual parameter `other='@other.name'` requires an explanation.
+# The first `other` refers to `<other>` in the URL. The second refers
+# to the parameter given to `NamedDocument.url_for` in the test below.
+@app1.route('/<doc>/with/<other>')
+@NamedDocument.is_url_for('with', doc='name', other='**other.name')
+def doc_with(doc, other):
+    return u'{} {} {}'.format(doc, 'with', other)
+
+
 @app1.route('/<container>/<doc>')
 @ScopedNamedDocument.is_url_for('view', container='parent.id', doc='name')
 def sdoc_view(container, doc):
@@ -174,6 +183,16 @@ class TestUrlFor(TestUrlForBase):
         # This action is only available in this app
         assert doc1.url_for('app_only') == '/document1/app_only'
 
+    def test_linked_doc(self):
+        """URLs linking two unrelated models are possible"""
+        doc1 = NamedDocument(name=u'document1', title=u"Document 1")
+        doc2 = NamedDocument(name=u'document2', title=u"Document 2")
+        self.session.add_all([doc1, doc2])
+        self.session.commit()
+
+        # url_for is given an object and extracts an attribute from it
+        assert doc1.url_for('with', other=doc2) == '/document1/with/document2'
+
 
 class TestUrlFor2(TestUrlForBase):
     app = app2
diff --git a/tests/test_views_classview.py b/tests/test_views_classview.py
index 3863d19..764fb20 100644
--- a/tests/test_views_classview.py
+++ b/tests/test_views_classview.py
@@ -31,6 +31,7 @@ class ViewDocument(BaseNameMixin, db.Model):
 
     def permissions(self, actor, inherited=()):
         perms = super(ViewDocument, self).permissions(actor, inherited)
+        perms.add('view')
         if actor == 'this-is-the-owner':  # Our hack of a user object, for testing
             perms.add('edit')
             perms.add('delete')
@@ -239,6 +240,28 @@ class RenameableDocumentView(UrlChangeCheck, InstanceLoader, ModelView):
 RenameableDocumentView.init_app(app)
 
 
+@route('/multi/<doc1>/<doc2>')
+class MultiDocumentView(UrlForView, ModelView):
+    model = ViewDocument
+    route_model_map = {
+        'doc1': 'name',
+        'doc2': '**doc2.url_name'
+    }
+
+    def loader(self, doc1, doc2):
+        doc1 = ViewDocument.query.filter_by(name=doc1).first_or_404()
+        doc2 = RenameableDocument.query.filter_by(url_name=doc2).first_or_404()
+        return (doc1, doc2)
+
+    @route('')
+    @requires_permission('view')
+    def linked_view(self):
+        return self.obj[0].url_for('linked_view', doc2=self.obj[1])
+
+
+MultiDocumentView.init_app(app)
+
+
 # --- Tests -------------------------------------------------------------------
 
 class TestClassView(unittest.TestCase):
@@ -493,3 +516,17 @@ class TestClassView(unittest.TestCase):
         assert rv.status_code == 200
         data = json.loads(rv.data)
         assert data['name'] == 'renamed'
+
+    def test_multi_view(self):
+        """
+        A ModelView view can handle multiple objects and also construct URLs
+        for objects that do not have a well defined relationship between each other.
+        """
+        doc1 = ViewDocument(name='test1', title="Test 1")
+        doc2 = RenameableDocument(name='test2', title="Test 2")
+        self.session.add_all([doc1, doc2])
+        self.session.commit()
+
+        rv = self.client.get('/multi/test1/1-test2')
+        assert rv.status_code == 200
+        assert rv.data == b'/multi/test1/1-test2'

pip install -e .
parse_log_pytest
requirements.txt
pytest coverage coveralls psycopg2
3.6
test_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alembic==1.7.7
attrs==22.2.0
bcrypt==4.0.1
bleach==4.1.0
blinker==1.5
certifi==2021.5.30
charset-normalizer==2.0.12
click==8.0.4
-e git+https://github.com/hasgeek/coaster.git@78aa13a0789c57fbd5c8d1230a5b35908588f189#egg=coaster
coverage==6.2
coveralls==3.3.1
dataclasses==0.8
docflow==0.3.3
docopt==0.6.2
filelock==3.4.1
Flask==2.0.3
Flask-Assets==2.1.0
Flask-Migrate==4.0.1
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
greenlet==2.0.2
html2text==2020.1.16
html5lib==1.1
idna==3.10
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
isoweek==1.3.3
itsdangerous==2.0.1
Jinja2==3.0.3
joblib==1.1.1
Mako==1.1.6
Markdown==3.3.7
MarkupSafe==2.0.1
nltk==3.6.7
packaging==21.3
pluggy==1.0.0
psycopg2==2.7.7
py==1.11.0
PyExecJS==1.5.1
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
regex==2023.8.8
requests==2.27.1
requests-file==2.1.0
semantic-version==2.10.0
shortuuid==1.0.13
simplejson==3.20.1
six==1.17.0
SQLAlchemy==1.4.54
SQLAlchemy-Utils==0.41.1
tldextract==3.1.2
tomli==1.2.3
tqdm==4.64.1
typing_extensions==4.1.1
UgliPyJS==0.2.5
Unidecode==1.3.8
urllib3==1.26.20
webassets==2.0
webencodings==0.5.1
Werkzeug==2.0.3
zipp==3.6.0

name: coaster
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alembic==1.7.7
      - attrs==22.2.0
      - bcrypt==4.0.1
      - bleach==4.1.0
      - blinker==1.5
      - charset-normalizer==2.0.12
      - click==8.0.4
      - coverage==6.2
      - coveralls==3.3.1
      - dataclasses==0.8
      - docflow==0.3.3
      - docopt==0.6.2
      - filelock==3.4.1
      - flask==2.0.3
      - flask-assets==2.1.0
      - flask-migrate==4.0.1
      - flask-script==2.0.6
      - flask-sqlalchemy==2.5.1
      - greenlet==2.0.2
      - html2text==2020.1.16
      - html5lib==1.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - isoweek==1.3.3
      - itsdangerous==2.0.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - mako==1.1.6
      - markdown==3.3.7
      - markupsafe==2.0.1
      - nltk==3.6.7
      - packaging==21.3
      - pluggy==1.0.0
      - psycopg2==2.7.7
      - py==1.11.0
      - pyexecjs==1.5.1
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - regex==2023.8.8
      - requests==2.27.1
      - requests-file==2.1.0
      - semantic-version==2.10.0
      - shortuuid==1.0.13
      - simplejson==3.20.1
      - six==1.17.0
      - sqlalchemy==1.4.54
      - sqlalchemy-utils==0.41.1
      - tldextract==3.1.2
      - tomli==1.2.3
      - tqdm==4.64.1
      - typing-extensions==4.1.1
      - uglipyjs==0.2.5
      - unidecode==1.3.8
      - urllib3==1.26.20
      - webassets==2.0
      - webencodings==0.5.1
      - werkzeug==2.0.3
      - zipp==3.6.0
prefix: /opt/conda/envs/coaster

tests/test_url_for.py::TestUrlFor::test_linked_doc tests/test_views_classview.py::TestClassView::test_multi_view
tests/test_url_for.py::TestUrlFor::test_absolute_url tests/test_url_for.py::TestUrlFor::test_absolute_url_in_access_proxy tests/test_url_for.py::TestUrlFor::test_absolute_url_missing tests/test_url_for.py::TestUrlFor::test_app_only tests/test_url_for.py::TestUrlFor::test_class_has_url_for tests/test_url_for.py::TestUrlFor::test_per_app tests/test_url_for.py::TestUrlFor::test_url_for tests/test_url_for.py::TestUrlFor2::test_app_only tests/test_url_for.py::TestUrlFor2::test_per_app tests/test_views_classview.py::TestClassView::test_added_routes tests/test_views_classview.py::TestClassView::test_callable_view tests/test_views_classview.py::TestClassView::test_cant_route_missing_method tests/test_views_classview.py::TestClassView::test_current_handler_is_self tests/test_views_classview.py::TestClassView::test_current_handler_is_wrapper tests/test_views_classview.py::TestClassView::test_current_view tests/test_views_classview.py::TestClassView::test_document_404 tests/test_views_classview.py::TestClassView::test_document_edit tests/test_views_classview.py::TestClassView::test_document_view tests/test_views_classview.py::TestClassView::test_endpoints tests/test_views_classview.py::TestClassView::test_index tests/test_views_classview.py::TestClassView::test_inherited tests/test_views_classview.py::TestClassView::test_modelview_instanceloader_requires_permission_edit tests/test_views_classview.py::TestClassView::test_modelview_instanceloader_view tests/test_views_classview.py::TestClassView::test_modelview_url_for tests/test_views_classview.py::TestClassView::test_page tests/test_views_classview.py::TestClassView::test_redirectablemodel_view tests/test_views_classview.py::TestClassView::test_rerouted tests/test_views_classview.py::TestClassView::test_rerouted_with_new_routes tests/test_views_classview.py::TestClassView::test_scopedmodelview_view tests/test_views_classview.py::TestClassView::test_second_subview_reroute tests/test_views_classview.py::TestClassView::test_unrouted tests/test_views_classview.py::TestClassView::test_view_args_are_received tests/test_views_classview.py::TestClassView::test_viewdata tests/test_views_classview.py::TestClassView::test_viewlist
coaster/views/classview.py coaster/sqlalchemy/mixins.py
coaster/views/classview.py coaster/sqlalchemy/mixins.py