blob: 45135f417ca4d80159220590776960b4eba117fb [file] [log] [blame]
# -*- coding: utf-8 -*-
#
# Copyright (C) 2007-2009 Edgewall Software
# Copyright (C) 2007 Alec Thomas <alec@swapoff.org>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at http://trac.edgewall.org/log/.
#
# Author: Alec Thomas <alec@swapoff.org>
from fnmatch import fnmatchcase
from itertools import groupby
import os
from trac.core import *
from trac.config import ConfigurationError, Option
from trac.perm import PermissionSystem, IPermissionPolicy
from trac.util import lazy
from trac.util.text import to_unicode
ConfigObj = None
try:
from configobj import ConfigObj, ConfigObjError
except ImportError:
pass
class AuthzPolicy(Component):
"""Permission policy using an authz-like configuration file.
Refer to SVN documentation for syntax of the authz file. Groups are
supported.
As the fine-grained permissions brought by this permission policy are
often used in complement of the other pemission policies (like the
`DefaultPermissionPolicy`), there's no need to redefine all the
permissions here. Only additional rights or restrictions should be added.
=== Installation ===
Note that this plugin requires the `configobj` package:
http://www.voidspace.org.uk/python/configobj.html
You should be able to install it by doing a simple `easy_install configobj`
Enabling this policy requires listing it in `trac.ini:
{{{
[trac]
permission_policies = AuthzPolicy, DefaultPermissionPolicy
[authz_policy]
authz_file = conf/authzpolicy.conf
}}}
This means that the `AuthzPolicy` permissions will be checked first, and
only if no rule is found will the `DefaultPermissionPolicy` be used.
=== Configuration ===
The `authzpolicy.conf` file is a `.ini` style configuration file.
- Each section of the config is a glob pattern used to match against a
Trac resource descriptor. These descriptors are in the form:
{{{
<realm>:<id>@<version>[/<realm>:<id>@<version> ...]
}}}
Resources are ordered left to right, from parent to child. If any
component is inapplicable, `*` is substituted. If the version pattern is
not specified explicitely, all versions (`@*`) is added implicitly
Example: Match the WikiStart page
{{{
[wiki:*]
[wiki:WikiStart*]
[wiki:WikiStart@*]
[wiki:WikiStart]
}}}
Example: Match the attachment `wiki:WikiStart@117/attachment/FOO.JPG@*`
on WikiStart
{{{
[wiki:*]
[wiki:WikiStart*]
[wiki:WikiStart@*]
[wiki:WikiStart@*/attachment/*]
[wiki:WikiStart@117/attachment/FOO.JPG]
}}}
- Sections are checked against the current Trac resource '''IN ORDER''' of
appearance in the configuration file. '''ORDER IS CRITICAL'''.
- Once a section matches, the current username is matched, '''IN ORDER''',
against the keys of the section. If a key is prefixed with a `@`, it is
treated as a group. If a key is prefixed with a `!`, the permission is
denied rather than granted. The username will match any of 'anonymous',
'authenticated', <username> or '*', using normal Trac permission rules.
Example configuration:
{{{
[groups]
administrators = athomas
[*/attachment:*]
* = WIKI_VIEW, TICKET_VIEW
[wiki:WikiStart@*]
@administrators = WIKI_ADMIN
anonymous = WIKI_VIEW
* = WIKI_VIEW
# Deny access to page templates
[wiki:PageTemplates/*]
* =
# Match everything else
[*]
@administrators = TRAC_ADMIN
anonymous = BROWSER_VIEW, CHANGESET_VIEW, FILE_VIEW, LOG_VIEW,
MILESTONE_VIEW, POLL_VIEW, REPORT_SQL_VIEW, REPORT_VIEW, ROADMAP_VIEW,
SEARCH_VIEW, TICKET_CREATE, TICKET_MODIFY, TICKET_VIEW, TIMELINE_VIEW,
WIKI_CREATE, WIKI_MODIFY, WIKI_VIEW
# Give authenticated users some extra permissions
authenticated = REPO_SEARCH, XML_RPC
}}}
"""
implements(IPermissionPolicy)
authz_file = Option('authz_policy', 'authz_file', '',
'Location of authz policy configuration file.')
authz = None
authz_mtime = None
# IPermissionPolicy methods
def check_permission(self, action, username, resource, perm):
if not self.authz_mtime or \
os.path.getmtime(self.get_authz_file) > self.authz_mtime:
self.parse_authz()
resource_key = self.normalise_resource(resource)
self.log.debug('Checking %s on %s', action, resource_key)
permissions = self.authz_permissions(resource_key, username)
if permissions is None:
return None # no match, can't decide
elif permissions == ['']:
return False # all actions are denied
# FIXME: expand all permissions once for all
ps = PermissionSystem(self.env)
for deny, perms in groupby(permissions,
key=lambda p: p.startswith('!')):
if deny and action in ps.expand_actions([p[1:] for p in perms]):
return False # action is explicitly denied
elif action in ps.expand_actions(perms):
return True # action is explicitly granted
return None # no match for action, can't decide
# Internal methods
@lazy
def get_authz_file(self):
if not self.authz_file:
self.log.error('The `[authz_policy] authz_file` configuration '
'option in trac.ini is empty or not defined.')
raise ConfigurationError()
authz_file = self.authz_file if os.path.isabs(self.authz_file) \
else os.path.join(self.env.path,
self.authz_file)
try:
os.stat(authz_file)
except OSError, e:
self.log.error("Error parsing authz permission policy file: %s",
to_unicode(e))
raise ConfigurationError()
return authz_file
def parse_authz(self):
if ConfigObj is None:
self.log.error('ConfigObj package not found.')
raise ConfigurationError()
self.log.debug('Parsing authz security policy %s',
self.get_authz_file)
try:
self.authz = ConfigObj(self.get_authz_file, encoding='utf8',
raise_errors=True)
except ConfigObjError, e:
self.log.error("Error parsing authz permission policy file: %s",
to_unicode(e))
raise ConfigurationError()
groups = {}
for group, users in self.authz.get('groups', {}).iteritems():
if isinstance(users, basestring):
users = [users]
groups[group] = map(to_unicode, users)
self.groups_by_user = {}
def add_items(group, items):
for item in items:
if item.startswith('@'):
add_items(group, groups[item[1:]])
else:
self.groups_by_user.setdefault(item, set()).add(group)
for group, users in groups.iteritems():
add_items('@' + group, users)
self.authz_mtime = os.path.getmtime(self.get_authz_file)
def normalise_resource(self, resource):
def to_descriptor(resource):
id = resource.id
return '%s:%s@%s' % (resource.realm or '*',
id if id is not None else '*',
resource.version or '*')
def flatten(resource):
if not resource:
return ['*:*@*']
descriptor = to_descriptor(resource)
if not resource.realm and resource.id is None:
return [descriptor]
# XXX Due to the mixed functionality in resource we can end up with
# ticket, ticket:1, ticket:1@10. This code naively collapses all
# subsets of the parent resource into one. eg. ticket:1@10
parent = resource.parent
while parent and resource.realm == parent.realm:
parent = parent.parent
if parent:
return flatten(parent) + [descriptor]
else:
return [descriptor]
return '/'.join(flatten(resource))
def authz_permissions(self, resource_key, username):
# TODO: Handle permission negation in sections. eg. "if in this
# ticket, remove TICKET_MODIFY"
if username and username != 'anonymous':
valid_users = ['*', 'authenticated', username]
else:
valid_users = ['*', 'anonymous']
for resource_section in [a for a in self.authz.sections
if a != 'groups']:
resource_glob = to_unicode(resource_section)
if '@' not in resource_glob:
resource_glob += '@*'
if fnmatchcase(resource_key, resource_glob):
section = self.authz[resource_section]
for who, permissions in section.iteritems():
who = to_unicode(who)
if who in valid_users or \
who in self.groups_by_user.get(username, []):
self.log.debug('%s matched section %s for user %s',
resource_key, resource_glob, username)
if isinstance(permissions, basestring):
return [permissions]
else:
return permissions
return None