Code

Bugger it. Here's the current shape of the new security implementation.
authorrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Thu, 25 Jul 2002 07:14:06 +0000 (07:14 +0000)
committerrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Thu, 25 Jul 2002 07:14:06 +0000 (07:14 +0000)
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

15 files changed:
COPYING.txt [new file with mode: 0644]
TODO.txt
doc/Makefile
doc/default.css
doc/security.txt
roundup/admin.py
roundup/backends/back_anydbm.py
roundup/cgi_client.py
roundup/htmltemplate.py
roundup/mailgw.py
roundup/security.py [new file with mode: 0644]
roundup/volatiledb.py [new file with mode: 0644]
test/test_db.py
test/test_htmltemplate.py
test/test_security.py [new file with mode: 0644]

diff --git a/COPYING.txt b/COPYING.txt
new file mode 100644 (file)
index 0000000..330c6f7
--- /dev/null
@@ -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.
+
index e41bb1e1128f0e0287c2b585881f30afd730bbf6..b5889345504b49ef35836daaeed84f53129d93fb 100644 (file)
--- 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)
 
index 1f22b0ea466fba622ec2345c470e1f49fc61360b..1141e1432e97b0431a77afc32ef53eb5cfa3b087 100644 (file)
@@ -10,5 +10,5 @@ COMPILED := $(SOURCE:.txt=.html)
 all: ${COMPILED}
 
 %.html: %.txt
-       ${PYTHON} ${STXTOHTML} -d -v $< $@
+       ${PYTHON} ${STXTOHTML} -d $< $@
 
index e2bfb08eb18f91c2bdb5499ffab59e53504161a5..2495eed223e4c9b42a8f968dcdef2b1d5dd0a929 100644 (file)
@@ -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; }
+}
+
index 4cdb3b0ef6737fbf4b2dfdc60877ffbc1db6ca89..2d3748a77cd188222108714d379994344504868e 100644 (file)
@@ -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, <permission> 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, ``<require>``
+which has the form::
 
-  <permission require=name,name,name node=assignedto>
+  <require permission="name,name,name" assignedto="$userid" status="open">
    HTML to display if the user has the permission.
   <else>
    HTML to display if the user does not have the permission.
-  </permission>
+  </require>
+
+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 ``<require>`` check will fail. The section
+of html within the side of the ``<else>`` 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 ``<else>`` 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
 ======
 
index b213c14d70624d8cee19dcd3cfc038ae3694fa89..305da4e355e1e5f49fffc5715299356b207fd29e 100644 (file)
@@ -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
 #
index 933738dbc6d6a436c398502f4d574fb6fefc4a9c..128453ff67575d67f11b1ded6dfd1912d8d3759a 100644 (file)
@@ -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.
 #
index f45667373c2afeb88ba536845a725f820e1e3fb1..9574d95690cf0df269752160c3af5fc5e4974c41 100644 (file)
@@ -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.
index 2dc9af7ea6d11aa3e146ba92d8811216d80d2bb1..2558f3750f42edae51cc119c319903534433fbc8 100644 (file)
 # 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 = '<form onSubmit="return submit_once()" action="%s%s">\n'%(self.classname,self.nodeid)
+        qform = '<form onSubmit="return submit_once()" action="%s%s">\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 + '</table>\n'
-        
+
+    # 
+    # templating subtitution methods
+    #
+    def execute_template(self, text):
+        ''' do the replacement of the template stuff with useful
+            information
+        '''
+        replace = re.compile(
+            r'((<require\s+(?P<cond>.+?)>(?P<ok>.+?)'
+                r'(<else>(?P<fail>.*?))?</require>)|'
+            r'(<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+            r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
+        return replace.sub(self.subfunc, text)
+
+    #
+    # secutiry <require> 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'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
-        r'(?P<display><display\s+call="(?P<command>[^"]+)">))', 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('<form onSubmit="return submit_once()" action="%s">\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('<table width=100% border=0 cellspacing=0 cellpadding=2>\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('<td><span class="list-header"><a href="%s">%s</a></span></td>\n'%(
-                    anchor, cname))
+                w('<td><span class="list-header"><a href="%s">%s</a>'
+                    '</span></td>\n'%(anchor, cname))
             else:
                 w('<td><span class="list-header">%s</span></td>\n'%cname)
         w('</tr>\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('</table>\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 = '<a href="%s&:startwith=%s">&lt;&lt; Previous page</a>' % \
-                          (baseurl, max(0, startwith-pagesize)) 
+                prevurl = '<a href="%s&:startwith=%s">&lt;&lt; '\
+                    'Previous page</a>'%(baseurl, max(0, startwith-pagesize)) 
             else:
                 prevurl = "" 
             if startwith + pagesize < len(nodeids):
-                nexturl = '<a href="%s&:startwith=%s">Next page &gt;&gt;</a>' % (baseurl, startwith+pagesize)
+                nexturl = '<a href="%s&:startwith=%s">Next page '\
+                    '&gt;&gt;</a>'%(baseurl, startwith+pagesize)
             else:
                 nexturl = ""
             if prevurl or nexturl:
-                w('<table width="100%%"><tr><td width="50%%" align="center">%s</td><td width="50%%" align="center">%s</td></tr></table>\n' % (prevurl, nexturl))
+                w('''<table width="100%%"><tr>
+                      <td width="50%%" align="center">%s</td>
+                      <td width="50%%" align="center">%s</td>
+                     </tr></table>\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('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
-            self.filter_section(search_text, filter, columns, group, all_columns, sort, filterspec,
-                                pagesize, startwith)
-
+            w('<form onSubmit="return submit_once()" action="%s">\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):
                 '&nbsp;&nbsp;Matched files: %s</td></tr>\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'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
-        r'(?P<display><display\s+call="(?P<command>[^"]+)">))', 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('<form onSubmit="return submit_once()" action="%s%s" method="POST" enctype="multipart/form-data">'%(
             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('</form>')
         
         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('<input type="hidden" name="%s" value="%s">'%(key, value))
-        replace = ItemTemplateReplace(self.globals, locals(), None, None)
-        w(replace.go(s))
+        w(self.execute_template(s))
         w('</form>')
         
         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.
 #
index b5d802d150b6b2af0db135c5cd50c5c3536c30be..88868fd39cfa789d5333183e0de60eed58013f59 100644 (file)
@@ -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 (file)
index 0000000..7475ca6
--- /dev/null
@@ -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 (file)
index 0000000..6bffec3
--- /dev/null
@@ -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
+
index 47a1bbadb93bda06f8901be6b0d432ee5d050e34..ffc07c7c8e8414c96d7179d750f0171213ab1d3f 100644 (file)
@@ -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.
 #
index 8461e7e67a01096581a3144185a98955e956047e..8b155fc2422845b87a5487696153426b03e54106 100644 (file)
@@ -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:<input type="checkbox" checked name="multilink" value="the key2">''')
             '<a href="javascript:help_window(\'classhelp?classname=theclass'
             '&properties=prop1,prop2\', \'400\', \'400\')"><b>(?)</b></a>')
 
-#    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:<input type="checkbox" checked name="multilink" value="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('<display call="plain(\'title\')">'), 'spam')
+
+    def testColumnSelection(self):
+        self.tf.nodeid = self.db.issue.create(title="spam", status='1')
+        self.assertEqual(self.tf.execute_template('<property name="title">'
+            '<display call="plain(\'title\')"></property>'
+            '<property name="bar">hello</property>'), 'spam')
+        self.tf.props = ['bar']
+        self.assertEqual(self.tf.execute_template('<property name="title">'
+            '<display call="plain(\'title\')"></property>'
+            '<property name="bar">hello</property>'), 'hello')
+
+    def testSecurityPass(self):
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">hello<else>foo</require>'), 'hello')
+
+    def testSecurityPassValue(self):
+        self.tf.nodeid = self.db.issue.create(title="spam", status='1')
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">'
+            '<display call="plain(\'title\')">'
+            '<else>not allowed</require>'), 'spam')
+
+    def testSecurityFail(self):
+        self.tf.client.user = 'anonymous'
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">hello<else>foo</require>'), '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(
+            '<require permission="Edit">allowed<else>'
+            '<display call="plain(\'title\')"></require>'), '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('<display call="plain(\'title\')">'), 'spam')
+
+    def testSecurityPass(self):
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">hello<else>foo</require>'), 'hello')
+
+    def testSecurityPassValue(self):
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">'
+            '<display call="plain(\'title\')">'
+            '<else>not allowed</require>'), 'spam')
+
+    def testSecurityFail(self):
+        self.tf.client.user = 'anonymous'
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">hello<else>foo</require>'), 'foo')
+
+    def testSecurityFailValue(self):
+        self.tf.client.user = 'anonymous'
+        self.assertEqual(self.tf.execute_template(
+            '<require permission="Edit">allowed<else>'
+            '<display call="plain(\'title\')"></require>'), '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 (file)
index 0000000..89b4c46
--- /dev/null
@@ -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