api: allow per-client user attributes restriction (#78332)
gitea/authentic/pipeline/head This commit looks good
Details
gitea/authentic/pipeline/head This commit looks good
Details
This commit is contained in:
parent
610faab28c
commit
e465ffc506
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue