Skip to content

Commit

Permalink
Merge pull request #202 from permitio/omer/per-10809-list-role-assign…
Browse files Browse the repository at this point in the history
…ments

Add list role assignments usage of external data manager
  • Loading branch information
omer9564 authored Oct 27, 2024
2 parents e5c0128 + 666b0da commit 565509a
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 26 deletions.
30 changes: 29 additions & 1 deletion horizon/data_manager/policy_store.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import time
from typing import Optional, Iterator, Callable
from typing import Optional, Iterator, Callable, Any

import aiohttp
from aiohttp import ClientSession
Expand Down Expand Up @@ -115,3 +115,31 @@ async def _apply_data_update(
f"Data update applied to External Data Manager: status={res.status} duration={elapsed_time_ms:.2f}ms"
)
return res

async def list_facts_by_type(
self,
fact_type: str,
page: int = 1,
per_page: int = 30,
filters: dict[str, Any] | None = None,
) -> aiohttp.ClientResponse:
logger.info(
"Performing list facts for '{fact_type}' fact type from the External Data Manager",
fact_type=fact_type,
)
query_params = {
"page": page,
"per_page": per_page,
} | (filters or {})
res = await self.client.get(
f"/v1/facts/{fact_type}",
params=query_params,
)
if res.status != 200:
logger.error(
"Failed to list '{fact_type}' facts from External Data Manager: {res}",
fact_type=fact_type,
res=await res.text(),
)
res.raise_for_status()
return res
47 changes: 32 additions & 15 deletions horizon/local/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any, Dict, List, Optional, cast

from fastapi import APIRouter, Depends, HTTPException, status, Query
from loguru import logger
from opal_client.policy_store.base_policy_store_client import BasePolicyStoreClient
from opal_client.policy_store.policy_store_client_factory import (
DEFAULT_POLICY_STORE_GETTER,
Expand All @@ -9,14 +10,16 @@
from starlette.responses import Response

from horizon.authentication import enforce_pdp_token
from horizon.config import sidecar_config
from horizon.data_manager.policy_store import DataManagerPolicyStoreClient
from horizon.local.schemas import (
Message,
SyncedRole,
SyncedUser,
RoleAssignment,
ListRoleAssignmentsFilters,
ListRoleAssignmentsPDPBody,
WrappedResponse,
ListRoleAssignmentsPagination,
)


Expand Down Expand Up @@ -136,25 +139,39 @@ async def list_role_assignments(
resource=resource,
resource_instance=resource_instance,
).dict(exclude_none=True)
pagination = ListRoleAssignmentsFilters.construct(
pagination = ListRoleAssignmentsPagination.construct(
page=page,
per_page=per_page,
)

# the type hint of the get_data_with_input is incorrect, it claims it returns a dict but it
# actually returns a Response
result = cast(
Response | Dict,
await policy_store.get_data_with_input(
"/permit/api/role_assignments/list_role_assignments",
ListRoleAssignmentsPDPBody.construct(
filters=filters, pagination=pagination
async def legacy_list_role_assignments() -> list[RoleAssignment]:
# the type hint of the get_data_with_input is incorrect, it claims it returns a dict but it
# actually returns a Response
result = cast(
Response | Dict,
await policy_store.get_data_with_input(
"/permit/api/role_assignments/list_role_assignments",
ListRoleAssignmentsPDPBody.construct(
filters=filters, pagination=pagination
),
),
),
)
if isinstance(result, Response):
return parse_raw_as(WrappedResponse, result.body).result
)
if isinstance(result, Response):
return parse_raw_as(WrappedResponse, result.body).result
else:
return parse_obj_as(WrappedResponse, result).result

if sidecar_config.ENABLE_EXTERNAL_DATA_MANAGER:
if not isinstance(policy_store, DataManagerPolicyStoreClient):
logger.warning(
"External Data Manager is enabled by policy store is not set to {store_type}",
store_type=DataManagerPolicyStoreClient.__name__,
)
return await legacy_list_role_assignments()
else:
res = await policy_store.list_facts_by_type("role_assignments")
return parse_obj_as(list[RoleAssignment], await res.json())
else:
return parse_obj_as(WrappedResponse, result).result
return await legacy_list_role_assignments()

return router
111 changes: 101 additions & 10 deletions horizon/tests/test_local_api.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
import asyncio
import random
from contextlib import asynccontextmanager

import aiohttp
import pytest
from aioresponses import aioresponses
from fastapi import FastAPI
from fastapi.testclient import TestClient
from loguru import logger
from opal_client.client import OpalClient
from opal_client.config import opal_client_config
from starlette import status

from horizon.config import sidecar_config
from horizon.enforcer.api import stats_manager
from horizon.enforcer.schemas import *
from horizon.data_manager.client import DataManagerClient
from horizon.pdp import PermitPDP


class MockPermitPDP(PermitPDP):
def __init__(self):
def __init__(self, opal: OpalClient | None = None):
self._setup_temp_logger()

# sidecar_config.OPA_BEARER_TOKEN_REQUIRED = False
# self._configure_inline_opa_config()
self._opal = OpalClient()
self._opal = opal or OpalClient()

sidecar_config.API_KEY = "mock_api_key"
app: FastAPI = self._opal.app
Expand All @@ -32,6 +26,15 @@ def __init__(self):
self._app: FastAPI = app


class MockDataManagerPermitPDP(MockPermitPDP):
def __init__(self):
super().__init__(
opal=DataManagerClient(
shard_id=sidecar_config.SHARD_ID, data_topics=self._fix_data_topics()
)
)


sidecar = MockPermitPDP()


Expand Down Expand Up @@ -64,6 +67,94 @@ async def test_list_role_assignments() -> None:
headers={"authorization": f"Bearer {sidecar_config.API_KEY}"},
)

m.assert_called_once()
assert response.status_code == 200
res_json = response.json()
assert len(res_json) == 1
assert res_json[0] == {
"user": "user1",
"role": "role1",
"tenant": "tenant1",
"resource_instance": "resource_instance1",
}


@pytest.mark.asyncio
async def test_list_role_assignments_wrong_data_manager_config() -> None:
_sidecar = MockDataManagerPermitPDP()
# the ENABLE_EXTERNAL_DATA_MANAGER is set to True after the PDP was created
# this causes the PDP to be without the DataManagerPolicyStoreClient - it is a uniquely rare case
# that will probably never happen as this config is managed either by a remote config or env var
sidecar_config.ENABLE_EXTERNAL_DATA_MANAGER = True
_client = TestClient(_sidecar._app)
with aioresponses() as m:
# 'http://localhost:8181/v1/data/permit/api/role_assignments/list_role_assignments'
opa_url = f"{opal_client_config.POLICY_STORE_URL}/v1/data/permit/api/role_assignments/list_role_assignments"

# Test valid response from OPA
m.post(
opa_url,
status=200,
repeat=True,
payload={
"result": [
{
"user": "user1",
"role": "role1",
"tenant": "tenant1",
"resource_instance": "resource_instance1",
}
]
},
)

response = _client.get(
"/local/role_assignments",
headers={"authorization": f"Bearer {sidecar_config.API_KEY}"},
)

m.assert_called_once()
assert response.status_code == 200
res_json = response.json()
assert len(res_json) == 1
assert res_json[0] == {
"user": "user1",
"role": "role1",
"tenant": "tenant1",
"resource_instance": "resource_instance1",
}


@pytest.mark.asyncio
async def test_list_role_assignments_external_data_store() -> None:
sidecar_config.ENABLE_EXTERNAL_DATA_MANAGER = True
_sidecar = MockDataManagerPermitPDP()
_client = TestClient(_sidecar._app)
with aioresponses() as m:
# The policy store client of the data manager has base url configured, this means that the url
# we need to mock is '/v1/facts/role_assignments' - without the base url server
data_manager_url = f"/v1/facts/role_assignments?page=1"
logger.info("mocking data manager url: {}", data_manager_url)
# Test valid response from OPA
m.get(
data_manager_url,
status=200,
repeat=True,
payload=[
{
"user": "user1",
"role": "role1",
"tenant": "tenant1",
"resource_instance": "resource_instance1",
}
],
)

response = _client.get(
"/local/role_assignments",
headers={"authorization": f"Bearer {sidecar_config.API_KEY}"},
)

assert response.status_code == 200
res_json = response.json()
assert len(res_json) == 1
Expand Down

0 comments on commit 565509a

Please sign in to comment.