api: allow per-client user attributes restriction (#78332)
gitea/authentic/pipeline/head This commit looks good Details

This commit is contained in:
Paul Marillonnet 2023-08-23 14:49:01 +02:00
parent 610faab28c
commit e465ffc506
7 changed files with 175 additions and 6 deletions

View File

@ -370,7 +370,15 @@ class BaseUserSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for at in Attribute.objects.all():
attributes = Attribute.objects.all()
if (
(request := self.context.get('request'))
and isinstance(request.user, APIClient)
and (attrs := request.user.allowed_user_attributes.all())
):
attributes = attrs
for at in attributes:
if at.name in self.fields:
self.fields[at.name].required = at.required
if at.required and isinstance(self.fields[at.name], serializers.CharField):
@ -1468,6 +1476,11 @@ class CheckAPIClientSerializer(serializers.Serializer):
required=False,
allow_null=True,
)
allowed_user_attributes = serializers.SlugRelatedField(
slug_field='name',
many=True,
read_only=True,
)
class CheckPasswordAPI(BaseRpcView):
@ -1525,6 +1538,7 @@ class CheckAPIClientAPI(BaseRpcView):
'ou': api_client.ou.slug if api_client.ou else None,
'restrict_to_anonymised_data': api_client.restrict_to_anonymised_data,
'roles': Role.objects.for_user(api_client).values_list('uuid', flat=True),
'allowed_user_attributes': [attr.name for attr in api_client.allowed_user_attributes.all()],
}
return result, status.HTTP_200_OK

View File

@ -43,7 +43,7 @@ from authentic2.forms.fields import (
)
from authentic2.forms.mixins import SlugMixin
from authentic2.forms.profile import BaseUserForm
from authentic2.models import APIClient, PasswordReset, Service, Setting
from authentic2.models import APIClient, Attribute, PasswordReset, Service, Setting
from authentic2.passwords import generate_password, validate_password
from authentic2.utils.misc import (
RUNTIME_SETTINGS,
@ -942,10 +942,21 @@ class APIClientForm(forms.ModelForm):
'identifier',
'password',
'ou',
'restrict_to_anonymised_data',
'apiclient_roles',
# more specific config options, would deserve appearing in a separate tab
'restrict_to_anonymised_data',
'allowed_user_attributes',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
core_attributes = ['first_name', 'last_name', 'email', 'username']
# restrict to AttributeManager(disabled=False) and discard core attributes
self.fields['allowed_user_attributes'].queryset = Attribute.objects.exclude(name__in=core_attributes)
self.fields['allowed_user_attributes'].help_text = _(
"Select one or multiple attributes if you want to restrict the client's access to these attributes."
)
def clean(self):
ou = self.cleaned_data['ou']
if ou:
@ -970,6 +981,7 @@ class APIClientForm(forms.ModelForm):
'ou',
'restrict_to_anonymised_data',
'apiclient_roles',
'allowed_user_attributes',
)
field_classes = {'apiclient_roles': ChooseRolesField}

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.18 on 2023-08-23 12:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2', '0048_rename_services_runtime_settings'),
]
operations = [
migrations.AddField(
model_name='apiclient',
name='allowed_user_attributes',
field=models.ManyToManyField(
blank=True,
related_name='apiclients',
to='authentic2.Attribute',
verbose_name='allowed user attributes',
),
),
]

View File

@ -725,6 +725,12 @@ class APIClient(models.Model):
null=True,
blank=True,
)
allowed_user_attributes = models.ManyToManyField(
'Attribute',
verbose_name=_('allowed user attributes'),
related_name='apiclients',
blank=True,
)
class Meta:
verbose_name = _('APIClient')

View File

@ -3348,6 +3348,7 @@ def test_check_api_client(app, superuser, ou1, ou2):
assert data['restrict_to_anonymised_data'] is False
assert data['roles'] == [role1.uuid]
assert data['ou'] == get_default_ou().slug
assert data['allowed_user_attributes'] == []
api_client.ou = ou1
api_client.save()
@ -3363,6 +3364,35 @@ def test_check_api_client(app, superuser, ou1, ou2):
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'api client not found'
color = Attribute.objects.create(
name='preferred_color',
label='Preferred color',
kind='string',
disabled=False,
multiple=False,
)
phone2 = Attribute.objects.create(
name='phone2',
label='Second phone number',
kind='phone_number',
disabled=False,
multiple=False,
)
api_client.allowed_user_attributes.add(color, phone2)
api_client.save()
payload = {'identifier': 'foo', 'password': 'foo'}
resp = app.post_json(url, params=payload)
assert resp.json['err'] == 0
data = resp.json['data']
assert data['is_active'] is True
assert data['is_anonymous'] is False
assert data['is_authenticated'] is True
assert data['is_superuser'] is False
assert data['restrict_to_anonymised_data'] is False
assert data['roles'] == [role1.uuid]
assert data['ou'] == ou1.slug
assert set(data['allowed_user_attributes']) == {'preferred_color', 'phone2'}
def test_check_api_client_role_inheritance(app, superuser):
api_client = APIClient.objects.create(identifier='foo', password='foo')

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from authentic2.a2_rbac.models import ADD_OP, SEARCH_OP, VIEW_OP, Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.models import APIClient
from authentic2.models import APIClient, Attribute
User = get_user_model()
@ -51,7 +51,7 @@ def test_has_perm_ou(api_client, ou1):
def test_api_users_list(app, api_client):
User.objects.create(username='user1')
user = User.objects.create(username='user1')
app.authorization = ('Basic', ('foo', 'bar'))
resp = app.get('/api/users/', status=401)
@ -68,6 +68,52 @@ def test_api_users_list(app, api_client):
resp = app.get('/api/users/')
assert len(resp.json['results']) == 1
preferred_color = Attribute.objects.create(
name='preferred_color',
label='Preferred color',
kind='string',
disabled=False,
multiple=False,
)
phone2 = Attribute.objects.create(
name='phone2',
label='Second phone number',
kind='phone_number',
disabled=False,
multiple=False,
)
user.attributes.preferred_color = 'blue'
user.attributes.phone2 = '+33122334455'
user.save()
api_client.allowed_user_attributes.add(preferred_color, phone2)
api_client.save()
resp = app.get('/api/users/')
assert len(resp.json['results']) == 1
assert resp.json['results'][0]['preferred_color'] == 'blue'
assert resp.json['results'][0]['phone2'] == '+33122334455'
api_client.allowed_user_attributes.remove(preferred_color)
api_client.save()
resp = app.get('/api/users/')
assert len(resp.json['results']) == 1
assert 'preferred_color' not in resp.json['results'][0]
assert resp.json['results'][0]['phone2'] == '+33122334455'
api_client.allowed_user_attributes.remove(phone2)
api_client.save()
resp = app.get('/api/users/')
assert len(resp.json['results']) == 1
assert resp.json['results'][0]['preferred_color'] == 'blue'
assert resp.json['results'][0]['phone2'] == '+33122334455'
api_client.allowed_user_attributes.add(preferred_color)
api_client.save()
resp = app.get('/api/users/')
assert len(resp.json['results']) == 1
assert resp.json['results'][0]['preferred_color'] == 'blue'
assert 'phone2' not in resp.json['results'][0]
def test_api_user_synchronization(app, api_client):
uuids = []

View File

@ -21,7 +21,7 @@ from django.urls import reverse
from authentic2.a2_rbac.models import Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.models import APIClient
from authentic2.models import APIClient, Attribute
from .utils import login
@ -159,6 +159,20 @@ def test_list_show_objects_local_admin(admin_ou1, app, ou1, ou2):
def test_add(superuser, app):
preferred_color = Attribute.objects.create(
name='preferred_color',
label='Preferred color',
kind='string',
disabled=False,
multiple=False,
)
phone2 = Attribute.objects.create(
name='phone2',
label='Second phone number',
kind='phone_number',
disabled=False,
multiple=False,
)
assert APIClient.objects.count() == 0
role_1 = Role.objects.create(name='role-1', ou=get_default_ou())
role_2 = Role.objects.create(name='role-2', ou=get_default_ou())
@ -172,10 +186,12 @@ def test_add(superuser, app):
form.set('identifier', 'api-client-identifier')
form.set('password', 'api-client-password')
form['apiclient_roles'].force_value([role_1.id, role_2.id])
form.set('allowed_user_attributes', [preferred_color.id, phone2.id])
response = form.submit().follow()
assert APIClient.objects.count() == 1
api_client = APIClient.objects.get(name='api-client-name')
assert set(api_client.apiclient_roles.all()) == {role_1, role_2}
assert set(api_client.allowed_user_attributes.all()) == {preferred_color, phone2}
assert urlparse(response.request.url).path == api_client.get_absolute_url()
@ -243,6 +259,20 @@ def test_detail(superuser, app):
def test_edit(superuser, app, ou1, ou2):
preferred_color = Attribute.objects.create(
name='preferred_color',
label='Preferred color',
kind='string',
disabled=False,
multiple=False,
)
phone2 = Attribute.objects.create(
name='phone2',
label='Second phone number',
kind='phone_number',
disabled=False,
multiple=False,
)
role_1 = Role.objects.create(name='role-1', ou=ou1)
role_2 = Role.objects.create(name='role-2', ou=ou2)
role_3 = Role.objects.create(name='role-3', ou=ou1)
@ -253,10 +283,13 @@ def test_edit(superuser, app, ou1, ou2):
password='foo-password',
ou=ou1,
)
api_client.allowed_user_attributes.add(preferred_color, phone2)
api_client.save()
assert APIClient.objects.count() == 1
resp = login(app, superuser, 'a2-manager-api-client-edit', kwargs={'pk': api_client.pk})
form = resp.form
assert form.get('password').value == 'foo-password'
assert set(form.get('allowed_user_attributes').value) == {str(preferred_color.id), str(phone2.id)}
assert ('', False, '---------') in form['ou'].options
resp.form.set('password', 'easy')
with pytest.raises(KeyError):
@ -265,11 +298,13 @@ def test_edit(superuser, app, ou1, ou2):
form['apiclient_roles'].force_value([role_1.id, role_2.id])
form.submit()
form['apiclient_roles'].force_value([role_1.id, role_3.id])
form['allowed_user_attributes'].force_value([phone2.id])
response = form.submit().follow()
assert urlparse(response.request.url).path == api_client.get_absolute_url()
assert APIClient.objects.count() == 1
api_client = APIClient.objects.get(password='easy')
assert api_client.identifier == 'foo-identifier'
assert set(api_client.allowed_user_attributes.all()) == {phone2}
resp = app.get(reverse('a2-manager-api-client-edit', kwargs={'pk': api_client.pk}))
form = resp.form
@ -281,18 +316,22 @@ def test_edit(superuser, app, ou1, ou2):
)
response.form.set('ou', ou2.id)
response.form['apiclient_roles'].force_value([])
response.form['allowed_user_attributes'].force_value([])
response.form.submit().follow()
api_client = APIClient.objects.get()
assert set(api_client.apiclient_roles.all()) == set()
assert set(api_client.allowed_user_attributes.all()) == set()
assert api_client.ou == ou2
resp = app.get(reverse('a2-manager-api-client-edit', kwargs={'pk': api_client.pk}))
form = resp.form
form['apiclient_roles'].force_value([role_2.id])
form['allowed_user_attributes'].force_value([preferred_color.id])
response = form.submit().follow()
api_client = APIClient.objects.get()
assert api_client.ou == ou2
assert set(api_client.apiclient_roles.all()) == {role_2}
assert set(api_client.allowed_user_attributes.all()) == {preferred_color}
def test_edit_local_admin(admin_ou1, app, ou1, ou2):