diff --git a/src/agents/hyperledger-fabric/channel/serializers.py b/src/agents/hyperledger-fabric/channel/serializers.py index 323d9dea6..ba614f612 100644 --- a/src/agents/hyperledger-fabric/channel/serializers.py +++ b/src/agents/hyperledger-fabric/channel/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from channel.service import create_channel +from channel.service import create_channel, generate_invitation_definition class ChannelSerializer(serializers.Serializer): name = serializers.CharField(help_text="Channel Name") @@ -8,3 +8,19 @@ class ChannelSerializer(serializers.Serializer): def create(self, validated_data): create_channel(validated_data["name"]) return self + + +class InvitationDefinitionSerializer(serializers.Serializer): + organization_msp_ids = serializers.ListField( + child=serializers.CharField(help_text="Organization MSP ID"), + help_text="List of MSP IDs for invited organizations", + ) + + def create(self, validated_data): + channel_name = self.context["channel_name"] + artifact = generate_invitation_definition( + channel_name, + validated_data["organization_msp_ids"], + ) + validated_data["artifact"] = artifact + return validated_data diff --git a/src/agents/hyperledger-fabric/channel/service.py b/src/agents/hyperledger-fabric/channel/service.py index a671f7f8e..f4d9ef1f7 100644 --- a/src/agents/hyperledger-fabric/channel/service.py +++ b/src/agents/hyperledger-fabric/channel/service.py @@ -1,3 +1,4 @@ +import base64 from copy import deepcopy import json import logging @@ -11,6 +12,7 @@ LOG = logging.getLogger(__name__) + def create_channel(channel_name: str): with open( CRYPTO_CONFIG, @@ -61,15 +63,15 @@ def create_channel(channel_name: str): "Readers": { "Type": "Signature", "Rule": "OR('{}MSP.admin', '{}MSP.peer', '{}MSP.client')".format( - crypto_config["PeerOrgs"][0]["Name"], - crypto_config["PeerOrgs"][0]["Name"], + crypto_config["PeerOrgs"][0]["Name"], + crypto_config["PeerOrgs"][0]["Name"], crypto_config["PeerOrgs"][0]["Name"] ), }, "Writers": { "Type": "Signature", "Rule": "OR('{}MSP.admin', '{}MSP.client')".format( - crypto_config["PeerOrgs"][0]["Name"], + crypto_config["PeerOrgs"][0]["Name"], crypto_config["PeerOrgs"][0]["Name"] ), }, @@ -85,8 +87,8 @@ def create_channel(channel_name: str): } orderer_directories = [os.path.join( - orderer_organization_directory, - "orderers", + orderer_organization_directory, + "orderers", orderer_host) for orderer_host in orderer_hosts] with open(os.path.join(CELLO_HOME, "config", "configtx.yaml"), "r", encoding="utf-8") as f: @@ -123,7 +125,7 @@ def create_channel(channel_name: str): yaml.safe_dump( { "Organizations": [ - orderer_organizations, + orderer_organizations, peer_organizations ], "Capabilities": { @@ -185,8 +187,8 @@ def create_channel(channel_name: str): for spec in peer_org["Specs"]: peer_domain_name = "{}.{}".format(spec["Hostname"], peer_org["Domain"]) peer_dir = os.path.join( - peer_organization_directory, - "peers", + peer_organization_directory, + "peers", peer_domain_name ) peer_env = { @@ -194,9 +196,9 @@ def create_channel(channel_name: str): "CORE_PEER_LOCALMSPID": crypto_config["PeerOrgs"][0]["Name"] + "MSP", "CORE_PEER_TLS_ROOTCERT_FILE": os.path.join(peer_dir, "tls", "ca.crt"), "CORE_PEER_MSPCONFIGPATH": os.path.join( - peer_organization_directory, - "users", - "Admin@" + crypto_config["PeerOrgs"][0]["Domain"], + peer_organization_directory, + "users", + "Admin@" + crypto_config["PeerOrgs"][0]["Domain"], "msp" ), "CORE_PEER_ADDRESS": peer_domain_name + ":7051", @@ -225,9 +227,9 @@ def create_channel(channel_name: str): "--tls", "--cafile", os.path.join( - orderer_directories[0], - "msp", - "tlscacerts", + orderer_directories[0], + "msp", + "tlscacerts", "tlsca.{}-cert.pem".format(crypto_config["OrdererOrgs"][0]["Domain"]) ), ] @@ -359,9 +361,9 @@ def create_channel(channel_name: str): "--tls", "--cafile", os.path.join( - orderer_directories[0], - "msp", - "tlscacerts", + orderer_directories[0], + "msp", + "tlscacerts", "tlsca.{}-cert.pem".format(crypto_config["OrdererOrgs"][0]["Domain"]) ) ] @@ -382,3 +384,289 @@ def create_channel(channel_name: str): os.remove(os.path.join(channel_directory, "config_update.json")) os.remove(os.path.join(channel_directory, "config_update_in_envelope.json")) os.remove(os.path.join(channel_directory, "config_update_in_envelope.pb")) + + +def _channel_dir(channel_name): + return os.path.join(CELLO_HOME, channel_name) + + +def _read_crypto_config(): + with open(CRYPTO_CONFIG, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def _read_b64(path): + with open(path, "rb") as f: + return base64.b64encode(f.read()).decode() + + +def _build_org_group(crypto_config, msp_id): + org_name = msp_id.replace("MSP", "") + peer_org = None + for org in crypto_config["PeerOrgs"]: + if org["Name"] == org_name: + peer_org = org + break + if not peer_org: + raise ValueError(f"Organization '{org_name}' not found in crypto config") + + domain = peer_org["Domain"] + org_dir = os.path.join(CELLO_HOME, "peerOrganizations", domain) + msp_dir = os.path.join(org_dir, "msp") + + ca_cert_path = os.path.join(msp_dir, "cacerts", f"ca.{domain}-cert.pem") + admin_cert_path = os.path.join(msp_dir, "admincerts", f"Admin@{domain}-cert.pem") + tls_ca_cert_path = os.path.join(msp_dir, "tlscacerts", f"tlsca.{domain}-cert.pem") + + root_certs = [_read_b64(ca_cert_path)] + admin_certs = [_read_b64(admin_cert_path)] + tls_root_certs = [_read_b64(tls_ca_cert_path)] + + spec = peer_org.get("Specs", [{}])[0] + peer_host = f"{spec.get('Hostname', 'peer0')}.{domain}" + + return { + "values": { + "MSP": { + "mod_policy": "Admins", + "value": { + "type": 0, + "value": { + "name": msp_id, + "root_certs": root_certs, + "intermediate_certs": [], + "admin_sign_certs": admin_certs, + "tls_root_certs": tls_root_certs, + "tls_intermediate_certs": [], + } + }, + "version": 0, + }, + "AnchorPeers": { + "mod_policy": "Admins", + "value": { + "anchor_peers": [ + {"host": peer_host, "port": 7051} + ] + }, + "version": 0, + }, + }, + "policies": { + "Readers": { + "mod_policy": "Admins", + "policy": { + "type": 3, + "value": { + "rule": f"OR('{msp_id}.admin', '{msp_id}.peer', '{msp_id}.client')" + } + }, + "version": 0, + }, + "Writers": { + "mod_policy": "Admins", + "policy": { + "type": 3, + "value": { + "rule": f"OR('{msp_id}.admin', '{msp_id}.client')" + } + }, + "version": 0, + }, + "Admins": { + "mod_policy": "Admins", + "policy": { + "type": 3, + "value": { + "rule": f"OR('{msp_id}.admin')" + } + }, + "version": 0, + }, + "Endorsement": { + "mod_policy": "Admins", + "policy": { + "type": 3, + "value": { + "rule": f"OR('{msp_id}.peer')" + } + }, + "version": 0, + }, + }, + "mod_policy": "Admins", + "version": 0, + } + + +def generate_invitation_definition(channel_name, organization_msp_ids): + channel_dir = _channel_dir(channel_name) + os.makedirs(channel_dir, exist_ok=True) + + config_block_pb = os.path.join(channel_dir, "config_block.pb") + config_block_json = os.path.join(channel_dir, "config_block.json") + config_json = os.path.join(channel_dir, "config.json") + modified_config_json = os.path.join(channel_dir, "modified_config.json") + config_pb = os.path.join(channel_dir, "config.pb") + modified_config_pb = os.path.join(channel_dir, "modified_config.pb") + config_update_pb = os.path.join(channel_dir, "config_update.pb") + config_update_json = os.path.join(channel_dir, "config_update.json") + envelope_json = os.path.join(channel_dir, "envelope.json") + envelope_pb = os.path.join(channel_dir, "envelope.pb") + + temp_files = [ + config_block_pb, config_block_json, config_json, + modified_config_json, config_pb, modified_config_pb, + config_update_pb, config_update_json, envelope_json, envelope_pb, + ] + + try: + crypto_config = _read_crypto_config() + + peer_org = crypto_config["PeerOrgs"][0] + domain = peer_org["Domain"] + peer_org_dir = os.path.join(CELLO_HOME, "peerOrganizations", domain) + order_org = crypto_config["OrdererOrgs"][0] + orderer_host = "{}.{}".format(order_org["Specs"][0]["Hostname"], order_org["Domain"]) + orderer_directory = os.path.join( + CELLO_HOME, "ordererOrganizations", order_org["Domain"], "orderers", orderer_host + ) + + peer_env = { + "CORE_PEER_TLS_ENABLED": "true", + "CORE_PEER_LOCALMSPID": peer_org["Name"] + "MSP", + "CORE_PEER_TLS_ROOTCERT_FILE": os.path.join( + peer_org_dir, "peers", + "{}.{}".format(peer_org["Specs"][0]["Hostname"], domain), + "tls", "ca.crt" + ), + "CORE_PEER_MSPCONFIGPATH": os.path.join( + peer_org_dir, "users", "Admin@" + domain, "msp" + ), + "CORE_PEER_ADDRESS": "{}.{}:7051".format( + peer_org["Specs"][0]["Hostname"], domain + ), + "FABRIC_CFG_PATH": os.path.join( + peer_org_dir, "peers", + "{}.{}".format(peer_org["Specs"][0]["Hostname"], domain) + ), + } + + subprocess.run( + [ + os.path.join(FABRIC_TOOL, "peer"), + "channel", "fetch", "config", + config_block_pb, + "-c", channel_name, + "-o", "{}:7050".format(orderer_host), + "--ordererTLSHostnameOverride", orderer_host, + "--tls", + "--cafile", os.path.join( + orderer_directory, "msp", "tlscacerts", + "tlsca.{}-cert.pem".format(order_org["Domain"]) + ), + ], + check=True, env=peer_env, + ) + + subprocess.run( + [ + os.path.join(FABRIC_TOOL, "configtxlator"), + "proto_decode", + f"--input={config_block_pb}", + "--type=common.Block", + f"--output={config_block_json}", + ], + check=True, + ) + + with open(config_block_json, "r", encoding="utf-8") as f: + config_block = json.load(f) + + config = config_block["data"]["data"][0]["payload"]["data"]["config"] + + with open(config_json, "w", encoding="utf-8") as f: + json.dump(config, f, sort_keys=False, indent=4) + + modified_config = deepcopy(config) + app_groups = modified_config["channel_group"]["groups"]["Application"]["groups"] + + for msp_id in organization_msp_ids: + org_group = _build_org_group(crypto_config, msp_id) + org_name = msp_id.replace("MSP", "") + app_groups[org_name] = org_group + + with open(modified_config_json, "w", encoding="utf-8") as f: + json.dump(modified_config, f, sort_keys=False, indent=4) + + for src_json, dst_pb in [(config_json, config_pb), (modified_config_json, modified_config_pb)]: + subprocess.run( + [ + os.path.join(FABRIC_TOOL, "configtxlator"), + "proto_encode", + f"--input={src_json}", + "--type=common.Config", + f"--output={dst_pb}", + ], + check=True, + ) + + subprocess.run( + [ + os.path.join(FABRIC_TOOL, "configtxlator"), + "compute_update", + f"--original={config_pb}", + f"--updated={modified_config_pb}", + f"--channel_id={channel_name}", + f"--output={config_update_pb}", + ], + check=True, + ) + + subprocess.run( + [ + os.path.join(FABRIC_TOOL, "configtxlator"), + "proto_decode", + f"--input={config_update_pb}", + "--type=common.ConfigUpdate", + f"--output={config_update_json}", + ], + check=True, + ) + + with open(config_update_json, "r", encoding="utf-8") as f: + config_update = json.load(f) + + envelope = { + "payload": { + "header": { + "channel_header": {"channel_id": channel_name, "type": 2} + }, + "data": {"config_update": config_update}, + } + } + with open(envelope_json, "w", encoding="utf-8") as f: + json.dump(envelope, f, sort_keys=False, indent=4) + + subprocess.run( + [ + os.path.join(FABRIC_TOOL, "configtxlator"), + "proto_encode", + f"--input={envelope_json}", + "--type=common.Envelope", + f"--output={envelope_pb}", + ], + check=True, + ) + + with open(envelope_pb, "rb") as f: + artifact = f.read() + + return artifact + + finally: + for f_path in temp_files: + try: + os.remove(f_path) + except OSError: + pass diff --git a/src/agents/hyperledger-fabric/channel/tests.py b/src/agents/hyperledger-fabric/channel/tests.py index 7ce503c2d..0e6b9c07d 100644 --- a/src/agents/hyperledger-fabric/channel/tests.py +++ b/src/agents/hyperledger-fabric/channel/tests.py @@ -1,3 +1,193 @@ +import json +import os +import subprocess +from unittest.mock import MagicMock, call, patch + from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from channel.serializers import InvitationDefinitionSerializer +from channel.service import generate_invitation_definition + + +FAKE_CRYPTO_CONFIG = { + "PeerOrgs": [ + { + "Name": "Org1", + "Domain": "org1.example.com", + "Specs": [{"Hostname": "peer0"}], + }, + { + "Name": "Org2", + "Domain": "org2.example.com", + "Specs": [{"Hostname": "peer0"}], + }, + ], + "OrdererOrgs": [ + { + "Name": "Orderer", + "Domain": "example.com", + "Specs": [{"Hostname": "orderer"}], + } + ], +} + +FAKE_CONFIG_BLOCK = { + "data": { + "data": [ + { + "payload": { + "data": { + "config": { + "channel_group": { + "groups": { + "Application": { + "groups": { + "Org1": { + "values": {"MSP": {"version": 0}}, + "policies": {}, + } + } + } + } + } + } + } + } + } + ] + } +} + +FAKE_CONFIG_UPDATE = { + "read_set": {}, + "write_set": {}, + "type": 0, +} + + +class InvitationDefinitionSerializerTest(TestCase): + def test_serializer_accepts_valid_data(self): + serializer = InvitationDefinitionSerializer( + data={"organization_msp_ids": ["Org1MSP", "Org2MSP"]}, + context={"channel_name": "testchannel"}, + ) + self.assertTrue(serializer.is_valid()) + + def test_serializer_requires_msp_ids(self): + serializer = InvitationDefinitionSerializer( + data={}, + context={"channel_name": "testchannel"}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("organization_msp_ids", serializer.errors) + + +class GenerateInvitationDefinitionTest(TestCase): + def setUp(self): + self.channel_name = "testchannel" + self.org_msp_ids = ["Org2MSP"] + + @patch("channel.service.CRYPTO_CONFIG", "/fake/crypto-config.yaml") + @patch("channel.service.CELLO_HOME", "/fake/cello") + @patch("channel.service.FABRIC_TOOL", "/fake/bin") + @patch("channel.service._read_crypto_config", return_value=FAKE_CRYPTO_CONFIG) + @patch("channel.service._read_b64", return_value="ZmFrZQ==") + @patch("channel.service.subprocess.run") + @patch("builtins.open", new_callable=MagicMock) + @patch("os.remove") + @patch("os.makedirs") + def test_generates_artifact(self, mock_makedirs, mock_remove, mock_open, + mock_subprocess, mock_read_b64, mock_read_crypto): + read_values = [ + json.dumps(FAKE_CONFIG_BLOCK).encode(), + json.dumps(FAKE_CONFIG_UPDATE).encode(), + b"artifact-data", + ] + mock_fp = MagicMock() + mock_fp.__enter__.return_value = mock_fp + mock_fp.read.side_effect = read_values + mock_open.return_value = mock_fp + + artifact = generate_invitation_definition(self.channel_name, self.org_msp_ids) + + self.assertIsNotNone(artifact) + self.assertGreater(len(artifact), 0) + + @patch("channel.service.CRYPTO_CONFIG", "/fake/crypto-config.yaml") + @patch("channel.service.CELLO_HOME", "/fake/cello") + @patch("channel.service.FABRIC_TOOL", "/fake/bin") + @patch("channel.service._read_crypto_config", return_value=FAKE_CRYPTO_CONFIG) + @patch("channel.service._read_b64", return_value="ZmFrZQ==") + @patch("channel.service.subprocess.run") + @patch("builtins.open", new_callable=MagicMock) + @patch("os.remove") + @patch("os.makedirs") + def test_cleans_up_temp_files(self, mock_makedirs, mock_remove, mock_open, + mock_subprocess, mock_read_b64, mock_read_crypto): + mock_fp = MagicMock() + mock_fp.__enter__.return_value = mock_fp + mock_fp.read.side_effect = [ + json.dumps(FAKE_CONFIG_BLOCK).encode(), + json.dumps(FAKE_CONFIG_UPDATE).encode(), + b"artifact-data", + ] + mock_open.return_value = mock_fp + + generate_invitation_definition(self.channel_name, self.org_msp_ids) + + mock_remove.assert_called() + + @patch("channel.service.CRYPTO_CONFIG", "/fake/crypto-config.yaml") + @patch("channel.service.CELLO_HOME", "/fake/cello") + @patch("channel.service.FABRIC_TOOL", "/fake/bin") + @patch("channel.service._read_crypto_config", return_value=FAKE_CRYPTO_CONFIG) + @patch("channel.service._read_b64", return_value="ZmFrZQ==") + @patch("channel.service.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "peer")) + @patch("builtins.open", new_callable=MagicMock) + @patch("os.remove") + @patch("os.makedirs") + def test_subprocess_failure_surfaces_error(self, mock_makedirs, mock_remove, + mock_open, mock_subprocess, + mock_read_b64, mock_read_crypto): + mock_fp = MagicMock() + mock_fp.__enter__.return_value = mock_fp + mock_open.return_value = mock_fp + + with self.assertRaises(subprocess.CalledProcessError): + generate_invitation_definition(self.channel_name, self.org_msp_ids) + + +class InvitationDefinitionEndpointTest(TestCase): + def setUp(self): + self.client = APIClient() + self.channel_name = "testchannel" + self.url = f"/api/v1/channels/{self.channel_name}/invitations/definition" + + @patch("channel.views.InvitationDefinitionSerializer") + def test_endpoint_returns_200_with_binary(self, mock_serializer_cls): + mock_serializer = MagicMock() + mock_serializer.is_valid.return_value = True + mock_serializer.save.return_value = {"artifact": b"fake-artifact-data"} + mock_serializer_cls.return_value = mock_serializer + + resp = self.client.post( + self.url, + {"organization_msp_ids": ["Org2MSP"]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp["Content-Type"], "application/octet-stream") + self.assertEqual(resp.content, b"fake-artifact-data") + + def test_endpoint_returns_400_for_invalid_input(self): + resp = self.client.post( + self.url, + {}, + format="json", + ) -# Create your tests here. + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/src/agents/hyperledger-fabric/channel/views.py b/src/agents/hyperledger-fabric/channel/views.py index 527b162cd..45a443097 100644 --- a/src/agents/hyperledger-fabric/channel/views.py +++ b/src/agents/hyperledger-fabric/channel/views.py @@ -1,10 +1,12 @@ +from django.http import HttpResponse from drf_spectacular.utils import extend_schema from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response -from channel.serializers import ChannelSerializer +from channel.serializers import ChannelSerializer, InvitationDefinitionSerializer + -# Create your views here. class ChannelViewSet(viewsets.ViewSet): @extend_schema( request=ChannelSerializer, @@ -15,3 +17,21 @@ def create(self, request): serializer.is_valid(raise_exception=True) serializer.save() return Response(data=serializer.validated_data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], url_path="invitations/definition") + @extend_schema( + request=InvitationDefinitionSerializer, + responses={200: None}, + ) + def invitation_definition(self, request, pk=None): + serializer = InvitationDefinitionSerializer( + data=request.data, + context={"channel_name": pk}, + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return HttpResponse( + result["artifact"], + content_type="application/octet-stream", + status=status.HTTP_200_OK, + ) diff --git a/src/api-engine/api/common/enums.py b/src/api-engine/api/common/enums.py index d500c7bde..13e52bc8f 100644 --- a/src/api-engine/api/common/enums.py +++ b/src/api-engine/api/common/enums.py @@ -75,8 +75,12 @@ def __new__(mcs, name, bases, attrs): if display_strings is not None and inspect.isclass(display_strings): del attrs["DisplayStrings"] - if hasattr(attrs, "_member_names"): - attrs._member_names.remove("DisplayStrings") + member_names = getattr(attrs, "_member_names", None) + if member_names is not None and "DisplayStrings" in member_names: + try: + member_names.remove("DisplayStrings") + except (AttributeError, TypeError): + pass obj = super().__new__(mcs, name, bases, attrs) for m in obj: diff --git a/src/api-engine/auth/serializers.py b/src/api-engine/auth/serializers.py index 58036e45b..1bafae6b0 100644 --- a/src/api-engine/auth/serializers.py +++ b/src/api-engine/auth/serializers.py @@ -16,9 +16,14 @@ class RegisterBody(serializers.Serializer): email = serializers.EmailField(help_text="Admin Email") password = serializers.CharField(help_text="Admin Password") agent_url = serializers.CharField(help_text="Agent URL") + msp_id = serializers.CharField( + help_text="Fabric MSP ID (e.g. Org1MSP)", + required=False, + allow_blank=True, + ) class Meta: - fields = ("org_name", "email", "password") + fields = ("org_name", "email", "password", "msp_id") extra_kwargs = { "org_name": {"required": True}, "email": {"required": True}, @@ -40,8 +45,17 @@ def validate_agent_url(agent_url: str) -> str: return agent_url + def validate_msp_id(self, msp_id: str) -> str: + if msp_id and Organization.objects.filter(msp_id=msp_id).exists(): + raise serializers.ValidationError("MSP ID already exists!") + return msp_id + def create(self, validated_data: Dict[str, Any]) -> Optional[Organization]: - organization = create_organization(validated_data.get("org_name"), validated_data.get("agent_url")) + organization = create_organization( + validated_data.get("org_name"), + validated_data.get("agent_url"), + validated_data.get("msp_id", ""), + ) create_user(organization, validated_data["email"], validated_data["password"], UserProfile.Role.ADMIN) return organization diff --git a/src/api-engine/chaincode/migrations/0001_initial.py b/src/api-engine/chaincode/migrations/0001_initial.py index 5497402d2..cc03e52de 100644 --- a/src/api-engine/chaincode/migrations/0001_initial.py +++ b/src/api-engine/chaincode/migrations/0001_initial.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ('label', models.CharField(help_text='Chaincode Label', max_length=128)), ('language', models.CharField(help_text='Chaincode Language', max_length=128)), ('init_required', models.BooleanField(default=False, help_text='Whether Chaincode Initialization Required')), - ('signature_policy', models.CharField(blank=True, help_text='Chaincode Signature Policy', null=True)), + ('signature_policy', models.CharField(blank=True, help_text='Chaincode Signature Policy', max_length=1024, null=True)), ('description', models.CharField(blank=True, help_text='Chaincode Description', max_length=128, null=True)), ('created_at', models.DateTimeField(auto_now_add=True, help_text='Chaincode Creation Timestamp')), ], diff --git a/src/api-engine/chaincode/models.py b/src/api-engine/chaincode/models.py index 5fd15d6e2..245b90252 100644 --- a/src/api-engine/chaincode/models.py +++ b/src/api-engine/chaincode/models.py @@ -75,6 +75,7 @@ class Status(models.TextChoices): ) signature_policy = models.CharField( help_text="Chaincode Signature Policy", + max_length=1024, null=True, blank=True, ) diff --git a/src/api-engine/channel/migrations/0002_channel_invitation.py b/src/api-engine/channel/migrations/0002_channel_invitation.py new file mode 100644 index 000000000..53b7edafe --- /dev/null +++ b/src/api-engine/channel/migrations/0002_channel_invitation.py @@ -0,0 +1,221 @@ +import common.utils +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("channel", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ChannelInvitation", + fields=[ + ( + "id", + models.UUIDField( + default=common.utils.make_uuid, + help_text="Channel Invitation ID", + primary_key=True, + serialize=False, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("DRAFT", "Draft"), + ("SIGNING", "Signing"), + ("READY", "Ready"), + ("ACCEPTED", "Accepted"), + ("REJECTED", "Rejected"), + ("FAILED", "Failed"), + ("CANCELED", "Canceled"), + ], + default="DRAFT", + help_text="Channel invitation status", + max_length=32, + ), + ), + ( + "artifact", + models.FileField( + blank=True, + help_text="Update Artifact", + null=True, + upload_to="channel_invitations/", + ), + ), + ( + "artifact_hash", + models.CharField( + blank=True, + default="", + help_text="Artifact Hash", + max_length=64, + ), + ), + ( + "required_signatures", + models.PositiveSmallIntegerField( + default=0, + help_text="Required Signatures", + ), + ), + ( + "error_message", + models.TextField( + blank=True, + default="", + help_text="Error Message", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True), + ), + ( + "channel", + models.ForeignKey( + help_text="Invitation Channel", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="channel.channel", + ), + ), + ( + "creator_organization", + models.ForeignKey( + help_text="Creator Organization", + on_delete=django.db.models.deletion.CASCADE, + related_name="created_invitations", + to="organization.organization", + ), + ), + ], + options={ + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="ChannelInvitationSignature", + fields=[ + ( + "id", + models.UUIDField( + default=common.utils.make_uuid, + help_text="Channel Invitation Signature ID", + primary_key=True, + serialize=False, + ), + ), + ( + "artifact_hash", + models.CharField( + help_text="Artifact Hash", + max_length=64, + ), + ), + ( + "signed_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "invitation", + models.ForeignKey( + help_text="Invitation", + on_delete=django.db.models.deletion.CASCADE, + related_name="signatures", + to="channel.channelinvitation", + ), + ), + ( + "organization", + models.ForeignKey( + help_text="Signing Organization", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitation_signatures", + to="organization.organization", + ), + ), + ], + options={ + "ordering": ("signed_at",), + }, + ), + migrations.CreateModel( + name="ChannelInvitationInvitee", + fields=[ + ( + "id", + models.UUIDField( + default=common.utils.make_uuid, + help_text="Channel Invitation Invitee ID", + primary_key=True, + serialize=False, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("ACCEPTED", "Accepted"), + ("REJECTED", "Rejected"), + ], + default="PENDING", + help_text="Invitee Status", + max_length=32, + ), + ), + ( + "responded_at", + models.DateTimeField( + blank=True, + null=True, + ), + ), + ( + "invitation", + models.ForeignKey( + help_text="Invitation", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitees", + to="channel.channelinvitation", + ), + ), + ( + "organization", + models.ForeignKey( + help_text="Invited Organization", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitee_invitations", + to="organization.organization", + ), + ), + ], + options={ + "ordering": ("id",), + }, + ), + migrations.AddConstraint( + model_name="channelinvitationsignature", + constraint=models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_signature", + ), + ), + migrations.AddConstraint( + model_name="channelinvitationinvitee", + constraint=models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_invitee", + ), + ), + ] diff --git a/src/api-engine/channel/models.py b/src/api-engine/channel/models.py index c68b6aaf8..cabbdf18c 100644 --- a/src/api-engine/channel/models.py +++ b/src/api-engine/channel/models.py @@ -1,7 +1,7 @@ from django.db import models +from django.db.models import Q from common.utils import make_uuid -from node.models import Node from organization.models import Organization @@ -26,3 +26,161 @@ class Channel(models.Model): class Meta: ordering = ("-created_at",) + + +class ChannelInvitationQuerySet(models.QuerySet): + def visible_to_organization(self, organization: Organization): + return self.filter( + Q(channel__organizations=organization) + | Q( + status__in=("READY", "ACCEPTED", "REJECTED", "FAILED"), + invitees__organization=organization, + ) + ).distinct() + + +class ChannelInvitation(models.Model): + class Status(models.TextChoices): + DRAFT = "DRAFT", "Draft" + SIGNING = "SIGNING", "Signing" + READY = "READY", "Ready" + ACCEPTED = "ACCEPTED", "Accepted" + REJECTED = "REJECTED", "Rejected" + FAILED = "FAILED", "Failed" + CANCELED = "CANCELED", "Canceled" + + id = models.UUIDField( + primary_key=True, + help_text="Channel Invitation ID", + default=make_uuid, + ) + channel = models.ForeignKey( + Channel, + help_text="Invitation Channel", + related_name="invitations", + on_delete=models.CASCADE, + ) + creator_organization = models.ForeignKey( + Organization, + help_text="Creator Organization", + related_name="created_invitations", + on_delete=models.CASCADE, + ) + status = models.CharField( + help_text="Channel invitation status", + choices=Status.choices, + default=Status.DRAFT, + max_length=32, + ) + artifact = models.FileField( + help_text="Update Artifact", + upload_to="channel_invitations/", + null=True, + blank=True, + ) + artifact_hash = models.CharField( + help_text="Artifact Hash", + max_length=64, + blank=True, + default="", + ) + required_signatures = models.PositiveSmallIntegerField( + help_text="Required Signatures", + default=0, + ) + error_message = models.TextField( + help_text="Error Message", + blank=True, + default="", + ) + created_at = models.DateTimeField( + auto_now_add=True, + ) + updated_at = models.DateTimeField( + auto_now=True, + ) + + objects = ChannelInvitationQuerySet.as_manager() + + class Meta: + ordering = ("-created_at",) + + +class ChannelInvitationInvitee(models.Model): + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + ACCEPTED = "ACCEPTED", "Accepted" + REJECTED = "REJECTED", "Rejected" + + id = models.UUIDField( + primary_key=True, + help_text="Channel Invitation Invitee ID", + default=make_uuid, + ) + invitation = models.ForeignKey( + ChannelInvitation, + help_text="Invitation", + related_name="invitees", + on_delete=models.CASCADE, + ) + organization = models.ForeignKey( + Organization, + help_text="Invited Organization", + related_name="invitee_invitations", + on_delete=models.CASCADE, + ) + status = models.CharField( + help_text="Invitee Status", + choices=Status.choices, + default=Status.PENDING, + max_length=32, + ) + responded_at = models.DateTimeField( + null=True, + blank=True, + ) + + class Meta: + ordering = ("id",) + constraints = [ + models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_invitee", + ) + ] + + +class ChannelInvitationSignature(models.Model): + id = models.UUIDField( + primary_key=True, + help_text="Channel Invitation Signature ID", + default=make_uuid, + ) + invitation = models.ForeignKey( + ChannelInvitation, + help_text="Invitation", + related_name="signatures", + on_delete=models.CASCADE, + ) + organization = models.ForeignKey( + Organization, + help_text="Signing Organization", + related_name="invitation_signatures", + on_delete=models.CASCADE, + ) + artifact_hash = models.CharField( + help_text="Artifact Hash", + max_length=64, + ) + signed_at = models.DateTimeField( + auto_now_add=True, + ) + + class Meta: + ordering = ("signed_at",) + constraints = [ + models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_signature", + ) + ] diff --git a/src/api-engine/channel/serializers.py b/src/api-engine/channel/serializers.py index 9d7fd6c5c..5feaf88b4 100644 --- a/src/api-engine/channel/serializers.py +++ b/src/api-engine/channel/serializers.py @@ -1,11 +1,19 @@ from typing import Dict, Any +from django.core.files.base import ContentFile +from django.db import transaction from rest_framework import serializers -from channel.models import Channel -from channel.service import create +from channel.models import ( + Channel, + ChannelInvitation, + ChannelInvitationInvitee, + ChannelInvitationSignature, +) +from channel.service import create, create_invitation_artifact from common.serializers import ListResponseSerializer from node.service import organization_orderer_exists, organization_peer_exists +from organization.models import Organization from organization.serializers import OrganizationID @@ -47,3 +55,184 @@ def create(self, validated_data: Dict[str, Any]) -> ChannelID: return ChannelID(create( self.context["organization"], validated_data["name"])) + + +class ChannelInvitationInviteeResponse(serializers.ModelSerializer): + organization = OrganizationID() + + class Meta: + model = ChannelInvitationInvitee + fields = ( + "id", + "organization", + "status", + "responded_at", + ) + read_only_fields = fields + + +class ChannelInvitationSignatureResponse(serializers.ModelSerializer): + organization = OrganizationID() + + class Meta: + model = ChannelInvitationSignature + fields = ( + "id", + "organization", + "artifact_hash", + "signed_at", + ) + read_only_fields = fields + + +class ChannelInvitationResponse(serializers.ModelSerializer): + channel = ChannelID() + creator_organization = OrganizationID() + invitees = ChannelInvitationInviteeResponse(many=True) + signatures = ChannelInvitationSignatureResponse(many=True) + + class Meta: + model = ChannelInvitation + fields = ( + "id", + "channel", + "creator_organization", + "status", + "artifact_hash", + "required_signatures", + "error_message", + "invitees", + "signatures", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class ChannelInvitationList(ListResponseSerializer): + data = ChannelInvitationResponse(many=True) + + +class ChannelInvitationCreateBody(serializers.Serializer): + organization_ids = serializers.ListField( + child=serializers.UUIDField(), + allow_empty=False, + ) + required_signatures = serializers.IntegerField( + required=False, + min_value=1, + ) + + def validate_organization_ids(self, value): + if len(set(value)) != len(value): + raise serializers.ValidationError( + "Duplicated organizations are not allowed." + ) + return value + + def validate(self, attrs): + channel = self.context["channel"] + creator = self.context["organization"] + + if not channel.organizations.filter(pk=creator.pk).exists(): + raise serializers.ValidationError( + "Not a channel member." + ) + + org_ids = set(attrs["organization_ids"]) + existing = { + o.id: o.name + for o in Organization.objects.filter(pk__in=org_ids) + } + missing = [str(oid) for oid in org_ids if oid not in existing] + if missing: + raise serializers.ValidationError({ + "organization_ids": [ + f"Organization does not exist: {oid}" for oid in missing + ] + }) + + member_ids = set(channel.organizations.values_list("id", flat=True)) + already_members = [ + existing[oid] for oid in org_ids if oid in member_ids + ] + if already_members: + raise serializers.ValidationError({ + "organization_ids": [ + f"Already a member: {name}" for name in already_members + ] + }) + + active_invitees = ChannelInvitationInvitee.objects.filter( + organization__in=org_ids, + status=ChannelInvitationInvitee.Status.PENDING, + invitation__channel=channel, + invitation__status__in=( + ChannelInvitation.Status.DRAFT, + ChannelInvitation.Status.SIGNING, + ChannelInvitation.Status.READY, + ), + ) + if active_invitees.exists(): + raise serializers.ValidationError( + "An active invitation already exists for one or more " + "of the specified organizations." + ) + + member_count = channel.organizations.count() + required = attrs.get("required_signatures", member_count) + if required > member_count: + raise serializers.ValidationError({ + "required_signatures": "Cannot exceed member count." + }) + + attrs["organizations"] = Organization.objects.filter( + pk__in=org_ids + ) + attrs["required_signatures"] = required + return attrs + + def create(self, validated_data): + orgs = validated_data.pop("organizations") + validated_data.pop("organization_ids") + channel = self.context["channel"] + creator = self.context["organization"] + artifact_bytes, artifact_hash = create_invitation_artifact( + agent_url=creator.agent_url, + channel_name=channel.name, + msp_ids=[o.msp_id for o in orgs], + ) + with transaction.atomic(): + invitation = ChannelInvitation.objects.create( + channel=channel, + creator_organization=creator, + required_signatures=validated_data["required_signatures"], + artifact_hash=artifact_hash, + ) + invitation.artifact.save( + f"channel_update_{channel.name}.bin", + ContentFile(artifact_bytes), + ) + ChannelInvitationInvitee.objects.bulk_create([ + ChannelInvitationInvitee( + invitation=invitation, organization=o + ) for o in orgs + ]) + return invitation + + +class ChannelInvitationCancelSerializer(serializers.Serializer): + def validate(self, attrs): + invitation = self.context["invitation"] + + if invitation.status not in ( + ChannelInvitation.Status.DRAFT, + ChannelInvitation.Status.SIGNING, + ChannelInvitation.Status.FAILED, + ChannelInvitation.Status.READY, + ): + raise serializers.ValidationError( + "Only DRAFT, SIGNING, FAILED, or READY invitations can be canceled." + ) + + return attrs diff --git a/src/api-engine/channel/service.py b/src/api-engine/channel/service.py index f4ecc74b8..f6e838fb8 100644 --- a/src/api-engine/channel/service.py +++ b/src/api-engine/channel/service.py @@ -1,3 +1,4 @@ +import hashlib import logging from urllib.parse import urljoin @@ -22,3 +23,14 @@ def create( res = Channel.objects.create(name=channel_name) res.organizations.add(channel_organization) return res + + +def create_invitation_artifact(agent_url, channel_name, msp_ids): + requests.get(urljoin(agent_url, "health")).raise_for_status() + resp = requests.post( + urljoin(agent_url, f"channels/{channel_name}/invitations/definition"), + json={"organization_msp_ids": msp_ids} + ) + resp.raise_for_status() + content = resp.content + return content, hashlib.sha256(content).hexdigest() diff --git a/src/api-engine/channel/tests.py b/src/api-engine/channel/tests.py index 7ce503c2d..34dd95400 100644 --- a/src/api-engine/channel/tests.py +++ b/src/api-engine/channel/tests.py @@ -1,3 +1,616 @@ +from unittest.mock import patch + +from django.db import IntegrityError, transaction from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from channel.models import ( + Channel, + ChannelInvitation, + ChannelInvitationInvitee, + ChannelInvitationSignature, +) +from channel.serializers import ChannelInvitationCreateBody +from organization.models import Organization +from user.models import UserProfile + + +class ChannelInvitationTestCase(TestCase): + def setUp(self): + self.member_org = Organization.objects.create( + name="member.example.com", + agent_url="http://member-agent.example.com", + msp_id="MemberMSP", + ) + self.second_member_org = Organization.objects.create( + name="second.example.com", + agent_url="http://second-agent.example.com", + msp_id="SecondMSP", + ) + self.invited_org = Organization.objects.create( + name="invited.example.com", + agent_url="http://invited-agent.example.com", + msp_id="InvitedMSP", + ) + self.other_org = Organization.objects.create( + name="other.example.com", + agent_url="http://other-agent.example.com", + msp_id="OtherMSP", + ) + self.channel = Channel.objects.create(name="testchannel") + self.channel.organizations.add( + self.member_org, + self.second_member_org, + ) + + def create_invitation(self, status=ChannelInvitation.Status.DRAFT): + invitation = ChannelInvitation.objects.create( + channel=self.channel, + creator_organization=self.member_org, + status=status, + required_signatures=2, + ) + ChannelInvitationInvitee.objects.create( + invitation=invitation, + organization=self.invited_org, + ) + return invitation + + def test_invitation_defaults_to_draft(self): + invitation = ChannelInvitation.objects.create( + channel=self.channel, + creator_organization=self.member_org, + ) + + self.assertEqual(invitation.status, ChannelInvitation.Status.DRAFT) + self.assertEqual(invitation.artifact_hash, "") + self.assertEqual(invitation.required_signatures, 0) + + def test_invitee_is_unique_per_invitation(self): + invitation = self.create_invitation() + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + ChannelInvitationInvitee.objects.create( + invitation=invitation, + organization=self.invited_org, + ) + + def test_signature_is_unique_per_invitation(self): + invitation = self.create_invitation() + ChannelInvitationSignature.objects.create( + invitation=invitation, + organization=self.member_org, + artifact_hash="a" * 64, + ) + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + ChannelInvitationSignature.objects.create( + invitation=invitation, + organization=self.member_org, + artifact_hash="b" * 64, + ) + + def test_member_organization_can_see_draft_invitation(self): + invitation = self.create_invitation() + + visible = ChannelInvitation.objects.visible_to_organization( + self.member_org + ) + + self.assertIn(str(invitation.pk), [str(i.pk) for i in visible]) + + def test_invited_organization_cannot_see_draft_invitation(self): + invitation = self.create_invitation() + + visible = ChannelInvitation.objects.visible_to_organization( + self.invited_org + ) + + self.assertNotIn(str(invitation.pk), [str(i.pk) for i in visible]) + + def test_invited_organization_can_see_ready_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.READY + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.invited_org + ) + + self.assertIn(str(invitation.pk), [str(i.pk) for i in visible]) + + def test_unrelated_organization_cannot_see_ready_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.READY + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.other_org + ) + + self.assertNotIn(str(invitation.pk), [str(i.pk) for i in visible]) + + def test_member_organization_can_see_canceled_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.CANCELED + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.member_org + ) + + self.assertIn(str(invitation.pk), [str(i.pk) for i in visible]) + + def test_invited_organization_cannot_see_canceled_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.CANCELED + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.invited_org + ) + + self.assertNotIn(str(invitation.pk), [str(i.pk) for i in visible]) + + def test_create_serializer_rejects_non_member_creator(self): + serializer = ChannelInvitationCreateBody( + data={"organization_ids": [self.invited_org.id]}, + context={ + "channel": self.channel, + "organization": self.other_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + + def test_create_serializer_rejects_existing_member_invitee(self): + serializer = ChannelInvitationCreateBody( + data={"organization_ids": [self.second_member_org.id]}, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("organization_ids", serializer.errors) + + def test_create_serializer_rejects_duplicate_invitees(self): + serializer = ChannelInvitationCreateBody( + data={ + "organization_ids": [ + self.invited_org.id, + self.invited_org.id, + ] + }, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("organization_ids", serializer.errors) + + def test_create_serializer_rejects_too_many_required_signatures(self): + serializer = ChannelInvitationCreateBody( + data={ + "organization_ids": [self.invited_org.id], + "required_signatures": 3, + }, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("required_signatures", serializer.errors) + + @patch("channel.serializers.create_invitation_artifact") + def test_create_serializer_defaults_required_signatures( + self, mock_create_artifact + ): + mock_create_artifact.return_value = (b"artifact", "a" * 64) + serializer = ChannelInvitationCreateBody( + data={"organization_ids": [self.invited_org.id]}, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + invitation = serializer.save() + + self.assertEqual(invitation.required_signatures, 2) + + @patch("channel.serializers.create_invitation_artifact") + def test_create_serializer_creates_invitation_and_invitees( + self, mock_create_artifact + ): + mock_create_artifact.return_value = (b"artifact", "a" * 64) + serializer = ChannelInvitationCreateBody( + data={ + "organization_ids": [self.invited_org.id], + "required_signatures": 1, + }, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + invitation = serializer.save() + + self.assertEqual(invitation.channel, self.channel) + self.assertEqual(invitation.creator_organization, self.member_org) + self.assertEqual(invitation.required_signatures, 1) + self.assertEqual(invitation.invitees.count(), 1) + self.assertEqual( + str(invitation.invitees.get().organization.pk), + str(self.invited_org.pk), + ) + + +class ChannelInvitationEndpointTests(TestCase): + def setUp(self): + self.client = APIClient() + self.member_org = Organization.objects.create( + name="member.example.com", + agent_url="http://member-agent.example.com", + msp_id="MemberMSP", + ) + self.second_member_org = Organization.objects.create( + name="second-member.example.com", + agent_url="http://second-agent.example.com", + msp_id="Second-memberMSP", + ) + self.other_org = Organization.objects.create( + name="other.example.com", + agent_url="http://other-agent.example.com", + msp_id="OtherMSP", + ) + self.invited_org = Organization.objects.create( + name="invited.example.com", + agent_url="http://invited-agent.example.com", + msp_id="InvitedMSP", + ) + + self.channel = Channel.objects.create(name="testchannel") + self.channel.organizations.add(self.member_org, self.second_member_org) + + self.admin_user = UserProfile.objects.create_user( + username="admin", + email="admin@example.com", + password="testpass123", + organization=self.member_org, + role=UserProfile.Role.ADMIN, + ) + self.non_admin_user = UserProfile.objects.create_user( + username="user", + email="user@example.com", + password="testpass123", + organization=self.member_org, + role=UserProfile.Role.USER, + ) + self.second_admin_user = UserProfile.objects.create_user( + username="second_admin", + email="second@example.com", + password="testpass123", + organization=self.second_member_org, + role=UserProfile.Role.ADMIN, + ) + self.other_user = UserProfile.objects.create_user( + username="other", + email="other@example.com", + password="testpass123", + organization=self.other_org, + ) + + self.admin_token = str( + RefreshToken.for_user(self.admin_user).access_token + ) + self.user_token = str( + RefreshToken.for_user(self.non_admin_user).access_token + ) + self.second_admin_token = str( + RefreshToken.for_user(self.second_admin_user).access_token + ) + self.other_token = str( + RefreshToken.for_user(self.other_user).access_token + ) + + self.invited_user = UserProfile.objects.create_user( + username="invited", + email="invited@example.com", + password="testpass123", + organization=self.invited_org, + ) + self.invited_token = str( + RefreshToken.for_user(self.invited_user).access_token + ) + + def _url(self, path=""): + return f"/api/v1/channels/{self.channel.id}/{path}" + + def _auth(self, token): + self.client.credentials(HTTP_AUTHORIZATION=f"JWT {token}") + + def create_invitation(self, status=ChannelInvitation.Status.DRAFT): + invitation = ChannelInvitation.objects.create( + channel=self.channel, + creator_organization=self.member_org, + status=status, + required_signatures=1, + ) + ChannelInvitationInvitee.objects.create( + invitation=invitation, + organization=self.invited_org, + ) + return invitation + + def test_member_lists_invitations(self): + self.create_invitation() + self._auth(self.admin_token) + + resp = self.client.get(self._url("invitations")) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data["data"]["data"]), 1) + + def test_invitee_does_not_see_draft(self): + self.create_invitation() + self._auth(self.other_token) + + resp = self.client.get(self._url("invitations")) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data["data"]["data"]), 0) + + def test_invitee_sees_ready(self): + self.create_invitation(status=ChannelInvitation.Status.READY) + self._auth(self.invited_token) + + resp = self.client.get(self._url("invitations")) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data["data"]["data"]), 1) + + @patch("channel.serializers.create_invitation_artifact") + def test_admin_creates_invitation(self, mock_create_artifact): + mock_create_artifact.return_value = (b"artifact", "a" * 64) + self._auth(self.admin_token) + + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + self.assertEqual( + ChannelInvitation.objects.count(), 1 + ) + invitation = ChannelInvitation.objects.get() + self.assertEqual(invitation.artifact_hash, "a" * 64) + self.assertTrue(invitation.artifact) + + def test_non_admin_cannot_create(self): + self._auth(self.user_token) + + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_non_member_cannot_create(self): + self._auth(self.other_token) + + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + def test_retrieve_invitation(self): + invitation = self.create_invitation() + self._auth(self.admin_token) + + resp = self.client.get( + self._url(f"invitations/{invitation.id}") + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual( + resp.data["data"]["id"], str(invitation.id) + ) + + def test_cancel_draft_invitation(self): + invitation = self.create_invitation() + self._auth(self.admin_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + self.assertEqual( + invitation.status, ChannelInvitation.Status.CANCELED + ) + + def test_cancel_already_canceled_fails(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.CANCELED + ) + self._auth(self.admin_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + def test_member_admin_cancels_ready(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.READY, + ) + self._auth(self.admin_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + self.assertEqual( + invitation.status, ChannelInvitation.Status.CANCELED, + ) + + def test_member_admin_non_creator_cancels(self): + invitation = self.create_invitation() + self._auth(self.second_admin_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + self.assertEqual( + invitation.status, ChannelInvitation.Status.CANCELED, + ) + + def test_non_admin_member_cannot_cancel(self): + invitation = self.create_invitation() + self._auth(self.user_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_unrelated_org_cannot_cancel(self): + invitation = self.create_invitation() + self._auth(self.other_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_invited_org_can_cancel(self): + invitation = self.create_invitation() + self._auth(self.invited_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + self.assertEqual(invitation.status, ChannelInvitation.Status.CANCELED) + + def test_invited_org_non_pending_cannot_cancel(self): + invitation = self.create_invitation() + invitee = invitation.invitees.get() + invitee.status = ChannelInvitationInvitee.Status.ACCEPTED + invitee.save() + self._auth(self.invited_token) + + resp = self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + @patch("channel.serializers.create_invitation_artifact") + def test_reinvite_after_cancel(self, mock_create_artifact): + mock_create_artifact.return_value = (b"artifact", "a" * 64) + invitation = self.create_invitation() + self._auth(self.admin_token) + + self.client.post( + self._url(f"invitations/{invitation.id}/cancel"), + format="json", + ) + + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + @patch("channel.serializers.create_invitation_artifact") + def test_reinvite_after_reject(self, mock_create_artifact): + mock_create_artifact.return_value = (b"artifact", "a" * 64) + invitation = self.create_invitation() + invitee = invitation.invitees.get() + invitee.status = ChannelInvitationInvitee.Status.REJECTED + invitee.save() + + self._auth(self.admin_token) + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + @patch("channel.serializers.create_invitation_artifact") + def test_duplicate_active_invitation_blocked(self, mock_create_artifact): + mock_create_artifact.return_value = (b"artifact", "a" * 64) + self._auth(self.admin_token) + + self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("channel.serializers.create_invitation_artifact") + def test_agent_failure_no_partial_state(self, mock_create_artifact): + mock_create_artifact.side_effect = RuntimeError("Agent error") + self._auth(self.admin_token) + + resp = self.client.post( + self._url("invitations"), + {"organization_ids": [self.invited_org.id]}, + format="json", + ) -# Create your tests here. + self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(ChannelInvitation.objects.count(), 0) diff --git a/src/api-engine/channel/views.py b/src/api-engine/channel/views.py index 4a03c38d7..313fd02e3 100644 --- a/src/api-engine/channel/views.py +++ b/src/api-engine/channel/views.py @@ -1,24 +1,62 @@ from django.core.paginator import Paginator +from django.shortcuts import get_object_or_404 from drf_yasg.utils import swagger_auto_schema from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from api.common import ok +from api.common import ok, err from api.common.response import make_response_serializer -from channel.models import Channel -from channel.serializers import ChannelList, ChannelID, ChannelResponse, ChannelCreateBody +from channel.models import Channel, ChannelInvitation, ChannelInvitationInvitee +from channel.serializers import ( + ChannelList, + ChannelID, + ChannelResponse, + ChannelCreateBody, + ChannelInvitationCreateBody, + ChannelInvitationResponse, + ChannelInvitationList, + ChannelInvitationCancelSerializer, +) from common.responses import with_common_response from common.serializers import PageQuerySerializer -# Create your views here. - class ChannelViewSet(viewsets.ViewSet): permission_classes = [ IsAuthenticated, ] + def _get_channel(self, pk): + return get_object_or_404(Channel, pk=pk) + + def _get_invitation(self, invitation_pk): + return get_object_or_404( + ChannelInvitation.objects.visible_to_organization( + self.request.user.organization + ), + pk=invitation_pk, + ) + + def _can_admin(self, org, channel): + return self.request.user.is_admin and channel.organizations.filter( + pk=org.pk + ).exists() + + def _is_invitee(self, org, invitation): + return ChannelInvitationInvitee.objects.filter( + invitation=invitation, + organization=org, + status=ChannelInvitationInvitee.Status.PENDING, + ).exists() + + def _is_invited(self, org, invitation): + return ChannelInvitationInvitee.objects.filter( + invitation=invitation, + organization=org, + ).exists() + @swagger_auto_schema( operation_summary="List all channels of the current organization", query_serializer=PageQuerySerializer(), @@ -51,3 +89,136 @@ def create(self, request): status=status.HTTP_201_CREATED, data=ok(serializer.save().data) ) + + @action(detail=True, methods=["get", "post"], url_path="invitations") + @swagger_auto_schema( + operation_summary="List or create channel invitations", + query_serializer=PageQuerySerializer(), + request_body=ChannelInvitationCreateBody, + responses=with_common_response( + { + status.HTTP_200_OK: make_response_serializer(ChannelInvitationList), + status.HTTP_201_CREATED: make_response_serializer(ChannelInvitationResponse), + } + ), + ) + def invitations(self, request, pk=None): + channel = self._get_channel(pk) + + if request.method == "GET": + invitations = ChannelInvitation.objects.visible_to_organization( + request.user.organization + ).filter(channel=channel) + page_serializer = PageQuerySerializer(data=request.GET) + p = page_serializer.get_paginator(invitations) + return Response( + status=status.HTTP_200_OK, + data=ok(ChannelInvitationList({ + "total": p.count, + "data": ChannelInvitationResponse( + p.page(page_serializer.data["page"]).object_list, many=True + ).data, + }).data), + ) + + elif request.method == "POST": + serializer = ChannelInvitationCreateBody( + data=request.data, + context={ + "channel": channel, + "organization": request.user.organization, + }, + ) + serializer.is_valid(raise_exception=True) + if not request.user.is_admin: + return Response( + status=status.HTTP_403_FORBIDDEN, + data=err("Admin role required."), + ) + try: + invitation = serializer.save() + except Exception as e: + return Response( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + data=err(str(e)), + ) + return Response( + status=status.HTTP_201_CREATED, + data=ok(ChannelInvitationResponse(invitation).data), + ) + + @action(detail=True, methods=["get"], url_path=r"invitations/(?P[^/.]+)") + @swagger_auto_schema( + operation_summary="Retrieve a single channel invitation", + responses=with_common_response( + {status.HTTP_200_OK: make_response_serializer(ChannelInvitationResponse)} + ), + ) + def invitation_detail(self, request, pk=None, invitation_pk=None): + channel = self._get_channel(pk) + invitation = self._get_invitation(invitation_pk) + + if str(invitation.channel_id) != str(channel.id): + return Response( + status=status.HTTP_404_NOT_FOUND, + data=err("Not found."), + ) + + return Response( + status=status.HTTP_200_OK, + data=ok(ChannelInvitationResponse(invitation).data), + ) + + @action( + detail=True, + methods=["post"], + url_path=r"invitations/(?P[^/.]+)/cancel", + ) + @swagger_auto_schema( + operation_summary="Cancel a channel invitation", + responses=with_common_response( + {status.HTTP_200_OK: make_response_serializer(ChannelInvitationResponse)} + ), + ) + def cancel_invitation(self, request, pk=None, invitation_pk=None): + channel = self._get_channel(pk) + + invitation = ChannelInvitation.objects.filter( + pk=invitation_pk, channel=channel + ).first() + if not invitation: + return Response( + status=status.HTTP_404_NOT_FOUND, + data=err("Not found."), + ) + + org = request.user.organization + is_member = channel.organizations.filter(pk=org.pk).exists() + is_invited = self._is_invited(org, invitation) + can_cancel_as_invitee = self._is_invitee(org, invitation) + + if not is_member and not is_invited: + return Response( + status=status.HTTP_404_NOT_FOUND, + data=err("Not found."), + ) + + if not self._can_admin(org, channel) and not can_cancel_as_invitee: + return Response( + status=status.HTTP_403_FORBIDDEN, + data=err("Permission denied."), + ) + + serializer = ChannelInvitationCancelSerializer( + data=request.data, + context={"invitation": invitation}, + ) + serializer.is_valid(raise_exception=True) + + invitation.status = ChannelInvitation.Status.CANCELED + invitation.save() + + return Response( + status=status.HTTP_200_OK, + data=ok(ChannelInvitationResponse(invitation).data), + ) diff --git a/src/api-engine/organization/migrations/0001_initial.py b/src/api-engine/organization/migrations/0001_initial.py index 84f5bf158..3816f9074 100644 --- a/src/api-engine/organization/migrations/0001_initial.py +++ b/src/api-engine/organization/migrations/0001_initial.py @@ -17,8 +17,8 @@ class Migration(migrations.Migration): name='Organization', fields=[ ('id', models.UUIDField(default=common.utils.make_uuid, help_text='Organization ID', primary_key=True, serialize=False)), - ('name', models.CharField(help_text='Organization Name', unique=True, validators=[common.validators.validate_host])), - ('agent_url', models.CharField(default=None, help_text='Organization Agent URL', unique=True, validators=[common.validators.validate_url])), + ('name', models.CharField(help_text='Organization Name', max_length=256, unique=True, validators=[common.validators.validate_host])), + ('agent_url', models.CharField(default='', help_text='Organization Agent URL', max_length=2048, unique=True, validators=[common.validators.validate_url])), ('created_at', models.DateTimeField(auto_now_add=True)), ], options={ diff --git a/src/api-engine/organization/migrations/0002_organization_msp_id.py b/src/api-engine/organization/migrations/0002_organization_msp_id.py new file mode 100644 index 000000000..69395d464 --- /dev/null +++ b/src/api-engine/organization/migrations/0002_organization_msp_id.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organization", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="msp_id", + field=models.CharField( + blank=True, + help_text="Fabric MSP ID", + max_length=256, + unique=True, + ), + preserve_default=False, + ), + ] diff --git a/src/api-engine/organization/models.py b/src/api-engine/organization/models.py index 61955c6c1..0aa1543ed 100644 --- a/src/api-engine/organization/models.py +++ b/src/api-engine/organization/models.py @@ -12,16 +12,29 @@ class Organization(models.Model): ) name = models.CharField( help_text="Organization Name", + max_length=256, unique=True, validators=[validate_host] ) agent_url = models.CharField( help_text="Organization Agent URL", + max_length=2048, unique=True, validators=[validate_url], - default=None + default="", + ) + msp_id = models.CharField( + help_text="Fabric MSP ID", + max_length=256, + unique=True, + blank=True, ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if not self.msp_id: + self.msp_id = "{}MSP".format(self.name.split(".", 1)[0].capitalize()) + super().save(*args, **kwargs) diff --git a/src/api-engine/organization/serializers.py b/src/api-engine/organization/serializers.py index 17780fc30..aed6f5137 100644 --- a/src/api-engine/organization/serializers.py +++ b/src/api-engine/organization/serializers.py @@ -16,6 +16,7 @@ class Meta: fields = ( "id", "name", + "msp_id", "created_at" ) diff --git a/src/api-engine/organization/service.py b/src/api-engine/organization/service.py index 754783d8e..7e2e748d2 100644 --- a/src/api-engine/organization/service.py +++ b/src/api-engine/organization/service.py @@ -5,9 +5,11 @@ from organization.models import Organization -def create_organization(org_name: str, agent_url: str) -> Organization: +def create_organization(org_name: str, agent_url: str, msp_id: str = "") -> Organization: _create_organization(org_name, agent_url) - organization = Organization(name=org_name, agent_url=agent_url) + if not msp_id: + msp_id = "{}MSP".format(org_name.split(".", 1)[0].capitalize()) + organization = Organization(name=org_name, agent_url=agent_url, msp_id=msp_id) organization.save() return organization diff --git a/src/api-engine/organization/views.py b/src/api-engine/organization/views.py index 5886b7b93..bb84527e8 100644 --- a/src/api-engine/organization/views.py +++ b/src/api-engine/organization/views.py @@ -18,7 +18,6 @@ class OrganizationViewSet(viewsets.ViewSet): - """Class represents organization related operations.""" permission_classes = [IsAuthenticated] @swagger_auto_schema(