Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cdip_admin/accounts/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ class OrganzationMemberInline(admin.TabularInline):


@admin.register(AccountProfile)
class AccountProfile(admin.ModelAdmin):
class AccountProfileAdmin(admin.ModelAdmin):

list_display = ("username",)
list_display = ("username", "contact_email",)
list_select_related = ("user",)

inlines = [
OrganzationMemberInline,
Expand All @@ -22,9 +23,12 @@ def username(self, obj):
search_fields = (
"user__username",
"organizations__name",
"contact_email",
)

fieldsets = ((None, {"classes": ("wide",), "fields": (("user",))}),)
fieldsets = (
(None, {"classes": ("wide",), "fields": ("user", "contact_email")}),
)


@admin.register(EULA)
Expand Down
64 changes: 64 additions & 0 deletions cdip_admin/accounts/keycloak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import logging

import requests

from cdip_admin import settings
from core.utils import get_admin_access_token


KEYCLOAK_SERVER = settings.KEYCLOAK_SERVER
KEYCLOAK_REALM = settings.KEYCLOAK_REALM

KEYCLOAK_ADMIN_API = f"{KEYCLOAK_SERVER}/auth/admin/realms/{KEYCLOAK_REALM}/"

# (connect_timeout, read_timeout) for outbound Keycloak admin calls.
# Bounds the time a portal request can spend blocked on Keycloak.
KEYCLOAK_HTTP_TIMEOUT = (5, 10)

logger = logging.getLogger(__name__)


def add_account(user):
"""Create a user in Keycloak.

`user` is a dict with keys: ``email``, ``first_name``, ``last_name``.

Returns ``True`` if the user exists in Keycloak after the call
(newly created, already-present, or any 2xx). Returns ``False`` if
the call could not be made (no admin token) or Keycloak returned an
unexpected error.
"""
token = get_admin_access_token()
if not token:
logger.warning("Cannot get a valid Keycloak admin access_token.")
return False

url = KEYCLOAK_ADMIN_API + "users"
headers = {
"authorization": f"{token['token_type']} {token['access_token']}",
"Content-type": "application/json",
}
user_data = {
"email": user["email"],
"firstName": user["first_name"],
"lastName": user["last_name"],
"enabled": True,
}

try:
response = requests.post(
url=url, headers=headers, json=user_data, timeout=KEYCLOAK_HTTP_TIMEOUT,
)
except requests.RequestException:
logger.exception("Keycloak add_account network error")
return False

if 200 <= response.status_code < 300:
logger.info("User created successfully")
return True
if response.status_code == 409:
logger.info(f'Keycloak user {user["email"]} already exists.')
return True

logger.error(f"Error adding account: {response.status_code}, {response.text}")
return False
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0016_alter_eula_options'),
]

operations = [
migrations.AddField(
model_name='accountprofile',
name='contact_email',
field=models.EmailField(
blank=True,
help_text='User-controlled contact email, independent of the auth provider email.',
max_length=254,
null=True,
),
),
]
Comment thread
amicavi marked this conversation as resolved.
4 changes: 4 additions & 0 deletions cdip_admin/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class AccountProfile(models.Model):
organizations = models.ManyToManyField(
Organization, through=AccountProfileOrganization
)
contact_email = models.EmailField(
max_length=254, null=True, blank=True,
help_text=_("User-controlled contact email, independent of the auth provider email."),
)

def __str__(self):
return self.user.username
Expand Down
101 changes: 21 additions & 80 deletions cdip_admin/accounts/utils.py
Original file line number Diff line number Diff line change
@@ -1,101 +1,46 @@
import logging
import requests
from django.http import JsonResponse

from django.contrib.auth.models import User, Group
from django.core.exceptions import SuspiciousOperation
from django.db.models import Q

from core.enums import DjangoGroups
from cdip_admin import settings
from core.utils import get_admin_access_token
from organizations.models import Organization
from .models import AccountProfile, AccountProfileOrganization

from . import keycloak
from .models import AccountProfile, AccountProfileOrganization

KEYCLOAK_SERVER = settings.KEYCLOAK_SERVER
KEYCLOAK_REALM = settings.KEYCLOAK_REALM
KEYCLOAK_CLIENT = settings.KEYCLOAK_CLIENT_ID

KEYCLOAK_ADMIN_API = f"{KEYCLOAK_SERVER}/auth/admin/realms/{KEYCLOAK_REALM}/"

logger = logging.getLogger(__name__)


def add_account(user):

url = KEYCLOAK_ADMIN_API + "users"

token = get_admin_access_token()

if not token:
logger.warning("Cannot get a valid access_token.")
response = JsonResponse({"message": "You don't have access to this resource"})
response.status_code = 403
return response

headers = {
"authorization": f"{token['token_type']} {token['access_token']}",
"Content-type": "application/json",
}
# Prepare the payload in the format expected by keycloak
user_data = {
"email": user["email"],
"firstName": user["first_name"],
"lastName": user["last_name"],
"enabled": True # Enable user in keycloak
}
response = requests.post(url=url, headers=headers, json=user_data)

if response.ok:
logger.info(f"User created successfully")
elif response.status_code == 409:
logger.info(f'Keycloak user {user["email"]} already exists.')
else:
logger.error(f"Error adding account: {response.status_code}], {response.text}")
return False

return True


def add_or_create_user_in_org(org_id, role, user_data):
email = user_data["email"]
first_name = user_data["first_name"]
last_name = user_data["last_name"]
username = email
user_created = False
org_members_group_id = Group.objects.get(name=DjangoGroups.ORGANIZATION_MEMBER.value).id

Comment thread
amicavi marked this conversation as resolved.
try:
user = User.objects.get(Q(username=email) | Q(email=email))
if not user.groups.filter(
name=DjangoGroups.ORGANIZATION_MEMBER.value
).exists():
group_id = Group.objects.get(
name=DjangoGroups.ORGANIZATION_MEMBER
).id
user.groups.add(group_id)
if not user.groups.filter(name=DjangoGroups.ORGANIZATION_MEMBER.value).exists():
user.groups.add(org_members_group_id)

except User.DoesNotExist:
# create keycloak user
response = add_account(user_data)
# create django user
if response:
user = User.objects.create(
email=email,
username=username,
first_name=first_name,
last_name=last_name,
)
group_id = Group.objects.get(
name=DjangoGroups.ORGANIZATION_MEMBER
).id
user.groups.add(group_id)
user_created = True
else:
if not keycloak.add_account(user_data):
raise SuspiciousOperation

account_profile, created = AccountProfile.objects.get_or_create(
user_id=user.id,
)
apo, created = AccountProfileOrganization.objects.get_or_create(
accountprofile_id=account_profile.id, organization_id=org_id, role=role
user = User.objects.create(
email=email,
username=email,
first_name=first_name,
last_name=last_name,
)
user.groups.add(org_members_group_id)
user_created = True

account_profile, _ = AccountProfile.objects.get_or_create(user_id=user.id)
AccountProfileOrganization.objects.get_or_create(
accountprofile_id=account_profile.id, organization_id=org_id, role=role,
)
return user, user_created

Expand All @@ -105,10 +50,6 @@ def remove_members_from_organization(org_id, profile_ids):
return removed_qty


def get_password_reset_link(user):
return f"{KEYCLOAK_SERVER}/auth/realms/{KEYCLOAK_REALM}/login-actions/reset-credentials?client_id={KEYCLOAK_CLIENT}"


def get_user_organizations_qs(user):
# Returns a queryset with the organizations that the user is allowed to see
if user.is_superuser:
Expand Down
72 changes: 72 additions & 0 deletions cdip_admin/api/v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,43 @@
User = get_user_model()


class UserWorkspaceSerializer(serializers.ModelSerializer):
"""One workspace a user belongs to.

``id`` is the membership id (``AccountProfileOrganization.id``) and is
what future membership-scoped actions (e.g. leave-workspace) target.
``workspace_id`` is the organization id and is what the frontend uses
to link to the workspace itself.
"""
Comment thread
amicavi marked this conversation as resolved.

name = serializers.CharField(source="organization.name")
workspace_id = serializers.UUIDField(source="organization.id")
role = serializers.CharField()

class Meta:
model = AccountProfileOrganization
fields = ("id", "workspace_id", "name", "role")


class UserDetailsRetrieveSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
accepted_eula = serializers.SerializerMethodField()
contact_email = serializers.SerializerMethodField()
workspaces = serializers.SerializerMethodField()

class Meta:
model = User
fields = (
"id",
"username",
"email",
"first_name",
"last_name",
"full_name",
"is_superuser",
"accepted_eula",
"contact_email",
"workspaces",
)

def get_full_name(self, obj):
Expand All @@ -51,6 +75,54 @@ def get_accepted_eula(self, obj):
else:
return agreement.accept

def get_contact_email(self, obj):
profile = getattr(obj, "accountprofile", None)
return profile.contact_email if profile else None

def get_workspaces(self, obj):
profile = getattr(obj, "accountprofile", None)
if not profile:
return []
memberships = (
profile.accountprofileorganization_set
.select_related("organization")
.all()
)
return UserWorkspaceSerializer(memberships, many=True).data


class UserDetailsUpdateSerializer(serializers.Serializer):
"""PATCH /v2/users/me/ — splits writes between User (first/last name)
and AccountProfile (contact_email), atomically. Response uses the
retrieve serializer shape so the frontend can refresh from one call.
"""

USER_FIELDS = ("first_name", "last_name")

first_name = serializers.CharField(required=False, max_length=150, allow_blank=True)
last_name = serializers.CharField(required=False, max_length=150, allow_blank=True)
contact_email = serializers.EmailField(required=False, allow_null=True)

def update(self, instance, validated_data):
with transaction.atomic():
user_fields = {
k: v for k, v in validated_data.items() if k in self.USER_FIELDS
}
if user_fields:
for k, v in user_fields.items():
setattr(instance, k, v)
instance.save(update_fields=list(user_fields.keys()))

if "contact_email" in validated_data:
profile, _ = AccountProfile.objects.get_or_create(user=instance)
profile.contact_email = validated_data["contact_email"]
profile.save(update_fields=["contact_email"])

return instance

def to_representation(self, instance):
return UserDetailsRetrieveSerializer(instance, context=self.context).data


class OrganizationSerializer(serializers.ModelSerializer):
role = serializers.SerializerMethodField()
Expand Down
4 changes: 2 additions & 2 deletions cdip_admin/api/v2/tests/test_organizations_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def _test_invite_user(

api_client.force_authenticate(inviter)
# Mock external dependencies
mocker.patch("accounts.utils.add_account", mock_add_account) # mock user creation in Keycloak
mocker.patch("accounts.keycloak.add_account", mock_add_account) # mock user creation in Keycloak
mocker.patch("api.v2.views.send_invite_email_task", mock_send_invite_email_task) # mock email sending
response = api_client.put(
reverse("members-invite", kwargs={"organization_pk": organization.id}),
Expand Down Expand Up @@ -292,7 +292,7 @@ def _test_cannot_invite_user(

api_client.force_authenticate(inviter)
# Mock external dependencies
mocker.patch("accounts.utils.add_account", mock_add_account) # mock user creation in Keycloak
mocker.patch("accounts.keycloak.add_account", mock_add_account) # mock user creation in Keycloak
mocker.patch("api.v2.views.send_invite_email_task", mock_send_invite_email_task) # mock email sending
response = api_client.put(
reverse("members-invite", kwargs={"organization_pk": organization.id}),
Expand Down
Loading