Skip to content

Commit 097110f

Browse files
feat: Allow user groups to be passed in allowed_to_use and allowed_to_manage when creating QuickSight resources (#2278)
1 parent 4542b0f commit 097110f

File tree

4 files changed

+157
-51
lines changed

4 files changed

+157
-51
lines changed

awswrangler/quicksight/_create.py

+128-41
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@
22

33
import logging
44
import uuid
5-
from typing import Any, Dict, List, Literal, Optional, Union, cast
5+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Set, TypeVar, Union, cast
66

77
import boto3
88

99
from awswrangler import _utils, exceptions, sts
10-
from awswrangler.quicksight._get_list import get_data_source_arn, get_dataset_id, list_users
11-
from awswrangler.quicksight._utils import extract_athena_query_columns, extract_athena_table_columns
10+
from awswrangler.quicksight._get_list import get_data_source_arn, get_dataset_id, list_groups, list_users
11+
from awswrangler.quicksight._utils import (
12+
_QuicksightPrincipalList,
13+
extract_athena_query_columns,
14+
extract_athena_table_columns,
15+
)
16+
17+
if TYPE_CHECKING:
18+
from mypy_boto3_quicksight.type_defs import GroupTypeDef, UserTypeDef
19+
1220

1321
_logger: logging.Logger = logging.getLogger(__name__)
1422

23+
1524
_ALLOWED_ACTIONS: Dict[str, Dict[str, List[str]]] = {
1625
"data_source": {
1726
"allowed_to_use": [
@@ -52,41 +61,51 @@
5261
}
5362

5463

55-
def _usernames_to_arns(user_names: List[str], all_users: List[Dict[str, Any]]) -> List[str]:
56-
return [cast(str, u["Arn"]) for u in all_users if u.get("UserName") in user_names]
64+
def _groupnames_to_arns(group_names: Set[str], all_groups: List["GroupTypeDef"]) -> List[str]:
65+
return [u["Arn"] for u in all_groups if u.get("GroupName") in group_names]
5766

5867

59-
def _generate_permissions(
68+
def _usernames_to_arns(user_names: Set[str], all_users: List["UserTypeDef"]) -> List[str]:
69+
return [u["Arn"] for u in all_users if u.get("UserName") in user_names]
70+
71+
72+
_PrincipalTypeDef = TypeVar("_PrincipalTypeDef", "UserTypeDef", "GroupTypeDef")
73+
74+
75+
def _generate_permissions_base(
6076
resource: str,
6177
namespace: str,
6278
account_id: str,
6379
boto3_session: Optional[boto3.Session],
64-
allowed_to_use: Optional[List[str]] = None,
65-
allowed_to_manage: Optional[List[str]] = None,
80+
allowed_to_use: Optional[List[str]],
81+
allowed_to_manage: Optional[List[str]],
82+
principal_names_to_arns_func: Callable[[Set[str], List[_PrincipalTypeDef]], List[str]],
83+
list_principals: Callable[[str, str, Optional[boto3.Session]], List[Dict[str, Any]]],
6684
) -> List[Dict[str, Union[str, List[str]]]]:
6785
permissions: List[Dict[str, Union[str, List[str]]]] = []
6886
if (allowed_to_use is None) and (allowed_to_manage is None):
6987
return permissions
7088

71-
# Forcing same user not be in both lists at the same time.
72-
if (allowed_to_use is not None) and (allowed_to_manage is not None):
73-
allowed_to_use = list(set(allowed_to_use) - set(allowed_to_manage))
89+
allowed_to_use_set = set(allowed_to_use) if allowed_to_use else None
90+
allowed_to_manage_set = set(allowed_to_manage) if allowed_to_manage else None
7491

75-
all_users: List[Dict[str, Any]] = list_users(
76-
namespace=namespace, account_id=account_id, boto3_session=boto3_session
77-
)
92+
# Forcing same principal not be in both lists at the same time.
93+
if (allowed_to_use_set is not None) and (allowed_to_manage_set is not None):
94+
allowed_to_use_set = allowed_to_use_set - allowed_to_manage_set
95+
96+
all_principals = cast(List[_PrincipalTypeDef], list_principals(namespace, account_id, boto3_session))
7897

79-
if allowed_to_use is not None:
80-
allowed_arns: List[str] = _usernames_to_arns(user_names=allowed_to_use, all_users=all_users)
98+
if allowed_to_use_set is not None:
99+
allowed_arns: List[str] = principal_names_to_arns_func(allowed_to_use_set, all_principals)
81100
permissions += [
82101
{
83102
"Principal": arn,
84103
"Actions": _ALLOWED_ACTIONS[resource]["allowed_to_use"],
85104
}
86105
for arn in allowed_arns
87106
]
88-
if allowed_to_manage is not None:
89-
allowed_arns = _usernames_to_arns(user_names=allowed_to_manage, all_users=all_users)
107+
if allowed_to_manage_set is not None:
108+
allowed_arns = principal_names_to_arns_func(allowed_to_manage_set, all_principals)
90109
permissions += [
91110
{
92111
"Principal": arn,
@@ -97,6 +116,41 @@ def _generate_permissions(
97116
return permissions
98117

99118

119+
def _generate_permissions(
120+
resource: str,
121+
namespace: str,
122+
account_id: str,
123+
boto3_session: Optional[boto3.Session],
124+
allowed_users_to_use: Optional[List[str]] = None,
125+
allowed_groups_to_use: Optional[List[str]] = None,
126+
allowed_users_to_manage: Optional[List[str]] = None,
127+
allowed_groups_to_manage: Optional[List[str]] = None,
128+
) -> List[Dict[str, Union[str, List[str]]]]:
129+
permissions_users = _generate_permissions_base(
130+
resource=resource,
131+
namespace=namespace,
132+
account_id=account_id,
133+
boto3_session=boto3_session,
134+
allowed_to_use=allowed_users_to_use,
135+
allowed_to_manage=allowed_users_to_manage,
136+
principal_names_to_arns_func=_usernames_to_arns,
137+
list_principals=list_users,
138+
)
139+
140+
permissions_groups = _generate_permissions_base(
141+
resource=resource,
142+
namespace=namespace,
143+
account_id=account_id,
144+
boto3_session=boto3_session,
145+
allowed_to_use=allowed_groups_to_use,
146+
allowed_to_manage=allowed_groups_to_manage,
147+
principal_names_to_arns_func=_groupnames_to_arns,
148+
list_principals=list_groups,
149+
)
150+
151+
return permissions_users + permissions_groups
152+
153+
100154
def _generate_transformations(
101155
rename_columns: Optional[Dict[str, str]],
102156
cast_columns_types: Optional[Dict[str, str]],
@@ -115,11 +169,27 @@ def _generate_transformations(
115169
return trans
116170

117171

172+
_AllowedType = Optional[Union[List[str], _QuicksightPrincipalList]]
173+
174+
175+
def _get_principal_names(principals: _AllowedType, type: Literal["users", "groups"]) -> Optional[List[str]]:
176+
if principals is None:
177+
return None
178+
179+
if isinstance(principals, list):
180+
if type == "users":
181+
return principals
182+
else:
183+
return None
184+
185+
return principals.get(type)
186+
187+
118188
def create_athena_data_source(
119189
name: str,
120190
workgroup: str = "primary",
121-
allowed_to_use: Optional[List[str]] = None,
122-
allowed_to_manage: Optional[List[str]] = None,
191+
allowed_to_use: _AllowedType = None,
192+
allowed_to_manage: _AllowedType = None,
123193
tags: Optional[Dict[str, str]] = None,
124194
account_id: Optional[str] = None,
125195
boto3_session: Optional[boto3.Session] = None,
@@ -140,13 +210,19 @@ def create_athena_data_source(
140210
Athena workgroup.
141211
tags : Dict[str, str], optional
142212
Key/Value collection to put on the Cluster.
143-
e.g. {"foo": "boo", "bar": "xoo"})
144-
allowed_to_use : optional
145-
List of principals that will be allowed to see and use the data source.
146-
e.g. ["John"]
147-
allowed_to_manage : optional
148-
List of principals that will be allowed to see, use, update and delete the data source.
149-
e.g. ["Mary"]
213+
e.g. ```{"foo": "boo", "bar": "xoo"})```
214+
allowed_to_use: dict["users" | "groups", list[str]], optional
215+
Dictionary containing usernames and groups that will be allowed to see and
216+
use the data.
217+
e.g. ```{"users": ["john", "Mary"], "groups": ["engineering", "customers"]}```
218+
Alternatively, if a list of string is passed,
219+
it will be interpreted as a list of usernames only.
220+
allowed_to_manage: dict["users" | "groups", list[str]], optional
221+
Dictionary containing usernames and groups that will be allowed to see, use,
222+
update and delete the data source.
223+
e.g. ```{"users": ["Mary"], "groups": ["engineering"]}```
224+
Alternatively, if a list of string is passed,
225+
it will be interpreted as a list of usernames only.
150226
account_id : str, optional
151227
If None, the account ID will be inferred from your boto3 session.
152228
boto3_session : boto3.Session(), optional
@@ -180,11 +256,13 @@ def create_athena_data_source(
180256
}
181257
permissions: List[Dict[str, Union[str, List[str]]]] = _generate_permissions(
182258
resource="data_source",
259+
namespace=namespace,
183260
account_id=account_id,
184261
boto3_session=boto3_session,
185-
allowed_to_use=allowed_to_use,
186-
allowed_to_manage=allowed_to_manage,
187-
namespace=namespace,
262+
allowed_users_to_use=_get_principal_names(allowed_to_use, "users"),
263+
allowed_users_to_manage=_get_principal_names(allowed_to_manage, "users"),
264+
allowed_groups_to_use=_get_principal_names(allowed_to_use, "groups"),
265+
allowed_groups_to_manage=_get_principal_names(allowed_to_manage, "groups"),
188266
)
189267
if permissions:
190268
args["Permissions"] = permissions
@@ -203,8 +281,8 @@ def create_athena_dataset(
203281
data_source_name: Optional[str] = None,
204282
data_source_arn: Optional[str] = None,
205283
import_mode: Literal["SPICE", "DIRECT_QUERY"] = "DIRECT_QUERY",
206-
allowed_to_use: Optional[List[str]] = None,
207-
allowed_to_manage: Optional[List[str]] = None,
284+
allowed_to_use: _AllowedType = None,
285+
allowed_to_manage: _AllowedType = None,
208286
logical_table_alias: str = "LogicalTable",
209287
rename_columns: Optional[Dict[str, str]] = None,
210288
cast_columns_types: Optional[Dict[str, str]] = None,
@@ -251,12 +329,18 @@ def create_athena_dataset(
251329
tags : Dict[str, str], optional
252330
Key/Value collection to put on the Cluster.
253331
e.g. {"foo": "boo", "bar": "xoo"}
254-
allowed_to_use : optional
255-
List of usernames that will be allowed to see and use the data source.
256-
e.g. ["john", "Mary"]
257-
allowed_to_manage : optional
258-
List of usernames that will be allowed to see, use, update and delete the data source.
259-
e.g. ["Mary"]
332+
allowed_to_use: dict["users" | "groups", list[str]], optional
333+
Dictionary containing usernames and groups that will be allowed to see and
334+
use the data.
335+
e.g. ```{"users": ["john", "Mary"], "groups": ["engineering", "customers"]}```
336+
Alternatively, if a list of string is passed,
337+
it will be interpreted as a list of usernames only.
338+
allowed_to_manage: dict["users" | "groups", list[str]], optional
339+
Dictionary containing usernames and groups that will be allowed to see, use,
340+
update and delete the data source.
341+
e.g. ```{"users": ["Mary"], "groups": ["engineering"]}```
342+
Alternatively, if a list of string is passed,
343+
it will be interpreted as a list of usernames only.
260344
logical_table_alias : str
261345
A display name for the logical table.
262346
rename_columns : Dict[str, str], optional
@@ -347,13 +431,16 @@ def create_athena_dataset(
347431
)
348432
if trans:
349433
args["LogicalTableMap"][table_uuid]["DataTransforms"] = trans
434+
350435
permissions: List[Dict[str, Union[str, List[str]]]] = _generate_permissions(
351436
resource="dataset",
437+
namespace=namespace,
352438
account_id=account_id,
353439
boto3_session=boto3_session,
354-
allowed_to_use=allowed_to_use,
355-
allowed_to_manage=allowed_to_manage,
356-
namespace=namespace,
440+
allowed_users_to_use=_get_principal_names(allowed_to_use, "users"),
441+
allowed_users_to_manage=_get_principal_names(allowed_to_manage, "users"),
442+
allowed_groups_to_use=_get_principal_names(allowed_to_use, "groups"),
443+
allowed_groups_to_manage=_get_principal_names(allowed_to_manage, "groups"),
357444
)
358445
if permissions:
359446
args["Permissions"] = permissions

awswrangler/quicksight/_utils.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
"""Internal (private) Amazon QuickSight Utilities Module."""
22

33
import logging
4-
from typing import Any, Dict, List, Optional
4+
from typing import Any, Dict, List, Optional, TypedDict
55

66
import boto3
7+
from typing_extensions import NotRequired
78

89
from awswrangler import _data_types, athena, catalog, exceptions
910
from awswrangler.quicksight._get_list import list_data_sources
1011

1112
_logger: logging.Logger = logging.getLogger(__name__)
1213

1314

15+
class _QuicksightPrincipalList(TypedDict):
16+
users: NotRequired[List[str]]
17+
groups: NotRequired[List[str]]
18+
19+
1420
def extract_athena_table_columns(
1521
database: str, table: str, boto3_session: Optional[boto3.Session]
1622
) -> List[Dict[str, str]]:

tests/unit/test_quicksight.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ def test_quicksight(path, quicksight_datasource, quicksight_dataset, glue_databa
5050
sql=f"SELECT * FROM {glue_database}.{glue_table}",
5151
data_source_name=quicksight_datasource,
5252
import_mode="SPICE",
53-
allowed_to_use=[wr.sts.get_current_identity_name()],
54-
allowed_to_manage=[wr.sts.get_current_identity_name()],
53+
allowed_to_use={"users": [wr.sts.get_current_identity_name()]},
54+
allowed_to_manage={"users": [wr.sts.get_current_identity_name()]},
5555
rename_columns={"iint16": "new_col"},
5656
cast_columns_types={"new_col": "STRING"},
5757
tag_columns={"string": [{"ColumnGeographicRole": "CITY"}, {"ColumnDescription": {"Text": "some description"}}]},
@@ -84,7 +84,9 @@ def test_quicksight_delete_all_datasources_filter():
8484
wr.quicksight.delete_all_data_sources(regex_filter="test.*")
8585
resource_name = f"test-delete-{uuid.uuid4()}"
8686
wr.quicksight.create_athena_data_source(
87-
name=resource_name, allowed_to_manage=[wr.sts.get_current_identity_name()], tags={"Env": "aws-sdk-pandas"}
87+
name=resource_name,
88+
allowed_to_manage={"users": [wr.sts.get_current_identity_name()]},
89+
tags={"Env": "aws-sdk-pandas"},
8890
)
8991
wr.quicksight.delete_all_data_sources(regex_filter="test-no-delete")
9092

@@ -104,15 +106,17 @@ def test_quicksight_delete_all_datasets(path, glue_database, glue_table):
104106

105107
resource_name = f"test{str(uuid.uuid4())[:8]}"
106108
wr.quicksight.create_athena_data_source(
107-
name=resource_name, allowed_to_manage=[wr.sts.get_current_identity_name()], tags={"Env": "aws-sdk-pandas"}
109+
name=resource_name,
110+
allowed_to_manage={"users": [wr.sts.get_current_identity_name()]},
111+
tags={"Env": "aws-sdk-pandas"},
108112
)
109113
wr.quicksight.create_athena_dataset(
110114
name=f"{resource_name}-sql",
111115
sql=f"SELECT * FROM {glue_database}.{glue_table}",
112116
data_source_name=resource_name,
113117
import_mode="SPICE",
114-
allowed_to_use=[wr.sts.get_current_identity_name()],
115-
allowed_to_manage=[wr.sts.get_current_identity_name()],
118+
allowed_to_use={"users": [wr.sts.get_current_identity_name()]},
119+
allowed_to_manage={"users": [wr.sts.get_current_identity_name()]},
116120
rename_columns={"iint16": "new_col"},
117121
cast_columns_types={"new_col": "STRING"},
118122
tag_columns={"string": [{"ColumnGeographicRole": "CITY"}, {"ColumnDescription": {"Text": "some description"}}]},

0 commit comments

Comments
 (0)