From a1517f87a8d51eff747de3da853343169a14f598 Mon Sep 17 00:00:00 2001 From: Slavik Markovich Date: Sun, 8 Dec 2024 10:15:00 -0800 Subject: [PATCH] Add FGA APIs (#463) * Add FGA APIs * README update * now with the missing fga files * formating I * typo fix * typo fix II * typo fix III --- README.md | 194 +++++++------------------------- descope/management/common.py | 6 + descope/management/fga.py | 140 +++++++++++++++++++++++ descope/mgmt.py | 6 + tests/management/test_fga.py | 209 +++++++++++++++++++++++++++++++++++ 5 files changed, 404 insertions(+), 151 deletions(-) create mode 100644 descope/management/fga.py create mode 100644 tests/management/test_fga.py diff --git a/README.md b/README.md index 6c9ba9fd..f9c71133 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ These sections show how to use the SDK to perform permission and user management 10. [Impersonate](#impersonate) 11. [Embedded links](#embedded-links) 12. [Audit](#audit) -13. [Manage ReBAC Authz](#manage-rebac-authz) +13. [Manage FGA (Fine-grained Authorization)](#manage-fga-fine-grained-authorization) 14. [Manage Project](#manage-project) 15. [Manage SSO Applications](#manage-sso-applications) @@ -1107,183 +1107,75 @@ await descopeClient.management.audit.create_event( ) ``` -### Manage ReBAC Authz +### Manage FGA (Fine-grained Authorization) Descope supports full relation based access control (ReBAC) using a zanzibar like schema and operations. -A schema is comprized of namespaces (entities like documents, folders, orgs, etc.) and each namespace has relation definitions to define relations. -Each relation definition can be simple (either you have it or not) or complex (union of nodes). +A schema is comprized of types (entities like documents, folders, orgs, etc.) and each type has relation definitions and permission to define relations to other types. A simple example for a file system like schema would be: ```yaml -# Example schema for the authz tests -name: Files -namespaces: - - name: org - relationDefinitions: - - name: parent - - name: member - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationLeft - relationDefinition: parent - relationDefinitionNamespace: org - targetRelationDefinition: member - targetRelationDefinitionNamespace: org - - name: folder - relationDefinitions: - - name: parent - - name: owner - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: folder - targetRelationDefinition: owner - targetRelationDefinitionNamespace: folder - - name: editor - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: folder - targetRelationDefinition: editor - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: owner - targetRelationDefinitionNamespace: folder - - name: viewer - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: folder - targetRelationDefinition: viewer - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: editor - targetRelationDefinitionNamespace: folder - - name: doc - relationDefinitions: - - name: parent - - name: owner - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: doc - targetRelationDefinition: owner - targetRelationDefinitionNamespace: folder - - name: editor - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: doc - targetRelationDefinition: editor - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: owner - targetRelationDefinitionNamespace: doc - - name: viewer - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: doc - targetRelationDefinition: viewer - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: editor - targetRelationDefinitionNamespace: doc -``` +model AuthZ 1.0 + +type user + +type org + relation member: user + relation parent: org + +type folder + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + +type doc + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + ``` Descope SDK allows you to fully manage the schema and relations as well as perform simple (and not so simple) checks regarding the existence of relations. ```python -# Load the existing schema -schema = descope_client.mgmt.authz.load_schema() - -# Save schema and make sure to remove all namespaces not listed -descope_client.mgmt.authz.save_schema(schema, True) +# Save schema (where schema is an str as defined above) +descope_client.mgmt.fga.save_schema(schema) # Create a relation between a resource and user -descope_client.mgmt.authz.create_relations( +descope_client.mgmt.fga.create_relations( [ { "resource": "some-doc", - "relationDefinition": "owner", - "namespace": "doc", + "resourceType": "doc", + "relation": "owner", "target": "u1", + "targetType": "user", } ] ) -# Check if target has the relevant relation -# The answer should be true because an owner is also a viewer -relations = descope_client.mgmt.authz.has_relations( +# Check if target has a relevant relation +# The answer should be true because an owner can also view +relations = descope_client.mgmt.fga.check( [ { "resource": "some-doc", - "relationDefinition": "viewer", - "namespace": "doc", + "resourceType": "doc", + "relation": "owner", "target": "u1", + "targetType": "user", } ] ) - -# Get list of targets and resources changed since the given date. -res = descope_client.mgmt.authz.get_modified() ``` ### Manage Project diff --git a/descope/management/common.py b/descope/management/common.py index a7773bd0..5829e88c 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -131,6 +131,12 @@ class MgmtV1: authz_re_target_with_relation = "/v1/mgmt/authz/re/targetwithrelation" authz_get_modified = "/v1/mgmt/authz/getmodified" + # FGA (new style Authz) + fga_save_schema = "/v1/mgmt/fga/schema" + fga_create_relations = "/v1/mgmt/fga/relations" + fga_delete_relations = "/v1/mgmt/fga/relations/delete" + fga_check = "/v1/mgmt/fga/check" + # Project project_update_name = "/v1/mgmt/project/update/name" project_update_tags = "/v1/mgmt/project/update/tags" diff --git a/descope/management/fga.py b/descope/management/fga.py new file mode 100644 index 00000000..b05a1c28 --- /dev/null +++ b/descope/management/fga.py @@ -0,0 +1,140 @@ +from datetime import datetime, timezone +from typing import Any, List, Optional + +from descope._auth_base import AuthBase +from descope.management.common import MgmtV1 + + +class FGA(AuthBase): + def save_schema(self, schema: str): + """ + Create or update an FGA schema. + Args: + schema (str): the schema in the AuthZ 1.0 DSL + model AuthZ 1.0 + + type user + + type org + relation member: user + relation parent: org + + type folder + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + + type doc + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + Raise: + AuthException: raised if saving fails + """ + self._auth.do_post( + MgmtV1.fga_save_schema, + {"dsl": schema}, + pswd=self._auth.management_key, + ) + + def create_relations( + self, + relations: List[dict], + ): + """ + Create the given relations based on the existing schema + Args: + relations (List[dict]): the relations to create. Each in the following format: + { + "resource": "id of the resource that has the relation", + "resourceType": "the type of the resource (namespace)", + "relation": "the relation definition for the relation", + "target": "the target that has the relation - usually users or other resources", + "targetType": "the type of the target (namespace) - can also be group#member for target sets" + } + Raise: + AuthException: raised if create relations fails + """ + self._auth.do_post( + MgmtV1.fga_create_relations, + { + "tuples": relations, + }, + pswd=self._auth.management_key, + ) + + def delete_relations( + self, + relations: List[dict], + ): + """ + Delete the given relations based on the existing schema + Args: + relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) + Raise: + AuthException: raised if delete relations fails + """ + self._auth.do_post( + MgmtV1.fga_delete_relations, + { + "tuples": relations, + }, + pswd=self._auth.management_key, + ) + + def check( + self, + relations: List[dict], + ) -> List[dict]: + """ + Queries the given relations to see if they exist returning true if they do + Args: + relations (List[dict]): List of relation queries each in the format of: + { + "resource": "id of the resource that has the relation", + "resourceType": "the type of the resource (namespace)", + "relation": "the relation definition for the relation", + "target": "the target that has the relation - usually users or other resources", + "targetType": "the type of the target (namespace)" + } + + Return value (List[dict]): + Return List in the format + [ + { + "allowed": True|False + "relation": { + "resource": "id of the resource that has the relation", + "resourceType": "the type of the resource (namespace)", + "relation": "the relation definition for the relation", + "target": "the target that has the relation - usually users or other resources", + "targetType": "the type of the target (namespace)" + } + } + ] + Raise: + AuthException: raised if query fails + """ + response = self._auth.do_post( + MgmtV1.fga_check, + { + "tuples": relations, + }, + pswd=self._auth.management_key, + ) + return list( + map( + lambda tuple: {"relation": tuple["tuple"], "allowed": tuple["allowed"]}, + response.json()["tuples"], + ) + ) diff --git a/descope/mgmt.py b/descope/mgmt.py index dfa5a2ee..52901ac2 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -2,6 +2,7 @@ from descope.management.access_key import AccessKey # noqa: F401 from descope.management.audit import Audit # noqa: F401 from descope.management.authz import Authz # noqa: F401 +from descope.management.fga import FGA # noqa: F401 from descope.management.flow import Flow # noqa: F401 from descope.management.group import Group # noqa: F401 from descope.management.jwt import JWT # noqa: F401 @@ -31,6 +32,7 @@ def __init__(self, auth: Auth): self._flow = Flow(auth) self._audit = Audit(auth) self._authz = Authz(auth) + self._fga = FGA(auth) self._project = Project(auth) @property @@ -81,6 +83,10 @@ def audit(self): def authz(self): return self._authz + @property + def fga(self): + return self._fga + @property def project(self): return self._project diff --git a/tests/management/test_fga.py b/tests/management/test_fga.py new file mode 100644 index 00000000..de51de7a --- /dev/null +++ b/tests/management/test_fga.py @@ -0,0 +1,209 @@ +from unittest.mock import patch + +from descope import AuthException, DescopeClient +from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope.management.common import MgmtV1 + +from .. import common + + +class TestFGA(common.DescopeTest): + def setUp(self) -> None: + super().setUp() + self.dummy_project_id = "dummy" + self.dummy_management_key = "key" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", + } + + def test_save_schema(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed save_schema + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises(AuthException, client.mgmt.fga.save_schema, "") + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone(client.mgmt.fga.save_schema("model AuthZ 1.0")) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + }, + params=None, + json={"dsl": "model AuthZ 1.0"}, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_create_relations(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed create_relations + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises(AuthException, client.mgmt.fga.create_relations, []) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.fga.create_relations( + [ + { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", + } + ] + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_create_relations}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + }, + params=None, + json={ + "tuples": [ + { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", + } + ] + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete_relations(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed delete_relations + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises(AuthException, client.mgmt.fga.delete_relations, []) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.fga.delete_relations( + [ + { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", + } + ] + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_delete_relations}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + }, + params=None, + json={ + "tuples": [ + { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", + } + ] + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_check(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed has_relations + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises(AuthException, client.mgmt.fga.check, []) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNotNone( + client.mgmt.fga.check( + [ + { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", + } + ] + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_check}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + }, + params=None, + json={ + "tuples": [ + { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", + } + ] + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + )