LakeFormation: extend permissions catalog functionality (#7156)
This commit is contained in:
parent
c0eef70514
commit
9d30b1aa43
@ -1,6 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from moto.core import BackendDict, BaseBackend, BaseModel
|
from moto.core import BackendDict, BaseBackend, BaseModel
|
||||||
from moto.utilities.tagging_service import TaggingService
|
from moto.utilities.tagging_service import TaggingService
|
||||||
@ -27,6 +30,103 @@ class Resource(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Permission:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
principal: Dict[str, str],
|
||||||
|
resource: Dict[str, Any],
|
||||||
|
permissions: List[str],
|
||||||
|
permissions_with_grant_options: List[str],
|
||||||
|
):
|
||||||
|
self.principal = principal
|
||||||
|
self.resource = resource
|
||||||
|
self.permissions = permissions
|
||||||
|
self.permissions_with_grant_options = permissions_with_grant_options
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
if isinstance(other, Permission):
|
||||||
|
return (
|
||||||
|
(self.principal == other.principal)
|
||||||
|
and (self.resource == other.resource)
|
||||||
|
and (self.permissions == other.permissions)
|
||||||
|
and (
|
||||||
|
self.permissions_with_grant_options
|
||||||
|
== other.permissions_with_grant_options
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash(
|
||||||
|
(
|
||||||
|
json.dumps(self.principal),
|
||||||
|
json.dumps(self.resource),
|
||||||
|
json.dumps(self.permissions),
|
||||||
|
json.dumps(self.permissions_with_grant_options),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def equal_principal_and_resouce(self, other: Permission) -> bool:
|
||||||
|
return (self.principal == other.principal) and (self.resource == other.resource)
|
||||||
|
|
||||||
|
def merge(self, other: Permission) -> None:
|
||||||
|
self.permissions = list(set(self.permissions).union(other.permissions))
|
||||||
|
self.permissions_with_grant_options = list(
|
||||||
|
set(self.permissions_with_grant_options).union(
|
||||||
|
other.permissions_with_grant_options
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def diff(self, other: Permission) -> None:
|
||||||
|
if self.permissions is not None:
|
||||||
|
self.permissions = list(set(self.permissions).difference(other.permissions))
|
||||||
|
if self.permissions_with_grant_options is not None:
|
||||||
|
self.permissions_with_grant_options = list(
|
||||||
|
set(self.permissions_with_grant_options).difference(
|
||||||
|
other.permissions_with_grant_options
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return (
|
||||||
|
len(self.permissions) == 0 and len(self.permissions_with_grant_options) == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_external_form(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"Permissions": self.permissions,
|
||||||
|
"PermissionsWithGrantOption": self.permissions_with_grant_options,
|
||||||
|
"Resource": self.resource,
|
||||||
|
"Principal": self.principal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionCatalog:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.permissions: Set[Permission] = set()
|
||||||
|
|
||||||
|
def add_permission(self, permission: Permission) -> None:
|
||||||
|
for existing_permission in self.permissions:
|
||||||
|
if permission.equal_principal_and_resouce(existing_permission):
|
||||||
|
# Permission with same principal and resouce, only once of these can exist
|
||||||
|
existing_permission.merge(permission)
|
||||||
|
return
|
||||||
|
# found no match
|
||||||
|
self.permissions.add(permission)
|
||||||
|
|
||||||
|
def remove_permission(self, permission: Permission) -> None:
|
||||||
|
for existing_permission in self.permissions:
|
||||||
|
if permission.equal_principal_and_resouce(existing_permission):
|
||||||
|
# Permission with same principal and resouce, only once of these can exist
|
||||||
|
# remove and readd to recalculate the hash value after the diff
|
||||||
|
self.permissions.remove(existing_permission)
|
||||||
|
existing_permission.diff(permission)
|
||||||
|
self.permissions.add(existing_permission)
|
||||||
|
if existing_permission.is_empty():
|
||||||
|
self.permissions.remove(existing_permission)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
class ListPermissionsResourceDatabase:
|
class ListPermissionsResourceDatabase:
|
||||||
def __init__(self, catalog_id: Optional[str], name: str):
|
def __init__(self, catalog_id: Optional[str], name: str):
|
||||||
self.name = name
|
self.name = name
|
||||||
@ -171,7 +271,7 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
super().__init__(region_name, account_id)
|
super().__init__(region_name, account_id)
|
||||||
self.resources: Dict[str, Resource] = dict()
|
self.resources: Dict[str, Resource] = dict()
|
||||||
self.settings: Dict[str, Dict[str, Any]] = defaultdict(default_settings)
|
self.settings: Dict[str, Dict[str, Any]] = defaultdict(default_settings)
|
||||||
self.grants: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
self.grants: Dict[str, PermissionCatalog] = {}
|
||||||
self.tagger = TaggingService()
|
self.tagger = TaggingService()
|
||||||
self.lf_database_tags: Dict[Tuple[str, str], List[Dict[str, str]]] = {}
|
self.lf_database_tags: Dict[Tuple[str, str], List[Dict[str, str]]] = {}
|
||||||
self.lf_table_tags: Dict[Tuple[str, str, str], List[Dict[str, str]]] = {}
|
self.lf_table_tags: Dict[Tuple[str, str, str], List[Dict[str, str]]] = {}
|
||||||
@ -211,13 +311,16 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
permissions: List[str],
|
permissions: List[str],
|
||||||
permissions_with_grant_options: List[str],
|
permissions_with_grant_options: List[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.grants[catalog_id].append(
|
if catalog_id not in self.grants:
|
||||||
{
|
self.grants[catalog_id] = PermissionCatalog()
|
||||||
"Principal": principal,
|
|
||||||
"Resource": resource,
|
self.grants[catalog_id].add_permission(
|
||||||
"Permissions": permissions,
|
Permission(
|
||||||
"PermissionsWithGrantOption": permissions_with_grant_options,
|
principal=principal,
|
||||||
}
|
resource=resource,
|
||||||
|
permissions=permissions or [],
|
||||||
|
permissions_with_grant_options=permissions_with_grant_options or [],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def revoke_permissions(
|
def revoke_permissions(
|
||||||
@ -228,22 +331,19 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
permissions_to_revoke: List[str],
|
permissions_to_revoke: List[str],
|
||||||
permissions_with_grant_options_to_revoke: List[str],
|
permissions_with_grant_options_to_revoke: List[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
for grant in self.grants[catalog_id]:
|
if catalog_id not in self.grants:
|
||||||
if grant["Principal"] == principal and grant["Resource"] == resource:
|
return
|
||||||
grant["Permissions"] = [
|
|
||||||
perm
|
catalog = self.grants[catalog_id]
|
||||||
for perm in grant["Permissions"]
|
catalog.remove_permission(
|
||||||
if perm not in permissions_to_revoke
|
Permission(
|
||||||
]
|
principal=principal,
|
||||||
if grant.get("PermissionsWithGrantOption") is not None:
|
resource=resource,
|
||||||
grant["PermissionsWithGrantOption"] = [
|
permissions=permissions_to_revoke or [],
|
||||||
perm
|
permissions_with_grant_options=permissions_with_grant_options_to_revoke
|
||||||
for perm in grant["PermissionsWithGrantOption"]
|
or [],
|
||||||
if perm not in permissions_with_grant_options_to_revoke
|
)
|
||||||
]
|
)
|
||||||
self.grants[catalog_id] = [
|
|
||||||
grant for grant in self.grants[catalog_id] if grant["Permissions"] != []
|
|
||||||
]
|
|
||||||
|
|
||||||
def list_permissions(
|
def list_permissions(
|
||||||
self,
|
self,
|
||||||
@ -255,18 +355,21 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
"""
|
"""
|
||||||
No pagination has been implemented yet.
|
No pagination has been implemented yet.
|
||||||
"""
|
"""
|
||||||
permissions = self.grants[catalog_id]
|
if catalog_id not in self.grants:
|
||||||
|
return []
|
||||||
|
|
||||||
def filter_for_principal(permission: Dict[str, Any]) -> bool:
|
permissions = list(self.grants[catalog_id].permissions)
|
||||||
return permission["Principal"] == principal
|
|
||||||
|
def filter_for_principal(permission: Permission) -> bool:
|
||||||
|
return permission.principal == principal
|
||||||
|
|
||||||
if principal is not None:
|
if principal is not None:
|
||||||
permissions = list(filter(filter_for_principal, permissions))
|
permissions = list(filter(filter_for_principal, permissions))
|
||||||
|
|
||||||
def filter_for_resource_type(permission: Dict[str, Any]) -> bool:
|
def filter_for_resource_type(permission: Permission) -> bool:
|
||||||
if resource_type is None: # Check for mypy
|
if resource_type is None: # Check for mypy
|
||||||
return False
|
return False
|
||||||
resource = permission["Resource"]
|
resource = permission.resource
|
||||||
if resource_type == RessourceType.catalog:
|
if resource_type == RessourceType.catalog:
|
||||||
return "Catalog" in resource
|
return "Catalog" in resource
|
||||||
elif resource_type == RessourceType.database:
|
elif resource_type == RessourceType.database:
|
||||||
@ -280,7 +383,7 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
if resource_type is not None:
|
if resource_type is not None:
|
||||||
permissions = list(filter(filter_for_resource_type, permissions))
|
permissions = list(filter(filter_for_resource_type, permissions))
|
||||||
|
|
||||||
def filter_for_resource(permission: Dict[str, Any]) -> bool:
|
def filter_for_resource(permission: Permission) -> bool:
|
||||||
"""
|
"""
|
||||||
If catalog is provided:
|
If catalog is provided:
|
||||||
only matching permissions with resource-type "Catalog" are returned;
|
only matching permissions with resource-type "Catalog" are returned;
|
||||||
@ -293,7 +396,7 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
"""
|
"""
|
||||||
if resource is None: # Check for linter
|
if resource is None: # Check for linter
|
||||||
return False
|
return False
|
||||||
permission_resource = permission["Resource"]
|
permission_resource = permission.resource
|
||||||
catalog = resource.catalog
|
catalog = resource.catalog
|
||||||
if catalog is not None and "Catalog" in permission_resource:
|
if catalog is not None and "Catalog" in permission_resource:
|
||||||
return catalog == permission_resource["Catalog"]
|
return catalog == permission_resource["Catalog"]
|
||||||
@ -346,7 +449,8 @@ class LakeFormationBackend(BaseBackend):
|
|||||||
|
|
||||||
if resource is not None:
|
if resource is not None:
|
||||||
permissions = list(filter(filter_for_resource, permissions))
|
permissions = list(filter(filter_for_resource, permissions))
|
||||||
return permissions
|
|
||||||
|
return [permission.to_external_form() for permission in permissions]
|
||||||
|
|
||||||
def create_lf_tag(self, catalog_id: str, key: str, values: List[str]) -> None:
|
def create_lf_tag(self, catalog_id: str, key: str, values: List[str]) -> None:
|
||||||
# There is no ARN that we can use, so just create another unique identifier that's easy to recognize and reproduce
|
# There is no ARN that we can use, so just create another unique identifier that's easy to recognize and reproduce
|
||||||
|
@ -93,6 +93,72 @@ def test_data_lake_settings():
|
|||||||
assert resp["DataLakeSettings"] == settings
|
assert resp["DataLakeSettings"] == settings
|
||||||
|
|
||||||
|
|
||||||
|
@mock_lakeformation
|
||||||
|
def test_grant_permissions():
|
||||||
|
client = boto3.client("lakeformation", region_name="us-east-2")
|
||||||
|
|
||||||
|
resp = client.grant_permissions(
|
||||||
|
Principal={"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
Resource={"Database": {"Name": "db"}},
|
||||||
|
Permissions=["ALL"],
|
||||||
|
)
|
||||||
|
del resp["ResponseMetadata"]
|
||||||
|
assert resp == {}
|
||||||
|
resp = client.list_permissions()
|
||||||
|
assert resp["PrincipalResourcePermissions"] == [
|
||||||
|
{
|
||||||
|
"Principal": {"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
"Resource": {"Database": {"Name": "db"}},
|
||||||
|
"Permissions": ["ALL"],
|
||||||
|
"PermissionsWithGrantOption": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mock_lakeformation
|
||||||
|
def test_grant_permissions_idempotent():
|
||||||
|
client = boto3.client("lakeformation", region_name="us-east-2")
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
resp = client.grant_permissions(
|
||||||
|
Principal={"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
Resource={"Database": {"Name": "db"}},
|
||||||
|
Permissions=["ALL"],
|
||||||
|
)
|
||||||
|
del resp["ResponseMetadata"]
|
||||||
|
assert resp == {}
|
||||||
|
resp = client.list_permissions()
|
||||||
|
assert resp["PrincipalResourcePermissions"] == [
|
||||||
|
{
|
||||||
|
"Principal": {"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
"Resource": {"Database": {"Name": "db"}},
|
||||||
|
"Permissions": ["ALL"],
|
||||||
|
"PermissionsWithGrantOption": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mock_lakeformation
|
||||||
|
def test_grant_permissions_staggered():
|
||||||
|
client = boto3.client("lakeformation", region_name="us-east-2")
|
||||||
|
client.grant_permissions(
|
||||||
|
Principal={"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
Resource={"Database": {"Name": "db"}},
|
||||||
|
Permissions=["DESCRIBE"],
|
||||||
|
)
|
||||||
|
client.grant_permissions(
|
||||||
|
Principal={"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
Resource={"Database": {"Name": "db"}},
|
||||||
|
Permissions=["CREATE_TABLE"],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.list_permissions()
|
||||||
|
assert len(resp["PrincipalResourcePermissions"]) == 1
|
||||||
|
assert set(resp["PrincipalResourcePermissions"][0]["Permissions"]) == set(
|
||||||
|
["DESCRIBE", "CREATE_TABLE"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock_lakeformation
|
@mock_lakeformation
|
||||||
def test_list_permissions():
|
def test_list_permissions():
|
||||||
client = boto3.client("lakeformation", region_name="eu-west-2")
|
client = boto3.client("lakeformation", region_name="eu-west-2")
|
||||||
@ -448,14 +514,49 @@ def test_revoke_permissions():
|
|||||||
|
|
||||||
# list all
|
# list all
|
||||||
resp = client.list_permissions()
|
resp = client.list_permissions()
|
||||||
assert resp["PrincipalResourcePermissions"] == [
|
assert resp["PrincipalResourcePermissions"][0]["Principal"] == {
|
||||||
{
|
"DataLakePrincipalIdentifier": "asdf"
|
||||||
"Principal": {"DataLakePrincipalIdentifier": "asdf"},
|
|
||||||
"Resource": {"Database": {"Name": "db"}},
|
|
||||||
"Permissions": ["SELECT", "ALTER"],
|
|
||||||
"PermissionsWithGrantOption": ["SELECT", "DROP"],
|
|
||||||
}
|
}
|
||||||
|
assert resp["PrincipalResourcePermissions"][0]["Resource"] == {
|
||||||
|
"Database": {"Name": "db"}
|
||||||
|
}
|
||||||
|
# compare as sets to be order independent
|
||||||
|
assert set(resp["PrincipalResourcePermissions"][0]["Permissions"]) == set(
|
||||||
|
["SELECT", "ALTER"]
|
||||||
|
)
|
||||||
|
assert set(
|
||||||
|
resp["PrincipalResourcePermissions"][0]["PermissionsWithGrantOption"]
|
||||||
|
) == set(
|
||||||
|
[
|
||||||
|
"SELECT",
|
||||||
|
"DROP",
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_lakeformation
|
||||||
|
def test_revoke_permissions_unknown_catalog_id():
|
||||||
|
client = boto3.client("lakeformation", region_name="eu-west-2")
|
||||||
|
|
||||||
|
client.grant_permissions(
|
||||||
|
CatalogId="valid_id",
|
||||||
|
Principal={"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
Resource={"Database": {"Name": "db"}},
|
||||||
|
Permissions=["SELECT"],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.revoke_permissions(
|
||||||
|
CatalogId="different_id",
|
||||||
|
Principal={"DataLakePrincipalIdentifier": "asdf"},
|
||||||
|
Resource={"Database": {"Name": "db"}},
|
||||||
|
Permissions=["SELECT"],
|
||||||
|
)
|
||||||
|
|
||||||
|
del resp["ResponseMetadata"]
|
||||||
|
assert resp == {}
|
||||||
|
|
||||||
|
resp = client.list_permissions()
|
||||||
|
assert len(["PrincipalResourcePermissions"]) == 1
|
||||||
|
|
||||||
|
|
||||||
@mock_lakeformation
|
@mock_lakeformation
|
||||||
|
41
tests/test_lakeformation/test_permission.py
Normal file
41
tests/test_lakeformation/test_permission.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from moto.lakeformation.models import Permission
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_equals():
|
||||||
|
permission_1 = Permission(
|
||||||
|
principal={"test": "test"},
|
||||||
|
resource={"test": "test"},
|
||||||
|
permissions=[],
|
||||||
|
permissions_with_grant_options=[],
|
||||||
|
)
|
||||||
|
permission_2 = Permission(
|
||||||
|
principal={"test": "test"},
|
||||||
|
resource={"test": "test"},
|
||||||
|
permissions=[],
|
||||||
|
permissions_with_grant_options=[],
|
||||||
|
)
|
||||||
|
assert permission_1 == permission_2
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_not_equals():
|
||||||
|
permission_1 = Permission(
|
||||||
|
principal={"test": "test"},
|
||||||
|
resource={"test": "test"},
|
||||||
|
permissions=[],
|
||||||
|
permissions_with_grant_options=[],
|
||||||
|
)
|
||||||
|
permission_2 = Permission(
|
||||||
|
principal={"test": "test_2"},
|
||||||
|
resource={"test": "test_2"},
|
||||||
|
permissions=[],
|
||||||
|
permissions_with_grant_options=[],
|
||||||
|
)
|
||||||
|
permission_3 = Permission(
|
||||||
|
principal={"test": "test"},
|
||||||
|
resource={"test": "test"},
|
||||||
|
permissions=["new_permission"],
|
||||||
|
permissions_with_grant_options=[],
|
||||||
|
)
|
||||||
|
assert permission_1 != permission_2
|
||||||
|
assert permission_1 is not None
|
||||||
|
assert permission_1 != permission_3
|
Loading…
Reference in New Issue
Block a user