Skip to content

Commit 1b7bb30

Browse files
authored
Merge pull request #159 from permitio/raz/per-9141-allow-the-pdp-to-use-org-project-keys-v2
add api_keys and other refactors for startup pkg
2 parents 1788b70 + 43f723b commit 1b7bb30

File tree

12 files changed

+271
-70
lines changed

12 files changed

+271
-70
lines changed

horizon/authentication.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import Header, HTTPException, status
22

33
from horizon.config import MOCK_API_KEY, sidecar_config
4+
from horizon.startup.api_keys import get_env_api_key
45

56

67
def enforce_pdp_token(authorization=Header(None)):
@@ -10,7 +11,7 @@ def enforce_pdp_token(authorization=Header(None)):
1011
)
1112
schema, token = authorization.split(" ")
1213

13-
if schema.strip().lower() != "bearer" or token.strip() != sidecar_config.API_KEY:
14+
if schema.strip().lower() != "bearer" or token.strip() != get_env_api_key():
1415
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid PDP token")
1516

1617

horizon/config.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,21 @@
22

33
MOCK_API_KEY = "MUST BE DEFINED"
44

5+
# scopes enum
6+
class ApiKeyLevel(str):
7+
ORGANIZATION = "organization"
8+
PROJECT = "project"
9+
ENVIRONMENT = "environment"
10+
511

612
class SidecarConfig(Confi):
13+
def __new__(cls, prefix=None, is_model=True):
14+
"""creates a singleton object, if it is not created,
15+
or else returns the previous singleton object"""
16+
if not hasattr(cls, "instance"):
17+
cls.instance = super(SidecarConfig, cls).__new__(cls)
18+
return cls.instance
19+
720
SHARD_ID = confi.str(
821
"SHARD_ID",
922
None,
@@ -48,7 +61,35 @@ class SidecarConfig(Confi):
4861
REMOTE_STATE_ENDPOINT = confi.str("REMOTE_STATE_ENDPOINT", "/v2/pdps/me/state")
4962

5063
# access token to access backend api
51-
API_KEY = confi.str("API_KEY", MOCK_API_KEY)
64+
API_KEY = confi.str(
65+
"API_KEY",
66+
MOCK_API_KEY,
67+
description="set this to your environment's API key if you prefer to use the environment level API key.",
68+
)
69+
70+
# access token to your organization
71+
ORG_API_KEY = confi.str(
72+
"ORG_API_KEY",
73+
None,
74+
description="set this to your organization's API key if you prefer to use the organization level API key. If not set, the PDP will use the project level API key",
75+
)
76+
77+
# access token to your project
78+
PROJECT_API_KEY = confi.str(
79+
"PROJECT_API_KEY",
80+
None,
81+
description="set this to your project's API key if you prefer to use the project level API key. If not set, the PDP will use the default project API key",
82+
)
83+
84+
# chosen project id/key to use for the PDP
85+
ACTIVE_PROJECT = confi.str(
86+
"ACTIVE_PROJECT", None, description="the project id/key to use for the PDP"
87+
)
88+
89+
# chosen environment id/key to use for the PDP
90+
ACTIVE_ENV = confi.str(
91+
"ACTIVE_ENV", None, description="the environment id/key to use for the PDP"
92+
)
5293

5394
# access token to perform system control operations
5495
CONTAINER_CONTROL_KEY = confi.str("CONTAINER_CONTROL_KEY", MOCK_API_KEY)

horizon/enforcer/opa/config_maker.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from opal_common.logger import logger
55

66
from horizon.config import SidecarConfig
7+
from horizon.startup.api_keys import get_env_api_key
78

89

910
def get_jinja_environment() -> jinja2.Environment:
@@ -51,7 +52,7 @@ def get_opa_config_file_path(
5152
template = env.get_template(template_path)
5253
contents = template.render(
5354
cloud_service_url=decision_logs_backend_tier,
54-
bearer_token=sidecar_config.API_KEY,
55+
bearer_token=get_env_api_key(),
5556
log_ingress_endpoint=sidecar_config.OPA_DECISION_LOG_INGRESS_ROUTE,
5657
min_delay_seconds=sidecar_config.OPA_DECISION_LOG_MIN_DELAY,
5758
max_delay_seconds=sidecar_config.OPA_DECISION_LOG_MAX_DELAY,
@@ -84,7 +85,7 @@ def get_opa_authz_policy_file_path(
8485
try:
8586
template = env.get_template(template_path)
8687
contents = template.render(
87-
bearer_token=sidecar_config.API_KEY,
88+
bearer_token=get_env_api_key(),
8889
allow_metrics_unauthenticated=sidecar_config.ALLOW_METRICS_UNAUTHENTICATED,
8990
)
9091
except jinja2.TemplateNotFound:

horizon/facts/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from horizon.config import sidecar_config
1212
from horizon.startup.remote_config import get_remote_config
13+
from horizon.startup.api_keys import get_env_api_key
1314

1415

1516
class FactsClient:
@@ -19,9 +20,10 @@ def __init__(self):
1920
@property
2021
def client(self) -> AsyncClient:
2122
if self._client is None:
23+
env_api_key = get_env_api_key()
2224
self._client = AsyncClient(
2325
base_url=sidecar_config.CONTROL_PLANE,
24-
headers={"Authorization": f"Bearer {sidecar_config.API_KEY}"},
26+
headers={"Authorization": f"Bearer {env_api_key}"},
2527
)
2628
return self._client
2729

horizon/local/api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,4 @@ async def list_role_assignments(
157157
else:
158158
return parse_obj_as(WrappedResponse, result).result
159159

160-
161160
return router

horizon/opal_relay_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pydantic import BaseModel
1515

1616
from horizon.config import sidecar_config
17+
from horizon.startup.api_keys import get_env_api_key
1718
from horizon.state import PersistentStateHandler
1819

1920

@@ -95,8 +96,9 @@ def _apply_context(self, context: dict[str, str]):
9596

9697
def api_session(self) -> ClientSession:
9798
if self._api_session is None:
99+
env_api_key = get_env_api_key()
98100
self._api_session = ClientSession(
99-
headers={"Authorization": f"Bearer {sidecar_config.API_KEY}"}
101+
headers={"Authorization": f"Bearer {env_api_key}"}
100102
)
101103
return self._api_session
102104

horizon/pdp.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import os
23
import sys
34
from typing import List
45
from uuid import uuid4, UUID
@@ -21,7 +22,7 @@
2122

2223
from horizon.facts.router import facts_router
2324
from horizon.authentication import enforce_pdp_token
24-
from horizon.config import MOCK_API_KEY, sidecar_config
25+
from horizon.config import MOCK_API_KEY, sidecar_config, ApiKeyLevel
2526
from horizon.enforcer.api import init_enforcer_api_router, stats_manager
2627
from horizon.enforcer.opa.config_maker import (
2728
get_opa_authz_policy_file_path,
@@ -30,7 +31,9 @@
3031
from horizon.local.api import init_local_cache_api_router
3132
from horizon.opal_relay_api import OpalRelayAPIClient
3233
from horizon.proxy.api import router as proxy_router
33-
from horizon.startup.remote_config import InvalidPDPTokenException, get_remote_config
34+
from horizon.startup.remote_config import get_remote_config
35+
from horizon.startup.exceptions import InvalidPDPTokenException
36+
from horizon.startup.api_keys import get_env_api_key
3437
from horizon.state import PersistentStateHandler
3538
from horizon.system.api import init_system_api_router
3639
from horizon.system.consts import GUNICORN_EXIT_APP
@@ -85,8 +88,7 @@ class PermitPDP:
8588

8689
def __init__(self):
8790
self._setup_temp_logger()
88-
PersistentStateHandler.initialize()
89-
self._verify_config()
91+
PersistentStateHandler.initialize(get_env_api_key())
9092
# fetch and apply config override from cloud control plane
9193
try:
9294
remote_config = get_remote_config()
@@ -247,7 +249,7 @@ def _configure_inline_opa_config(self):
247249

248250
if sidecar_config.OPA_BEARER_TOKEN_REQUIRED:
249251
# overrides OPAL client config so that OPAL passes the bearer token in requests
250-
opal_client_config.POLICY_STORE_AUTH_TOKEN = sidecar_config.API_KEY
252+
opal_client_config.POLICY_STORE_AUTH_TOKEN = get_env_api_key()
251253
opal_client_config.POLICY_STORE_AUTH_TYPE = PolicyStoreAuth.TOKEN
252254

253255
# append the bearer token authz policy to inline OPA config
@@ -390,7 +392,7 @@ def app(self):
390392
return self._app
391393

392394
def _verify_config(self):
393-
if sidecar_config.API_KEY == MOCK_API_KEY:
395+
if get_env_api_key() == MOCK_API_KEY:
394396
logger.critical(
395397
"No API key specified. Please specify one with the PDP_API_KEY environment variable."
396398
)

horizon/startup/api_keys.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import requests
2+
from tenacity import retry, retry_if_not_exception_type, stop, wait
3+
4+
from horizon.config import ApiKeyLevel, sidecar_config, MOCK_API_KEY
5+
from opal_common.logger import logger
6+
7+
from horizon.startup.exceptions import NoRetryException
8+
from horizon.startup.blocking_request import BlockingRequest
9+
from horizon.system.consts import GUNICORN_EXIT_APP
10+
11+
12+
class EnvApiKeyFetcher:
13+
14+
DEFAULT_RETRY_CONFIG = {
15+
"retry": retry_if_not_exception_type(NoRetryException),
16+
"wait": wait.wait_random_exponential(max=10),
17+
"stop": stop.stop_after_attempt(10),
18+
"reraise": True,
19+
}
20+
21+
def __init__(
22+
self,
23+
backend_url: str = sidecar_config.CONTROL_PLANE,
24+
retry_config=None,
25+
):
26+
self._backend_url = backend_url
27+
self._retry_config = retry_config or self.DEFAULT_RETRY_CONFIG
28+
self.api_key_level = self._get_api_key_level()
29+
30+
@staticmethod
31+
def _get_api_key_level() -> ApiKeyLevel:
32+
if sidecar_config.API_KEY != MOCK_API_KEY:
33+
if sidecar_config.ORG_API_KEY or sidecar_config.PROJECT_API_KEY:
34+
logger.warning(
35+
"PDP_API_KEY is set, but PDP_ORG_API_KEY or PDP_PROJECT_API_KEY are also set and will be ignored."
36+
)
37+
return ApiKeyLevel.ENVIRONMENT
38+
39+
if sidecar_config.PROJECT_API_KEY:
40+
if sidecar_config.ORG_API_KEY:
41+
logger.warning(
42+
"PDP_PROJECT_API_KEY is set, but PDP_ORG_API_KEY is also set and will be ignored."
43+
)
44+
if not sidecar_config.ACTIVE_ENV:
45+
logger.error(
46+
"PDP_PROJECT_API_KEY is set, but PDP_ACTIVE_ENV is not. Please set it with Environment ID or Key."
47+
)
48+
raise
49+
return ApiKeyLevel.PROJECT
50+
51+
if sidecar_config.ORG_API_KEY:
52+
if not sidecar_config.ACTIVE_ENV or not sidecar_config.ACTIVE_PROJECT:
53+
logger.error(
54+
"PDP_ORG_API_KEY is set, but PDP_ACTIVE_ENV or PDP_ACTIVE_PROJECT are not. Please set them with Environment ID/Key and Project ID/Key."
55+
)
56+
raise
57+
return ApiKeyLevel.ORGANIZATION
58+
59+
logger.critical(
60+
"No API key specified. Please specify one with the PDP_API_KEY environment variable."
61+
)
62+
raise
63+
64+
def get_env_api_key_by_level(self) -> str:
65+
api_key_level = self.api_key_level
66+
api_key = sidecar_config.ORG_API_KEY
67+
active_project_id = sidecar_config.ACTIVE_PROJECT
68+
active_env_id = sidecar_config.ACTIVE_ENV
69+
70+
if api_key_level == ApiKeyLevel.ENVIRONMENT:
71+
return sidecar_config.API_KEY
72+
if api_key_level == ApiKeyLevel.PROJECT:
73+
api_key = sidecar_config.PROJECT_API_KEY
74+
active_project_id = get_scope(sidecar_config.ORG_API_KEY).get("project_id")
75+
if not active_project_id:
76+
logger.error(
77+
"PDP_PROJECT_API_KEY is set, but failed to get Project ID from provided Organization API Key."
78+
)
79+
raise
80+
return self._fetch_env_key(api_key, active_project_id, active_env_id)
81+
82+
def _fetch_env_key(
83+
self, api_key: str, active_project_key: str, active_env_key: str
84+
) -> str:
85+
"""
86+
fetches the active environment's API Key by identifying with the provided Project/Organization API Key.
87+
"""
88+
api_key_url = (
89+
f"{self._backend_url}/v2/api-key/{active_project_key}/{active_env_key}"
90+
)
91+
logger.info(
92+
"Fetching Environment API Key from control plane: {url}", url=api_key_url
93+
)
94+
fetch_with_retry = retry(**self._retry_config)(
95+
lambda: BlockingRequest(
96+
token=api_key,
97+
).get(url=api_key_url)
98+
)
99+
try:
100+
secret = fetch_with_retry().get("secret")
101+
if secret is None:
102+
logger.error("No secret found in response from control plane")
103+
raise
104+
return secret
105+
106+
except requests.RequestException as e:
107+
logger.warning(f"Failed to get Environment API Key: {e}")
108+
raise
109+
110+
def fetch_scope(self, api_key: str) -> dict | None:
111+
"""
112+
fetches the provided Project/Organization Scope.
113+
"""
114+
api_key_url = f"{self._backend_url}/v2/api-key/scope"
115+
logger.info("Fetching Scope from control plane: {url}", url=api_key_url)
116+
fetch_with_retry = retry(**self._retry_config)(
117+
lambda: BlockingRequest(
118+
token=api_key,
119+
).get(url=api_key_url)
120+
)
121+
try:
122+
return fetch_with_retry()
123+
except requests.RequestException:
124+
logger.warning("Failed to get scope from provided API Key")
125+
return
126+
127+
128+
_env_api_key: str | None = None
129+
130+
131+
def get_env_api_key() -> str:
132+
global _env_api_key
133+
if not _env_api_key:
134+
try:
135+
_env_api_key = EnvApiKeyFetcher().get_env_api_key_by_level()
136+
except Exception as e:
137+
logger.error(f"Failed to get Environment API Key: {e}")
138+
raise SystemExit(GUNICORN_EXIT_APP)
139+
return _env_api_key
140+
141+
142+
def get_scope(api_key: str) -> dict:
143+
if scope := EnvApiKeyFetcher().fetch_scope(api_key) is None:
144+
logger.warning("Failed to get scope from provided API Key")
145+
raise
146+
return scope
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import Optional, Any, Dict
2+
3+
import requests
4+
5+
from horizon.startup.exceptions import InvalidPDPTokenException
6+
7+
8+
class BlockingRequest:
9+
def __init__(
10+
self, token: Optional[str], extra_headers: dict[str, Any] | None = None
11+
):
12+
self._token = token
13+
self._extra_headers = {
14+
k: v for k, v in (extra_headers or {}).items() if v is not None
15+
}
16+
17+
def _headers(self) -> Dict[str, str]:
18+
headers = {}
19+
if self._token is not None:
20+
headers["Authorization"] = f"Bearer {self._token}"
21+
22+
headers.update(self._extra_headers)
23+
return headers
24+
25+
def get(self, url: str, params=None) -> dict:
26+
"""
27+
utility method to send a *blocking* HTTP GET request and get the response back.
28+
"""
29+
response = requests.get(url, headers=self._headers(), params=params)
30+
31+
if response.status_code == 401:
32+
raise InvalidPDPTokenException()
33+
34+
return response.json()
35+
36+
def post(self, url: str, payload: dict = None, params=None) -> dict:
37+
"""
38+
utility method to send a *blocking* HTTP POST request with a JSON payload and get the response back.
39+
"""
40+
response = requests.post(
41+
url, json=payload, headers=self._headers(), params=params
42+
)
43+
44+
if response.status_code == 401:
45+
raise InvalidPDPTokenException()
46+
47+
return response.json()

0 commit comments

Comments
 (0)