source: asadb/groups/models.py @ b42118c

space-accessstablestage
Last change on this file since b42118c was cf4b7f4, checked in by Alex Dehnert <adehnert@…>, 14 years ago

Use a new PAG for accessing constitutions

This fixes a potential privilege escalation issue from asa-internal (or anybody
else who can read constitutions) to asa-db-root. In particular, by putting a
path within the asa-db locker in for their constitution, they could convince
the DB to copy /mit/asa-db/.my.cnf or other sensitive files into the
constitutions directory, and then read it. By creating a new PAG, we drop the
daemon.scripts privileges and prevent the attack.

In a future change, we may wish to aklog with a new principal so as to be able
to read non-public constitutions. When we do so, we should be careful not to
use daemon.asa-db or any other principal with privileged read access to AFS.

  • Property mode set to 100644
File size: 21.0 KB
Line 
1from django.db import models
2from django.core.validators import RegexValidator
3from django.contrib.auth.models import User
4from django.template.defaultfilters import slugify
5import reversion
6
7import datetime
8import filecmp
9import mimetypes
10import os
11import re
12import shutil
13import subprocess
14import urlparse
15import urllib
16import urllib2
17
18import settings
19
20import mit
21
22# Create your models here.
23
24class ActiveGroupManager(models.Manager):
25    def get_query_set(self, ):
26        return super(ActiveGroupManager, self).get_query_set().filter(
27            group_status__slug='active',
28        )
29
30locker_validator = RegexValidator(regex=r'^[-A-Za-z0-9_.]+$', message='Enter a valid Athena locker.')
31
32class Group(models.Model):
33    name = models.CharField(max_length=100, db_index=True, )
34    abbreviation = models.CharField(max_length=10, blank=True, db_index=True, )
35    description = models.TextField()
36    activity_category = models.ForeignKey('ActivityCategory', null=True, blank=True, db_index=True, )
37    group_class = models.ForeignKey('GroupClass', db_index=True, )
38    group_status = models.ForeignKey('GroupStatus', db_index=True, )
39    group_funding = models.ForeignKey('GroupFunding', null=True, blank=True, db_index=True, )
40    website_url = models.URLField()
41    constitution_url = models.CharField(max_length=200, blank=True, validators=[mit.UrlOrAfsValidator])
42    meeting_times = models.TextField(blank=True)
43    advisor_name = models.CharField(max_length=100, blank=True)
44    num_undergrads = models.IntegerField(null=True, blank=True, )
45    num_grads = models.IntegerField(null=True, blank=True, )
46    num_community = models.IntegerField(null=True, blank=True, )
47    num_other = models.IntegerField(null=True, blank=True, )
48    group_email = models.EmailField(verbose_name="group email list", blank=True, )
49    officer_email = models.EmailField(verbose_name="officers' email list")
50    main_account_id = models.IntegerField(null=True, blank=True, )
51    funding_account_id = models.IntegerField(null=True, blank=True, )
52    athena_locker = models.CharField(max_length=20, blank=True, validators=[locker_validator])
53    recognition_date = models.DateTimeField()
54    update_date = models.DateTimeField(editable=False, )
55    updater = models.CharField(max_length=30, editable=False, null=True, ) # match Django username field
56    _updater_set = False
57
58    objects = models.Manager()
59    active_groups = ActiveGroupManager()
60
61    def update_string(self, ):
62        updater = self.updater or "unknown"
63        return "%s by %s" % (self.update_date.strftime(settings.DATETIME_FORMAT_PYTHON), updater, )
64
65    def set_updater(self, who):
66        if hasattr(who, 'username'):
67            self.updater = who.username
68        else:
69            self.updater = who
70        self._updater_set = True
71
72    def save(self, ):
73        if not self._updater_set:
74            self.updater = None
75        self.update_date = datetime.datetime.now()
76        super(Group, self).save()
77
78    def viewable_notes(self, user):
79        return GroupNote.viewable_notes(self, user)
80
81    def officers(self, role=None, person=None, as_of="now",):
82        """Get the set of people holding some office.
83
84        If None is passed for role, person, or as_of, that field will not
85        be constrained. If as_of is "now" (default) the status will be
86        required to be current. If any of the three parameters are set
87        to another value, the corresponding filter will be applied.
88        """
89        office_holders = OfficeHolder.objects.filter(group=self,)
90        if role:
91            if isinstance(role, str):
92                office_holders = office_holders.filter(role__slug=role)
93            else:
94                office_holders = office_holders.filter(role=role)
95        if person:
96            office_holders = office_holders.filter(person=person)
97        if as_of:
98            if as_of == "now": as_of = datetime.datetime.now()
99            office_holders = office_holders.filter(start_time__lte=as_of, end_time__gte=as_of)
100        return office_holders
101
102    def slug(self, ):
103        return slugify(self.name)
104
105    def __str__(self, ):
106        return self.name
107
108    @classmethod
109    def reporting_prefetch(cls, ):
110        return set(['activity_category', 'group_class', 'group_status', 'group_funding'])
111
112    @classmethod
113    def reporting_fields(cls, ):
114        fields = cls._meta.fields
115        return [(f.name, f.verbose_name) for f in fields]
116
117    class Meta:
118        ordering = ('name', )
119        permissions = (
120            ('view_group_private_info', 'View private group information'),
121            # ability to update normal group info or people
122            # this is weaker than change_group, which is the built-in
123            # permission that controls the admin interface
124            ('admin_group', 'Administer basic group information'),
125            ('view_signatories', 'View signatory information for all groups'),
126            ('recognize_nge', 'Recognize Non-Group Entity'),
127            ('recognize_group', 'Recognize groups'),
128        )
129reversion.register(Group)
130
131
132constitution_dir = os.path.join(settings.SITE_ROOT, '..', 'constitutions')
133
134class GroupConstitution(models.Model):
135    group = models.ForeignKey(Group, unique=True, )
136    source_url = models.URLField()
137    dest_file = models.CharField(max_length=100)
138    last_update = models.DateTimeField(help_text='Last time when this constitution actually changed.')
139    last_download = models.DateTimeField(help_text='Last time we downloaded this constitution to see if it had changed.')
140    failure_date = models.DateTimeField(null=True, blank=True, default=None, help_text='Time this URL started failing to download. (Null if currently working.)')
141    status_msg = models.CharField(max_length=100)
142    failure_reason = models.CharField(max_length=100, blank=True, default="")
143
144    def record_failure(self, msg):
145        now = datetime.datetime.now()
146        if not self.failure_date:
147            self.failure_date = now
148        self.status_msg = msg
149        self.failure_reason = self.status_msg
150        self.save()
151
152    def record_success(self, msg, updated):
153        now = datetime.datetime.now()
154        if updated:
155            self.last_update = now
156        self.status_msg = msg
157        self.last_download = now
158        self.failure_date = None
159        self.failure_reason = ""
160        self.save()
161
162    def update(self, ):
163        url = self.source_url
164        success = None
165        old_success = (self.failure_date is None)
166        if url:
167            # Fetch the file
168            error_msg = None
169            try:
170                new_mimetype = None
171                if url.startswith('/afs/') or url.startswith('/mit/'):
172                    new_data = mit.pag_check_output(['/bin/cat', url], aklog=False, stderr=subprocess.STDOUT)
173                else:
174                    new_fp = urllib2.urlopen(url)
175                    if new_fp.info().getheader('Content-Type'):
176                        new_mimetype = new_fp.info().gettype()
177                    new_data = new_fp.read()
178                    new_fp.close()
179            except urllib2.HTTPError, e:
180                error_msg = "HTTPError: %s %s" % (e.code, e.msg)
181            except urllib2.URLError, e:
182                error_msg = "URLError: %s" % (e.reason)
183            except subprocess.CalledProcessError, e:
184                results = e.output.split(": ")
185                if len(results) == 3 and results[0] == '/bin/cat' and results[1] == url:
186                    cat_err = results[2]
187                else:
188                    cat_err = e.output
189                error_msg = "CalledProcessError %d: %s" % (e.returncode, cat_err)
190            except IOError:
191                error_msg = "IOError"
192            except ValueError, e:
193                if e.args[0].startswith('unknown url type'):
194                    error_msg = "unknown url type"
195                else:
196                    raise
197            if error_msg:
198                self.record_failure(error_msg)
199                return (False, self.status_msg, old_success, )
200
201            # At this point, failures are our fault, not the group's.
202            # We can let any errors bubble all the way up, rather than
203            # trying to catch and neatly record them
204            success = True
205
206            # Find a destination, and how to put it there
207            old_path = self.path_from_filename(self.dest_file)
208            new_filename = self.compute_filename(url, new_mimetype, )
209
210            # Process the update
211            if new_filename != self.dest_file: # new filename
212                if self.dest_file:
213                    if os.path.exists(old_path):
214                        os.remove(old_path)
215                    else:
216                        print "Warning: %s doesn't exist, but is referenced by dest_file" % (old_path, )
217                self.dest_file = new_filename
218                new_path = self.path_from_filename(new_filename)
219                with open(new_path, 'wb') as fp:
220                    fp.write(new_data)
221                self.record_success("new path", updated=True)
222            else: # old filename
223                with open(old_path, 'rb') as old_fp:
224                    old_data = old_fp.read()
225                if old_data == new_data: # unchanged
226                    self.record_success("no change", updated=False)
227                else: # changed
228                    with open(old_path, 'wb') as fp:
229                        fp.write(new_data)
230                    self.record_success("updated in place", updated=True)
231
232        else:
233            self.record_failure("no url")
234            success = False
235
236        return (success, self.status_msg, old_success, )
237
238    def compute_filename(self, url, mimetype):
239        slug = self.group.slug()
240        known_ext = set([
241            '.pdf',
242            '.ps',
243            '.doc',
244            '.rtf',
245            '.html',
246            '.tex',
247            '.txt'
248        ])
249
250        # This probably breaks on Windows. But that's probably true of
251        # everything...
252        path = urlparse.urlparse(url).path
253        basename, fileext = os.path.splitext(path)
254
255        if fileext:
256            ext = fileext
257        else:
258            if mimetype:
259                extensions = mimetypes.guess_all_extensions(mimetype)
260                for extension in extensions:
261                    if extension in known_ext:
262                        ext = extension
263                        break
264                else:
265                    if len(extensions) > 0:
266                        ext = extensions[0]
267                    else:
268                        ext = ''
269            else:
270                ext = ''
271
272        extmap = {
273            '.htm': '.html',
274            '.php': '.html',
275            '.PS':  '.ps',
276            '.shtml':   '.html',
277            '.text':    '.txt',
278        }
279        # we have no real handling of no extension, .old, and .ksh
280        if ext in extmap: ext = extmap[ext]
281        if ext not in known_ext: ext = ext + '.unknown'
282
283        return "%04d-%s%s" % (self.group.pk, slug, ext, )
284
285    def path_from_filename(self, filename):
286        path = os.path.join(constitution_dir, filename)
287        return path
288
289    def webstat(self, ):
290        url = self.source_url
291        if url:
292            try:
293                stream = urllib.urlopen(self.source_url)
294                return stream.getcode()
295            except IOError:
296                return "IOError"
297        else:
298            return "no-url"
299
300reversion.register(GroupConstitution)
301
302GROUP_STARTUP_STAGE_SUBMITTED = 10
303GROUP_STARTUP_STAGE_APPROVED = 20
304GROUP_STARTUP_STAGE_REJECTED = -10
305GROUP_STARTUP_STAGE = (
306    (GROUP_STARTUP_STAGE_SUBMITTED,     'submitted'),
307    (GROUP_STARTUP_STAGE_APPROVED,      'approved'),
308    (GROUP_STARTUP_STAGE_REJECTED,      'rejected'),
309)
310
311
312class GroupStartup(models.Model):
313    group = models.ForeignKey(Group, unique=True, )
314    stage = models.IntegerField(choices=GROUP_STARTUP_STAGE)
315    submitter = models.CharField(max_length=30, editable=False, )
316    create_officer_list = models.BooleanField()
317    create_group_list = models.BooleanField()
318    create_athena_locker = models.BooleanField()
319    president_name = models.CharField(max_length=50)
320    president_kerberos = models.CharField(max_length=8)
321    treasurer_name = models.CharField(max_length=50)
322    treasurer_kerberos = models.CharField(max_length=8)
323reversion.register(GroupStartup)
324
325
326class GroupNote(models.Model):
327    author = models.CharField(max_length=30, db_index=True, ) # match Django username field
328    timestamp = models.DateTimeField(default=datetime.datetime.now, editable=False, )
329    body = models.TextField()
330    acl_read_group = models.BooleanField(default=True, help_text='Can the group read this note')
331    acl_read_offices = models.BooleanField(default=True, help_text='Can "offices" that interact with groups (SAO, CAC, and funding boards) read this note')
332    group = models.ForeignKey(Group, db_index=True, )
333
334    def __str__(self, ):
335        return "Note by %s on %s" % (self.author, self.timestamp, )
336
337    @classmethod
338    def viewable_notes(cls, group, user):
339        notes = cls.objects.filter(group=group)
340        if not user.has_perm('groups.view_note_all'):
341            q = models.Q(pk=0)
342            if user.has_perm('groups.view_note_group', group):
343                q |= models.Q(acl_read_group=True)
344            if user.has_perm('groups.view_note_office'):
345                q |= models.Q(acl_read_offices=True)
346            notes = notes.filter(q)
347        return notes
348
349    class Meta:
350        permissions = (
351            ('view_note_group',     'View notes intended for the group to see', ),
352            ('view_note_office',    'View notes intended for "offices" to see', ),
353            ('view_note_all',       'View all notes', ),
354        )
355reversion.register(GroupNote)
356
357
358class OfficerRole(models.Model):
359    UNLIMITED = 10000
360
361    display_name = models.CharField(max_length=50)
362    slug = models.SlugField(unique=True, )
363    description = models.TextField()
364    max_count = models.IntegerField(default=UNLIMITED, help_text='Maximum number of holders of this role. Use %d for no limit.' % UNLIMITED)
365    require_student = models.BooleanField(default=False)
366    grant_user = models.ForeignKey(User, null=True, blank=True,
367        limit_choices_to={ 'username__endswith': '@SYSTEM'})
368    publicly_visible = models.BooleanField(default=True, help_text='Can everyone see the holders of this office.')
369
370    def max_count_str(self, ):
371        if self.max_count == self.UNLIMITED:
372            return "unlimited"
373        else:
374            return str(self.max_count)
375
376    def __str__(self, ):
377        return self.display_name
378
379    @classmethod
380    def getGrantUsers(cls, roles):
381        ret = set([role.grant_user for role in roles])
382        if None in ret: ret.remove(None)
383        return ret
384
385    @classmethod
386    def retrieve(cls, slug, ):
387        return cls.objects.get(slug=slug)
388reversion.register(OfficerRole)
389
390
391class OfficeHolder_CurrentManager(models.Manager):
392    def get_query_set(self, ):
393        return super(OfficeHolder_CurrentManager, self).get_query_set().filter(
394            start_time__lte=datetime.datetime.now,
395            end_time__gte=datetime.datetime.now,
396        )
397
398class OfficeHolder(models.Model):
399    EXPIRE_OFFSET   = datetime.timedelta(seconds=1)
400    END_NEVER       = datetime.datetime.max
401
402    person = models.CharField(max_length=30, db_index=True, )
403    role = models.ForeignKey('OfficerRole', db_index=True, )
404    group = models.ForeignKey('Group', db_index=True, )
405    start_time = models.DateTimeField(default=datetime.datetime.now, db_index=True, )
406    end_time = models.DateTimeField(default=datetime.datetime.max, db_index=True, )
407
408    objects = models.Manager()
409    current_holders = OfficeHolder_CurrentManager()
410
411    def format_person(self, ):
412        return AthenaMoiraAccount.try_format_by_username(self.person, )
413
414    def expire(self, ):
415        self.end_time = datetime.datetime.now()-self.EXPIRE_OFFSET
416        self.save()
417
418    def __str__(self, ):
419        return "<OfficeHolder: person=%s, role=%s, group=%s, start_time=%s, end_time=%s>" % (
420            self.person, self.role, self.group, self.start_time, self.end_time, )
421
422    def __repr__(self, ):
423        return str(self)
424reversion.register(OfficeHolder)
425
426
427class PerGroupAuthz:
428    supports_anonymous_user = True
429    supports_inactive_user = True
430    supports_object_permissions = True
431
432    def authenticate(self, username=None, password=None, ):
433        return None # we don't do authn
434    def get_user(user_id, ):
435        return None # we don't do authn
436
437    def has_perm(self, user_obj, perm, obj=None, ):
438        print "Checking user %s for perm %s on obj %s" % (user_obj, perm, obj)
439        if not user_obj.is_active:
440            return False
441        if not user_obj.is_authenticated():
442            return False
443        if obj is None:
444            return False
445        # Great, we're active, authenticated, and not in a recursive call
446        # Check that we've got a reasonable object
447        if getattr(user_obj, 'is_system', False):
448            return False
449        if isinstance(obj, Group):
450            # Having the unqualified perm means that you should have it
451            # on any object
452            if user_obj.has_perm(perm):
453                return True
454            # Now we can do the real work
455            holders = obj.officers(person=user_obj.username).select_related('role__grant_user')
456            sys_users = OfficerRole.getGrantUsers([holder.role for holder in holders])
457            for sys_user in sys_users:
458                sys_user.is_system = True
459                if sys_user.has_perm(perm):
460                    print "While checking user %s for perm %s on obj %s: implicit user %s has perms" % (user_obj, perm, obj, sys_user, )
461                    return True
462        print "While checking user %s for perm %s on obj %s: no perms found (implicit: %s)" % (user_obj, perm, obj, sys_users)
463        return False
464
465
466
467class ActivityCategory(models.Model):
468    name = models.CharField(max_length=50)
469
470    def __str__(self, ):
471        return self.name
472
473    class Meta:
474        verbose_name_plural = "activity categories"
475
476
477class GroupClass(models.Model):
478    name = models.CharField(max_length=50)
479    slug = models.SlugField(unique=True, )
480    description = models.TextField()
481    gets_publicity = models.BooleanField(help_text="Gets publicity resources such as FYSM or Activities Midway")
482
483    def __str__(self, ):
484        return self.name
485
486    class Meta:
487        verbose_name_plural = "group classes"
488
489
490class GroupStatus(models.Model):
491    name = models.CharField(max_length=50)
492    slug = models.SlugField(unique=True, )
493    description = models.TextField()
494    is_active = models.BooleanField(default=True, help_text="This status represents an active group")
495
496    def __str__(self, ):
497        active = ""
498        if not self.is_active:
499            active = " (inactive)"
500        return "%s%s" % (self.name, active, )
501
502    class Meta:
503        verbose_name_plural= "group statuses"
504
505
506class GroupFunding(models.Model):
507    name = models.CharField(max_length=50)
508    slug = models.SlugField(unique=True, )
509    contact_email = models.EmailField()
510    funding_list = models.CharField(max_length=32, blank=True, help_text="List that groups receiving funding emails should be on. The database will attempt to make sure that ONLY those groups are on it.")
511
512    def __str__(self, ):
513        return "%s (%s)" % (self.name, self.contact_email, )
514
515
516class AthenaMoiraAccount_ActiveManager(models.Manager):
517    def get_query_set(self, ):
518        return super(AthenaMoiraAccount_ActiveManager, self).get_query_set().filter(del_date=None)
519
520class AthenaMoiraAccount(models.Model):
521    username = models.CharField(max_length=8, unique=True, )
522    mit_id = models.CharField(max_length=15)
523    first_name      = models.CharField(max_length=45)
524    last_name       = models.CharField(max_length=45)
525    account_class   = models.CharField(max_length=10)
526    mutable         = models.BooleanField(default=True)
527    add_date        = models.DateField(help_text="Date when this person was added to the dump.", )
528    del_date        = models.DateField(help_text="Date when this person was removed from the dump.", blank=True, null=True, )
529    mod_date        = models.DateField(help_text="Date when this person's record was last changed.", blank=True, null=True, )
530
531    objects = models.Manager()
532    active_accounts = AthenaMoiraAccount_ActiveManager()
533
534    def is_student(self, ):
535        # XXX: Is this... right?
536        return self.account_class == 'G' or self.account_class.isdigit()
537
538    def format(self, ):
539        return "%s %s <%s>" % (self.first_name, self.last_name, self.username, )
540
541    def __str__(self, ):
542        if self.mutable:
543            mutable_str = ""
544        else:
545            mutable_str = " (immutable)"
546        return "<AthenaMoiraAccount: username=%s name='%s, %s' account_class=%s%s>" % (
547            self.username, self.last_name, self.first_name,
548            self.account_class, mutable_str,
549        )
550
551    def __repr__(self, ):
552        return str(self)
553
554    @classmethod
555    def try_format_by_username(cls, username):
556        try:
557            moira = AthenaMoiraAccount.objects.get(username=username)
558            return moira.format()
559        except AthenaMoiraAccount.DoesNotExist:
560            return "%s (name not available)" % (username)
561
562    class Meta:
563        verbose_name = "Athena (Moira) account"
Note: See TracBrowser for help on using the repository browser.