Index: trac/attachment.py
===================================================================
--- trac/attachment.py	(revision 3304)
+++ trac/attachment.py	(working copy)
@@ -33,6 +33,15 @@
 from trac.wiki import IWikiSyntaxProvider
 
 
+class IAttachmentPointProvider(Interface):
+    def get_attachment_points():
+        """Provide details on file attachment points provided by this
+        component.  Returns an iterable of tuples in the form `(type,
+        description, title, (view_perm, write_perm, delete_perm))`.
+        `description` and `title` will be used as the formatting string for the
+        attachment point identifier."""
+
+
 class Attachment(object):
 
     def __init__(self, env, parent_type, parent_id, filename=None, db=None):
@@ -86,8 +95,9 @@
         return req.href(self.parent_type, self.parent_id)
 
     def _get_title(self):
-        return '%s%s: %s' % (self.parent_type == 'ticket' and '#' or '',
-                             self.parent_id, self.filename)
+        title = AttachmentModule(self.env)._get_attachment_point(self.parent_type)[2]
+        title = title % self.parent_id
+        return '%s: %s' % (title, self.filename)
     title = property(_get_title)
 
     def delete(self, db=None):
@@ -215,10 +225,12 @@
     implements(IEnvironmentSetupParticipant, IRequestHandler,
                INavigationContributor, IWikiSyntaxProvider)
 
+    attachment_points = ExtensionPoint(IAttachmentPointProvider)
+
     CHUNK_SIZE = 4096
 
     max_size = IntOption('attachment', 'max_size', 262144,
-        """Maximum allowed file size for ticket and wiki attachments.""")
+        """Maximum allowed file size for attachments.""")
 
     render_unsafe_content = BoolOption('attachment', 'render_unsafe_content',
                                        'false',
@@ -256,7 +268,7 @@
     # IRequestHandler methods
 
     def match_request(self, req):
-        match = re.match(r'^/attachment/(ticket|wiki)(?:[/:](.*))?$',
+        match = re.match(r'^/attachment/([^/]+)(?:[/:](.*))?$',
                          req.path_info)
         if match:
             req.args['type'] = match.group(1)
@@ -268,8 +280,15 @@
         path = req.args.get('path')
         if not parent_type or not path:
             raise HTTPBadRequest('Bad request')
-        if not parent_type in ['ticket', 'wiki']:
-            raise HTTPBadRequest('Unknown attachment type')
+        for point in self.attachment_points:
+            for descriptor in point.get_attachment_points():
+                if descriptor[0] == parent_type:
+                    break
+            else:
+                continue
+            break
+        else:
+            raise HTTPBadRequest('Unknown attachment type "%s"' % parent_type)
 
         action = req.args.get('action', 'view')
         if action == 'new':
@@ -306,10 +325,8 @@
     def _parent_to_hdf(self, req, parent_type, parent_id):
         # Populate attachment.parent:
         parent_link = req.href(parent_type, parent_id)
-        if parent_type == 'ticket':
-            parent_text = 'Ticket #' + parent_id
-        else: # 'wiki'
-            parent_text = parent_id
+        type, description, title, perms = self._get_attachment_point(parent_type)
+        parent_text = description % parent_id
         req.hdf['attachment.parent'] = {
             'type': parent_type, 'id': parent_id,
             'name': parent_text, 'href': parent_link
@@ -326,9 +343,22 @@
 
     # Internal methods
 
+    def _get_attachment_point(self, point_type):
+        for point in self.attachment_points:
+            for descriptor in point.get_attachment_points():
+                if descriptor[0] == point_type:
+                    return descriptor
+        raise TracError('No attachment point found for %s' %
+                        attachment.parent_type)
+
+    def _get_permission(self, operation, attachment):
+        """Find the actual permission required to perform `operation`, which can
+        be one of `view`, `write`, `delete`."""
+        operation = {'view': 0, 'modify': 1, 'delete': 2}[operation]
+        return self._get_attachment_point(attachment.parent_type)[-1][operation]
+
     def _do_save(self, req, attachment):
-        perm_map = {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY'}
-        req.perm.assert_permission(perm_map[attachment.parent_type])
+        req.perm.assert_permission(self._get_permission('modify', attachment))
 
         if req.args.has_key('cancel'):
             req.redirect(attachment.parent_href(req))
@@ -367,8 +397,8 @@
                                             attachment.parent_id, filename)
                 if not (old_attachment.author and req.authname \
                         and old_attachment.author == req.authname):
-                    perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
-                    req.perm.assert_permission(perm_map[old_attachment.parent_type])
+                    req.perm.assert_permission(self._get_permission('delete',
+                                               old_attachment))
                 old_attachment.delete()
             except TracError:
                 pass # don't worry if there's nothing to replace
@@ -379,8 +409,7 @@
         req.redirect(attachment.href(req))
 
     def _do_delete(self, req, attachment):
-        perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
-        req.perm.assert_permission(perm_map[attachment.parent_type])
+        req.perm.assert_permission(self._get_permission('delete', attachment))
 
         if req.args.has_key('cancel'):
             req.redirect(attachment.href(req))
@@ -391,23 +420,20 @@
         req.redirect(attachment.parent_href(req))
 
     def _render_confirm(self, req, attachment):
-        perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
-        req.perm.assert_permission(perm_map[attachment.parent_type])
+        req.perm.assert_permission(self._get_permission('delete', attachment))
 
         req.hdf['title'] = '%s (delete)' % attachment.title
         req.hdf['attachment'] = {'filename': attachment.filename,
                                  'mode': 'delete'}
 
     def _render_form(self, req, attachment):
-        perm_map = {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY'}
-        req.perm.assert_permission(perm_map[attachment.parent_type])
+        req.perm.assert_permission(self._get_permission('modify', attachment))
 
         req.hdf['attachment'] = {'mode': 'new',
                                  'author': util.get_reporter_id(req)}
 
     def _render_view(self, req, attachment):
-        perm_map = {'ticket': 'TICKET_VIEW', 'wiki': 'WIKI_VIEW'}
-        req.perm.assert_permission(perm_map[attachment.parent_type])
+        req.perm.assert_permission(self._get_permission('view', attachment))
 
         req.check_modified(attachment.time)
 
@@ -416,8 +442,7 @@
         req.hdf['attachment'] = attachment_to_hdf(self.env, req, None,
                                                   attachment)
         
-        perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'}
-        if req.perm.has_permission(perm_map[attachment.parent_type]):
+        if req.perm.has_permission(self._get_permission('delete', attachment)):
             req.hdf['attachment.can_delete'] = 1
 
         fd = attachment.open()
Index: trac/ticket/web_ui.py
===================================================================
--- trac/ticket/web_ui.py	(revision 3304)
+++ trac/ticket/web_ui.py	(working copy)
@@ -18,7 +18,8 @@
 import re
 import time
 
-from trac.attachment import attachments_to_hdf, Attachment
+from trac.attachment import attachments_to_hdf, Attachment, \
+                            IAttachmentPointProvider
 from trac.config import BoolOption, Option
 from trac.core import *
 from trac.env import IEnvironmentSetupParticipant
@@ -179,7 +180,8 @@
 
 class TicketModule(TicketModuleBase):
 
-    implements(INavigationContributor, IRequestHandler, ITimelineEventProvider)
+    implements(INavigationContributor, IRequestHandler, ITimelineEventProvider,
+               IAttachmentPointProvider)
 
     default_version = Option('ticket', 'default_version', '',
         """Default version for newly created tickets.""")
@@ -200,6 +202,12 @@
         """Enable the display of all ticket changes in the timeline
         (''since 0.9'').""")
 
+    # IAttachmentPointProvider methods
+
+    def get_attachment_points(self):
+        yield ('ticket', 'Ticket #%s', '#%s',
+               ('TICKET_VIEW', 'TICKET_APPEND', 'TICKET_ADMIN'))
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
Index: trac/wiki/web_ui.py
===================================================================
--- trac/wiki/web_ui.py	(revision 3304)
+++ trac/wiki/web_ui.py	(working copy)
@@ -19,7 +19,8 @@
 import re
 import StringIO
 
-from trac.attachment import attachments_to_hdf, Attachment
+from trac.attachment import attachments_to_hdf, Attachment, \
+                            IAttachmentPointProvider
 from trac.core import *
 from trac.perm import IPermissionRequestor
 from trac.Search import ISearchSource, search_to_sql, shorten_result
@@ -38,10 +39,15 @@
 class WikiModule(Component):
 
     implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
-               ITimelineEventProvider, ISearchSource)
+               ITimelineEventProvider, ISearchSource, IAttachmentPointProvider)
 
     page_manipulators = ExtensionPoint(IWikiPageManipulator)
 
+    # IAttachmentPointProvider methods
+
+    def get_attachment_points(self):
+        yield ('wiki', '%s', '%s', ('WIKI_VIEW', 'WIKI_MODIFY', 'WIKI_DELETE'))
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
