Skip to content

Commit

Permalink
Merge pull request #1886 from dandi/audit-backend
Browse files Browse the repository at this point in the history
Add audit backend
  • Loading branch information
waxlamp committed Aug 13, 2024
2 parents 97d9bc0 + 84b37bc commit 31f926d
Show file tree
Hide file tree
Showing 18 changed files with 869 additions and 45 deletions.
37 changes: 37 additions & 0 deletions dandiapi/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from dandiapi.api.models import (
Asset,
AssetBlob,
AuditRecord,
Dandiset,
Upload,
UserMetadata,
Expand Down Expand Up @@ -228,3 +229,39 @@ class AssetAdmin(admin.ModelAdmin):
class UploadAdmin(admin.ModelAdmin):
list_display = ['id', 'upload_id', 'blob', 'etag', 'upload_id', 'size', 'created']
list_display_links = ['id', 'upload_id']


@admin.register(AuditRecord)
class AuditRecordAdmin(admin.ModelAdmin):
actions = None
date_hierarchy = 'timestamp'
search_fields = [
'dandiset_id',
'username',
'record_type',
]
list_display = [
'id',
'timestamp',
'dandiset',
'record_type',
'details',
'username',
]

@admin.display(description='Dandiset', ordering='dandiset_id')
def dandiset(self, obj):
return format_html(
'<a href="{}">{}</a>',
reverse('admin:api_dandiset_change', args=(obj.dandiset_id,)),
f'{obj.dandiset_id:06}',
)

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False
51 changes: 51 additions & 0 deletions dandiapi/api/migrations/0010_auditrecord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 4.1.13 on 2024-07-24 01:30
from __future__ import annotations

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
('api', '0009_remove_embargoedassetblob_dandiset_and_more'),
]

operations = [
migrations.CreateModel(
name='AuditRecord',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
('timestamp', models.DateTimeField(auto_now_add=True)),
('dandiset_id', models.IntegerField()),
('username', models.CharField(max_length=39)),
('user_email', models.CharField(max_length=254)),
('user_fullname', models.CharField(max_length=301)),
(
'record_type',
models.CharField(
choices=[
('create_dandiset', 'create_dandiset'),
('change_owners', 'change_owners'),
('update_metadata', 'update_metadata'),
('add_asset', 'add_asset'),
('update_asset', 'update_asset'),
('remove_asset', 'remove_asset'),
('create_zarr', 'create_zarr'),
('upload_zarr_chunks', 'upload_zarr_chunks'),
('delete_zarr_chunks', 'delete_zarr_chunks'),
('finalize_zarr', 'finalize_zarr'),
('unembargo_dandiset', 'unembargo_dandiset'),
('publish_dandiset', 'publish_dandiset'),
('delete_dandiset', 'delete_dandiset'),
],
max_length=32,
),
),
('details', models.JSONField(blank=True)),
],
),
]
2 changes: 2 additions & 0 deletions dandiapi/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .asset import Asset, AssetBlob
from .asset_paths import AssetPath, AssetPathRelation
from .audit import AuditRecord
from .dandiset import Dandiset
from .oauth import StagingApplication
from .upload import Upload
Expand All @@ -13,6 +14,7 @@
'AssetBlob',
'AssetPath',
'AssetPathRelation',
'AuditRecord',
'Dandiset',
'StagingApplication',
'Upload',
Expand Down
47 changes: 47 additions & 0 deletions dandiapi/api/models/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

from typing import Literal, get_args

from django.db import models

AuditRecordType = Literal[
'create_dandiset',
'change_owners',
'update_metadata',
'add_asset',
'update_asset',
'remove_asset',
'create_zarr',
'upload_zarr_chunks',
'delete_zarr_chunks',
'finalize_zarr',
'unembargo_dandiset',
'publish_dandiset',
'delete_dandiset',
]
AUDIT_RECORD_CHOICES = [(t, t) for t in get_args(AuditRecordType)]


class AuditRecord(models.Model):
timestamp = models.DateTimeField(auto_now_add=True)
dandiset_id = models.IntegerField()

# GitHub enforces a 39 character limit on usernames (see, e.g.,
# https://docs.github.com/en/enterprise-cloud@latest/admin/managing-iam/iam-configuration-reference/username-considerations-for-external-authentication).
username = models.CharField(max_length=39)

# According to RFC 5321 (https://www.rfc-editor.org/rfc/rfc5321.txt),
# section 4.5.3.1.3, an email address "path" is limited to 256 octets,
# including the surrounding angle brackets. Without the brackets, that
# leaves 254 characters for the email address itself.
user_email = models.CharField(max_length=254)

# The signup questionnaire imposes a 150 character limit on both first and
# last names; together with a space to separate them, that makes a 301
# character limit on the full name.
user_fullname = models.CharField(max_length=301)
record_type = models.CharField(max_length=32, choices=AUDIT_RECORD_CHOICES)
details = models.JSONField(blank=True)

def __str__(self):
return f'{self.record_type}/{self.dandiset_id:06}'
10 changes: 0 additions & 10 deletions dandiapi/api/models/dandiset.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,6 @@ def set_owners(self, new_owners):
# Return the owners added/removed so they can be emailed
return removed_owners, added_owners

def add_owner(self, new_owner):
old_owners = get_users_with_perms(self, only_with_perms_in=['owner'])
if new_owner not in old_owners:
assign_perm('owner', new_owner, self)

def remove_owner(self, owner):
owners = get_users_with_perms(self, only_with_perms_in=['owner'])
if owner in owners:
remove_perm('owner', owner, self)

@classmethod
def published_count(cls):
"""Return the number of Dandisets with published Versions."""
Expand Down
23 changes: 18 additions & 5 deletions dandiapi/api/services/asset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from dandiapi.api.models.asset import Asset, AssetBlob
from dandiapi.api.models.dandiset import Dandiset
from dandiapi.api.models.version import Version
from dandiapi.api.services import audit
from dandiapi.api.services.asset.exceptions import (
AssetAlreadyExistsError,
AssetPathConflictError,
Expand Down Expand Up @@ -84,29 +85,33 @@ def change_asset( # noqa: PLR0913
raise AssetAlreadyExistsError

with transaction.atomic():
remove_asset_from_version(user=user, asset=asset, version=version)
remove_asset_from_version(user=user, asset=asset, version=version, do_audit=False)

new_asset = add_asset_to_version(
user=user,
version=version,
asset_blob=new_asset_blob,
zarr_archive=new_zarr_archive,
metadata=new_metadata,
do_audit=False,
)
# Set previous asset and save
new_asset.previous = asset
new_asset.save()

audit.update_asset(dandiset=version.dandiset, user=user, asset=new_asset)

return new_asset, True


def add_asset_to_version(
def add_asset_to_version( # noqa: PLR0913, C901
*,
user,
version: Version,
asset_blob: AssetBlob | None = None,
zarr_archive: ZarrArchive | None = None,
metadata: dict,
do_audit: bool = True,
) -> Asset:
"""Create an asset, adding it to a version."""
if not asset_blob and not zarr_archive:
Expand Down Expand Up @@ -158,15 +163,20 @@ def add_asset_to_version(
# Save the version so that the modified field is updated
version.save()

# Perform this after the above transaction has finished, to ensure we only operate on
# unembargoed asset blobs
if do_audit:
audit.add_asset(dandiset=version.dandiset, user=user, asset=asset)

# Perform this after the above transaction has finished, to ensure we only
# operate on unembargoed asset blobs
if asset_blob and unembargo_asset_blob:
remove_asset_blob_embargoed_tag_task.delay(blob_id=asset_blob.blob_id)

return asset


def remove_asset_from_version(*, user, asset: Asset, version: Version) -> Version:
def remove_asset_from_version(
*, user, asset: Asset, version: Version, do_audit: bool = True
) -> Version:
if not user.has_perm('owner', version.dandiset):
raise DandisetOwnerRequiredError
if version.version != 'draft':
Expand All @@ -182,4 +192,7 @@ def remove_asset_from_version(*, user, asset: Asset, version: Version) -> Versio
# Save the version so that the modified field is updated
version.save()

if do_audit:
audit.remove_asset(dandiset=version.dandiset, user=user, asset=asset)

return version
Loading

0 comments on commit 31f926d

Please sign in to comment.