From e96a709f2edb4fcdfcfb2a56e4bc0b3dc679eda8 Mon Sep 17 00:00:00 2001 From: Nikita Karetnikov Date: Tue, 3 Oct 2023 13:02:55 +0000 Subject: [PATCH] Change API to be able to assign roles to namespaces Fixes #491. --- ...28642d_change_namespacerolemapping_and_.py | 47 ++++ conda-store-server/conda_store_server/api.py | 174 +++++++++++++-- conda-store-server/conda_store_server/orm.py | 25 +-- .../conda_store_server/schema.py | 29 ++- .../conda_store_server/server/auth.py | 55 ++--- .../conda_store_server/server/views/api.py | 173 ++++++++++++++- tests/test_api.py | 208 ++++++++++-------- 7 files changed, 553 insertions(+), 158 deletions(-) create mode 100644 conda-store-server/conda_store_server/alembic/versions/46bdf428642d_change_namespacerolemapping_and_.py diff --git a/conda-store-server/conda_store_server/alembic/versions/46bdf428642d_change_namespacerolemapping_and_.py b/conda-store-server/conda_store_server/alembic/versions/46bdf428642d_change_namespacerolemapping_and_.py new file mode 100644 index 000000000..6441bcade --- /dev/null +++ b/conda-store-server/conda_store_server/alembic/versions/46bdf428642d_change_namespacerolemapping_and_.py @@ -0,0 +1,47 @@ +"""Change NamespaceRoleMapping and Namespace + +Revision ID: 46bdf428642d +Revises: b387747ca9b7 +Create Date: 2023-10-08 10:40:06.227854 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "46bdf428642d" +down_revision = "b387747ca9b7" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'namespace_role_mapping_new', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('namespace_id', sa.Integer(), nullable=False), + sa.Column('other_namespace_id', sa.Integer(), nullable=False), + sa.Column('role', sa.Unicode(length=255), nullable=False), + sa.ForeignKeyConstraint(['namespace_id'], ['namespace.id'], ), + sa.ForeignKeyConstraint(['other_namespace_id'], ['namespace.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('namespace_id', 'other_namespace_id', 'role', name='_uc') + ) + # Note: data is NOT copied before dropping the old table + op.drop_table("namespace_role_mapping") + op.rename_table("namespace_role_mapping_new", "namespace_role_mapping") + + +def downgrade(): + op.create_table( + 'namespace_role_mapping_new', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('namespace_id', sa.Integer(), nullable=False), + sa.Column('entity', sa.Unicode(length=255), nullable=False), + sa.Column('role', sa.Unicode(length=255), nullable=False), + sa.ForeignKeyConstraint(['namespace_id'], ['namespace.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # Note: data is NOT copied before dropping the old table + op.drop_table("namespace_role_mapping") + op.rename_table("namespace_role_mapping_new", "namespace_role_mapping") diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index 82ffd7d77..71c8f6850 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -3,6 +3,7 @@ from conda_store_server import conda_utils, orm, schema, utils from sqlalchemy import distinct, func, null, or_ +from sqlalchemy.orm import aliased def list_namespaces(db, show_soft_deleted: bool = False): @@ -45,11 +46,10 @@ def create_namespace(db, name: str): return namespace -def update_namespace( +def update_namespace_metadata( db, name: str, metadata_: Dict[str, Any] = None, - role_mappings: Dict[str, List[str]] = None, ): namespace = get_namespace(db, name) if namespace is None: @@ -58,30 +58,162 @@ def update_namespace( if metadata_ is not None: namespace.metadata_ = metadata_ - if role_mappings is not None: - # deletes all the existing role mappings ... - for rm in namespace.role_mappings: - db.delete(rm) - - # ... before adding all the new ones - mappings_orm = [] - for entity, roles in role_mappings.items(): - for role in roles: - mapping_orm = orm.NamespaceRoleMapping( - namespace_id=namespace.id, - namespace=namespace, - entity=entity, - role=role, - ) - mappings_orm.append(mapping_orm) - - namespace.role_mappings = mappings_orm - db.commit() return namespace +def get_namespace_roles( + db, + name: str, +): + """Which namespaces can access namespace 'name'?""" + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + nrm = aliased(orm.NamespaceRoleMapping) + this = aliased(orm.Namespace) + other = aliased(orm.Namespace) + q = ( + db.query(nrm.id, this.name, other.name, nrm.role) + .filter(nrm.namespace_id == namespace.id) + .filter(nrm.namespace_id == this.id) + .filter(nrm.other_namespace_id == other.id) + .all() + ) + return [schema.NamespaceRoleMapping.from_list(x) for x in q] + + +def get_other_namespace_roles( + db, + name: str, +): + """To which namespaces does namespace 'name' have access?""" + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + nrm = aliased(orm.NamespaceRoleMapping) + this = aliased(orm.Namespace) + other = aliased(orm.Namespace) + q = ( + db.query(nrm.id, this.name, other.name, nrm.role) + .filter(nrm.other_namespace_id == namespace.id) + .filter(nrm.namespace_id == this.id) + .filter(nrm.other_namespace_id == other.id) + .all() + ) + return [schema.NamespaceRoleMapping.from_list(x) for x in q] + + +def delete_namespace_roles( + db, + name: str, +): + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + nrm = orm.NamespaceRoleMapping + db.query(nrm).filter(nrm.namespace_id == namespace.id).delete() + db.commit() + + +def get_namespace_role( + db, + name: str, + other: str, +): + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + other_namespace = get_namespace(db, other) + if other_namespace is None: + raise ValueError(f"Namespace='{other}' not found") + + nrm = aliased(orm.NamespaceRoleMapping) + this = aliased(orm.Namespace) + other = aliased(orm.Namespace) + q = ( + db.query(nrm.id, this.name, other.name, nrm.role) + .filter(nrm.namespace_id == namespace.id) + .filter(nrm.other_namespace_id == other_namespace.id) + .filter(nrm.namespace_id == this.id) + .filter(nrm.other_namespace_id == other.id) + .first() + ) + if q is None: + return None + return schema.NamespaceRoleMapping.from_list(q) + + +def create_namespace_role( + db, + name: str, + other: str, + role: str, +): + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + other_namespace = get_namespace(db, other) + if other_namespace is None: + raise ValueError(f"Namespace='{other}' not found") + + db.add( + orm.NamespaceRoleMapping( + namespace_id=namespace.id, + other_namespace_id=other_namespace.id, + role=role, + ) + ) + db.commit() + + +def update_namespace_role( + db, + name: str, + other: str, + role: str, +): + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + other_namespace = get_namespace(db, other) + if other_namespace is None: + raise ValueError(f"Namespace='{other}' not found") + + nrm = orm.NamespaceRoleMapping + db.query(nrm).filter(nrm.namespace_id == namespace.id).filter( + nrm.other_namespace_id == other_namespace.id + ).update({"role": role}) + db.commit() + + +def delete_namespace_role( + db, + name: str, + other: str, +): + namespace = get_namespace(db, name) + if namespace is None: + raise ValueError(f"Namespace='{name}' not found") + + other_namespace = get_namespace(db, other) + if other_namespace is None: + raise ValueError(f"Namespace='{other}' not found") + + nrm = orm.NamespaceRoleMapping + db.query(nrm).filter(nrm.namespace_id == namespace.id).filter( + nrm.other_namespace_id == other_namespace.id + ).delete() + db.commit() + + def delete_namespace(db, name: str = None, id: int = None): namespace = get_namespace(db, name=name, id=id) if namespace: diff --git a/conda-store-server/conda_store_server/orm.py b/conda-store-server/conda_store_server/orm.py index 5f2ae301c..ee19f5622 100644 --- a/conda-store-server/conda_store_server/orm.py +++ b/conda-store-server/conda_store_server/orm.py @@ -54,8 +54,6 @@ class Namespace(Base): metadata_ = Column(JSON, default=dict, nullable=True) - role_mappings = relationship("NamespaceRoleMapping", back_populates="namespace") - class NamespaceRoleMapping(Base): """Mapping between roles and namespaces""" @@ -63,23 +61,17 @@ class NamespaceRoleMapping(Base): __tablename__ = "namespace_role_mapping" id = Column(Integer, primary_key=True) + # Provides access to this namespace namespace_id = Column(Integer, ForeignKey("namespace.id"), nullable=False) - namespace = relationship(Namespace, back_populates="role_mappings") + namespace = relationship(Namespace, foreign_keys=[namespace_id]) - # arn e.g. / like `quansight-*/*` or `quansight-devops/*` - # The entity must match with ARN_ALLOWED defined in schema.py - entity = Column(Unicode(255), nullable=False) + # ... for other namespace + other_namespace_id = Column(Integer, ForeignKey("namespace.id"), nullable=False) + other_namespace = relationship(Namespace, foreign_keys=[other_namespace_id]) - # e.g. viewer + # ... with this role, like 'viewer' role = Column(Unicode(255), nullable=False) - @validates("entity") - def validate_entity(self, key, entity): - if not ARN_ALLOWED_REGEX.match(entity): - raise ValueError(f"invalid entity={entity}") - - return entity - @validates("role") def validate_role(self, key, role): if role not in ["admin", "viewer", "developer"]: @@ -87,6 +79,11 @@ def validate_role(self, key, role): return role + __table_args__ = ( + # Ensures no duplicates can be added with this combination of fields + UniqueConstraint("namespace_id", "other_namespace_id", "role", name="_uc"), + ) + class Specification(Base): """The specifiction for a given conda environment""" diff --git a/conda-store-server/conda_store_server/schema.py b/conda-store-server/conda_store_server/schema.py index 11dd685cb..05a5afb59 100644 --- a/conda-store-server/conda_store_server/schema.py +++ b/conda-store-server/conda_store_server/schema.py @@ -95,12 +95,17 @@ class Config: class NamespaceRoleMapping(BaseModel): id: int - entity: str + namespace: str + other_namespace: str role: str class Config: orm_mode = True + @classmethod + def from_list(cls, lst): + return cls(**{k: v for k, v in zip(cls.__fields__.keys(), lst)}) + class Namespace(BaseModel): id: int @@ -579,6 +584,28 @@ class APIGetNamespace(APIResponse): data: Namespace +# GET /api/v1/namespace/{name}/role +class APIGetNamespaceRole(BaseModel): + other_namespace: str + + +# POST /api/v1/namespace/{name}/role +class APIPostNamespaceRole(BaseModel): + other_namespace: str + role: str + + +# PUT /api/v1/namespace/{name}/role +class APIPutNamespaceRole(BaseModel): + other_namespace: str + role: str + + +# DELETE /api/v1/namespace/{name}/role +class APIDeleteNamespaceRole(BaseModel): + other_namespace: str + + # GET /api/v1/environment class APIListEnvironment(APIPaginatedResponse): data: List[Environment] diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index ec0e2d0ae..b5f55dc2b 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -8,12 +8,12 @@ import jwt import requests import yarl -from conda_store_server import orm, schema, utils +from conda_store_server import api, orm, schema, utils from conda_store_server.server import dependencies from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from sqlalchemy import and_, or_, text +from sqlalchemy import and_, or_ from sqlalchemy.orm import sessionmaker from traitlets import Bool, Callable, Dict, Instance, Type, Unicode, Union, default from traitlets.config import LoggingConfigurable @@ -164,7 +164,7 @@ def is_arn_subset(arn_1: str, arn_2: str): ) return (arn_1_matches_arn_2 and arn_2_matches_arn_1) or arn_2_matches_arn_1 - def get_entity_bindings(self, entity): + def get_entity_bindings(self, entity: schema.AuthenticationToken): authenticated = entity is not None entity_role_bindings = {} if entity is None else entity.role_bindings @@ -188,14 +188,17 @@ def convert_roles_to_permissions(self, roles): permissions = permissions | self.role_mappings[role] return permissions - def get_entity_binding_permissions(self, entity): + def convert_namespace_to_entity_arn(self, namespace): + return f"{namespace}/*" + + def get_entity_binding_permissions(self, entity: schema.AuthenticationToken): entity_bindings = self.get_entity_bindings(entity) return { entity_arn: self.convert_roles_to_permissions(roles=entity_roles) for entity_arn, entity_roles in entity_bindings.items() } - def get_entity_permissions(self, entity, arn: str): + def get_entity_permissions(self, entity: schema.AuthenticationToken, arn: str): """Get set of permissions for given ARN given AUTHENTICATION state and entity_bindings @@ -233,31 +236,33 @@ def is_subset_entity_permissions(self, entity, new_entity): return False return True - def authorize(self, entity, arn, required_permissions): + def authorize( + self, entity: schema.AuthenticationToken, arn: str, required_permissions + ): return required_permissions <= self.get_entity_permissions( entity=entity, arn=arn ) - def database_role_bindings(self, entity): + def database_role_bindings(self, entity: schema.AuthenticationToken): with self.authentication_db() as db: - result = db.execute( - text( - """ - SELECT nrm.entity, nrm.role - FROM namespace n - RIGHT JOIN namespace_role_mapping nrm ON nrm.namespace_id = n.id - WHERE n.name = :primary_namespace - """ - ), - {"primary_namespace": entity.primary_namespace}, - ) - raw_role_mappings = result.mappings().all() - - db_role_mappings = defaultdict(set) - for row in raw_role_mappings: - db_role_mappings[row["entity"]].add(row["role"]) - - return db_role_mappings + # Must have the same format as authenticated_role_bindings: + # { + # "default/*": {"viewer"}, + # "filesystem/*": {"viewer"}, + # } + res = defaultdict(set) + + # FIXME: Remove try-except. + # Used in tests to check default permissions without populating the + # DB, which raises an exception since namespace is not found. + try: + roles = api.get_other_namespace_roles(db, name=entity.primary_namespace) + except Exception: + return res + + for x in roles: + res[self.convert_namespace_to_entity_arn(x.namespace)].add(x.role) + return res class Authentication(LoggingConfigurable): diff --git a/conda-store-server/conda_store_server/server/views/api.py b/conda-store-server/conda_store_server/server/views/api.py index 2661c49e7..8c25cf288 100644 --- a/conda-store-server/conda_store_server/server/views/api.py +++ b/conda-store-server/conda_store_server/server/views/api.py @@ -307,17 +307,14 @@ async def api_create_namespace( return {"status": "ok"} -@router_api.put( - "/namespace/{namespace}/", - response_model=schema.APIAckResponse, -) -async def api_update_namespace( +def _api_namespace_common( namespace: str, request: Request, - metadata: Dict[str, Any] = None, - role_mappings: Dict[str, List[str]] = None, auth=Depends(dependencies.get_auth), db: Session = Depends(dependencies.get_db), + *, + func, + **kwargs, ): auth.authorize_request( request, @@ -335,10 +332,170 @@ async def api_update_namespace( raise HTTPException(status_code=404, detail="namespace does not exist") try: - api.update_namespace(db, namespace, metadata, role_mappings) + res = func(db, namespace, **kwargs) except ValueError as e: raise HTTPException(status_code=400, detail=str(e.args[0])) db.commit() + return res + + +@router_api.put( + "/namespace/{namespace}/metadata", + response_model=schema.APIAckResponse, +) +async def api_update_namespace_metadata( + namespace: str, + request: Request, + metadata: Dict[str, Any] = None, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.update_namespace_metadata, + metadata_=metadata, + ) + return {"status": "ok"} + + +@router_api.get( + "/namespace/{namespace}/roles", + response_model=schema.APIResponse, +) +async def api_get_namespace_roles( + namespace: str, + request: Request, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + data = _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.get_namespace_roles, + ) + return { + "status": "ok", + "data": [x.dict() for x in data], + } + + +@router_api.delete( + "/namespace/{namespace}/roles", + response_model=schema.APIAckResponse, +) +async def api_delete_namespace_roles( + namespace: str, + request: Request, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.delete_namespace_roles, + ) + return {"status": "ok"} + + +@router_api.get( + "/namespace/{namespace}/role", + response_model=schema.APIResponse, +) +async def api_get_namespace_role( + namespace: str, + request: Request, + role_mapping: schema.APIGetNamespaceRole, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + data = _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.get_namespace_role, + other=role_mapping.other_namespace, + ) + if data is None: + raise HTTPException(status_code=404, detail="failed to find role") + return { + "status": "ok", + "data": data.dict(), + } + + +@router_api.post( + "/namespace/{namespace}/role", + response_model=schema.APIAckResponse, +) +async def api_create_namespace_role( + namespace: str, + request: Request, + role_mapping: schema.APIPostNamespaceRole, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.create_namespace_role, + other=role_mapping.other_namespace, + role=role_mapping.role, + ) + return {"status": "ok"} + + +@router_api.put( + "/namespace/{namespace}/role", + response_model=schema.APIAckResponse, +) +async def api_update_namespace_role( + namespace: str, + request: Request, + role_mapping: schema.APIPutNamespaceRole, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.update_namespace_role, + other=role_mapping.other_namespace, + role=role_mapping.role, + ) + return {"status": "ok"} + + +@router_api.delete( + "/namespace/{namespace}/role", + response_model=schema.APIAckResponse, +) +async def api_delete_namespace_role( + namespace: str, + request: Request, + role_mapping: schema.APIDeleteNamespaceRole, + auth=Depends(dependencies.get_auth), + db: Session = Depends(dependencies.get_db), +): + _api_namespace_common( + namespace=namespace, + request=request, + auth=auth, + db=db, + func=api.delete_namespace_role, + other=role_mapping.other_namespace, + ) return {"status": "ok"} diff --git a/tests/test_api.py b/tests/test_api.py index 73bc973c9..6b41a2156 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,7 @@ """ import json import uuid +from functools import partial from typing import List import conda_store_server @@ -500,95 +501,6 @@ def test_create_namespace_auth(testclient): assert r.data.name == namespace -def test_update_namespace_noauth(testclient): - namespace = f"filesystem" - # namespace = f"pytest-{uuid.uuid4()}" - - test_role_mappings = { - f"{namespace}/*": ["viewer"], - f"{namespace}/admin": ["admin"], - f"{namespace}/test": ["admin", "viewer", "developer"], - } - - # Updates the metadata only - response = testclient.put( - f"api/v1/namespace/{namespace}/", - json={ - "metadata": {"test_key1": "test_value1", "test_key2": "test_value2"}, - }, - ) - assert response.status_code == 403 - - r = schema.APIResponse.parse_obj(response.json()) - assert r.status == schema.APIStatus.ERROR - - # Updates the role mappings only - response = testclient.put( - f"api/v1/namespace/{namespace}", json={"role_mappings": test_role_mappings} - ) - assert response.status_code == 403 - - r = schema.APIResponse.parse_obj(response.json()) - assert r.status == schema.APIStatus.ERROR - - # Updates both the metadata and the role mappings - response = testclient.put( - f"api/v1/namespace/{namespace}", - json={ - "metadata": {"test_key1": "test_value1", "test_key2": "test_value2"}, - "role_mappings": test_role_mappings, - }, - ) - assert response.status_code == 403 - - r = schema.APIResponse.parse_obj(response.json()) - assert r.status == schema.APIStatus.ERROR - - -def test_update_namespace_auth(testclient): - namespace = f"filesystem" - - testclient.login() - - test_role_mappings = { - f"{namespace}/*": ["viewer"], - f"{namespace}/admin": ["admin"], - f"{namespace}/test": ["admin", "viewer", "developer"], - } - - # Updates both the metadata and the role mappings - response = testclient.put( - f"api/v1/namespace/{namespace}", - json={ - "metadata": {"test_key1": "test_value1", "test_key2": "test_value2"}, - "role_mappings": test_role_mappings, - }, - ) - - r = schema.APIResponse.parse_obj(response.json()) - assert r.status == schema.APIStatus.OK - - # Updates the metadata only - response = testclient.put( - f"api/v1/namespace/{namespace}/", - json={ - "metadata": {"test_key1": "test_value1", "test_key2": "test_value2"}, - }, - ) - response.raise_for_status() - - r = schema.APIResponse.parse_obj(response.json()) - assert r.status == schema.APIStatus.OK - - # Updates the role mappings only - response = testclient.put( - f"api/v1/namespace/{namespace}", json={"role_mappings": test_role_mappings} - ) - - r = schema.APIResponse.parse_obj(response.json()) - assert r.status == schema.APIStatus.OK - - def test_create_get_delete_namespace_auth(testclient): namespace = f"pytest-delete-namespace-{uuid.uuid4()}" @@ -619,6 +531,124 @@ def test_create_get_delete_namespace_auth(testclient): assert r.status == schema.APIStatus.ERROR +def _crud_common(testclient, auth, method, route, json=None, data_pred=None): + if auth: + testclient.login() + + if json is not None: + response = method(route, json=json) + else: + response = method(route) + + if auth: + response.raise_for_status() + else: + assert response.status_code == 403 + + r = schema.APIResponse.parse_obj(response.json()) + if auth: + assert r.status == schema.APIStatus.OK + if data_pred is None: + assert r.data is None + else: + assert data_pred(r.data) is True + else: + assert r.status == schema.APIStatus.ERROR + + +@pytest.mark.parametrize("auth", [True, False]) +def test_update_namespace_metadata(testclient, auth): + namespace = f"filesystem" + make_request = partial(_crud_common, testclient=testclient, auth=auth) + + make_request( + method=testclient.put, + route=f"api/v1/namespace/{namespace}/metadata", + json={"test_key1": "test_value1", "test_key2": "test_value2"}, + ) + + +@pytest.mark.parametrize("auth", [True, False]) +def test_crud_namespace_roles(testclient, auth): + other_namespace = f"pytest-{uuid.uuid4()}" + namespace = f"filesystem" + make_request = partial(_crud_common, testclient=testclient, auth=auth) + + # Deletes roles to start with a clean state + make_request( + method=testclient.delete, + route=f"api/v1/namespace/{namespace}/roles", + ) + + # Creates new namespace + make_request( + method=testclient.post, + route=f"api/v1/namespace/{other_namespace}", + ) + + # Creates role for 'other_namespace' with access to 'namespace' + make_request( + method=testclient.post, + route=f"api/v1/namespace/{namespace}/role", + json={ + "other_namespace": other_namespace, + "role": "developer" + }, + ) + + # Reads created role + make_request( + method=testclient.get, + route=f"api/v1/namespace/{namespace}/role", + json={ + "other_namespace": other_namespace, + }, + data_pred=lambda data: ( + data['namespace'] == 'filesystem' and + data['other_namespace'] == other_namespace and + data['role'] == 'developer' + ), + ) + + # Updates created role + make_request( + method=testclient.put, + route=f"api/v1/namespace/{namespace}/role", + json={ + "other_namespace": other_namespace, + "role": "admin" + }, + ) + + # Reads updated roles + make_request( + method=testclient.get, + route=f"api/v1/namespace/{namespace}/roles", + data_pred=lambda data: ( + data[0]['namespace'] == 'filesystem' and + data[0]['other_namespace'] == other_namespace and + data[0]['role'] == 'admin' and + len(data) == 1 + ), + ) + + # Deletes created role + make_request( + method=testclient.delete, + route=f"api/v1/namespace/{namespace}/role", + json={ + "other_namespace": other_namespace, + }, + ) + + # Reads roles to check if deleted + make_request( + method=testclient.get, + route=f"api/v1/namespace/{namespace}/roles", + data_pred=lambda data: data == [], + ) + + def test_update_environment_build_unauth(testclient): namespace = "filesystem" name = "python-flask-env"