From c4bc35e8d1ce1e803c3efa0e64d3dd2d9be3bf03 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 25 Jul 2002 07:14:06 +0000 Subject: [PATCH] Bugger it. Here's the current shape of the new security implementation. Still to do: . call the security funcs from cgi and mailgw . change shipped templates to include correct initialisation and remove the old config vars ... that seems like a lot. The bulk of the work has been done though. Honest :) git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@918 57a73879-2fb5-44c3-a270-3262357dd7e2 --- COPYING.txt | 23 +++ TODO.txt | 5 +- doc/Makefile | 2 +- doc/default.css | 12 +- doc/security.txt | 140 ++++++++-------- roundup/admin.py | 27 ++-- roundup/backends/back_anydbm.py | 33 ++-- roundup/cgi_client.py | 15 +- roundup/htmltemplate.py | 244 +++++++++++++++++----------- roundup/mailgw.py | 17 +- roundup/security.py | 143 +++++++++++++++++ roundup/volatiledb.py | 274 ++++++++++++++++++++++++++++++++ test/test_db.py | 44 ++--- test/test_htmltemplate.py | 160 +++++++++++++++++-- test/test_security.py | 99 ++++++++++++ 15 files changed, 1016 insertions(+), 222 deletions(-) create mode 100644 COPYING.txt create mode 100644 roundup/security.py create mode 100644 roundup/volatiledb.py create mode 100644 test/test_security.py diff --git a/COPYING.txt b/COPYING.txt new file mode 100644 index 0000000..330c6f7 --- /dev/null +++ b/COPYING.txt @@ -0,0 +1,23 @@ + +Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) +Copyright (c) 2002 eKit.com Inc (http://www.ekit.com/) + +This module is free software, and you may redistribute it and/or modify +under the same terms as Python, so long as this copyright message and +disclaimer are retained in their original form. + +IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OF THIS CODE, EVEN IF BIZAR SOFTWARE PTY LTD HAS BEEN +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" +BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, +SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +The stylesheet included with this package has been copied from the Zope +management interface and presumably belongs to Digital Creations. + diff --git a/TODO.txt b/TODO.txt index e41bb1e..b588934 100644 --- a/TODO.txt +++ b/TODO.txt @@ -11,6 +11,7 @@ pending hyperdb: range searching of values (dates in particular) comparison functions: lt, le, eq, ge, gt. eq and [value, value, ...] implies "in" pending hyperdb: make creator, creation and activity available pre-commit +pending hyperdb: migrate "id" property to be Number type pending instance: including much simpler upgrade path and the use of non-Python configuration files (ConfigParser) pending instance: cleanup to support config (feature request #498658) @@ -28,7 +29,7 @@ pending mailgw: Allow multiple email addresses at one gw with different default roundup: "|roundup-mailgw /instances/dev" vmbugs: "|roundup-mailgw /instances/dev component=voicemail" pending project: switch to a Roundup instance for Roundup bug/feature tracking -active security: finish doc/security.txt (RJ) +active security: finish doc/security.txt pending security: implement and use the new logical control mechanisms pending security: at least an LDAP user database implementation pending security: authenticate over a secure connection @@ -42,7 +43,6 @@ pending web: Quick help links next to the property labels giving a description of the property. Combine with help for the actual form element too, eg. how to use the nosy list edit box. pending web: feature request #507842 -active web: saving of named queries (GM) ongoing any bugs @@ -53,4 +53,5 @@ done hyperdb: fix the journal bloat (RJ) done hyperdb: add Boolean and Number types (GM) done mailgw: better help message (feature request #558562) (RJ) done security: switch to sessions for web authentication (RJ) +done web: saving of named queries (GM) diff --git a/doc/Makefile b/doc/Makefile index 1f22b0e..1141e14 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -10,5 +10,5 @@ COMPILED := $(SOURCE:.txt=.html) all: ${COMPILED} %.html: %.txt - ${PYTHON} ${STXTOHTML} -d -v $< $@ + ${PYTHON} ${STXTOHTML} -d $< $@ diff --git a/doc/default.css b/doc/default.css index e2bfb08..2495eed 100644 --- a/doc/default.css +++ b/doc/default.css @@ -1,8 +1,8 @@ /* :Author: David Goodger :Contact: goodger@users.sourceforge.net -:date: $Date: 2002-06-24 00:57:23 $ -:version: $Revision: 1.5 $ +:date: $Date: 2002-07-25 07:14:05 $ +:version: $Revision: 1.6 $ :copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. @@ -160,3 +160,11 @@ table.docinfo { table.footnote { border-left: solid thin black ; padding-left: 0.5ex } + +@media print { + h1 {page-break-before: always; } + h1, h2, h3, h4, h5, h6 { page-break-after: avoid; page-break-inside: avoid; } + blockquote, pre { page-break-inside: avoid; } + ul, ol, dl { page-break-before: avoid; } +} + diff --git a/doc/security.txt b/doc/security.txt index 4cdb3b0..2d3748a 100644 --- a/doc/security.txt +++ b/doc/security.txt @@ -2,7 +2,7 @@ Security Mechanisms =================== -:Version: $Revision: 1.11 $ +:Version: $Revision: 1.12 $ Current situation ================= @@ -41,6 +41,9 @@ Issues than the From address. Support for strong identification through digital signatures should be added. 5. The command-line tool has no logical controls. +6. The anonymous control needs revising - there should only be one way to be + an anonymous user, not two (currently there is user==None and + user=='anonymous). Possible approaches @@ -138,8 +141,8 @@ with specific nodes by way of their user-linked properties. A security module defines:: - class InMemoryImmutableClass(hyperdb.Class): - ''' Don't allow changes to this class's nodes. + class InMemoryClass(hyperdb.Class): + ''' Just be an in-memory class ''' def __init__(self, db, classname, **properties): ''' Set up an in-memory store for the nodes of this class @@ -154,20 +157,21 @@ A security module defines:: ''' def set(self, *args): - raise ValueError, "%s are immutable"%self.__class__.__name__ + ''' Set values on the node + ''' - class PermissionClass(InMemoryImmutableClass): + class PermissionClass(InMemoryClass): ''' Include the default attributes: - name (String) - - classname (String) + - klass (String) - description (String) - The classname may be unset, indicating that this permission is not + The klass may be unset, indicating that this permission is not locked to a particular class. That means there may be multiple Permissions for the same name for different classes. ''' - class RoleClass(InMemoryImmutableClass): + class RoleClass(InMemoryClass): ''' Include the default attributes: - name (String, key) - description (String) @@ -179,32 +183,6 @@ A security module defines:: ''' Initialise the permission and role classes, and add in the base roles (for admin user). ''' - # use a weak ref to avoid circularity - self.db = weakref.proxy(db) - - # create the permission class instance (we only need one)) - self.permission = PermissionClass(db, "permission") - - # create the role class instance (we only need one) - self.role = RoleClass(db, "role") - - # the default Roles - self.addRole(name="User", description="A regular user, no privs") - self.addRole(name="Admin", description="An admin user, full privs") - self.addRole(name="No Rego", - description="A user who can't register") - - ee = self.addPermission(name="Edit", - description="User may edit everthing") - self.addPermissionToRole('Admin', ee) - ae = self.addPermission(name="Assign", - description="User may be assigned to anything") - self.addPermissionToRole('Admin', ae) - - # initialise the permissions and roles needed for the UIs - from roundup import cgi_client, mailgw - cgi_client.initialiseSecurity(self) - mailgw.initialiseSecurity(self) def hasClassPermission(self, db, classname, permission, userid): ''' Look through all the Roles, and hence Permissions, and see if @@ -212,18 +190,16 @@ A security module defines:: ''' - def hasNodePermission(self, db, classname, nodeid, userid, properties): + def hasNodePermission(self, db, classname, nodeid, **propspec): ''' Check the named properties of the given node to see if the userid appears in them. If it does, then the user is granted this permission check. - 'propspec' consists of a list of property names. The property - names must be the name of a property of classname, or a - KeyError is raised. That property must be a Link or Multilink - property, or a TypeError is raised. + 'propspec' consists of a set of properties and values that + must be present on the given node for access to be granted. - If the property is a Link, the userid must match the property - value. If the property is a Multilink, the userid must appear + If a property is a Link, the value must match the property + value. If a property is a Multilink, the value must appear in the Multilink list. ''' @@ -251,13 +227,9 @@ permissions like so (this example is ``cgi_client.py``):: This function is directly invoked by security.Security.__init__() as a part of the Security object instantiation. ''' - newid = security.addPermission(name="Web Access", - description="User may use the web interface") - security.addToRole('User', newid) - security.addToRole('No Rego', newid) newid = security.addPermission(name="Web Registration", - description="User may register through the web") - security.addToRole('User', newid) + description="Anonymous users may register through the web") + security.addToRole('Anonymous', newid) The instance dbinit module then has in ``open()``:: @@ -266,10 +238,10 @@ The instance dbinit module then has in ``open()``:: db = Database(instance_config, name) # add some extra permissions and associate them with roles - ei = db.security.addPermission(name="Edit", classname="issue", + ei = db.security.addPermission(name="Edit", klass="issue", description="User is allowed to edit issues") db.security.addPermissionToRole('User', ei) - ai = db.security.addPermission(name="Assign", classname="issue", + ai = db.security.addPermission(name="Assign", klass="issue", description="User may be assigned to issues") db.security.addPermissionToRole('User', ei) @@ -284,29 +256,65 @@ In the dbinit ``init()``:: r = db.getclass('role').lookup('User') user.create(username="anonymous", roles=[r]) -Then in the code that matters, calls to ``hasPermission`` are made to -determine if the user has permission to perform some action:: +Then in the code that matters, calls to ``hasClassPermission`` and +``hasNodePermission`` are made to determine if the user has permission +to perform some action:: - if db.security.hasClassPermission('issue', 'Edit', self.user): + if db.security.hasClassPermission('issue', 'Edit', userid): # all ok - if db.security.hasNodePermission('issue', nodeid, self.user, - ['assignedto']): + if db.security.hasNodePermission('issue', nodeid, assignedto=userid): # all ok -The htmltemplate will implement a new tag, which has the form:: +Code in the core will make use of these methods, as should code in auditors in +custom templates. The htmltemplate will implement a new tag, ```` +which has the form:: - + HTML to display if the user has the permission. HTML to display if the user does not have the permission. - + + +where: + +- the permission attribute gives a comma-separated list of permission names. + These are checked in turn using ``hasClassPermission`` and requires one to + be OK. +- the other attributes are lookups on the node using ``hasNodePermission``. If + the attribute value is "$userid" then the current user's userid is tested. + +Any of these tests must pass or the ```` check will fail. The section +of html within the side of the ```` that fails is remove from processing. + +Implementation as shipped +------------------------- + +A set of Permissions are built in to the security module by default: -where the require attribute gives a comma-separated list of permission names -which are required, and the node attribute gives a comma-separated list of -node properties whose value must match the current user's id. Either of these -tests must pass or the permission check will fail. The section of html within -the side of the ```` that fails is remove from processing. +- Edit (everything) +- Access (everything) +- Assign (everything) + +The default interfaces define: + +- Web Registration +- Email Registration + +These are hooked into the default Roles: + +- Admin (Edit everything, Access everything, Assign everything) +- User () +- Anonymous (Web Registration, Email Registration) + +And finally, the "admin" user gets the "Admin" Role, and the "anonymous" user +gets the "Anonymous" assigned when the database is initialised on installation. +The two default schemas then define: + +- Edit issue, Access issue (both) +- Edit support, Access support (extended only) + +and assign those Permissions to the "User" Role. Authentication of Users @@ -322,6 +330,14 @@ first message into the tracker should be at a lower level of trust to those who supply their signature to an admin for submission to their user details. +Anonymous Users +--------------- + +The "anonymous" user must always exist, and defines the access permissions for +anonymous users. The three ANONYMOUS_ configuration variables are subsumed by +this new functionality. + + Action ====== diff --git a/roundup/admin.py b/roundup/admin.py index b213c14..305da4e 100644 --- a/roundup/admin.py +++ b/roundup/admin.py @@ -16,7 +16,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: admin.py,v 1.18 2002-07-18 11:17:30 gmcm Exp $ +# $Id: admin.py,v 1.19 2002-07-25 07:14:05 richard Exp $ import sys, os, getpass, getopt, re, UserDict, shlex, shutil try: @@ -371,8 +371,8 @@ Command help: for designator in designators: # decode the node designator try: - classname, nodeid = roundupdb.splitDesignator(designator) - except roundupdb.DesignatorError, message: + classname, nodeid = hyperdb.splitDesignator(designator) + except hyperdb.DesignatorError, message: raise UsageError, message # get the class @@ -411,8 +411,8 @@ Command help: for designator in designators: # decode the node designator try: - classname, nodeid = roundupdb.splitDesignator(designator) - except roundupdb.DesignatorError, message: + classname, nodeid = hyperdb.splitDesignator(designator) + except hyperdb.DesignatorError, message: raise UsageError, message # get the class @@ -537,8 +537,8 @@ Command help: # decode the node designator try: - classname, nodeid = roundupdb.splitDesignator(args[0]) - except roundupdb.DesignatorError, message: + classname, nodeid = hyperdb.splitDesignator(args[0]) + except hyperdb.DesignatorError, message: raise UsageError, message # get the class @@ -747,8 +747,8 @@ Command help: if len(args) < 1: raise UsageError, _('Not enough arguments supplied') try: - classname, nodeid = roundupdb.splitDesignator(args[0]) - except roundupdb.DesignatorError, message: + classname, nodeid = hyperdb.splitDesignator(args[0]) + except hyperdb.DesignatorError, message: raise UsageError, message try: @@ -797,8 +797,8 @@ Command help: designators = args[0].split(',') for designator in designators: try: - classname, nodeid = roundupdb.splitDesignator(designator) - except roundupdb.DesignatorError, message: + classname, nodeid = hyperdb.splitDesignator(designator) + except hyperdb.DesignatorError, message: raise UsageError, message try: self.db.getclass(classname).retire(nodeid) @@ -1131,6 +1131,11 @@ if __name__ == '__main__': # # $Log: not supported by cvs2svn $ +# Revision 1.18 2002/07/18 11:17:30 gmcm +# Add Number and Boolean types to hyperdb. +# Add conversion cases to web, mail & admin interfaces. +# Add storage/serialization cases to back_anydbm & back_metakit. +# # Revision 1.17 2002/07/14 06:05:50 richard # . fixed the date module so that Date(". - 2d") works # diff --git a/roundup/backends/back_anydbm.py b/roundup/backends/back_anydbm.py index 933738d..128453f 100644 --- a/roundup/backends/back_anydbm.py +++ b/roundup/backends/back_anydbm.py @@ -15,7 +15,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -#$Id: back_anydbm.py,v 1.52 2002-07-19 03:36:34 richard Exp $ +#$Id: back_anydbm.py,v 1.53 2002-07-25 07:14:06 richard Exp $ ''' This module defines a backend that saves the hyperdatabase in a database chosen by anydbm. It is guaranteed to always be available in python @@ -24,7 +24,7 @@ serious bugs, and is not available) ''' import whichdb, anydbm, os, marshal, re, weakref, string, copy -from roundup import hyperdb, date, password, roundupdb +from roundup import hyperdb, date, password, roundupdb, security from blobfiles import FileStorage from roundup.indexer import Indexer from locking import acquire_lock, release_lock @@ -66,6 +66,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database): self.destroyednodes = {}# keep track of the destroyed nodes by class self.transactions = [] self.indexer = Indexer(self.dir) + self.security = security.Security(self) # ensure files are group readable and writable os.umask(0002) @@ -751,7 +752,7 @@ class Class(hyperdb.Class): except (TypeError, KeyError): raise IndexError, 'new property "%s": %s not a %s'%( key, value, link_class) - elif not self.db.hasnode(link_class, value): + elif not self.db.getclass(link_class).hasnode(value): raise IndexError, '%s has no node %s'%(link_class, value) # save off the value @@ -785,12 +786,13 @@ class Class(hyperdb.Class): propvalues[key] = value # handle additions - for id in value: - if not self.db.hasnode(link_class, id): - raise IndexError, '%s has no node %s'%(link_class, id) + for nodeid in value: + if not self.db.getclass(link_class).hasnode(nodeid): + raise IndexError, '%s has no node %s'%(link_class, + nodeid) # register the link with the newly linked node if self.do_journal and self.properties[key].do_journal: - self.db.addjournal(link_class, id, 'link', + self.db.addjournal(link_class, nodeid, 'link', (self.classname, newid, key)) elif isinstance(prop, String): @@ -1005,7 +1007,7 @@ class Class(hyperdb.Class): raise IndexError, 'new property "%s": %s not a %s'%( propname, value, self.properties[propname].classname) - if not self.db.hasnode(link_class, value): + if not self.db.getclass(link_class).hasnode(value): raise IndexError, '%s has no node %s'%(link_class, value) if self.do_journal and self.properties[propname].do_journal: @@ -1062,7 +1064,7 @@ class Class(hyperdb.Class): # handle additions for id in value: - if not self.db.hasnode(link_class, id): + if not self.db.getclass(link_class).hasnode(id): raise IndexError, '%s has no node %s'%(link_class, id) if id in l: continue @@ -1277,10 +1279,6 @@ class Class(hyperdb.Class): prop = self.properties[propname] if not isinstance(prop, Link) and not isinstance(prop, Multilink): raise TypeError, "'%s' not a Link/Multilink property"%propname - #XXX edit is expensive and of questionable use - #for nodeid in nodeids: - # if not self.db.hasnode(prop.classname, nodeid): - # raise ValueError, '%s has no node %s'%(prop.classname, nodeid) # ok, now do the find cldb = self.db.getclassdb(self.classname) @@ -1778,6 +1776,15 @@ class IssueClass(Class, roundupdb.IssueClass): # #$Log: not supported by cvs2svn $ +#Revision 1.52 2002/07/19 03:36:34 richard +#Implemented the destroy() method needed by the session database (and possibly +#others). At the same time, I removed the leading underscores from the hyperdb +#methods that Really Didn't Need Them. +#The journal also raises IndexError now for all situations where there is a +#request for the journal of a node that doesn't have one. It used to return +#[] in _some_ situations, but not all. This _may_ break code, but the tests +#pass... +# #Revision 1.51 2002/07/18 23:07:08 richard #Unit tests and a few fixes. # diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py index f456673..9574d95 100644 --- a/roundup/cgi_client.py +++ b/roundup/cgi_client.py @@ -15,7 +15,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: cgi_client.py,v 1.143 2002-07-20 19:29:10 gmcm Exp $ +# $Id: cgi_client.py,v 1.144 2002-07-25 07:14:05 richard Exp $ __doc__ = """ WWW request handler (also used in the stand-alone server). @@ -33,6 +33,16 @@ class Unauthorised(ValueError): class NotFound(ValueError): pass +def initialiseSecurity(security): + ''' Create some Permissions and Roles on the security object + + This function is directly invoked by security.Security.__init__() + as a part of the Security object instantiation. + ''' + newid = security.addPermission(name="Web Registration", + description="User may register through the web") + security.addPermissionToRole('Anonymous', newid) + class Client: ''' A note about login @@ -1613,6 +1623,9 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')): # # $Log: not supported by cvs2svn $ +# Revision 1.143 2002/07/20 19:29:10 gmcm +# Fixes/improvements to the search form & saved queries. +# # Revision 1.142 2002/07/18 11:17:30 gmcm # Add Number and Boolean types to hyperdb. # Add conversion cases to web, mail & admin interfaces. diff --git a/roundup/htmltemplate.py b/roundup/htmltemplate.py index 2dc9af7..2558f37 100644 --- a/roundup/htmltemplate.py +++ b/roundup/htmltemplate.py @@ -15,10 +15,27 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: htmltemplate.py,v 1.103 2002-07-20 19:29:10 gmcm Exp $ +# $Id: htmltemplate.py,v 1.104 2002-07-25 07:14:05 richard Exp $ __doc__ = """ Template engine. + +Three types of template files exist: + .index used by IndexTemplate + .item used by ItemTemplate and NewItemTemplate + .filter used by IndexTemplate + +Templating works by instantiating one of the *Template classes above, +passing in a handle to the cgi client, identifying the class and the +template source directory. + +The *Template class reads in the appropriate template text, and when the +render() method is called, the template text is fed to an re.sub which +calls the subfunc and then all the funky do_* methods as required. + +Templating is tested by the test_htmltemplate unit test suite. If you add +a template function, add a test for all data types or the angry pink bunny +will hunt you down. """ import os, re, StringIO, urllib, cgi, errno, types, urllib @@ -819,7 +836,8 @@ class TemplateFunctions: if k[0] != ':': filterspec[k] = v ixtmplt = IndexTemplate(self.client, self.templates, classname) - qform = '
\n'%(self.classname,self.nodeid) + qform = '\n'%( + self.classname,self.nodeid) qform += ixtmplt.filter_form(query.get('search_text', ''), query.get(':filter', []), query.get(':columns', []), @@ -830,46 +848,67 @@ class TemplateFunctions: pagesize) ixtmplt.clear() return qform + '\n' - + + # + # templating subtitution methods + # + def execute_template(self, text): + ''' do the replacement of the template stuff with useful + information + ''' + replace = re.compile( + r'((.+?)>(?P.+?)' + r'((?P.*?))?)|' + r'([^>]+)">(?P.+?))|' + r'(?P[^"]+)">))', re.I|re.S) + return replace.sub(self.subfunc, text) + + # + # secutiry tag handling + # + condre = re.compile('(\w+?)\s*=\s*"([^"]+?)"') + def handle_require(self, condition, ok, fail): + userid = self.db.user.lookup(self.client.user) + security = self.db.security + + # get the conditions + l = self.condre.findall(condition) + d = {} + for k,v in l: + d[k] = v + + # see if one of the permissions are available + if d.has_key('permission'): + l.remove(('permission', d['permission'])) + for value in d['permission'].split(','): + if security.hasClassPermission(self.classname, value, userid): + # just passing the permission is OK + return self.execute_template(ok) + + # try the attr conditions until one is met + for propname, value in d.items(): + if propname == 'permission': + continue + if not security.hasNodePermission(self.classname, self.nodeid, + **{value: userid}): + break + else: + if l: + # there were tests, and we didn't fail any of them so we're OK + return self.execute_template(ok) + + # nope, fail + return self.execute_template(fail) # # INDEX TEMPLATES # -class IndexTemplateReplace: - '''Regular-expression based parser that turns the template into HTML. - ''' - def __init__(self, globals, locals, props): - self.globals = globals - self.locals = locals - self.props = props - - replace=re.compile( - r'(([^>]+)">(?P.+?))|' - r'(?P[^"]+)">))', re.I|re.S) - def go(self, text): - newtext = self.replace.sub(self, text) - self.locals = self.globals = None - return newtext - - def __call__(self, m, search_text=None, filter=None, columns=None, - sort=None, group=None): - if m.group('name'): - if m.group('name') in self.props: - text = m.group('text') - replace = self.__class__(self.globals, {}, self.props) - return replace.go(text) - else: - return '' - if m.group('display'): - command = m.group('command') - return eval(command, self.globals, self.locals) - return '*** unhandled match: %s'%str(m.groupdict()) - class IndexTemplate(TemplateFunctions): '''Templating functionality specifically for index pages ''' def __init__(self, client, templates, classname): TemplateFunctions.__init__(self) + self.globals['handle_require'] = self.handle_require self.client = client self.instance = client.instance self.templates = templates @@ -883,7 +922,7 @@ class IndexTemplate(TemplateFunctions): def clear(self): self.db = self.cl = self.properties = None TemplateFunctions.clear(self) - + def buildurl(self, filterspec, search_text, filter, columns, sort, group, pagesize): d = {'pagesize':pagesize, 'pagesize':pagesize, 'classname':self.classname} d['filter'] = ','.join(map(urllib.quote,filter)) @@ -927,12 +966,16 @@ class IndexTemplate(TemplateFunctions): l.append(name) columns = l + # TODO this is for the RE replacer func, and could probably be done + # better + self.props = columns + # display the filter section if (show_display_form and self.instance.FILTER_POSITION in ('top and bottom', 'top')): w('\n'%self.classname) - self.filter_section(search_text, filter, columns, group, all_columns, sort, filterspec, - pagesize, startwith) + self.filter_section(search_text, filter, columns, group, + all_columns, sort, filterspec, pagesize, startwith) # now display the index section w('\n') @@ -940,10 +983,11 @@ class IndexTemplate(TemplateFunctions): for name in columns: cname = name.capitalize() if show_display_form: - sb = self.sortby(name, filterspec, columns, filter, group, sort, pagesize, startwith) + sb = self.sortby(name, filterspec, columns, filter, group, + sort, pagesize, startwith) anchor = "%s?%s"%(self.classname, sb) - w('\n'%( - anchor, cname)) + w('\n'%(anchor, cname)) else: w('\n'%cname) w('\n') @@ -1005,9 +1049,7 @@ class IndexTemplate(TemplateFunctions): old_group = this_group # display this node's row - replace = IndexTemplateReplace(self.globals, locals(), columns) - self.nodeid = nodeid - w(replace.go(template)) + w(replace.execute_template(template)) if matches: self.node_matches(matches[nodeid], len(columns)) self.nodeid = None @@ -1015,28 +1057,53 @@ class IndexTemplate(TemplateFunctions): w('
%s%s' + '%s
\n') # the previous and next links if nodeids: - baseurl = self.buildurl(filterspec, search_text, filter, columns, sort, group, pagesize) + baseurl = self.buildurl(filterspec, search_text, filter, + columns, sort, group, pagesize) if startwith > 0: - prevurl = '<< Previous page' % \ - (baseurl, max(0, startwith-pagesize)) + prevurl = '<< '\ + 'Previous page'%(baseurl, max(0, startwith-pagesize)) else: prevurl = "" if startwith + pagesize < len(nodeids): - nexturl = 'Next page >>' % (baseurl, startwith+pagesize) + nexturl = 'Next page '\ + '>>'%(baseurl, startwith+pagesize) else: nexturl = "" if prevurl or nexturl: - w('
%s%s
\n' % (prevurl, nexturl)) + w(''' + + +
%s%s
\n'''%(prevurl, nexturl)) # display the filter section if (show_display_form and hasattr(self.instance, 'FILTER_POSITION') and self.instance.FILTER_POSITION in ('top and bottom', 'bottom')): - w('\n'%self.classname) - self.filter_section(search_text, filter, columns, group, all_columns, sort, filterspec, - pagesize, startwith) - + w('\n'% + self.classname) + self.filter_section(search_text, filter, columns, group, + all_columns, sort, filterspec, pagesize, startwith) self.clear() + def subfunc(self, m, search_text=None, filter=None, columns=None, + sort=None, group=None): + ''' called as part of the template replacement + ''' + if m.group('cond'): + # call the template handler for require + require = self.globals['handle_require'] + return self.handle_require(m.group('cond'), m.group('ok'), + m.group('fail')) + if m.group('name'): + if m.group('name') in self.props: + text = m.group('text') + return self.execute_template(text) + else: + return '' + if m.group('display'): + command = m.group('command') + return eval(command, self.globals, {}) + return '*** unhandled match: %s'%str(m.groupdict()) + def node_matches(self, match, colspan): ''' display the files and messages for a node that matched a full text search @@ -1066,9 +1133,8 @@ class IndexTemplate(TemplateFunctions): '  Matched files: %s\n')%( colspan, ', '.join(file_links))) - def filter_form(self, search_text, filter, columns, group, all_columns, sort, filterspec, - pagesize): - + def filter_form(self, search_text, filter, columns, group, all_columns, + sort, filterspec, pagesize): sortspec = {} for i in range(len(sort)): mod = '' @@ -1183,9 +1249,8 @@ class IndexTemplate(TemplateFunctions): return '\n'.join(rslt) - def filter_section(self, search_text, filter, columns, group, all_columns, sort, filterspec, - pagesize, startwith): - + def filter_section(self, search_text, filter, columns, group, all_columns, + sort, filterspec, pagesize, startwith): w = self.client.write w(self.filter_form(search_text, filter, columns, group, all_columns, sort, filterspec, pagesize)) @@ -1252,45 +1317,12 @@ class IndexTemplate(TemplateFunctions): w(':sort=%s'%','.join(m[:2])) return '&'.join(l) -# -# ITEM TEMPLATES -# -class ItemTemplateReplace: - '''Regular-expression based parser that turns the template into HTML. - ''' - def __init__(self, globals, locals, cl, nodeid): - self.globals = globals - self.locals = locals - self.cl = cl - self.nodeid = nodeid - - replace=re.compile( - r'(([^>]+)">(?P.+?))|' - r'(?P[^"]+)">))', re.I|re.S) - def go(self, text): - newtext = self.replace.sub(self, text) - self.globals = self.locals = self.cl = None - return newtext - - def __call__(self, m, filter=None, columns=None, sort=None, group=None): - if m.group('name'): - if self.nodeid and self.cl.get(self.nodeid, m.group('name')): - replace = ItemTemplateReplace(self.globals, {}, self.cl, - self.nodeid) - return replace.go(m.group('text')) - else: - return '' - if m.group('display'): - command = m.group('command') - return eval(command, self.globals, self.locals) - return '*** unhandled match: %s'%str(m.groupdict()) - - class ItemTemplate(TemplateFunctions): '''Templating functionality specifically for item (node) display ''' def __init__(self, client, templates, classname): TemplateFunctions.__init__(self) + self.globals['handle_require'] = self.handle_require self.client = client self.instance = client.instance self.templates = templates @@ -1319,18 +1351,36 @@ class ItemTemplate(TemplateFunctions): w(''%( self.classname, nodeid)) s = open(os.path.join(self.templates, self.classname+'.item')).read() - replace = ItemTemplateReplace(self.globals, locals(), self.cl, nodeid) - w(replace.go(s)) + w(self.execute_template(s)) w('') self.clear() + def subfunc(self, m, search_text=None, filter=None, columns=None, + sort=None, group=None): + ''' called as part of the template replacement + ''' + if m.group('cond'): + # call the template handler for require + require = self.globals['handle_require'] + return self.handle_require(m.group('cond'), m.group('ok'), + m.group('fail')) + if m.group('name'): + if self.nodeid and self.cl.get(self.nodeid, m.group('name')): + return self.execute_template(m.group('text')) + else: + return '' + if m.group('display'): + command = m.group('command') + return eval(command, self.globals, {}) + return '*** unhandled match: %s'%str(m.groupdict()) -class NewItemTemplate(TemplateFunctions): +class NewItemTemplate(ItemTemplate): '''Templating functionality specifically for NEW item (node) display ''' def __init__(self, client, templates, classname): TemplateFunctions.__init__(self) + self.globals['handle_require'] = self.handle_require self.client = client self.instance = client.instance self.templates = templates @@ -1360,14 +1410,16 @@ class NewItemTemplate(TemplateFunctions): if type(value) != type([]): value = [value] for value in value: w(''%(key, value)) - replace = ItemTemplateReplace(self.globals, locals(), None, None) - w(replace.go(s)) + w(self.execute_template(s)) w('') self.clear() # # $Log: not supported by cvs2svn $ +# Revision 1.103 2002/07/20 19:29:10 gmcm +# Fixes/improvements to the search form & saved queries. +# # Revision 1.102 2002/07/18 23:07:08 richard # Unit tests and a few fixes. # diff --git a/roundup/mailgw.py b/roundup/mailgw.py index b5d802d..88868fd 100644 --- a/roundup/mailgw.py +++ b/roundup/mailgw.py @@ -73,7 +73,7 @@ are calling the create() method to create a new node). If an auditor raises an exception, the original message is bounced back to the sender with the explanatory message given in the exception. -$Id: mailgw.py,v 1.77 2002-07-18 11:17:31 gmcm Exp $ +$Id: mailgw.py,v 1.78 2002-07-25 07:14:06 richard Exp $ ''' @@ -96,6 +96,16 @@ class MailUsageHelp(Exception): class UnAuthorized(Exception): """ Access denied """ +def initialiseSecurity(security): + ''' Create some Permissions and Roles on the security object + + This function is directly invoked by security.Security.__init__() + as a part of the Security object instantiation. + ''' + newid = security.addPermission(name="Email Registration", + description="Anonymous may register through e-mail") + security.addPermissionToRole('Anonymous', newid) + class Message(mimetools.Message): ''' subclass mimetools.Message so we can retrieve the parts of the message... @@ -790,6 +800,11 @@ def parseContent(content, keep_citations, keep_body, # # $Log: not supported by cvs2svn $ +# Revision 1.77 2002/07/18 11:17:31 gmcm +# Add Number and Boolean types to hyperdb. +# Add conversion cases to web, mail & admin interfaces. +# Add storage/serialization cases to back_anydbm & back_metakit. +# # Revision 1.76 2002/07/10 06:39:37 richard # . made mailgw handle set and modify operations on multilinks (bug #579094) # diff --git a/roundup/security.py b/roundup/security.py new file mode 100644 index 0000000..7475ca6 --- /dev/null +++ b/roundup/security.py @@ -0,0 +1,143 @@ +import weakref + +from roundup import hyperdb, volatiledb + +class PermissionClass(volatiledb.VolatileClass): + ''' Include the default attributes: + - name (String) + - classname (String) + - description (String) + + The classname may be unset, indicating that this permission is not + locked to a particular class. That means there may be multiple + Permissions for the same name for different classes. + ''' + def __init__(self, db, classname, **properties): + """ set up the default properties + """ + if not properties.has_key('name'): + properties['name'] = hyperdb.String() + if not properties.has_key('klass'): + properties['klass'] = hyperdb.String() + if not properties.has_key('description'): + properties['description'] = hyperdb.String() + volatiledb.VolatileClass.__init__(self, db, classname, **properties) + +class RoleClass(volatiledb.VolatileClass): + ''' Include the default attributes: + - name (String, key) + - description (String) + - permissions (PermissionClass Multilink) + ''' + def __init__(self, db, classname, **properties): + """ set up the default properties + """ + if not properties.has_key('name'): + properties['name'] = hyperdb.String() + if not properties.has_key('description'): + properties['description'] = hyperdb.String() + if not properties.has_key('permissions'): + properties['permissions'] = hyperdb.Multilink('permission') + volatiledb.VolatileClass.__init__(self, db, classname, **properties) + self.setkey('name') + +class Security: + def __init__(self, db): + ''' Initialise the permission and role classes, and add in the + base roles (for admin user). + ''' + # use a weak ref to avoid circularity + self.db = weakref.proxy(db) + + # create the permission class instance (we only need one)) + self.permission = PermissionClass(db, "permission") + + # create the role class instance (we only need one) + self.role = RoleClass(db, "role") + + # the default Roles + self.addRole(name="User", description="A regular user, no privs") + self.addRole(name="Admin", description="An admin user, full privs") + self.addRole(name="Anonymous", description="An anonymous user") + + ee = self.addPermission(name="Edit", + description="User may edit everthing") + self.addPermissionToRole('Admin', ee) + ae = self.addPermission(name="Access", + description="User may access everything") + self.addPermissionToRole('Admin', ae) + ae = self.addPermission(name="Assign", + description="User may be assigned to anything") + self.addPermissionToRole('Admin', ae) + reg = self.addPermission(name="Register Web", + description="User may register through the web") + self.addPermissionToRole('Anonymous', reg) + reg = self.addPermission(name="Register Email", + description="User may register through the email") + self.addPermissionToRole('Anonymous', reg) + + # initialise the permissions and roles needed for the UIs + from roundup import cgi_client, mailgw + cgi_client.initialiseSecurity(self) + mailgw.initialiseSecurity(self) + + def hasClassPermission(self, classname, permission, userid): + ''' Look through all the Roles, and hence Permissions, and see if + "permission" is there for the specified classname. + + ''' + roles = self.db.user.get(userid, 'roles') + for roleid in roles: + for permissionid in self.db.role.get(roleid, 'permissions'): + if self.db.permission.get(permissionid, 'name') != permission: + continue + klass = self.db.permission.get(permissionid, 'klass') + if klass is None or klass == classname: + return 1 + return 0 + + def hasNodePermission(self, classname, nodeid, **propspec): + ''' Check the named properties of the given node to see if the + userid appears in them. If it does, then the user is granted + this permission check. + + 'propspec' consists of a set of properties and values that + must be present on the given node for access to be granted. + + If a property is a Link, the value must match the property + value. If a property is a Multilink, the value must appear + in the Multilink list. + ''' + klass = self.db.getclass(classname) + properties = klass.getprops() + for k,v in propspec.items(): + value = klass.get(nodeid, k) + if isinstance(properties[k], hyperdb.Multilink): + if v not in value: + return 0 + else: + if v != value: + return 0 + return 1 + + def addPermission(self, **propspec): + ''' Create a new Permission with the properties defined in + 'propspec' + ''' + return self.db.permission.create(**propspec) + + def addRole(self, **propspec): + ''' Create a new Role with the properties defined in 'propspec' + ''' + return self.db.role.create(**propspec) + + def addPermissionToRole(self, rolename, permissionid): + ''' Add the permission to the role's permission list. + + 'rolename' is the name of the role to add 'permissionid'. + ''' + roleid = self.db.role.lookup(rolename) + permissions = self.db.role.get(roleid, 'permissions') + permissions.append(permissionid) + self.db.role.set(roleid, permissions=permissions) + diff --git a/roundup/volatiledb.py b/roundup/volatiledb.py new file mode 100644 index 0000000..6bffec3 --- /dev/null +++ b/roundup/volatiledb.py @@ -0,0 +1,274 @@ +import weakref, re + +from roundup import hyperdb +from roundup.hyperdb import String, Password, Date, Interval, Link, \ + Multilink, DatabaseError, Boolean, Number + +class VolatileClass(hyperdb.Class): + ''' This is a class that just sits in memory, no saving to disk. + It has no journal. + ''' + def __init__(self, db, classname, **properties): + ''' Set up an in-memory store for the nodes of this class + ''' + self.db = weakref.proxy(db) # use a weak ref to avoid circularity + self.classname = classname + self.properties = properties + self.id_counter = 1 + self.store = {} + self.by_key = {} + self.key = '' + db.addclass(self) + + def setkey(self, propname): + prop = self.getprops()[propname] + if not isinstance(prop, String): + raise TypeError, 'key properties must be String' + self.key = propname + + def getprops(self, protected=1): + d = self.properties.copy() + if protected: + d['id'] = String() + return d + + def create(self, **propvalues): + ''' Create a new node in the in-memory store + ''' + if propvalues.has_key('id'): + raise KeyError, '"id" is reserved' + newid = str(self.id_counter) + self.id_counter += 1 + + # get the key value, validate it + if self.key: + keyvalue = propvalues[self.key] + try: + self.lookup(keyvalue) + except KeyError: + pass + else: + raise ValueError, 'node with key "%s" exists'%keyvalue + self.by_key[keyvalue] = newid + + # validate propvalues + num_re = re.compile('^\d+$') + + for key, value in propvalues.items(): + + # try to handle this property + try: + prop = self.properties[key] + except KeyError: + raise KeyError, '"%s" has no property "%s"'%(self.classname, + key) + + if isinstance(prop, Link): + if type(value) != type(''): + raise ValueError, 'link value must be String' + link_class = self.properties[key].classname + # if it isn't a number, it's a key + if not num_re.match(value): + try: + value = self.db.classes[link_class].lookup(value) + except (TypeError, KeyError): + raise IndexError, 'new property "%s": %s not a %s'%( + key, value, link_class) + elif not self.db.hasnode(link_class, value): + raise IndexError, '%s has no node %s'%(link_class, value) + + # save off the value + propvalues[key] = value + + elif isinstance(prop, Multilink): + if type(value) != type([]): + raise TypeError, 'new property "%s" not a list of ids'%key + + # clean up and validate the list of links + link_class = self.properties[key].classname + l = [] + for entry in value: + if type(entry) != type(''): + raise ValueError, '"%s" link value (%s) must be '\ + 'String'%(key, value) + # if it isn't a number, it's a key + if not num_re.match(entry): + try: + entry = self.db.classes[link_class].lookup(entry) + except (TypeError, KeyError): + raise IndexError, 'new property "%s": %s not a %s'%( + key, entry, self.properties[key].classname) + l.append(entry) + value = l + propvalues[key] = value + + # handle additions + for id in value: + if not self.db.hasnode(link_class, id): + raise IndexError, '%s has no node %s'%(link_class, id) + + elif isinstance(prop, String): + if type(value) != type(''): + raise TypeError, 'new property "%s" not a string'%key + + elif isinstance(prop, Password): + if not isinstance(value, password.Password): + raise TypeError, 'new property "%s" not a Password'%key + + elif isinstance(prop, Date): + if value is not None and not isinstance(value, date.Date): + raise TypeError, 'new property "%s" not a Date'%key + + elif isinstance(prop, Interval): + if value is not None and not isinstance(value, date.Interval): + raise TypeError, 'new property "%s" not an Interval'%key + + # make sure there's data where there needs to be + for key, prop in self.properties.items(): + if propvalues.has_key(key): + continue + if key == self.key: + raise ValueError, 'key property "%s" is required'%key + if isinstance(prop, Multilink): + propvalues[key] = [] + else: + propvalues[key] = None + + # done + self.store[newid] = propvalues + + return newid + + _marker = [] + def get(self, nodeid, propname, default=_marker, cache=1): + ''' Get the node from the in-memory store + ''' + if propname == 'id': + return nodeid + return self.store[nodeid][propname] + + def set(self, nodeid, **propvalues): + ''' Set properties on the node in the in-memory store + ''' + if not propvalues: + return + + if propvalues.has_key('id'): + raise KeyError, '"id" is reserved' + + node = self.store[nodeid] + num_re = re.compile('^\d+$') + + for propname, value in propvalues.items(): + # check to make sure we're not duplicating an existing key + if propname == self.key and node[propname] != value: + try: + self.lookup(value) + except KeyError: + pass + else: + raise ValueError, 'node with key "%s" exists'%value + + # this will raise the KeyError if the property isn't valid + # ... we don't use getprops() here because we only care about + # the writeable properties. + prop = self.properties[propname] + + # if the value's the same as the existing value, no sense in + # doing anything + if node.has_key(propname) and value == node[propname]: + del propvalues[propname] + continue + + # do stuff based on the prop type + if isinstance(prop, Link): + link_class = self.properties[propname].classname + # if it isn't a number, it's a key + if type(value) != type(''): + raise ValueError, 'link value must be String' + if not num_re.match(value): + try: + value = self.db.classes[link_class].lookup(value) + except (TypeError, KeyError): + raise IndexError, 'new property "%s": %s not a %s'%( + propname, value, self.properties[propname].classname) + + if not self.db.hasnode(link_class, value): + raise IndexError, '%s has no node %s'%(link_class, value) + + elif isinstance(prop, Multilink): + if type(value) != type([]): + raise TypeError, 'new property "%s" not a list of'\ + ' ids'%propname + link_class = self.properties[propname].classname + l = [] + for entry in value: + # if it isn't a number, it's a key + if type(entry) != type(''): + raise ValueError, 'new property "%s" link value ' \ + 'must be a string'%propname + if not num_re.match(entry): + try: + entry = self.db.classes[link_class].lookup(entry) + except (TypeError, KeyError): + raise IndexError, 'new property "%s": %s not a %s'%( + propname, entry, + self.properties[propname].classname) + l.append(entry) + value = l + propvalues[propname] = value + + elif isinstance(prop, String): + if value is not None and type(value) != type(''): + raise TypeError, 'new property "%s" not a string'%propname + + elif isinstance(prop, Password): + if not isinstance(value, password.Password): + raise TypeError, 'new property "%s" not a Password'%propname + propvalues[propname] = value + + elif value is not None and isinstance(prop, Date): + if not isinstance(value, date.Date): + raise TypeError, 'new property "%s" not a Date'% propname + propvalues[propname] = value + + elif value is not None and isinstance(prop, Interval): + if not isinstance(value, date.Interval): + raise TypeError, 'new property "%s" not an '\ + 'Interval'%propname + propvalues[propname] = value + + elif value is not None and isinstance(prop, Number): + try: + float(value) + except ValueError: + raise TypeError, 'new property "%s" not numeric'%propname + + elif value is not None and isinstance(prop, Boolean): + try: + int(value) + except ValueError: + raise TypeError, 'new property "%s" not boolean'%propname + + node[propname] = value + + # do the set + self.store[nodeid] = node + + def lookup(self, keyvalue): + ''' look up the key node in the store + ''' + return self.by_key[keyvalue] + + def hasnode(self, nodeid): + nodeid = str(nodeid) + return self.store.has_key(nodeid) + + def list(self): + l = self.store.keys() + l.sort() + return l + + def index(self, nodeid): + pass + diff --git a/test/test_db.py b/test/test_db.py index 47a1bba..ffc07c7 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -15,7 +15,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: test_db.py,v 1.36 2002-07-19 03:36:34 richard Exp $ +# $Id: test_db.py,v 1.37 2002-07-25 07:14:06 richard Exp $ import unittest, os, shutil, time @@ -28,12 +28,13 @@ def setupSchema(db, create, module): status = module.Class(db, "status", name=String()) status.setkey("name") user = module.Class(db, "user", username=String(), password=Password(), - assignable=Boolean(), age=Number()) + assignable=Boolean(), age=Number(), roles=Multilink('role')) + user.setkey("username") file = module.FileClass(db, "file", name=String(), type=String(), comment=String(indexme="yes")) issue = module.IssueClass(db, "issue", title=String(indexme="yes"), status=Link("status"), nosy=Multilink("user"), deadline=Date(), - foo=Interval(), files=Multilink("file")) + foo=Interval(), files=Multilink("file"), assignedto=Link('user')) session = module.Class(db, 'session', title=String()) session.disableJournalling() db.post_init() @@ -113,13 +114,13 @@ class anydbmDBTestCase(MyTestCase): self.assertNotEqual(self.db.issue.get('1', "foo"), a) def testBooleanChange(self): - self.db.user.create(username='foo', assignable=1) - self.db.user.create(username='foo', assignable=0) - a = self.db.user.get('1', 'assignable') - self.db.user.set('1', assignable=0) - self.assertNotEqual(self.db.user.get('1', 'assignable'), a) - self.db.user.set('1', assignable=0) - self.db.user.set('1', assignable=1) + userid = self.db.user.create(username='foo', assignable=1) + self.db.user.create(username='foo2', assignable=0) + a = self.db.user.get(userid, 'assignable') + self.db.user.set(userid, assignable=0) + self.assertNotEqual(self.db.user.get(userid, 'assignable'), a) + self.db.user.set(userid, assignable=0) + self.db.user.set(userid, assignable=1) def testNumberChange(self): self.db.user.create(username='foo', age='1') @@ -129,15 +130,14 @@ class anydbmDBTestCase(MyTestCase): self.db.user.set('1', age='1.0') def testNewProperty(self): - ' make sure a new property is added ok ' self.db.issue.create(title="spam", status='1') self.db.issue.addprop(fixer=Link("user")) props = self.db.issue.getprops() keys = props.keys() keys.sort() - self.assertEqual(keys, ['activity', 'creation', 'creator', 'deadline', - 'files', 'fixer', 'foo', 'id', 'messages', 'nosy', 'status', - 'superseder', 'title']) + self.assertEqual(keys, ['activity', 'assignedto', 'creation', + 'creator', 'deadline', 'files', 'fixer', 'foo', 'id', 'messages', + 'nosy', 'status', 'superseder', 'title']) self.assertEqual(self.db.issue.get('1', "fixer"), None) def testRetire(self): @@ -193,11 +193,9 @@ class anydbmDBTestCase(MyTestCase): self.assertEqual(num_files2, self.db.numfiles()) def testDestroyNoJournalling(self): - ' test destroy on a class with no journalling ' self.innerTestDestroy(klass=self.db.session) def testDestroyJournalling(self): - ' test destroy on a class with journalling ' self.innerTestDestroy(klass=self.db.issue) def innerTestDestroy(self, klass): @@ -332,8 +330,8 @@ class anydbmDBTestCase(MyTestCase): self.assertEqual(action, 'create') keys = params.keys() keys.sort() - self.assertEqual(keys, ['deadline', 'files', 'fixer', 'foo', - 'messages', 'nosy', 'status', 'superseder', 'title']) + self.assertEqual(keys, ['assignedto', 'deadline', 'files', 'fixer', + 'foo', 'messages', 'nosy', 'status', 'superseder', 'title']) self.assertEqual(None,params['deadline']) self.assertEqual(None,params['fixer']) self.assertEqual(None,params['foo']) @@ -455,7 +453,6 @@ class anydbmReadOnlyDBTestCase(MyTestCase): setupSchema(self.db2, 0, anydbm) def testExceptions(self): - ' make sure exceptions are raised on writes to a read-only db ' # this tests the exceptions that should be raised ar = self.assertRaises @@ -606,6 +603,15 @@ def suite(): # # $Log: not supported by cvs2svn $ +# Revision 1.36 2002/07/19 03:36:34 richard +# Implemented the destroy() method needed by the session database (and possibly +# others). At the same time, I removed the leading underscores from the hyperdb +# methods that Really Didn't Need Them. +# The journal also raises IndexError now for all situations where there is a +# request for the journal of a node that doesn't have one. It used to return +# [] in _some_ situations, but not all. This _may_ break code, but the tests +# pass... +# # Revision 1.35 2002/07/18 23:07:08 richard # Unit tests and a few fixes. # diff --git a/test/test_htmltemplate.py b/test/test_htmltemplate.py index 8461e7e..8b155fc 100644 --- a/test/test_htmltemplate.py +++ b/test/test_htmltemplate.py @@ -8,17 +8,17 @@ # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # -# $Id: test_htmltemplate.py,v 1.17 2002-07-18 23:07:07 richard Exp $ +# $Id: test_htmltemplate.py,v 1.18 2002-07-25 07:14:06 richard Exp $ -import unittest, cgi, time +import unittest, cgi, time, os, shutil from roundup import date, password -from roundup.htmltemplate import TemplateFunctions +from roundup.htmltemplate import TemplateFunctions, IndexTemplate, ItemTemplate from roundup.i18n import _ from roundup.hyperdb import String, Password, Date, Interval, Link, \ Multilink, Boolean, Number -class Class: +class TestClass: def get(self, nodeid, attribute, default=None): if attribute == 'string': return 'Node %s: I am a string'%nodeid @@ -62,26 +62,23 @@ class Class: def labelprop(self, default_to_id=0): return 'key' -class Database: - classes = {'other': Class()} +class TestDatabase: + classes = {'other': TestClass()} def getclass(self, name): return Class() def __getattr(self, name): return Class() -class Client: - write = None - -class NodeCase(unittest.TestCase): +class FunctionCase(unittest.TestCase): def setUp(self): ''' Set up the harness for calling the individual tests ''' self.tf = tf = TemplateFunctions() tf.nodeid = '1' - tf.cl = Class() + tf.cl = TestClass() tf.classname = 'test_class' tf.properties = tf.cl.getprops() - tf.db = Database() + tf.db = TestDatabase() # def do_plain(self, property, escape=0): def testPlain_string(self): @@ -400,7 +397,7 @@ the key2:''') '(?)') -# def do_multiline(self, property, rows=5, cols=40) +# def do_email(self, property, rows=5, cols=40) def testEmail_string(self): self.assertEqual(self.tf.do_email('email'), 'test at foo domain example') @@ -414,12 +411,147 @@ the key2:''') self.assertEqual(self.tf.do_email('boolean'), s) self.assertEqual(self.tf.do_email('number'), s) + +from test_db import setupSchema, MyTestCase, config + +class Client: + user = 'admin' + +class IndexTemplateCase(unittest.TestCase): + def setUp(self): + from roundup.backends import anydbm + # remove previous test, ignore errors + if os.path.exists(config.DATABASE): + shutil.rmtree(config.DATABASE) + os.makedirs(config.DATABASE + '/files') + self.db = anydbm.Database(config, 'test') + setupSchema(self.db, 1, anydbm) + + client = Client() + client.db = self.db + client.instance = None + self.tf = tf = IndexTemplate(client, '', 'issue') + tf.props = ['title'] + + # admin user + r = str(self.db.role.lookup('Admin')) + self.db.user.create(username="admin", roles=[r]) + r = str(self.db.role.lookup('User')) + self.db.user.create(username="anonymous", roles=[r]) + + def testBasic(self): + self.assertEqual(self.tf.execute_template('hello'), 'hello') + + def testValue(self): + self.tf.nodeid = self.db.issue.create(title="spam", status='1') + self.assertEqual(self.tf.execute_template(''), 'spam') + + def testColumnSelection(self): + self.tf.nodeid = self.db.issue.create(title="spam", status='1') + self.assertEqual(self.tf.execute_template('' + '' + 'hello'), 'spam') + self.tf.props = ['bar'] + self.assertEqual(self.tf.execute_template('' + '' + 'hello'), 'hello') + + def testSecurityPass(self): + self.assertEqual(self.tf.execute_template( + 'hellofoo'), 'hello') + + def testSecurityPassValue(self): + self.tf.nodeid = self.db.issue.create(title="spam", status='1') + self.assertEqual(self.tf.execute_template( + '' + '' + 'not allowed'), 'spam') + + def testSecurityFail(self): + self.tf.client.user = 'anonymous' + self.assertEqual(self.tf.execute_template( + 'hellofoo'), 'foo') + + def testSecurityFailValue(self): + self.tf.nodeid = self.db.issue.create(title="spam", status='1') + self.tf.client.user = 'anonymous' + self.assertEqual(self.tf.execute_template( + 'allowed' + ''), 'spam') + + def tearDown(self): + if os.path.exists('_test_dir'): + shutil.rmtree('_test_dir') + + +class ItemTemplateCase(unittest.TestCase): + def setUp(self): + ''' Set up the harness for calling the individual tests + ''' + from roundup.backends import anydbm + # remove previous test, ignore errors + if os.path.exists(config.DATABASE): + shutil.rmtree(config.DATABASE) + os.makedirs(config.DATABASE + '/files') + self.db = anydbm.Database(config, 'test') + setupSchema(self.db, 1, anydbm) + + client = Client() + client.db = self.db + client.instance = None + self.tf = tf = IndexTemplate(client, '', 'issue') + tf.nodeid = self.db.issue.create(title="spam", status='1') + + # admin user + r = str(self.db.role.lookup('Admin')) + self.db.user.create(username="admin", roles=[r]) + r = str(self.db.role.lookup('User')) + self.db.user.create(username="anonymous", roles=[r]) + + def testBasic(self): + self.assertEqual(self.tf.execute_template('hello'), 'hello') + + def testValue(self): + self.assertEqual(self.tf.execute_template(''), 'spam') + + def testSecurityPass(self): + self.assertEqual(self.tf.execute_template( + 'hellofoo'), 'hello') + + def testSecurityPassValue(self): + self.assertEqual(self.tf.execute_template( + '' + '' + 'not allowed'), 'spam') + + def testSecurityFail(self): + self.tf.client.user = 'anonymous' + self.assertEqual(self.tf.execute_template( + 'hellofoo'), 'foo') + + def testSecurityFailValue(self): + self.tf.client.user = 'anonymous' + self.assertEqual(self.tf.execute_template( + 'allowed' + ''), 'spam') + + def tearDown(self): + if os.path.exists('_test_dir'): + shutil.rmtree('_test_dir') + def suite(): - return unittest.makeSuite(NodeCase, 'test') + return unittest.TestSuite([ + unittest.makeSuite(FunctionCase, 'test'), + unittest.makeSuite(IndexTemplateCase, 'test'), + unittest.makeSuite(ItemTemplateCase, 'test'), + ]) # # $Log: not supported by cvs2svn $ +# Revision 1.17 2002/07/18 23:07:07 richard +# Unit tests and a few fixes. +# # Revision 1.16 2002/07/09 05:20:09 richard # . added email display function - mangles email addrs so they're not so easily # scraped from the web diff --git a/test/test_security.py b/test/test_security.py new file mode 100644 index 0000000..89b4c46 --- /dev/null +++ b/test/test_security.py @@ -0,0 +1,99 @@ +# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# $Id: test_security.py,v 1.1 2002-07-25 07:14:06 richard Exp $ + +import os, unittest, shutil + +from roundup.password import Password +from test_db import setupSchema, MyTestCase, config + +class PermissionTest(MyTestCase): + def setUp(self): + from roundup.backends import anydbm + # remove previous test, ignore errors + if os.path.exists(config.DATABASE): + shutil.rmtree(config.DATABASE) + os.makedirs(config.DATABASE + '/files') + self.db = anydbm.Database(config, 'test') + setupSchema(self.db, 1, anydbm) + + def testInterfaceSecurity(self): + ' test that the CGI and mailgw have initialised security OK ' + # TODO: some asserts + + def testInitialiseSecurity(self): + ''' Create some Permissions and Roles on the security object + + This function is directly invoked by security.Security.__init__() + as a part of the Security object instantiation. + ''' + ei = self.db.security.addPermission(name="Edit", klass="issue", + description="User is allowed to edit issues") + self.db.security.addPermissionToRole('User', ei) + ai = self.db.security.addPermission(name="Assign", klass="issue", + description="User may be assigned to issues") + self.db.security.addPermissionToRole('User', ai) + + def testDBinit(self): + r = str(self.db.role.lookup('Admin')) + self.db.user.create(username="admin", roles=[r]) + r = str(self.db.role.lookup('User')) + self.db.user.create(username="anonymous", roles=[r]) + + def testAccess(self): + self.testDBinit() + self.testInitialiseSecurity() + + # test class-level access + userid = self.db.user.lookup('admin') + self.assertEquals(self.db.security.hasClassPermission('issue', + 'Edit', userid), 1) + self.assertEquals(self.db.security.hasClassPermission('user', + 'Edit', userid), 1) + userid = self.db.user.lookup('anonymous') + self.assertEquals(self.db.security.hasClassPermission('issue', + 'Edit', userid), 1) + self.assertEquals(self.db.security.hasClassPermission('user', + 'Edit', userid), 0) + + # test node-level access + issueid = self.db.issue.create(title='foo', assignedto='admin') + userid = self.db.user.lookup('admin') + self.assertEquals(self.db.security.hasNodePermission('issue', + issueid, assignedto=userid), 1) + self.assertEquals(self.db.security.hasNodePermission('issue', + issueid, nosy=userid), 0) + self.db.issue.set(issueid, nosy=[userid]) + self.assertEquals(self.db.security.hasNodePermission('issue', + issueid, nosy=userid), 1) + +def suite(): + return unittest.makeSuite(PermissionTest) + + +# +# $Log: not supported by cvs2svn $ +# Revision 1.1 2002/07/10 06:40:01 richard +# ehem, forgot to add +# +# +# +# vim: set filetype=python ts=4 sw=4 et si -- 2.30.2