diff --git a/pubnub/endpoints/access/grant.py b/pubnub/endpoints/access/grant.py index 28c69b5c..41ae7928 100644 --- a/pubnub/endpoints/access/grant.py +++ b/pubnub/endpoints/access/grant.py @@ -15,6 +15,7 @@ def __init__(self, pubnub): self._channels = [] self._groups = [] self._uuids = [] + self._categories = [] self._read = None self._write = None self._manage = None @@ -54,6 +55,15 @@ def channel_groups(self, channel_groups): utils.extend_list(self._groups, channel_groups) return self + def categories(self, categories): + """ Grants category-level (enumeration) permissions on a whole App Context resource type. + + Supported values are `channels` and `uuids`. Requires an auth key and allows + only the `get` permission; the server rejects anything else. Revoke with `get(False)`. + """ + utils.extend_list(self._categories, categories) + return self + def read(self, flag): self._read = flag return self @@ -118,6 +128,9 @@ def custom_params(self): if self._uuids: params['target-uuid'] = utils.join_items(self._uuids) + if self._categories: + params['category'] = utils.join_items(self._categories) + if self._ttl is not None: params['ttl'] = str(int(self._ttl)) diff --git a/pubnub/endpoints/access/grant_token.py b/pubnub/endpoints/access/grant_token.py index c21dc1f6..93427e3d 100644 --- a/pubnub/endpoints/access/grant_token.py +++ b/pubnub/endpoints/access/grant_token.py @@ -6,6 +6,7 @@ from pubnub.enums import HttpMethod, PNOperationType from pubnub.models.consumer.common import PNStatus from pubnub.models.consumer.v3.access_manager import PNGrantTokenResult +from pubnub.models.consumer.v3.category import Category from pubnub.models.consumer.v3.channel import Channel from pubnub.models.consumer.v3.group import Group from pubnub.models.consumer.v3.space import Space @@ -24,7 +25,8 @@ class GrantToken(Endpoint): def __init__(self, pubnub, channels: Union[str, List[str]] = None, channel_groups: Union[str, List[str]] = None, users: Union[str, List[str]] = None, spaces: Union[str, List[str]] = None, - authorized_user_id: str = None, ttl: Optional[int] = None, meta: Optional[any] = None): + authorized_user_id: str = None, ttl: Optional[int] = None, meta: Optional[any] = None, + categories: Optional[List[Category]] = None): Endpoint.__init__(self, pubnub) self._ttl = ttl self._meta = meta @@ -42,6 +44,10 @@ def __init__(self, pubnub, channels: Union[str, List[str]] = None, channel_group if users: utils.extend_list(self._uuids, users) + self._categories = [] + if categories: + utils.extend_list(self._categories, categories) + self._sort_params = True def ttl(self, ttl: int) -> 'GrantToken': @@ -80,6 +86,10 @@ def uuids(self, uuids: List[UUID]) -> 'GrantToken': self._uuids = uuids return self + def categories(self, categories: List[Category]) -> 'GrantToken': + self._categories = categories + return self + def custom_params(self): return {} @@ -99,6 +109,11 @@ def build_data(self): permissions['resources'] = resources permissions['patterns'] = patterns + if self._categories: + permissions['categories'] = { + category.get_id(): utils.calculate_bitmask(category) for category in self._categories + } + if self._meta: if isinstance(self._meta, dict): permissions['meta'] = self._meta @@ -148,7 +163,7 @@ def name(self): return "Grant Token" def validate_resources(self): - if not any((self._channels, self._groups, self._uuids)): + if not any((self._channels, self._groups, self._uuids, self._categories)): raise PubNubException(pn_error=PNERR_RESOURCES_MISSING) def validate_ttl(self): diff --git a/pubnub/managers.py b/pubnub/managers.py index be763954..08e86877 100644 --- a/pubnub/managers.py +++ b/pubnub/managers.py @@ -431,6 +431,15 @@ def parse_token(cls, token): token[resource_type][resource] ) + categories = {} + for wire_key, category_name in (("chan", "channels"), ("uuid", "uuids")): + permissions = token.get("cat", {}).get(wire_key) + if permissions is not None: + categories[category_name] = {"get": utils.has_get_permission(permissions)} + + if categories: + parsed_token["categories"] = categories + return parsed_token @staticmethod diff --git a/pubnub/models/consumer/access_manager.py b/pubnub/models/consumer/access_manager.py index 1c68735a..21c148a2 100644 --- a/pubnub/models/consumer/access_manager.py +++ b/pubnub/models/consumer/access_manager.py @@ -4,7 +4,8 @@ class _PAMResult: - def __init__(self, level, subscribe_key, channels, groups, uuids, ttl=None, r=None, w=None, m=None, d=None): + def __init__(self, level, subscribe_key, channels, groups, uuids, ttl=None, r=None, w=None, m=None, d=None, + categories=None): self.level = level self.subscribe_key = subscribe_key self.channels = channels @@ -15,12 +16,14 @@ def __init__(self, level, subscribe_key, channels, groups, uuids, ttl=None, r=No self.write_enabled = w self.manage_enabled = m self.delete_enabled = d + self.categories = categories if categories is not None else {} @classmethod def from_json(cls, json_input): constructed_channels = {} constructed_groups = {} constructed_uuids = {} + constructed_categories = {} # only extract ttl, others are to be fetched on per uuid level r, w, m, d, g, u, j, ttl = fetch_permissions(json_input) @@ -72,6 +75,10 @@ def from_json(cls, json_input): for uuid, value in json_input['uuids'].items(): constructed_uuids[uuid] = PNAccessManagerUuidsData.from_json(uuid, value) + if 'categories' in json_input: + for category_name, value in json_input['categories'].items(): + constructed_categories[category_name] = PNAccessManagerCategoryData.from_json(category_name, value) + return cls( level=json_input['level'], subscribe_key=json_input['subscribe_key'], @@ -83,6 +90,7 @@ def from_json(cls, json_input): m=m, d=d, ttl=ttl, + categories=constructed_categories, ) @@ -136,6 +144,10 @@ class PNAccessManagerUuidsData(_PAMEntityData): pass +class PNAccessManagerCategoryData(_PAMEntityData): + pass + + class PNAccessManagerKeyData(object): def __init__(self, r, w, m, d, g, u, j, ttl=None): self.read_enabled = r diff --git a/pubnub/models/consumer/v3/category.py b/pubnub/models/consumer/v3/category.py new file mode 100644 index 00000000..c5bccc8d --- /dev/null +++ b/pubnub/models/consumer/v3/category.py @@ -0,0 +1,23 @@ +from pubnub.models.consumer.v3.pn_resource import PNResource + + +class Category(PNResource): + """Category-level permission for a whole App Context resource type.""" + + CHANNELS = 'channels' + UUIDS = 'uuids' + + def __init__(self, resource_name=None): + super(Category, self).__init__(resource_name) + + @staticmethod + def channels(): + return Category(resource_name=Category.CHANNELS) + + @staticmethod + def uuids(): + return Category(resource_name=Category.UUIDS) + + def get(self): + self._get = True + return self diff --git a/pubnub/pubnub_core.py b/pubnub/pubnub_core.py index 7117e8c6..0538bb36 100644 --- a/pubnub/pubnub_core.py +++ b/pubnub/pubnub_core.py @@ -83,6 +83,7 @@ def my_listener(message, event): from pubnub.models.consumer.objects_v2.channel_members import PNUUID from pubnub.models.consumer.objects_v2.common import MemberIncludes, MembershipIncludes from pubnub.models.consumer.objects_v2.page import PNPage +from pubnub.models.consumer.v3.category import Category from pubnub.models.subscription import PubNubChannel, PubNubChannelGroup, PubNubChannelMetadata, PubNubUserMetadata, \ PNSubscriptionRegistry, PubNubSubscriptionSet @@ -571,7 +572,8 @@ def grant_token( spaces: Union[str, List[str]] = None, authorized_user_id: str = None, ttl: Optional[int] = None, - meta: Optional[Any] = None + meta: Optional[Any] = None, + categories: Optional[List[Category]] = None ) -> GrantToken: return GrantToken( self, @@ -581,7 +583,8 @@ def grant_token( spaces=spaces, authorized_user_id=authorized_user_id, ttl=ttl, - meta=meta + meta=meta, + categories=categories ) def revoke_token(self, token: str) -> RevokeToken: diff --git a/tests/functional/test_grant.py b/tests/functional/test_grant.py index ac9385ea..a65204a3 100644 --- a/tests/functional/test_grant.py +++ b/tests/functional/test_grant.py @@ -27,7 +27,6 @@ def setUp(self): def test_grant_read_and_write_to_channel(self): self.grant.channels('ch').read(True).write(True).ttl(7) - self.assertEqual(self.grant.build_path(), Grant.GRANT_PATH % pnconf_pam.subscribe_key) pam_args = utils.prepare_pam_arguments({ @@ -54,6 +53,58 @@ def test_grant_read_and_write_to_channel(self): 'signature': "v2." + utils.sign_sha256(pnconf_pam.secret_key, sign_input).rstrip("=") }) + def test_grant_categories_to_auth_key(self): + self.grant.auth_keys('my-auth-key').categories(['channels', 'uuids']).get(True).ttl(1440) + + pam_args = utils.prepare_pam_arguments({ + 'g': '1', + 'auth': 'my-auth-key', + 'category': 'channels,uuids', + 'ttl': '1440', + 'timestamp': 123, + 'pnsdk': sdk_name, + 'uuid': self.pubnub.uuid, + }) + sign_input = HttpMethod.string(self.grant.http_method()).upper() + "\n" + \ + pnconf_pam.publish_key + "\n" + \ + self.grant.build_path() + "\n" + \ + pam_args + "\n" + + self.assertEqual(self.grant.build_params_callback()({}), { + 'g': '1', + 'auth': 'my-auth-key', + 'category': 'channels,uuids', + 'ttl': '1440', + 'timestamp': '123', + 'pnsdk': sdk_name, + 'uuid': self.pubnub.uuid, + 'signature': "v2." + utils.sign_sha256(pnconf_pam.secret_key, sign_input).rstrip("=") + }) + + def test_grant_categories_accepts_string(self): + self.grant.auth_keys('my-auth-key').categories('channels,uuids').get(True) + self.assertEqual(self.grant.build_params_callback()({})['category'], 'channels,uuids') + + def test_grant_categories_alongside_channel(self): + self.grant.auth_keys('my-auth-key').channels('ch1').categories(['channels']).get(True).ttl(1440) + + params = self.grant.build_params_callback()({}) + self.assertEqual(params['channel'], 'ch1') + self.assertEqual(params['category'], 'channels') + self.assertEqual(params['g'], '1') + + def test_grant_categories_revoke_with_get_false(self): + self.grant.auth_keys('my-auth-key').categories(['channels']).get(False) + + params = self.grant.build_params_callback()({}) + self.assertEqual(params['g'], '0') + self.assertEqual(params['category'], 'channels') + self.assertNotIn('ttl', params) + + def test_grant_without_categories_omits_param(self): + self.grant.channels('ch').read(True).write(True) + self.assertNotIn('category', self.grant.build_params_callback()({})) + def test_grant_read_and_write_to_channel_group(self): self.grant.channel_groups(['gr1', 'gr2']).read(True).write(True) diff --git a/tests/functional/test_grant_token.py b/tests/functional/test_grant_token.py new file mode 100644 index 00000000..509ad769 --- /dev/null +++ b/tests/functional/test_grant_token.py @@ -0,0 +1,59 @@ +import json +import unittest + +from pubnub.endpoints.access.grant_token import GrantToken +from pubnub.models.consumer.v3.category import Category +from pubnub.models.consumer.v3.channel import Channel +from pubnub.models.consumer.v3.uuid import UUID + +from pubnub.pubnub import PubNub +from tests.helper import pnconf_pam_copy + + +GET_PERMISSION = 32 + + +class TestGrantTokenCategories(unittest.TestCase): + def setUp(self): + self.pubnub = PubNub(pnconf_pam_copy()) + + def permissions(self, grant_token): + return json.loads(grant_token.build_data())['permissions'] + + def test_categories_only_grant(self): + grant_token = GrantToken( + self.pubnub, + ttl=1440, + categories=[Category.channels().get(), Category.uuids().get()] + ) + + grant_token.validate_resources() + categories_permission = self.permissions(grant_token)['categories'] + self.assertEqual(categories_permission, {'channels': GET_PERMISSION, 'uuids': GET_PERMISSION}) + + def test_categories_alongside_resources(self): + grant_token = GrantToken(self.pubnub, ttl=1440) \ + .channels([Channel.id('ch1').read(), Channel.pattern('.*').get()]) \ + .uuids([UUID.id('uuid1').get()]) \ + .categories([Category.uuids().get()]) + + permissions = self.permissions(grant_token) + self.assertEqual(permissions['categories'], {'uuids': GET_PERMISSION}) + self.assertEqual(permissions['resources']['channels'], {'ch1': 1}) + self.assertEqual(permissions['patterns']['channels'], {'.*': GET_PERMISSION}) + self.assertEqual(permissions['resources']['uuids'], {'uuid1': GET_PERMISSION}) + + def test_resource_get_permissions_do_not_create_category_permissions(self): + grant_token = GrantToken(self.pubnub, ttl=1440) \ + .channels([Channel.id('ch1').get(), Channel.pattern('.*').get()]) \ + .uuids([UUID.id('uuid1').get(), UUID.pattern('.*').get()]) + + permissions = self.permissions(grant_token) + self.assertNotIn('categories', permissions) + self.assertEqual(permissions['patterns']['channels'], {'.*': GET_PERMISSION}) + self.assertEqual(permissions['patterns']['uuids'], {'.*': GET_PERMISSION}) + + def test_request_without_categories_is_unchanged(self): + grant_token = GrantToken(self.pubnub, ttl=1440).channels([Channel.id('ch1').read()]) + + self.assertEqual(sorted(self.permissions(grant_token).keys()), ['meta', 'patterns', 'resources']) diff --git a/tests/integrational/asyncio/test_pam.py b/tests/integrational/asyncio/test_pam.py index 697ae575..edc8a85d 100644 --- a/tests/integrational/asyncio/test_pam.py +++ b/tests/integrational/asyncio/test_pam.py @@ -1,6 +1,7 @@ import pytest from pubnub.models.consumer.access_manager import PNAccessManagerGrantResult +from pubnub.models.consumer.v3.category import Category from pubnub.pubnub_asyncio import PubNubAsyncio from tests.helper import pnconf_pam_copy from tests.integrational.vcr_helper import pn_vcr @@ -225,3 +226,68 @@ async def test_multiple_channel_groups_with_auth(): assert env.result.groups[gr2].auth_keys[auth].delete_enabled is False await pubnub.stop() + + +@pn_vcr.use_cassette( + 'tests/integrational/fixtures/asyncio/pam/category_grant.yaml', + filter_query_parameters=['signature', 'timestamp', 'pnsdk', 'l_pam'] +) +@pytest.mark.asyncio +async def test_category_grant(): + pubnub = PubNubAsyncio(pnconf_pam_copy()) + pubnub.config.uuid = "my_uuid" + auth = "test-pam-asyncio-auth" + + env = await pubnub.grant().auth_keys(auth).categories(['channels', 'uuids']).get(True).ttl(1440).future() + + assert isinstance(env.result, PNAccessManagerGrantResult) + assert env.result.level == 'category' + assert env.result.categories['channels'].auth_keys[auth].get is True + assert env.result.categories['uuids'].auth_keys[auth].get is True + assert env.result.categories['channels'].auth_keys[auth].read_enabled is False + assert env.result.categories['channels'].auth_keys[auth].write_enabled is False + + await pubnub.stop() + + +@pn_vcr.use_cassette( + 'tests/integrational/fixtures/asyncio/pam/category_revoke.yaml', + filter_query_parameters=['signature', 'timestamp', 'pnsdk', 'l_pam'] +) +@pytest.mark.asyncio +async def test_category_revoke(): + pubnub = PubNubAsyncio(pnconf_pam_copy()) + pubnub.config.uuid = "my_uuid" + auth = "test-pam-asyncio-auth" + + env = await pubnub.grant().auth_keys(auth).categories(['channels']).get(False).future() + + assert isinstance(env.result, PNAccessManagerGrantResult) + assert env.result.categories['channels'].auth_keys[auth].get is False + + await pubnub.stop() + + +@pn_vcr.use_cassette( + 'tests/integrational/fixtures/asyncio/pam/category_grant_token.yaml', + filter_query_parameters=['signature', 'timestamp', 'pnsdk', 'l_pam', 'uuid', 'seqn'] +) +@pytest.mark.asyncio +async def test_category_grant_token(): + pubnub = PubNubAsyncio(pnconf_pam_copy()) + pubnub.config.uuid = "my_uuid" + + env = await pubnub.grant_token( + categories=[Category.channels().get(), Category.uuids().get()], + ttl=1440 + ).future() + + # `GrantToken` overrides `sync()` to wrap the result, but not `future()`, so the asyncio path + # yields a bare Envelope. + token = env.result.get_token() + assert token + + parsed = pubnub.parse_token(token) + assert parsed['categories'] == {'channels': {'get': True}, 'uuids': {'get': True}} + + await pubnub.stop() diff --git a/tests/integrational/fixtures/asyncio/pam/category_grant.yaml b/tests/integrational/fixtures/asyncio/pam/category_grant.yaml new file mode 100644 index 00000000..fbce283f --- /dev/null +++ b/tests/integrational/fixtures/asyncio/pam/category_grant.yaml @@ -0,0 +1,21 @@ +interactions: +- request: + body: null + headers: + User-Agent: + - PubNub-Python-Asyncio + method: GET + uri: https://ps.pndsn.com/v2/auth/grant/sub-key/sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f?auth=test-pam-asyncio-auth&category=channels,uuids&g=1&ttl=1440&uuid=my_uuid + response: + body: + string: '{"message":"Success","payload":{"level":"category","subscribe_key":"sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f","ttl":1440,"categories":{"channels":{"auths":{"test-pam-asyncio-auth":{"r":0,"w":0,"m":0,"d":0,"g":1,"u":0,"j":0}}},"uuids":{"auths":{"test-pam-asyncio-auth":{"r":0,"w":0,"m":0,"d":0,"g":1,"u":0,"j":0}}}}},"service":"Access + Manager","status":200}' + headers: + Access-Control-Allow-Origin: '*' + Cache-Control: no-cache, no-store, must-revalidate + Connection: keep-alive + Content-Type: text/javascript; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integrational/fixtures/asyncio/pam/category_grant_token.yaml b/tests/integrational/fixtures/asyncio/pam/category_grant_token.yaml new file mode 100644 index 00000000..aab8c394 --- /dev/null +++ b/tests/integrational/fixtures/asyncio/pam/category_grant_token.yaml @@ -0,0 +1,26 @@ +interactions: +- request: + body: '{"ttl": 1440, "permissions": {"resources": {"channels": {}, "groups": {}, + "uuids": {}, "users": {}, "spaces": {}}, "patterns": {"channels": {}, "groups": + {}, "uuids": {}, "users": {}, "spaces": {}}, "categories": {"channels": 32, + "uuids": 32}, "meta": {}}}' + headers: + Content-Type: + - application/json + User-Agent: + - PubNub-Python-Asyncio + method: POST + uri: https://ps.pndsn.com/v3/pam/sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f/grant + response: + body: + string: '{"status":200,"data":{"message":"Success","token":"qGF2AmF0GmFLd-NjdHRsGQWgY3Jlc6BjcGF0oGRtZXRhoGNjYXSiZGNoYW4YIGR1dWlkGCBjc2lnRAECAwQ="},"service":"Access + Manager"}' + headers: + Access-Control-Allow-Origin: '*' + Cache-Control: no-cache, no-store, must-revalidate + Connection: keep-alive + Content-Type: application/json + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integrational/fixtures/asyncio/pam/category_revoke.yaml b/tests/integrational/fixtures/asyncio/pam/category_revoke.yaml new file mode 100644 index 00000000..225112e0 --- /dev/null +++ b/tests/integrational/fixtures/asyncio/pam/category_revoke.yaml @@ -0,0 +1,21 @@ +interactions: +- request: + body: null + headers: + User-Agent: + - PubNub-Python-Asyncio + method: GET + uri: https://ps.pndsn.com/v2/auth/grant/sub-key/sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f?auth=test-pam-asyncio-auth&category=channels&g=0&uuid=my_uuid + response: + body: + string: '{"message":"Success","payload":{"level":"category","subscribe_key":"sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f","ttl":1,"categories":{"channels":{"auths":{"test-pam-asyncio-auth":{"r":0,"w":0,"m":0,"d":0,"g":0,"u":0,"j":0}}}}},"service":"Access + Manager","status":200}' + headers: + Access-Control-Allow-Origin: '*' + Cache-Control: no-cache, no-store, must-revalidate + Connection: keep-alive + Content-Type: text/javascript; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integrational/fixtures/native_sync/pam/category_grant.yaml b/tests/integrational/fixtures/native_sync/pam/category_grant.yaml new file mode 100644 index 00000000..893b7bd4 --- /dev/null +++ b/tests/integrational/fixtures/native_sync/pam/category_grant.yaml @@ -0,0 +1,21 @@ +interactions: +- request: + body: null + headers: + User-Agent: + - PubNub-Python + method: GET + uri: https://ps.pndsn.com/v2/auth/grant/sub-key/sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f?auth=category-auth-key&category=channels,uuids&g=1&ttl=1440 + response: + body: + string: '{"message":"Success","payload":{"level":"category","subscribe_key":"sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f","ttl":1440,"categories":{"channels":{"auths":{"category-auth-key":{"r":0,"w":0,"m":0,"d":0,"g":1,"u":0,"j":0}}},"uuids":{"auths":{"category-auth-key":{"r":0,"w":0,"m":0,"d":0,"g":1,"u":0,"j":0}}}}},"service":"Access + Manager","status":200}' + headers: + Access-Control-Allow-Origin: '*' + Cache-Control: no-cache, no-store, must-revalidate + Connection: keep-alive + Content-Type: text/javascript; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integrational/fixtures/native_sync/pam/category_grant_token.yaml b/tests/integrational/fixtures/native_sync/pam/category_grant_token.yaml new file mode 100644 index 00000000..3e58d806 --- /dev/null +++ b/tests/integrational/fixtures/native_sync/pam/category_grant_token.yaml @@ -0,0 +1,26 @@ +interactions: +- request: + body: '{"ttl": 1440, "permissions": {"resources": {"channels": {}, "groups": {}, + "uuids": {}, "users": {}, "spaces": {}}, "patterns": {"channels": {}, "groups": + {}, "uuids": {}, "users": {}, "spaces": {}}, "categories": {"channels": 32, + "uuids": 32}, "meta": {}}}' + headers: + Content-Type: + - application/json + User-Agent: + - PubNub-Python + method: POST + uri: https://ps.pndsn.com/v3/pam/sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f/grant + response: + body: + string: '{"status":200,"data":{"message":"Success","token":"qGF2AmF0GmFLd-NjdHRsGQWgY3Jlc6BjcGF0oGRtZXRhoGNjYXSiZGNoYW4YIGR1dWlkGCBjc2lnRAECAwQ="},"service":"Access + Manager"}' + headers: + Access-Control-Allow-Origin: '*' + Cache-Control: no-cache, no-store, must-revalidate + Connection: keep-alive + Content-Type: application/json + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integrational/fixtures/native_sync/pam/category_grant_with_channel.yaml b/tests/integrational/fixtures/native_sync/pam/category_grant_with_channel.yaml new file mode 100644 index 00000000..cd5116be --- /dev/null +++ b/tests/integrational/fixtures/native_sync/pam/category_grant_with_channel.yaml @@ -0,0 +1,21 @@ +interactions: +- request: + body: null + headers: + User-Agent: + - PubNub-Python + method: GET + uri: https://ps.pndsn.com/v2/auth/grant/sub-key/sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f?auth=category-auth-key&category=channels&channel=ch1&g=1&ttl=1440 + response: + body: + string: '{"message":"Success","payload":{"level":"channel+auth","subscribe_key":"sub-c-7ba2ac4c-4836-11e6-85a4-0619f8945a4f","ttl":1440,"channels":{"ch1":{"auths":{"category-auth-key":{"r":0,"w":0,"m":0,"d":0,"g":1,"u":0,"j":0}}}},"categories":{"channels":{"auths":{"category-auth-key":{"r":0,"w":0,"m":0,"d":0,"g":1,"u":0,"j":0}}}}},"service":"Access + Manager","status":200}' + headers: + Access-Control-Allow-Origin: '*' + Cache-Control: no-cache, no-store, must-revalidate + Connection: keep-alive + Content-Type: text/javascript; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/integrational/native_sync/test_grant.py b/tests/integrational/native_sync/test_grant.py index 308e8bc7..1e7aab75 100644 --- a/tests/integrational/native_sync/test_grant.py +++ b/tests/integrational/native_sync/test_grant.py @@ -1,4 +1,5 @@ from pubnub.pubnub import PubNub +from pubnub.models.consumer.v3.category import Category from pubnub.models.consumer.v3.channel import Channel from pubnub.models.consumer.v3.group import Group from tests.integrational.vcr_helper import pn_vcr @@ -43,3 +44,61 @@ def test_grant_token(): assert isinstance(envelope.result, PNGrantTokenResult) assert envelope.result.get_token() + + +@pn_vcr.use_cassette( + 'tests/integrational/fixtures/native_sync/pam/category_grant.yaml', + filter_query_parameters=['uuid', 'seqn', 'pnsdk', 'timestamp', 'signature'] +) +def test_grant_categories(): + auth = "category-auth-key" + + envelope = pubnub.grant()\ + .auth_keys(auth)\ + .categories(['channels', 'uuids'])\ + .get(True)\ + .ttl(1440)\ + .sync() + + assert isinstance(envelope.result, PNAccessManagerGrantResult) + assert envelope.result.level == 'category' + assert envelope.result.categories['channels'].auth_keys[auth].get is True + assert envelope.result.categories['uuids'].auth_keys[auth].get is True + + +@pn_vcr.use_cassette( + 'tests/integrational/fixtures/native_sync/pam/category_grant_with_channel.yaml', + filter_query_parameters=['uuid', 'seqn', 'pnsdk', 'timestamp', 'signature'] +) +def test_grant_categories_with_channel(): + """ A combined grant keeps its resource-level `level` and reports the category in addition. """ + auth = "category-auth-key" + + envelope = pubnub.grant()\ + .auth_keys(auth)\ + .channels("ch1")\ + .categories(['channels'])\ + .get(True)\ + .ttl(1440)\ + .sync() + + assert isinstance(envelope.result, PNAccessManagerGrantResult) + assert envelope.result.level == 'channel+auth' + assert envelope.result.channels['ch1'].auth_keys[auth].get is True + assert envelope.result.categories['channels'].auth_keys[auth].get is True + + +@pn_vcr.use_cassette( + 'tests/integrational/fixtures/native_sync/pam/category_grant_token.yaml', + filter_query_parameters=['uuid', 'seqn', 'pnsdk', 'timestamp', 'signature'] +) +def test_grant_token_categories(): + envelope = pubnub.grant_token()\ + .categories([Category.channels().get(), Category.uuids().get()])\ + .ttl(1440)\ + .sync() + + assert isinstance(envelope.result, PNGrantTokenResult) + + parsed = pubnub.parse_token(envelope.result.get_token()) + assert parsed['categories'] == {'channels': {'get': True}, 'uuids': {'get': True}} diff --git a/tests/unit/test_pam_v3.py b/tests/unit/test_pam_v3.py index 542fa6fc..4ae9c57b 100644 --- a/tests/unit/test_pam_v3.py +++ b/tests/unit/test_pam_v3.py @@ -2,21 +2,91 @@ from tests.helper import pnconf_pam_copy -TEST_TOKEN = ( +# Token without category-level permissions. +TOKEN_WITHOUT_CATEGORIES = ( "qEF2AkF0GmFLd-NDdHRsGQWgQ3Jlc6VEY2hhbqFjY2gxGP9DZ3JwoWNjZzEY_0N1c3KgQ3NwY6BEdXVpZKFldXVpZDEY_" "0NwYXSlRGNoYW6gQ2dycKBDdXNyoENzcGOgRHV1aWShYl4kAURtZXRho2VzY29yZRhkZWNvbG9yY3JlZGZhdXRob3JlcGFu" "ZHVEdXVpZGtteWF1dGh1dWlkMUNzaWdYIP2vlxHik0EPZwtgYxAW3-LsBaX_WgWdYvtAXpYbKll3" ) +# Token granting category-level GET permission for channels and UUIDs. +TOKEN_WITH_CATEGORIES = ( + "qGF2AmF0GmFLd-NjdHRsGQWgY3Jlc6BjcGF0oGRtZXRhoGNzaWdVcGxhY2Vob2xkZXItc2lnbmF0dXJlY2NhdKJkY2hh" + "bhggZHV1aWQYIA==" +) + +# Token granting category-level GET permission only for UUIDs. +TOKEN_WITH_UUID_CATEGORY = ( + "qGF2AmF0GmFLd-NjdHRsGQWgY3Jlc6BjcGF0oGRtZXRhoGNzaWdVcGxhY2Vob2xkZXItc2lnbmF0dXJlY2NhdKFkdXVp" + "ZBgg" +) + +# Token containing a channel category without the GET permission bit. +TOKEN_WITH_CHANNEL_CATEGORY_NO_GET = ( + "qGF2AmF0GmFLd-NjdHRsGQWgY3Jlc6BjcGF0oGRtZXRhoGNzaWdVcGxhY2Vob2xkZXItc2lnbmF0dXJlY2NhdKFkY2hh" + "bgA=" +) + +# Token containing an unsupported category that should be ignored. +TOKEN_WITH_UNKNOWN_CATEGORY = ( + "qGF2AmF0GmFLd-NjdHRsGQWgY3Jlc6BjcGF0oGRtZXRhoGNzaWdVcGxhY2Vob2xkZXItc2lnbmF0dXJlY2NhdKFjZ3Jw" + "GCA=" +) + +# Token granting GET on named resources and patterns, but not on categories. +TOKEN_WITH_RESOURCE_AND_PATTERN_GET = ( + "p2F2AmF0GmFLd-NjdHRsGQWgY3Jlc6JkY2hhbqFjY2gxGCBkdXVpZKFldXVpZDEYIGNwYXSiZGNoYW6hYi4qGCBkdXVp" + "ZKFiLioYIGRtZXRhoGNzaWdVcGxhY2Vob2xkZXItc2lnbmF0dXJl" +) + pubnub = PubNub(pnconf_pam_copy()) def test_v3_token_parsing(): - token = pubnub.parse_token(TEST_TOKEN) + token = pubnub.parse_token(TOKEN_WITHOUT_CATEGORIES) assert token["version"] == 2 assert token["timestamp"] == 1632335843 assert token["ttl"] == 1440 assert token["authorized_uuid"] == "myauthuuid1" assert token["meta"] == {"score": 100, "color": "red", "author": "pandu"} assert token["resources"]["channels"]["ch1"] + + +def test_v3_token_parsing_without_categories(): + token = pubnub.parse_token(TOKEN_WITHOUT_CATEGORIES) + assert "categories" not in token + + +def test_v3_token_parsing_categories(): + token = pubnub.parse_token(TOKEN_WITH_CATEGORIES) + + assert token["categories"] == { + "channels": {"get": True}, + "uuids": {"get": True}, + } + + +def test_v3_token_parsing_single_category(): + token = pubnub.parse_token(TOKEN_WITH_UUID_CATEGORY) + assert token["categories"] == {"uuids": {"get": True}} + + +def test_v3_token_parsing_category_without_get_bit(): + token = pubnub.parse_token(TOKEN_WITH_CHANNEL_CATEGORY_NO_GET) + assert token["categories"] == {"channels": {"get": False}} + + +def test_v3_token_parsing_unknown_category_is_ignored(): + token = pubnub.parse_token(TOKEN_WITH_UNKNOWN_CATEGORY) + assert "categories" not in token + + +def test_v3_token_parsing_resource_get_is_not_enumeration(): + token = pubnub.parse_token(TOKEN_WITH_RESOURCE_AND_PATTERN_GET) + + assert token["resources"]["channels"]["ch1"]["get"] is True + assert token["patterns"]["channels"][".*"]["get"] is True + assert token["resources"]["uuids"]["uuid1"]["get"] is True + assert token["patterns"]["uuids"][".*"]["get"] is True + assert "categories" not in token