From 7e061fc5f1cf118d90eec0be0a2cf37e00d15d02 Mon Sep 17 00:00:00 2001 From: Yevhenii Semendiak Date: Thu, 27 Apr 2023 18:14:37 +0300 Subject: [PATCH] Add default project name to created service account full token (#293) * add default project name to created service account NEURO_PASS_CONFIG_TOKEN * add default org name to created service account NEURO_PASS_CONFIG_TOKEN --- platform_service_accounts_api/schema.py | 2 + platform_service_accounts_api/service.py | 24 ++-- platform_service_accounts_api/storage/base.py | 2 + tests/integration/test_api.py | 109 ++++++++++++++++-- tests/unit/test_in_memory_storage.py | 2 + tests/unit/test_service.py | 3 + 6 files changed, 120 insertions(+), 22 deletions(-) diff --git a/platform_service_accounts_api/schema.py b/platform_service_accounts_api/schema.py index 1c0a434..4a6885f 100644 --- a/platform_service_accounts_api/schema.py +++ b/platform_service_accounts_api/schema.py @@ -45,6 +45,8 @@ def validate_name(name: str) -> None: class ServiceAccountCreateSchema(Schema): name = fields.String(required=False, load_default=None, validate=validate_name) default_cluster = fields.String(required=True) + default_project = fields.String(required=True) + default_org = fields.String(required=False) class ServiceAccountSchema(ServiceAccountCreateSchema): diff --git a/platform_service_accounts_api/service.py b/platform_service_accounts_api/service.py index 78a516b..421a2b1 100644 --- a/platform_service_accounts_api/service.py +++ b/platform_service_accounts_api/service.py @@ -29,6 +29,8 @@ class AccountCreateData: name: Optional[str] default_cluster: str owner: str + default_project: str + default_org: Optional[str] = None @dataclass(frozen=True) @@ -47,16 +49,16 @@ def __init__( def _make_token_uri(self, account_id: str) -> str: return f"token://service_account/{account_id}" - def _encode_token(self, auth_token: str, default_cluster: str) -> str: - return base64.b64encode( - json.dumps( - { - "token": auth_token, - "cluster": default_cluster, - "url": str(self._api_base_url), - } - ).encode() - ).decode() + def _encode_token(self, auth_token: str, account: ServiceAccount) -> str: + token = { + "token": auth_token, + "cluster": account.default_cluster, + "url": str(self._api_base_url), + "project_name": account.default_project, + } + if account.default_org: + token["org_name"] = account.default_org + return base64.b64encode(json.dumps(token).encode()).decode() async def create(self, data: AccountCreateData) -> ServiceAccountWithToken: if data.name: @@ -74,7 +76,7 @@ async def create(self, data: AccountCreateData) -> ServiceAccountWithToken: try: await self._auth_client.add_user(User(name=role)) auth_token = await self._auth_client.get_user_token(role) - token = self._encode_token(auth_token, account.default_cluster) + token = self._encode_token(auth_token, account) except Exception: await self._storage.delete(account.id) raise diff --git a/platform_service_accounts_api/storage/base.py b/platform_service_accounts_api/storage/base.py index 57ea2ce..72ebf3c 100644 --- a/platform_service_accounts_api/storage/base.py +++ b/platform_service_accounts_api/storage/base.py @@ -52,6 +52,8 @@ class ServiceAccountData: role: str owner: str created_at: datetime.datetime + default_project: str + default_org: Optional[str] @dataclass(frozen=True) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index c5d6206..51fbdc4 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -229,7 +229,11 @@ async def test_account_create( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -246,6 +250,7 @@ async def test_account_create( token_data = json.loads(base64.b64decode(token.encode()).decode()) assert token_data["cluster"] == "default" assert token_data["url"] == "https://dev.neu.ro/api/v1" + assert token_data["project_name"] == "some-project" auth_token = token_data["token"] @@ -262,7 +267,7 @@ async def test_account_create_no_name( async with client.post( url=service_accounts_api.accounts_url, - json={"default_cluster": "default"}, + json={"default_cluster": "default", "default_project": "some-project"}, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -273,6 +278,7 @@ async def test_account_create_no_name( token_data = json.loads(base64.b64decode(token.encode()).decode()) assert token_data["cluster"] == "default" assert token_data["url"] == "https://dev.neu.ro/api/v1" + assert token_data["project_name"] == "some-project" auth_token = token_data["token"] @@ -304,7 +310,11 @@ async def test_account_create_invalid_name( ) -> None: async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPBadRequest.status_code, await resp.text() @@ -327,6 +337,48 @@ async def test_account_create_no_cluster( payload = await resp.json() assert "Missing data for required field" in payload["error"] + async def test_account_create_default_org( + self, + service_accounts_api: ServiceAccountsApiEndpoints, + regular_user: _User, + client: aiohttp.ClientSession, + auth_client: AuthClient, + ) -> None: + role_name = f"{regular_user.name}/service-accounts/sa-name" + async with client.post( + url=service_accounts_api.accounts_url, + json={ + "name": "sa-name", + "default_cluster": "default", + "default_project": "some-project", + "default_org": "some-org", + }, + headers=regular_user.headers, + ) as resp: + assert resp.status == HTTPCreated.status_code, await resp.text() + payload = await resp.json() + assert payload["name"] == "sa-name" + assert payload["owner"] == regular_user.name + assert payload["default_cluster"] == "default" + assert payload["default_project"] == "some-project" + assert payload["default_org"] == "some-org" + assert datetime.fromisoformat(payload["created_at"]) + assert not payload["role_deleted"] + assert "id" in payload + token = payload["token"] + + token_data = json.loads(base64.b64decode(token.encode()).decode()) + assert token_data["cluster"] == "default" + assert token_data["url"] == "https://dev.neu.ro/api/v1" + assert token_data["org_name"] == "some-org" + assert token_data["cluster"] == "default" + assert token_data["project_name"] == "some-project" + + auth_token = token_data["token"] + + fetched_role = await auth_client.get_user(role_name, token=auth_token) + assert fetched_role.name == role_name + async def test_account_get( self, service_accounts_api: ServiceAccountsApiEndpoints, @@ -339,7 +391,11 @@ async def test_account_get( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -357,6 +413,7 @@ async def test_account_get( assert payload["name"] == sa_name assert payload["owner"] == regular_user.name assert payload["default_cluster"] == "default" + assert payload["default_project"] == "some-project" assert payload["role"] == role_name assert datetime.fromisoformat(payload["created_at"]) assert not payload["role_deleted"] @@ -373,7 +430,11 @@ async def test_account_get_by_name( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -391,6 +452,7 @@ async def test_account_get_by_name( assert payload["name"] == sa_name assert payload["owner"] == regular_user.name assert payload["default_cluster"] == "default" + assert payload["default_project"] == "some-project" assert payload["role"] == role_name assert datetime.fromisoformat(payload["created_at"]) assert not payload["role_deleted"] @@ -421,7 +483,11 @@ async def test_accounts_list_one( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -441,6 +507,7 @@ async def test_accounts_list_one( assert payload["name"] == sa_name assert payload["owner"] == regular_user.name assert payload["default_cluster"] == "default" + assert payload["default_project"] == "some-project" assert payload["role"] == role_name assert datetime.fromisoformat(payload["created_at"]) assert not payload["role_deleted"] @@ -454,7 +521,11 @@ async def test_accounts_list_many( ) -> None: async with client.post( url=service_accounts_api.accounts_url, - json={"name": "test1", "default_cluster": "default"}, + json={ + "name": "test1", + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -463,7 +534,11 @@ async def test_accounts_list_many( async with client.post( url=service_accounts_api.accounts_url, - json={"name": "test2", "default_cluster": "default"}, + json={ + "name": "test2", + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -490,7 +565,11 @@ async def test_account_delete( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -522,7 +601,11 @@ async def test_account_delete_role_deleted( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() @@ -552,7 +635,11 @@ async def test_account_delete_role_recreated( async with client.post( url=service_accounts_api.accounts_url, - json={"name": sa_name, "default_cluster": "default"}, + json={ + "name": sa_name, + "default_cluster": "default", + "default_project": "some-project", + }, headers=regular_user.headers, ) as resp: assert resp.status == HTTPCreated.status_code, await resp.text() diff --git a/tests/unit/test_in_memory_storage.py b/tests/unit/test_in_memory_storage.py index 30e5bf6..fa8ff02 100644 --- a/tests/unit/test_in_memory_storage.py +++ b/tests/unit/test_in_memory_storage.py @@ -39,6 +39,8 @@ async def gen_data(self, **kwargs: Any) -> ServiceAccountData: owner=secrets.token_hex(8), role=secrets.token_hex(8), default_cluster=secrets.token_hex(8), + default_project=secrets.token_hex(8), + default_org=None, created_at=datetime.now(timezone.utc), ) # Updating this way so constructor call is typechecked properly diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index c4eb8bb..7bbe338 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -53,6 +53,8 @@ class TestService: name="test", owner="testowner", default_cluster="default", + default_project="default-project", + default_org=None, ) def compare_data( @@ -99,6 +101,7 @@ async def test_create( assert token_data["token"] == f"token-{expected_role}" assert token_data["cluster"] == self.CREATE_DATA.default_cluster assert token_data["url"] == "https://dev.neu.ro/api/v1" + assert token_data["project_name"] == self.CREATE_DATA.default_project assert mock_auth_client.created_users[0].name == expected_role async def test_create_no_name(