Skip to content

Commit

Permalink
Add a few changes for 0.1.2 release
Browse files Browse the repository at this point in the history
  • Loading branch information
bogdanpetrea committed Feb 14, 2024
1 parent 8bff2fa commit 88d925f
Show file tree
Hide file tree
Showing 15 changed files with 297 additions and 384 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.2] - 2024-02-14
### Changes
These are **Breaking Changes**, but the project hasn't been announced yet anyway:
- Renamed `uuid` PKs to simply `id`, and changed the AssignedPerms PK field to UUID7 too.
- Removed `display_name` from UserGroup and shrunk `name` field to 80 chars.


## [0.1.1] - 2024-02-12
### Changes
- Changed the `object_id` field used for GenericForeignKey in AssignedPerm from TextField to CharField(max_length=40) to fix a MySQL indexing error. Technically this is a breaking change, but this project hasn't been announced yet anyway...
- Changed the `object_id` field used for GenericForeignKey in AssignedPerm from TextField to CharField(max_length=40) to fix a MySQL indexing error. This is a **Breaking Change**, but the project hasn't been announced yet anyway.
- Bump some deps.


## [0.1.0 - REMOVED] - 2024-02-09
- Initial release.
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# django-woah
A package intended to aid developers in implementing authorization for Django apps.

A package intended to aid developers in implementing authorization for Django apps.
*This project was developed at [Presslabs](https://www.presslabs.com/).*

## Installation
`pip install django-woah`
Expand Down Expand Up @@ -190,15 +190,16 @@ To see more code in action you can check the [examples](https://github.com/press
- Although the library hasn't reached version 1.0 yet, it is soon going to be used in production at Presslabs, with most, if not all of it's functionality tested.
- This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). That means until version 1.0, breaking changes are to be expected from one version to another, although they will be documented in the [changelog](CHANGELOG.MD).
- There is a good chance for pre-1.0 versions to be maintained for a while, in terms of compatibility with newer Django and Python versions, as well as critical bugfixes. You might have to provide a pull request yourself though, but we'll, at the least, review it and hopefully ship it in a maintenance release.
- The abstractions around how Conditions are composed and relate to AuthorizationSchemes/Solver could've been more inspired (see [Shortcomings](#shortcomings)). Therefore, a major rework could happen before the 1.0 release, but chances are it will take a while longer to materialize, as the current API is *usable* enough.
- The abstractions around how Conditions are composed and relate to AuthorizationSchemes/Solver could've been more inspired (see [Shortcomings and Limitations](#shortcomings-and-limitations)). Therefore, a major rework could happen before the 1.0 release, but chances are it will take a while longer to materialize, as the current API is *usable* enough.


## Shortcomings
## Shortcomings and Limitations

- The models and logic currently work with a single owner type relation, pointing to the Django `AUTH_USER_MODEL`. This implies that your Organizations must share the same model with your Users (which we believe simplifies things for most cases). It should be possible to work around this limitation, but out of the box everything is set up to work this way.
- It's hard (and not performant) to interrogate who all the users with privileges for a resource are.
- It's not possible to define and store new permissions/roles in the DB.
- It's cumbersome to verify if a subset of *Conditions* is being met. And when enforcing authorization, it's kind of impossible to reveal the conditions that have not been met.
- Verifying authorization for already prefetched resources, in cases where conditions can be satisfied without the need to fetch AssignedPerms, or the AssignedPerms have been prefetched as well, could be more performant. The best way of doing it now is filtering for which of them is satisfy authorization, as if they weren't prefetched to begin with.
- Verifying authorization for already prefetched resources, in cases where conditions can be satisfied without the need to fetch AssignedPerms, or the AssignedPerms have been prefetched as well, could be more performant. The best way of doing it now is filtering which of them the actor is authorized for, as if they weren't prefetched to begin with.
- For some cases, prefetching AssignedPerms could be avoided, and the whole authorization interrogation could be done with a single query... but not with how the abstraction is currently built. That single query would consist of more DB joins, so it's hard to tell if a potential performance increase is left on the table or not, without actual benchmarks.
- Memberships could be made more optional in the whole design, but it's not clear if that's of any importance right now.
- Some "meta" indirect privileges are hard (or even impossible) to implement, especially in a performant manner. For example: giving privileges that other users possess, based on a relation between the actor and the respective users, if say they are part of the same UserGroups.
Expand All @@ -213,7 +214,8 @@ If these are dealbreakers for you or you are simply looking for something else,
- For security related issues (think exploitable bugs), contact us at `[email protected]`.
- For other type of bugs, use the [issue tracker](https://github.com/presslabs/django-woah/issues).
- If you have questions, or want to talk about missing functionality, open a [discussion](https://github.com/presslabs/django-woah/discussions).
- You may send a [pull request](https://github.com/presslabs/django-woah/pulls) for bugfixing, if you think you've got it right. For anything else, if the implementation details have not already been decided, it's better to start a [discussion](https://github.com/presslabs/django-woah/discussions) first.
- You may send a [pull request](https://github.com/presslabs/django-woah/pulls) for bugfixing, if you think you've got it right. For anything else, if the implementation details have not already been decided, it's better to start a [discussion](https://github.com/presslabs/django-woah/discussions) first.
Do take note that we're looking to implement a CLA for code contributions.
- For anything else, just use common sense and it will probably be fine.


Expand Down
16 changes: 6 additions & 10 deletions django_woah/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Migration(migrations.Migration):
name="Membership",
fields=[
(
"uuid",
"id",
models.UUIDField(
default=uuid6.uuid7,
primary_key=True,
Expand Down Expand Up @@ -49,19 +49,15 @@ class Migration(migrations.Migration):
name="UserGroup",
fields=[
(
"uuid",
"id",
models.UUIDField(
default=uuid6.uuid7,
primary_key=True,
serialize=False,
unique=True,
),
),
("name", models.CharField(blank=True, max_length=128, null=True)),
(
"display_name",
models.CharField(blank=True, max_length=256, null=True),
),
("name", models.CharField(blank=True, max_length=80)),
(
"kind",
models.CharField(
Expand Down Expand Up @@ -152,11 +148,11 @@ class Migration(migrations.Migration):
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
models.UUIDField(
default=uuid6.uuid7,
primary_key=True,
serialize=False,
verbose_name="ID",
unique=True,
),
),
("perm", models.CharField(max_length=128)),
Expand Down
113 changes: 12 additions & 101 deletions django_woah/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,91 +13,15 @@
# limitations under the License.

import uuid6

from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Q, UniqueConstraint


class AutoCleanModel(models.Model):
class Meta:
abstract = True

def _init_states(self):
self.initial_state = self.current_state

self.cleaned_state = {} if not self.pk else self.initial_state.copy()
self.saved_state = {} if not self.pk else self.initial_state.copy()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._init_states()

@property
def current_state(self):
return {
field.name: self.__dict__[field.attname]
for field in self._meta.fields
if field.attname in self.__dict__
}

@staticmethod
def _states_diff(state, other_state):
return {key: value for key, value in other_state.items() if value != state[key]}

def get_dirty_fields(self):
return self._states_diff(self.current_state, self.cleaned_state)

def get_unsaved_fields(self):
if not self.saved_state:
return list(self.current_state.keys())

return list(self._states_diff(self.current_state, self.saved_state).keys())

@property
def is_cleaned(self):
if not getattr(self, ".cleaned", False):
return False

return not self.get_dirty_fields()

@is_cleaned.setter
def is_cleaned(self, value):
if value:
self.cleaned_state = self.current_state.copy()

setattr(self, ".cleaned", value)

def save(self, *args, **kwargs):
if not self.is_cleaned:
self.full_clean()

super().save(*args, **kwargs)

self.initial_state = self.current_state.copy()
if kwargs.get("update_fields") is None:
self.saved_state = self.current_state.copy()
else:
for field in kwargs["update_fields"]:
if field not in self.current_state:
continue

self.saved_state[field] = self.current_state[field]

def refresh_from_db(self, *args, **kwargs):
super().refresh_from_db(*args, **kwargs)

self._init_states()

def full_clean(self, *args, **kwargs):
if self.is_cleaned:
return

super().full_clean(*args, **kwargs)

self.is_cleaned = True
from django_woah.utils.models import AutoCleanModel


class UserGroupKind(models.TextChoices):
Expand All @@ -109,9 +33,9 @@ class UserGroupKind(models.TextChoices):
class UserGroup(AutoCleanModel):
KINDS = UserGroupKind

uuid = models.UUIDField(default=uuid6.uuid7, unique=True, primary_key=True)
name = models.CharField(max_length=128, null=True, blank=True)
display_name = models.CharField(max_length=256, null=True, blank=True)
id = models.UUIDField(default=uuid6.uuid7, unique=True, primary_key=True)

name = models.CharField(max_length=80, blank=True)
kind = models.CharField(choices=UserGroupKind.choices, max_length=16)

# TODO: should this be nullable?
Expand All @@ -138,8 +62,7 @@ class UserGroup(AutoCleanModel):
"django_woah.Membership", null=True, blank=True, on_delete=models.CASCADE
)

# rename to parent_user
# TODO: is this needed anymore, now that there is an owner field that points to org user
# This is some denormalization, but it's possibly useful
related_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="related_user_groups",
Expand All @@ -156,15 +79,6 @@ class Meta:
),
]

def full_clean(self, *args, **kwargs):
if self.kind == self.KINDS.ROOT:
try:
self.related_user
except UserGroup.related_user.RelatedObjectDoesNotExist:
self.related_user = self.owner

return super().full_clean(*args, **kwargs)

def clean(self):
if self.root and not self.parent:
raise ValidationError(
Expand All @@ -181,17 +95,12 @@ def clean(self):
if self.kind == UserGroupKind.USER:
username = f"user:{str(self.related_user)} "

self.name = "".join(
element
for element in [str(self.owner), username, str(UserGroup)]
if element
self.name = " ".join(
element for element in [str(self.owner), username, self.kind] if element
)

def __str__(self):
name = f"{self.owner} {self.kind}"

if self.parent_membership:
name = f"{name}: {self.parent_membership}"
name = f"{self.name}"

return name

Expand Down Expand Up @@ -243,6 +152,8 @@ def get_or_create(self, defaults=None, **kwargs):


class AssignedPerm(AutoCleanModel):
id = models.UUIDField(default=uuid6.uuid7, unique=True, primary_key=True)

user_group = models.ForeignKey(
UserGroup, on_delete=models.CASCADE, related_name="group_assigned_perms"
)
Expand Down Expand Up @@ -296,7 +207,7 @@ def full_clean(self, *args, **kwargs):


class Membership(AutoCleanModel):
uuid = models.UUIDField(default=uuid6.uuid7, unique=True, primary_key=True)
id = models.UUIDField(default=uuid6.uuid7, unique=True, primary_key=True)

user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="memberships"
Expand Down
94 changes: 94 additions & 0 deletions django_woah/utils/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2024 Pressinfra SRL
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from django.db import models


class AutoCleanModel(models.Model):
class Meta:
abstract = True

def _init_states(self):
self.initial_state = self.current_state

self.cleaned_state = {} if not self.pk else self.initial_state.copy()
self.saved_state = {} if not self.pk else self.initial_state.copy()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._init_states()

@property
def current_state(self):
return {
field.name: self.__dict__[field.attname]
for field in self._meta.fields
if field.attname in self.__dict__
}

@staticmethod
def _states_diff(state, other_state):
return {key: value for key, value in other_state.items() if value != state[key]}

def get_dirty_fields(self):
return self._states_diff(self.current_state, self.cleaned_state)

def get_unsaved_fields(self):
if not self.saved_state:
return list(self.current_state.keys())

return list(self._states_diff(self.current_state, self.saved_state).keys())

@property
def is_cleaned(self):
if not getattr(self, ".cleaned", False):
return False

return not self.get_dirty_fields()

@is_cleaned.setter
def is_cleaned(self, value):
if value:
self.cleaned_state = self.current_state.copy()

setattr(self, ".cleaned", value)

def save(self, *args, **kwargs):
if not self.is_cleaned:
self.full_clean()

super().save(*args, **kwargs)

self.initial_state = self.current_state.copy()
if kwargs.get("update_fields") is None:
self.saved_state = self.current_state.copy()
else:
for field in kwargs["update_fields"]:
if field not in self.current_state:
continue

self.saved_state[field] = self.current_state[field]

def refresh_from_db(self, *args, **kwargs):
super().refresh_from_db(*args, **kwargs)

self._init_states()

def full_clean(self, *args, **kwargs):
if self.is_cleaned:
return

super().full_clean(*args, **kwargs)

self.is_cleaned = True
Loading

0 comments on commit 88d925f

Please sign in to comment.