Rule -> Policies

This commit is contained in:
Jens Langhammer
2019-02-16 10:24:31 +01:00
parent d32699b332
commit d6f4832e90
37 changed files with 334 additions and 544 deletions

View File

@ -16,7 +16,7 @@ class PassbookCoreConfig(AppConfig):
verbose_name = 'passbook Core'
def ready(self):
import_module('passbook.core.rules')
import_module('passbook.core.policies')
factors_to_load = CONFIG.y('passbook.factors', [])
for factors_to_load in factors_to_load:
try:

View File

@ -49,7 +49,7 @@ class AuthenticationView(UserPassesTestMixin, View):
self.pending_factors = request.session[AuthenticationView.SESSION_PENDING_FACTORS]
else:
# Get an initial list of factors which are currently enabled
# and apply to the current user. We check rules here and block the request
# and apply to the current user. We check policies here and block the request
_all_factors = Factor.objects.filter(enabled=True)
self.pending_factors = []
for factor in _all_factors:

View File

@ -13,7 +13,7 @@ class ApplicationForm(forms.ModelForm):
model = Application
fields = ['name', 'slug', 'launch_url', 'icon_url',
'rules', 'provider', 'skip_authorization']
'policies', 'provider', 'skip_authorization']
widgets = {
'name': forms.TextInput(),
'launch_url': forms.TextInput(),

View File

@ -17,7 +17,7 @@ class FactorForm(forms.ModelForm):
class Meta:
model = Factor
fields = ['name', 'slug', 'order', 'rules', 'type', 'enabled']
fields = ['name', 'slug', 'order', 'policies', 'type', 'enabled']
widgets = {
'type': forms.Select(choices=get_factors()),
'name': forms.TextInput(),

View File

@ -1,18 +1,18 @@
"""passbook rule forms"""
"""passbook Policy forms"""
from django import forms
from django.utils.translation import gettext as _
from passbook.core.models import DebugRule, FieldMatcherRule, WebhookRule
from passbook.core.models import DebugPolicy, FieldMatcherPolicy, WebhookPolicy
GENERAL_FIELDS = ['name', 'action', 'negate', 'order', ]
class FieldMatcherRuleForm(forms.ModelForm):
"""FieldMatcherRule Form"""
class FieldMatcherPolicyForm(forms.ModelForm):
"""FieldMatcherPolicy Form"""
class Meta:
model = FieldMatcherRule
model = FieldMatcherPolicy
fields = GENERAL_FIELDS + ['user_field', 'match_action', 'value', ]
widgets = {
'name': forms.TextInput(),
@ -20,12 +20,12 @@ class FieldMatcherRuleForm(forms.ModelForm):
}
class WebhookRuleForm(forms.ModelForm):
"""WebhookRuleForm Form"""
class WebhookPolicyForm(forms.ModelForm):
"""WebhookPolicyForm Form"""
class Meta:
model = WebhookRule
model = WebhookPolicy
fields = GENERAL_FIELDS + ['url', 'method', 'json_body', 'json_headers',
'result_jsonpath', 'result_json_value', ]
widgets = {
@ -37,12 +37,12 @@ class WebhookRuleForm(forms.ModelForm):
}
class DebugRuleForm(forms.ModelForm):
"""DebugRuleForm Form"""
class DebugPolicyForm(forms.ModelForm):
"""DebugPolicyForm Form"""
class Meta:
model = DebugRule
model = DebugPolicy
fields = GENERAL_FIELDS + ['result', 'wait_min', 'wait_max']
widgets = {
'name': forms.TextInput(),

View File

@ -1,4 +1,4 @@
# Generated by Django 2.1.5 on 2019-02-08 10:42
# Generated by Django 2.1.7 on 2019-02-16 09:10
import uuid
@ -68,13 +68,7 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='Provider',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
),
migrations.CreateModel(
name='Rule',
name='Policy',
fields=[
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
@ -89,7 +83,7 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='RuleModel',
name='PolicyModel',
fields=[
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
@ -99,6 +93,12 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
migrations.CreateModel(
name='Provider',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
),
migrations.CreateModel(
name='UserSourceConnection',
fields=[
@ -111,51 +111,82 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='Application',
fields=[
('rulemodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.RuleModel')),
('policymodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.PolicyModel')),
('name', models.TextField()),
('slug', models.SlugField()),
('launch_url', models.URLField(blank=True, null=True)),
('icon_url', models.TextField(blank=True, null=True)),
('skip_authorization', models.BooleanField(default=False)),
('provider', models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='passbook_core.Provider')),
('provider', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='passbook_core.Provider')),
],
options={
'abstract': False,
},
bases=('passbook_core.rulemodel',),
bases=('passbook_core.policymodel',),
),
migrations.CreateModel(
name='DebugRule',
name='DebugPolicy',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Rule')),
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('result', models.BooleanField(default=False)),
('wait_min', models.IntegerField(default=5)),
('wait_max', models.IntegerField(default=30)),
],
options={
'verbose_name': 'Debug Rule',
'verbose_name_plural': 'Debug Rules',
'verbose_name': 'Debug Policy',
'verbose_name_plural': 'Debug Policys',
},
bases=('passbook_core.rule',),
bases=('passbook_core.policy',),
),
migrations.CreateModel(
name='FieldMatcherRule',
name='Factor',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Rule')),
('user_field', models.TextField(choices=[('username', 'username'), ('first_name', 'first_name'), ('last_name', 'last_name'), ('email', 'email'), ('is_staff', 'is_staff'), ('is_active', 'is_active'), ('data_joined', 'data_joined')])),
('policymodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.PolicyModel')),
('name', models.TextField()),
('slug', models.SlugField(unique=True)),
('order', models.IntegerField()),
('type', models.TextField(unique=True)),
('enabled', models.BooleanField(default=True)),
],
options={
'abstract': False,
},
bases=('passbook_core.policymodel',),
),
migrations.CreateModel(
name='FieldMatcherPolicy',
fields=[
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('user_field', models.TextField(choices=[('username', 'Username'), ('first_name', 'First Name'), ('last_name', 'Last Name'), ('email', 'E-Mail'), ('is_staff', 'Is staff'), ('is_active', 'Is active'), ('data_joined', 'Date joined')])),
('match_action', models.CharField(choices=[('startswith', 'Starts with'), ('endswith', 'Ends with'), ('endswith', 'Contains'), ('regexp', 'Regexp'), ('exact', 'Exact')], max_length=50)),
('value', models.TextField()),
],
options={
'verbose_name': 'Field matcher Rule',
'verbose_name_plural': 'Field matcher Rules',
'verbose_name': 'Field matcher Policy',
'verbose_name_plural': 'Field matcher Policys',
},
bases=('passbook_core.rule',),
bases=('passbook_core.policy',),
),
migrations.CreateModel(
name='PasswordPolicyPolicy',
fields=[
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('amount_uppercase', models.IntegerField(default=0)),
('amount_lowercase', models.IntegerField(default=0)),
('amount_symbols', models.IntegerField(default=0)),
('length_min', models.IntegerField(default=0)),
('symbol_charset', models.TextField(default='!\\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ')),
],
options={
'verbose_name': 'Password Policy Policy',
'verbose_name_plural': 'Password Policy Policys',
},
bases=('passbook_core.policy',),
),
migrations.CreateModel(
name='Source',
fields=[
('rulemodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.RuleModel')),
('policymodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.PolicyModel')),
('name', models.TextField()),
('slug', models.SlugField()),
('enabled', models.BooleanField(default=True)),
@ -163,12 +194,12 @@ class Migration(migrations.Migration):
options={
'abstract': False,
},
bases=('passbook_core.rulemodel',),
bases=('passbook_core.policymodel',),
),
migrations.CreateModel(
name='WebhookRule',
name='WebhookPolicy',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Rule')),
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('url', models.URLField()),
('method', models.CharField(choices=[('GET', 'GET'), ('POST', 'POST'), ('PATCH', 'PATCH'), ('DELETE', 'DELETE'), ('PUT', 'PUT')], max_length=10)),
('json_body', models.TextField()),
@ -177,15 +208,15 @@ class Migration(migrations.Migration):
('result_json_value', models.TextField()),
],
options={
'verbose_name': 'Webhook Rule',
'verbose_name_plural': 'Webhook Rules',
'verbose_name': 'Webhook Policy',
'verbose_name_plural': 'Webhook Policys',
},
bases=('passbook_core.rule',),
bases=('passbook_core.policy',),
),
migrations.AddField(
model_name='rulemodel',
name='rules',
field=models.ManyToManyField(blank=True, to='passbook_core.Rule'),
model_name='policymodel',
name='policies',
field=models.ManyToManyField(blank=True, to='passbook_core.Policy'),
),
migrations.AddField(
model_name='user',

View File

@ -1,35 +0,0 @@
# Generated by Django 2.1.5 on 2019-02-08 15:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PasswordPolicyRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Rule')),
('amount_uppercase', models.IntegerField(default=0)),
('amount_lowercase', models.IntegerField(default=0)),
('amount_symbols', models.IntegerField(default=0)),
('length_min', models.IntegerField(default=0)),
('symbol_charset', models.TextField(default='!\\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ')),
],
options={
'verbose_name': 'Password Policy Rule',
'verbose_name_plural': 'Password Policy Rules',
},
bases=('passbook_core.rule',),
),
migrations.AlterField(
model_name='fieldmatcherrule',
name='user_field',
field=models.TextField(choices=[('username', 'Username'), ('first_name', 'First Name'), ('last_name', 'Last Name'), ('email', 'E-Mail'), ('is_staff', 'Is staff'), ('is_active', 'Is active'), ('data_joined', 'Date joined')]),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 2.1.7 on 2019-02-14 15:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0002_auto_20190208_1514'),
]
operations = [
migrations.CreateModel(
name='Factor',
fields=[
('rulemodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.RuleModel')),
('name', models.TextField()),
('slug', models.SlugField(unique=True)),
('order', models.IntegerField()),
('type', models.TextField()),
],
options={
'abstract': False,
},
bases=('passbook_core.rulemodel',),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 2.1.7 on 2019-02-15 15:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0003_factor'),
]
operations = [
migrations.AddField(
model_name='factor',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='application',
name='provider',
field=models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='passbook_core.Provider'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 2.1.7 on 2019-02-16 08:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0004_auto_20190215_1534'),
]
operations = [
migrations.AlterField(
model_name='factor',
name='type',
field=models.TextField(unique=True),
),
]

View File

@ -49,19 +49,19 @@ class Provider(models.Model):
return getattr(self, 'name')
return super().__str__()
class RuleModel(UUIDModel, CreatedUpdatedModel):
"""Base model which can have rules applied to it"""
class PolicyModel(UUIDModel, CreatedUpdatedModel):
"""Base model which can have policies applied to it"""
rules = models.ManyToManyField('Rule', blank=True)
policies = models.ManyToManyField('Policy', blank=True)
def passes(self, user: User) -> bool:
"""Return true if user passes, otherwise False or raise Exception"""
for rule in self.rules:
if not rule.passes(user):
for policy in self.policies:
if not policy.passes(user):
return False
return True
class Factor(RuleModel):
class Factor(PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used"""
name = models.TextField()
@ -73,7 +73,7 @@ class Factor(RuleModel):
def __str__(self):
return "Factor %s" % self.slug
class Application(RuleModel):
class Application(PolicyModel):
"""Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
add custom fields and other properties"""
@ -90,13 +90,13 @@ class Application(RuleModel):
def user_is_authorized(self, user: User) -> bool:
"""Check if user is authorized to use this application"""
from passbook.core.rules import RuleEngine
return RuleEngine(self.rules.all()).for_user(user).result
from passbook.core.policies import PolicyEngine
return PolicyEngine(self.policies.all()).for_user(user).result
def __str__(self):
return self.name
class Source(RuleModel):
class Source(PolicyModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField()
@ -129,8 +129,8 @@ class UserSourceConnection(CreatedUpdatedModel):
unique_together = (('user', 'source'),)
class Rule(UUIDModel, CreatedUpdatedModel):
"""Rules which specify if a user is authorized to use an Application. Can be overridden by
class Policy(UUIDModel, CreatedUpdatedModel):
"""Policys which specify if a user is authorized to use an Application. Can be overridden by
other types to add other fields, more logic, etc."""
ACTION_ALLOW = 'allow'
@ -153,11 +153,11 @@ class Rule(UUIDModel, CreatedUpdatedModel):
return "%s action %s" % (self.name, self.action)
def passes(self, user: User) -> bool:
"""Check if user instance passes this rule"""
"""Check if user instance passes this policy"""
raise NotImplementedError()
class FieldMatcherRule(Rule):
"""Rule which checks if a field of the User model matches/doesn't match a
class FieldMatcherPolicy(Policy):
"""Policy which checks if a field of the User model matches/doesn't match a
certain pattern"""
MATCH_STARTSWITH = 'startswith'
@ -188,7 +188,7 @@ class FieldMatcherRule(Rule):
match_action = models.CharField(max_length=50, choices=MATCHES)
value = models.TextField()
form = 'passbook.core.forms.rules.FieldMatcherRuleForm'
form = 'passbook.core.forms.policies.FieldMatcherPolicyForm'
def __str__(self):
description = "%s, user.%s %s '%s'" % (self.name, self.user_field,
@ -205,13 +205,13 @@ class FieldMatcherRule(Rule):
LOGGER.debug("Checked '%s' %s with '%s'...",
user_field_value, self.match_action, self.value)
passes = False
if self.match_action == FieldMatcherRule.MATCH_STARTSWITH:
if self.match_action == FieldMatcherPolicy.MATCH_STARTSWITH:
passes = user_field_value.startswith(self.value)
if self.match_action == FieldMatcherRule.MATCH_ENDSWITH:
if self.match_action == FieldMatcherPolicy.MATCH_ENDSWITH:
passes = user_field_value.endswith(self.value)
if self.match_action == FieldMatcherRule.MATCH_CONTAINS:
if self.match_action == FieldMatcherPolicy.MATCH_CONTAINS:
passes = self.value in user_field_value
if self.match_action == FieldMatcherRule.MATCH_REGEXP:
if self.match_action == FieldMatcherPolicy.MATCH_REGEXP:
pattern = re.compile(self.value)
passes = bool(pattern.match(user_field_value))
if self.negate:
@ -221,11 +221,11 @@ class FieldMatcherRule(Rule):
class Meta:
verbose_name = _('Field matcher Rule')
verbose_name_plural = _('Field matcher Rules')
verbose_name = _('Field matcher Policy')
verbose_name_plural = _('Field matcher Policys')
class PasswordPolicyRule(Rule):
"""Rule to make sure passwords have certain properties"""
class PasswordPolicyPolicy(Policy):
"""Policy to make sure passwords have certain properties"""
amount_uppercase = models.IntegerField(default=0)
amount_lowercase = models.IntegerField(default=0)
@ -233,7 +233,7 @@ class PasswordPolicyRule(Rule):
length_min = models.IntegerField(default=0)
symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ")
form = 'passbook.core.forms.rules.PasswordPolicyRuleForm'
form = 'passbook.core.forms.policies.PasswordPolicyPolicyForm'
def passes(self, user: User) -> bool:
# Only check if password is being set
@ -254,12 +254,12 @@ class PasswordPolicyRule(Rule):
class Meta:
verbose_name = _('Password Policy Rule')
verbose_name_plural = _('Password Policy Rules')
verbose_name = _('Password Policy Policy')
verbose_name_plural = _('Password Policy Policys')
class WebhookRule(Rule):
"""Rule that asks webhook"""
class WebhookPolicy(Policy):
"""Policy that asks webhook"""
METHOD_GET = 'GET'
METHOD_POST = 'POST'
@ -282,7 +282,7 @@ class WebhookRule(Rule):
result_jsonpath = models.TextField()
result_json_value = models.TextField()
form = 'passbook.core.forms.rules.WebhookRuleForm'
form = 'passbook.core.forms.policies.WebhookPolicyForm'
def passes(self, user: User):
"""Call webhook asynchronously and report back"""
@ -290,30 +290,30 @@ class WebhookRule(Rule):
class Meta:
verbose_name = _('Webhook Rule')
verbose_name_plural = _('Webhook Rules')
verbose_name = _('Webhook Policy')
verbose_name_plural = _('Webhook Policys')
class DebugRule(Rule):
"""Rule used for debugging the RuleEngine. Returns a fixed result,
class DebugPolicy(Policy):
"""Policy used for debugging the PolicyEngine. Returns a fixed result,
but takes a random time to process."""
result = models.BooleanField(default=False)
wait_min = models.IntegerField(default=5)
wait_max = models.IntegerField(default=30)
form = 'passbook.core.forms.rules.DebugRuleForm'
form = 'passbook.core.forms.policies.DebugPolicyForm'
def passes(self, user: User):
"""Wait random time then return result"""
wait = SystemRandom().randrange(self.wait_min, self.wait_max)
LOGGER.debug("Rule '%s' waiting for %ds", self.name, wait)
LOGGER.debug("Policy '%s' waiting for %ds", self.name, wait)
sleep(wait)
return self.result
class Meta:
verbose_name = _('Debug Rule')
verbose_name_plural = _('Debug Rules')
verbose_name = _('Debug Policy')
verbose_name_plural = _('Debug Policys')
class Invitation(UUIDModel):
"""Single-use invitation link"""

48
passbook/core/policies.py Normal file
View File

@ -0,0 +1,48 @@
"""passbook core policy engine"""
from logging import getLogger
from celery import group
from passbook.core.celery import CELERY_APP
from passbook.core.models import Policy, User
LOGGER = getLogger(__name__)
@CELERY_APP.task()
def _policy_engine_task(user_pk, policy_pk, **kwargs):
"""Task wrapper to run policy checking"""
policy_obj = Policy.objects.filter(pk=policy_pk).select_subclasses().first()
user_obj = User.objects.get(pk=user_pk)
for key, value in kwargs.items():
setattr(user_obj, key, value)
LOGGER.debug("Running policy `%s`#%s for user %s...", policy_obj.name,
policy_obj.pk.hex, user_obj)
return policy_obj.passes(user_obj)
class PolicyEngine:
"""Orchestrate policy checking, launch tasks and return result"""
policies = None
_group = None
def __init__(self, policies):
self.policies = policies
def for_user(self, user):
"""Check policies for user"""
signatures = []
kwargs = {
'__password__': getattr(user, '__password__')
}
for policy in self.policies:
signatures.append(_policy_engine_task.s(user.pk, policy.pk.hex, **kwargs))
self._group = group(signatures)()
return self
@property
def result(self):
"""Get policy-checking result"""
for policy_result in self._group.get():
if policy_result is False:
return False
return True

View File

@ -1,47 +0,0 @@
"""passbook core rule engine"""
from logging import getLogger
from celery import group
from passbook.core.celery import CELERY_APP
from passbook.core.models import Rule, User
LOGGER = getLogger(__name__)
@CELERY_APP.task()
def _rule_engine_task(user_pk, rule_pk, **kwargs):
"""Task wrapper to run rule checking"""
rule_obj = Rule.objects.filter(pk=rule_pk).select_subclasses().first()
user_obj = User.objects.get(pk=user_pk)
for key, value in kwargs.items():
setattr(user_obj, key, value)
LOGGER.debug("Running rule `%s`#%s for user %s...", rule_obj.name, rule_obj.pk.hex, user_obj)
return rule_obj.passes(user_obj)
class RuleEngine:
"""Orchestrate rule checking, launch tasks and return result"""
rules = None
_group = None
def __init__(self, rules):
self.rules = rules
def for_user(self, user):
"""Check rules for user"""
signatures = []
kwargs = {
'__password__': getattr(user, '__password__')
}
for rule in self.rules:
signatures.append(_rule_engine_task.s(user.pk, rule.pk.hex, **kwargs))
self._group = group(signatures)()
return self
@property
def result(self):
"""Get rule-checking result"""
for rule_result in self._group.get():
if rule_result is False:
return False
return True