fix(users): only list roles and memberships with manage_account (#8281)

Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pepe Fagoaga
2025-09-22 11:25:24 +02:00
committed by GitHub
parent 1cfae546a0
commit b00602f109
14 changed files with 671 additions and 96 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.14.0] (Prowler 5.13.0)
### Changed
- Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281)
---
## [1.13.0] (Prowler 5.12.0)
### Added
+1 -1
View File
@@ -40,7 +40,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.13.0"
version = "1.14.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
+38 -45
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.13.0
version: 1.14.0
description: |-
Prowler API specification.
@@ -8220,6 +8220,7 @@ paths:
type: string
enum:
- roles
- memberships
description: include query parameter to allow the client to customize which
related resources should be returned.
explode: false
@@ -8339,6 +8340,7 @@ paths:
type: string
enum:
- roles
- memberships
description: include query parameter to allow the client to customize which
related resources should be returned.
explode: false
@@ -8652,6 +8654,7 @@ paths:
type: string
enum:
- roles
- memberships
description: include query parameter to allow the client to customize which
related resources should be returned.
explode: false
@@ -15553,59 +15556,49 @@ components:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
format: uuid
title: Resource Identifier
description: The identifier of the related object.
type:
type: string
enum:
- memberships
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
type: object
properties:
id:
type: string
type:
type: string
enum:
- memberships
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
required:
- data
description: A related resource object from type memberships
title: memberships
description: The identifier of the related object.
title: Resource Identifier
readOnly: true
roles:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
format: uuid
title: Resource Identifier
description: The identifier of the related object.
type:
type: string
enum:
- roles
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
type: object
properties:
id:
type: string
type:
type: string
enum:
- roles
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
required:
- data
description: A related resource object from type roles
title: roles
description: The identifier of the related object.
title: Resource Identifier
readOnly: true
UserCreate:
type: object
+336
View File
@@ -1,3 +1,4 @@
import json
from unittest.mock import ANY, Mock, patch
import pytest
@@ -151,6 +152,221 @@ class TestUserViewSet:
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["email"] == "rbac_limited@rbac.com"
def test_me_shows_own_roles_and_memberships_without_manage_account(
self, authenticated_client_no_permissions_rbac
):
response = authenticated_client_no_permissions_rbac.get(reverse("user-me"))
assert response.status_code == status.HTTP_200_OK
rels = response.json()["data"]["relationships"]
# Self should see own roles and memberships even without manage_account
assert isinstance(rels["roles"]["data"], list)
assert rels["memberships"]["meta"]["count"] == 1
def test_me_shows_roles_and_memberships_with_manage_account(
self, authenticated_client_rbac
):
response = authenticated_client_rbac.get(reverse("user-me"))
assert response.status_code == status.HTTP_200_OK
rels = response.json()["data"]["relationships"]
# Roles should have data when manage_account is True
assert len(rels["roles"]["data"]) > 0
# Memberships should be present and count > 0
assert rels["memberships"]["meta"]["count"] > 0
def test_me_include_roles_and_memberships_included_block(
self, authenticated_client_rbac
):
# Request current user info including roles and memberships
response = authenticated_client_rbac.get(
reverse("user-me"), {"include": "roles,memberships"}
)
assert response.status_code == status.HTTP_200_OK
payload = response.json()
# Included must contain memberships corresponding to relationships data
rel_memberships = payload["data"]["relationships"]["memberships"]
ids_in_relationship = {item["id"] for item in rel_memberships["data"]}
included = payload["included"]
included_membership_ids = {
item["id"] for item in included if item["type"] == "memberships"
}
# If there are memberships in relationships, they must be present in included
if ids_in_relationship:
assert ids_in_relationship.issubset(included_membership_ids)
else:
# At minimum, included should contain the user's membership when requested
# (count should align with meta count)
assert rel_memberships["meta"]["count"] == len(included_membership_ids)
def test_list_users_with_manage_account_only_forbidden(
self, authenticated_client_rbac_manage_account
):
response = authenticated_client_rbac_manage_account.get(reverse("user-list"))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_retrieve_other_user_with_manage_account_only_forbidden(
self, authenticated_client_rbac_manage_account, create_test_user
):
response = authenticated_client_rbac_manage_account.get(
reverse("user-detail", kwargs={"pk": create_test_user.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_users_with_manage_users_only_hides_relationships(
self, authenticated_client_rbac_manage_users_only
):
# Ensure there is at least one other user in the same tenant
mu_user = authenticated_client_rbac_manage_users_only.user
mu_membership = Membership.objects.filter(user=mu_user).first()
tenant = mu_membership.tenant
other_user = User.objects.create_user(
name="other_in_tenant",
email="other_in_tenant@rbac.com",
password="Password123@",
)
Membership.objects.create(user=other_user, tenant=tenant)
response = authenticated_client_rbac_manage_users_only.get(reverse("user-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert isinstance(data, list)
current_user_id = str(mu_user.id)
assert any(item["id"] == current_user_id for item in data)
for item in data:
rels = item["relationships"]
if item["id"] == current_user_id:
# Self should see own relationships
assert isinstance(rels["roles"]["data"], list)
assert rels["memberships"]["meta"].get("count", 0) >= 1
else:
# Others should be hidden without manage_account
assert rels["roles"]["data"] == []
assert rels["memberships"]["data"] == []
assert rels["memberships"]["meta"]["count"] == 0
def test_include_roles_hidden_without_manage_account(
self, authenticated_client_rbac_manage_users_only
):
# Arrange: ensure another user in the same tenant with its own role
mu_user = authenticated_client_rbac_manage_users_only.user
mu_membership = Membership.objects.filter(user=mu_user).first()
tenant = mu_membership.tenant
other_user = User.objects.create_user(
name="other_in_tenant_inc",
email="other_in_tenant_inc@rbac.com",
password="Password123@",
)
Membership.objects.create(user=other_user, tenant=tenant)
other_role = Role.objects.create(
name="other_inc_role",
tenant_id=tenant.id,
manage_users=False,
manage_account=False,
)
UserRoleRelationship.objects.create(
user=other_user, role=other_role, tenant_id=tenant.id
)
response = authenticated_client_rbac_manage_users_only.get(
reverse("user-list"), {"include": "roles"}
)
assert response.status_code == status.HTTP_200_OK
payload = response.json()
# Assert: included must not contain the other user's role
included = payload.get("included", [])
included_role_ids = {
item["id"] for item in included if item.get("type") == "roles"
}
assert str(other_role.id) not in included_role_ids
# Relationships for other user should be empty
for item in payload["data"]:
if item["id"] == str(other_user.id):
rels = item["relationships"]
assert rels["roles"]["data"] == []
def test_include_roles_visible_with_manage_account(
self, authenticated_client_rbac, tenants_fixture
):
# Arrange: another user in tenant[0] with its role
tenant = tenants_fixture[0]
other_user = User.objects.create_user(
name="other_with_role",
email="other_with_role@rbac.com",
password="Password123@",
)
Membership.objects.create(user=other_user, tenant=tenant)
other_role = Role.objects.create(
name="other_visible_role",
tenant_id=tenant.id,
manage_users=False,
manage_account=False,
)
UserRoleRelationship.objects.create(
user=other_user, role=other_role, tenant_id=tenant.id
)
response = authenticated_client_rbac.get(
reverse("user-list"), {"include": "roles"}
)
assert response.status_code == status.HTTP_200_OK
payload = response.json()
# Assert: included must contain the other user's role
included = payload.get("included", [])
included_role_ids = {
item["id"] for item in included if item.get("type") == "roles"
}
assert str(other_role.id) in included_role_ids
def test_retrieve_user_with_manage_users_only_hides_relationships(
self, authenticated_client_rbac_manage_users_only
):
# Create a target user in the same tenant to ensure visibility
mu_user = authenticated_client_rbac_manage_users_only.user
mu_membership = Membership.objects.filter(user=mu_user).first()
tenant = mu_membership.tenant
target_user = User.objects.create_user(
name="target_same_tenant",
email="target_same_tenant@rbac.com",
password="Password123@",
)
Membership.objects.create(user=target_user, tenant=tenant)
response = authenticated_client_rbac_manage_users_only.get(
reverse("user-detail", kwargs={"pk": target_user.id})
)
assert response.status_code == status.HTTP_200_OK
rels = response.json()["data"]["relationships"]
assert rels["roles"]["data"] == []
assert rels["memberships"]["data"] == []
assert rels["memberships"]["meta"]["count"] == 0
def test_list_users_with_all_permissions_shows_relationships(
self, authenticated_client_rbac
):
response = authenticated_client_rbac.get(reverse("user-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert isinstance(data, list)
rels = data[0]["relationships"]
assert len(rels["roles"]["data"]) >= 0
assert rels["memberships"]["meta"]["count"] >= 0
@pytest.mark.django_db
class TestProviderViewSet:
@@ -494,3 +710,123 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
@pytest.mark.django_db
class TestRolePermissions:
def test_role_create_with_manage_account_only_allowed(
self, authenticated_client_rbac_manage_account
):
data = {
"data": {
"type": "roles",
"attributes": {
"name": "Role Manage Account Only",
"manage_users": "false",
"manage_account": "true",
"manage_providers": "false",
"manage_scans": "false",
"unlimited_visibility": "false",
},
"relationships": {"provider_groups": {"data": []}},
}
}
response = authenticated_client_rbac_manage_account.post(
reverse("role-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
def test_role_create_with_manage_users_only_forbidden(
self, authenticated_client_rbac_manage_users_only
):
data = {
"data": {
"type": "roles",
"attributes": {
"name": "Role Manage Users Only",
"manage_users": "true",
"manage_account": "false",
"manage_providers": "false",
"manage_scans": "false",
"unlimited_visibility": "false",
},
"relationships": {"provider_groups": {"data": []}},
}
}
response = authenticated_client_rbac_manage_users_only.post(
reverse("role-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
class TestUserRoleLinkPermissions:
def test_link_user_roles_with_manage_account_only_allowed(
self, authenticated_client_rbac_manage_account
):
# Arrange: create a second user in the same tenant as the manage_account user
ma_user = authenticated_client_rbac_manage_account.user
ma_membership = Membership.objects.filter(user=ma_user).first()
tenant = ma_membership.tenant
user2 = User.objects.create_user(
name="target_user",
email="target_user_ma@rbac.com",
password="Password123@",
)
Membership.objects.create(user=user2, tenant=tenant)
# Create a role in the same tenant
role = Role.objects.create(
name="linkable_role",
tenant_id=tenant.id,
manage_users=False,
manage_account=False,
)
data = {"data": [{"type": "roles", "id": str(role.id)}]}
# Act
response = authenticated_client_rbac_manage_account.post(
reverse("user-roles-relationship", kwargs={"pk": user2.id}),
data=data,
content_type="application/vnd.api+json",
)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_link_user_roles_with_manage_users_only_forbidden(
self, authenticated_client_rbac_manage_users_only
):
mu_user = authenticated_client_rbac_manage_users_only.user
mu_membership = Membership.objects.filter(user=mu_user).first()
tenant = mu_membership.tenant
user2 = User.objects.create_user(
name="target_user2",
email="target_user_mu@rbac.com",
password="Password123@",
)
Membership.objects.create(user=user2, tenant=tenant)
role = Role.objects.create(
name="linkable_role_mu",
tenant_id=tenant.id,
manage_users=False,
manage_account=False,
)
data = {"data": [{"type": "roles", "id": str(role.id)}]}
response = authenticated_client_rbac_manage_users_only.post(
reverse("user-roles-relationship", kwargs={"pk": user2.id}),
data=data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
+97 -3
View File
@@ -15,6 +15,7 @@ from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.db_router import MainRouter
from api.exceptions import ConflictException
from api.models import (
Finding,
@@ -259,8 +260,15 @@ class UserSerializer(BaseSerializerV1):
Serializer for the User model.
"""
memberships = serializers.ResourceRelatedField(many=True, read_only=True)
roles = serializers.ResourceRelatedField(many=True, read_only=True)
# We use SerializerMethodResourceRelatedField so includes (e.g. ?include=roles)
# respect RBAC and do not leak relationships of other users when the requester
# lacks manage_account. The visibility logic lives in get_roles/get_memberships.
memberships = SerializerMethodResourceRelatedField(
many=True, read_only=True, source="memberships", method_name="get_memberships"
)
roles = SerializerMethodResourceRelatedField(
many=True, read_only=True, source="roles", method_name="get_roles"
)
class Meta:
model = User
@@ -278,9 +286,35 @@ class UserSerializer(BaseSerializerV1):
}
included_serializers = {
"roles": "api.v1.serializers.RoleSerializer",
"roles": "api.v1.serializers.RoleIncludeSerializer",
"memberships": "api.v1.serializers.MembershipIncludeSerializer",
}
def _can_view_relationships(self, instance) -> bool:
"""Allow self to view own relationships. Require manage_account to view others."""
role = self.context.get("role")
request = self.context.get("request")
is_self = bool(
request
and getattr(request, "user", None)
and getattr(instance, "id", None) == request.user.id
)
return is_self or (role and role.manage_account)
def get_roles(self, instance):
return (
instance.roles.all()
if self._can_view_relationships(instance)
else Role.objects.none()
)
def get_memberships(self, instance):
return (
instance.memberships.all()
if self._can_view_relationships(instance)
else Membership.objects.none()
)
class UserCreateSerializer(BaseWriteSerializer):
password = serializers.CharField(write_only=True)
@@ -502,6 +536,12 @@ class TenantSerializer(BaseSerializerV1):
fields = ["id", "name", "memberships"]
class TenantIncludeSerializer(BaseSerializerV1):
class Meta:
model = Tenant
fields = ["id", "name"]
# Memberships
@@ -523,6 +563,29 @@ class MembershipSerializer(serializers.ModelSerializer):
fields = ["id", "user", "tenant", "role", "date_joined"]
class MembershipIncludeSerializer(serializers.ModelSerializer):
"""
Include-oriented Membership serializer that enables including tenant objects with names
without altering the base MembershipSerializer behavior.
"""
role = MemberRoleEnumSerializerField()
user = serializers.ResourceRelatedField(read_only=True)
tenant = SerializerMethodResourceRelatedField(read_only=True, source="tenant")
class Meta:
model = Membership
fields = ["id", "user", "tenant", "role", "date_joined"]
included_serializers = {"tenant": "api.v1.serializers.TenantIncludeSerializer"}
def get_tenant(self, instance):
try:
return Tenant.objects.using(MainRouter.admin_db).get(id=instance.tenant_id)
except Tenant.DoesNotExist:
return None
# Provider Groups
class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
providers = serializers.ResourceRelatedField(
@@ -1692,6 +1755,37 @@ class RoleUpdateSerializer(RoleSerializer):
return super().update(instance, validated_data)
class RoleIncludeSerializer(RLSSerializer):
permission_state = serializers.SerializerMethodField()
def get_permission_state(self, obj) -> str:
return obj.permission_state
class Meta:
model = Role
fields = [
"id",
"name",
"manage_users",
"manage_account",
# Disable for the first release
# "manage_billing",
# /Disable for the first release
"manage_integrations",
"manage_providers",
"manage_scans",
"permission_state",
"unlimited_visibility",
"inserted_at",
"updated_at",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
}
class ProviderGroupResourceIdentifierSerializer(serializers.Serializer):
resource_type = serializers.CharField(source="type")
id = serializers.UUIDField()
+10 -2
View File
@@ -300,7 +300,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.13.0"
spectacular_settings.VERSION = "1.14.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -768,11 +768,13 @@ class UserViewSet(BaseUserViewset):
# If called during schema generation, return an empty queryset
if getattr(self, "swagger_fake_view", False):
return User.objects.none()
queryset = (
User.objects.filter(membership__tenant__id=self.request.tenant_id)
if hasattr(self.request, "tenant_id")
else User.objects.all()
)
return queryset.prefetch_related("memberships", "roles")
def get_permissions(self):
@@ -790,6 +792,12 @@ class UserViewSet(BaseUserViewset):
else:
return UserSerializer
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated:
context["role"] = get_role(self.request.user)
return context
@action(detail=False, methods=["get"], url_name="me")
def me(self, request):
user = self.request.user
@@ -919,7 +927,7 @@ class UserRoleRelationshipView(RelationshipView, BaseRLSViewSet):
http_method_names = ["post", "patch", "delete"]
schema = RelationshipViewSchema()
# RBAC required permissions
required_permissions = [Permissions.MANAGE_USERS]
required_permissions = [Permissions.MANAGE_ACCOUNT]
def get_queryset(self):
return User.objects.filter(membership__tenant__id=self.request.tenant_id)
+102
View File
@@ -191,6 +191,108 @@ def create_test_user_rbac_limited(django_db_setup, django_db_blocker):
return user
@pytest.fixture(scope="function")
def create_test_user_rbac_manage_account(django_db_setup, django_db_blocker):
"""User with only manage_account permission (no manage_users)."""
with django_db_blocker.unblock():
user = User.objects.create_user(
name="testing_manage_account",
email="rbac_manage_account@rbac.com",
password=TEST_PASSWORD,
)
tenant = Tenant.objects.create(
name="Tenant Test Manage Account",
)
Membership.objects.create(
user=user,
tenant=tenant,
role=Membership.RoleChoices.OWNER,
)
role = Role.objects.create(
name="manage_account",
tenant_id=tenant.id,
manage_users=False,
manage_account=True,
manage_billing=False,
manage_providers=False,
manage_integrations=False,
manage_scans=False,
unlimited_visibility=False,
)
UserRoleRelationship.objects.create(
user=user,
role=role,
tenant_id=tenant.id,
)
return user
@pytest.fixture
def authenticated_client_rbac_manage_account(
create_test_user_rbac_manage_account, tenants_fixture, client
):
client.user = create_test_user_rbac_manage_account
serializer = TokenSerializer(
data={
"type": "tokens",
"email": "rbac_manage_account@rbac.com",
"password": TEST_PASSWORD,
}
)
serializer.is_valid()
access_token = serializer.validated_data["access"]
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
return client
@pytest.fixture(scope="function")
def create_test_user_rbac_manage_users_only(django_db_setup, django_db_blocker):
"""User with only manage_users permission (no manage_account)."""
with django_db_blocker.unblock():
user = User.objects.create_user(
name="testing_manage_users_only",
email="rbac_manage_users_only@rbac.com",
password=TEST_PASSWORD,
)
tenant = Tenant.objects.create(name="Tenant Test Manage Users Only")
Membership.objects.create(
user=user,
tenant=tenant,
role=Membership.RoleChoices.OWNER,
)
role = Role.objects.create(
name="manage_users_only",
tenant_id=tenant.id,
manage_users=True,
manage_account=False,
manage_billing=False,
manage_providers=False,
manage_integrations=False,
manage_scans=False,
unlimited_visibility=False,
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
return user
@pytest.fixture
def authenticated_client_rbac_manage_users_only(
create_test_user_rbac_manage_users_only, client
):
client.user = create_test_user_rbac_manage_users_only
serializer = TokenSerializer(
data={
"type": "tokens",
"email": "rbac_manage_users_only@rbac.com",
"password": TEST_PASSWORD,
}
)
serializer.is_valid()
access_token = serializer.validated_data["access"]
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
return client
@pytest.fixture
def authenticated_client_rbac(create_test_user_rbac, tenants_fixture, client):
client.user = create_test_user_rbac
+1 -1
View File
@@ -207,7 +207,7 @@ Follow these steps to remove a role of your account:
Assign administrative permissions by selecting from the following options:
**Invite and Manage Users:** Invite new users and manage existing ones.<br>
**Manage Account:** Adjust account settings and delete users.<br>
**Manage Account:** Adjust account settings, delete users and read/manage users permissions.<br>
**Manage Scans:** Run and review scans.<br>
**Manage Cloud Providers:** Add or modify connected cloud providers.<br>
**Manage Integrations:** Add or modify the Prowler Integrations.
+2 -2
View File
@@ -39,7 +39,7 @@ export const createSamlConfig = async (_prevState: any, formData: FormData) => {
}),
});
handleApiResponse(response, "/integrations", false);
await handleApiResponse(response, "/integrations", false);
return { success: "SAML configuration created successfully!" };
} catch (error) {
console.error("Error creating SAML config:", error);
@@ -89,7 +89,7 @@ export const updateSamlConfig = async (_prevState: any, formData: FormData) => {
}),
});
handleApiResponse(response, "/integrations", false);
await handleApiResponse(response, "/integrations", false);
return { success: "SAML configuration updated successfully!" };
} catch (error) {
console.error("Error updating SAML config:", error);
+1 -1
View File
@@ -80,7 +80,7 @@ export async function updateTenantName(prevState: any, formData: FormData) {
throw new Error(`Failed to update tenant name: ${response.statusText}`);
}
handleApiResponse(response, "/profile", false);
await handleApiResponse(response, "/profile", false);
return { success: "Tenant name updated successfully!" };
} catch (error) {
return handleApiError(error);
+3 -1
View File
@@ -164,7 +164,9 @@ export const deleteUser = async (formData: FormData) => {
export const getUserInfo = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/users/me?include=roles`);
const url = new URL(
`${apiBaseUrl}/users/me?include=roles,memberships,memberships.tenant`,
);
try {
const response = await fetch(url.toString(), {
+62 -37
View File
@@ -1,17 +1,19 @@
import React, { Suspense } from "react";
import { getSamlConfig } from "@/actions/integrations/saml";
import { getAllTenants } from "@/actions/users/tenants";
import { getUserInfo } from "@/actions/users/users";
import { getUserMemberships } from "@/actions/users/users";
import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card";
import { ContentLayout } from "@/components/ui";
import { UserBasicInfoCard } from "@/components/users/profile";
import { MembershipsCard } from "@/components/users/profile/memberships-card";
import { RolesCard } from "@/components/users/profile/roles-card";
import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info";
import { isUserOwnerAndHasManageAccount } from "@/lib/permissions";
import { RoleDetail, TenantDetailData } from "@/types/users";
import {
MembershipDetailData,
RoleDetail,
TenantDetailData,
UserProfileResponse,
} from "@/types/users";
export default async function Profile() {
return (
@@ -24,69 +26,92 @@ export default async function Profile() {
}
const SSRDataUser = async () => {
const samlConfig = await getSamlConfig();
const userProfile = await getUserInfo();
const userProfile = (await getUserInfo()) as UserProfileResponse | undefined;
if (!userProfile?.data) {
return null;
}
const roleDetails =
userProfile.included?.filter((item: any) => item.type === "roles") || [];
const userData = userProfile.data;
const roleDetailsMap = roleDetails.reduce(
(acc: Record<string, RoleDetail>, role: RoleDetail) => {
const roleDetails =
userProfile.included?.filter(
(item): item is RoleDetail => item.type === "roles",
) || [];
const membershipsIncluded =
userProfile.included?.filter(
(item): item is MembershipDetailData => item.type === "memberships",
) || [];
const tenantsIncluded =
userProfile.included?.filter(
(item): item is TenantDetailData => item.type === "tenants",
) || [];
const roleDetailsMap = roleDetails.reduce<Record<string, RoleDetail>>(
(acc, role) => {
acc[role.id] = role;
return acc;
},
{} as Record<string, RoleDetail>,
{},
);
const memberships = await getUserMemberships(userProfile.data.id);
const tenants = await getAllTenants();
const tenantsMap = tenants?.data?.reduce(
(acc: Record<string, TenantDetailData>, tenant: TenantDetailData) => {
const tenantsMap = tenantsIncluded.reduce<Record<string, TenantDetailData>>(
(acc, tenant) => {
acc[tenant.id] = tenant;
return acc;
},
{} as Record<string, TenantDetailData>,
{},
);
const userMembershipIds =
userProfile.data.relationships?.memberships?.data?.map(
(membership: { id: string }) => membership.id,
) || [];
const userTenant = tenants?.data?.find((tenant: TenantDetailData) =>
tenant.relationships?.memberships?.data?.some(
(membership: { id: string }) => userMembershipIds.includes(membership.id),
),
const firstUserMembership = membershipsIncluded.find(
(m) => m.relationships?.user?.data?.id === userData.id,
);
const isOwner = isUserOwnerAndHasManageAccount(
roleDetails,
memberships?.data || [],
userProfile.data.id,
const userTenantId = firstUserMembership?.relationships?.tenant?.data?.id;
const userRoleIds =
userData.relationships?.roles?.data?.map((r) => r.id) || [];
const hasManageAccount = roleDetails.some(
(role) =>
role.attributes.manage_account === true && userRoleIds.includes(role.id),
);
const hasManageIntegrations = roleDetails.some(
(role) =>
role.attributes.manage_integrations === true &&
userRoleIds.includes(role.id),
);
const isOwner = membershipsIncluded.some(
(m) =>
m.attributes.role === "owner" &&
m.relationships?.user?.data?.id === userData.id,
);
const samlConfig = hasManageIntegrations ? await getSamlConfig() : undefined;
return (
<div className="flex w-full flex-col gap-6">
<UserBasicInfoCard user={userProfile?.data} tenantId={userTenant?.id} />
<UserBasicInfoCard user={userData} tenantId={userTenantId || ""} />
<div className="flex flex-col gap-6 xl:flex-row">
<div className="w-full lg:w-2/3 xl:w-1/2">
<RolesCard roles={roleDetails || []} roleDetails={roleDetailsMap} />
<RolesCard roles={roleDetails} roleDetails={roleDetailsMap} />
</div>
<div className="w-full lg:w-2/3 xl:w-1/2">
<MembershipsCard
memberships={memberships?.data || []}
memberships={membershipsIncluded}
tenantsMap={tenantsMap}
isOwner={isOwner}
isOwner={isOwner && hasManageAccount}
/>
</div>
</div>
<div className="w-full pr-0 lg:w-2/3 xl:w-1/2 xl:pr-3">
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
</div>
{hasManageIntegrations && (
<div className="w-full pr-0 lg:w-2/3 xl:w-1/2 xl:pr-3">
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
</div>
)}
</div>
);
};
+1 -3
View File
@@ -1,7 +1,6 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
import { getUsers } from "@/actions/users/users";
import { FilterControls } from "@/components/filters";
import { filterUsers } from "@/components/filters/data-filters";
@@ -52,7 +51,6 @@ const SSRDataTable = async ({
const query = (filters["filter[search]"] as string) || "";
const usersData = await getUsers({ query, page, sort, filters, pageSize });
const rolesData = await getRoles({});
// Create a dictionary for roles by user ID
const roleDict = (usersData?.included || []).reduce(
@@ -68,7 +66,7 @@ const SSRDataTable = async ({
// Generate the array of roles with all the roles available
const roles = Array.from(
new Map(
(rolesData?.data || []).map((role: Role) => [
(usersData?.included || []).map((role: Role) => [
role.id,
{ id: role.id, name: role.attributes?.name || "Unnamed Role" },
]),
+10
View File
@@ -149,3 +149,13 @@ export interface TenantDetailData {
};
};
}
export type IncludedItem = RoleDetail | MembershipDetailData | TenantDetailData;
export interface UserProfileResponse {
data: UserDataWithRoles;
included?: IncludedItem[];
meta?: {
version: string;
};
}