Skip to content

Commit 292509e

Browse files
authored
Merge pull request #40 from permitio/asaf/per-5996-python-fix-sync-sdk
Fix Sync SDK variant
2 parents e8023fe + 0d9c7c9 commit 292509e

23 files changed

Lines changed: 777 additions & 271 deletions

permit/api/base.py

Lines changed: 90 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
11
import functools
2-
from typing import Optional, Type, TypeVar, Union
2+
from typing import Callable, Optional, Type, TypeVar, Union
33

44
import aiohttp
55
from loguru import logger
66
from pydantic import BaseModel, Extra, Field, parse_obj_as
77

88
from ..config import PermitConfig
99
from ..exceptions import PermitContextError, handle_api_error, handle_client_error
10-
from .context import API_ACCESS_LEVELS, ApiKeyLevel
10+
from .context import API_ACCESS_LEVELS, ApiContextLevel, ApiKeyAccessLevel
1111
from .models import APIKeyScopeRead
1212

13+
T = TypeVar("T", bound=Callable)
14+
TModel = TypeVar("TModel", bound=BaseModel)
15+
TData = TypeVar("TData", bound=BaseModel)
1316

14-
class ClientConfig(BaseModel):
15-
class Config:
16-
extra = Extra.allow
1717

18-
base_url: str = Field(
19-
...,
20-
description="base url that will prefix the url fragment sent via the client",
21-
)
22-
headers: dict = Field(..., description="http headers sent to the API server")
18+
def required_permissions(access_level: ApiKeyAccessLevel):
19+
def decorator(func: T) -> T:
20+
@functools.wraps(func)
21+
async def wrapped(self: BasePermitApi, *args, **kwargs):
22+
await self._ensure_access_level(access_level)
23+
return await func(self, *args, **kwargs)
24+
25+
return wrapped
26+
27+
return decorator
2328

2429

25-
def ensure_context(call_level: ApiKeyLevel):
30+
def required_context(context: ApiContextLevel):
2631
"""
2732
a decorator that ensures that an API endpoint is called only after the SDK has initialized
2833
an API context (authorization level) by inferring it from the API key or manually by the user.
@@ -34,10 +39,10 @@ def ensure_context(call_level: ApiKeyLevel):
3439
PermitContextError: If the API context does not match the required endpoint context.
3540
"""
3641

37-
def decorator(func):
42+
def decorator(func: T) -> T:
3843
@functools.wraps(func)
3944
async def wrapped(self: BasePermitApi, *args, **kwargs):
40-
await self.ensure_context(call_level)
45+
await self._ensure_context(context)
4146
return await func(self, *args, **kwargs)
4247

4348
return wrapped
@@ -49,8 +54,15 @@ def pagination_params(page: int, per_page: int) -> dict:
4954
return {"page": page, "per_page": per_page}
5055

5156

52-
TModel = TypeVar("TModel", bound=BaseModel)
53-
TData = TypeVar("TData", bound=BaseModel)
57+
class ClientConfig(BaseModel):
58+
class Config:
59+
extra = Extra.allow
60+
61+
base_url: str = Field(
62+
...,
63+
description="base url that will prefix the url fragment sent via the client",
64+
)
65+
headers: dict = Field(..., description="http headers sent to the API server")
5466

5567

5668
class SimpleHttpClient:
@@ -206,76 +218,110 @@ def _build_http_client(self, endpoint_url: str = "", **kwargs):
206218

207219
async def _set_context_from_api_key(self) -> None:
208220
"""
209-
Set the API context based on the API key scope.
221+
Set the API context and permitted access level based on the API key scope.
210222
"""
223+
logger.debug("Fetching api key scope")
211224
scope = await self.__api_keys.get("/scope", model=APIKeyScopeRead)
212225

213226
if scope.organization_id is not None:
227+
# saves the permitted access level by that api key
228+
self.config.api_context._save_api_key_accessible_scope(
229+
org=str(scope.organization_id),
230+
project=(
231+
str(scope.project_id) if scope.project_id is not None else None
232+
),
233+
environment=(
234+
str(scope.environment_id)
235+
if scope.environment_id is not None
236+
else None
237+
),
238+
)
239+
214240
if scope.project_id is not None:
215241
if scope.environment_id is not None:
216242
# Set environment level context
217243
self.config.api_context.set_environment_level_context(
218-
scope.organization_id, scope.project_id, scope.environment_id
244+
str(scope.organization_id),
245+
str(scope.project_id),
246+
str(scope.environment_id),
219247
)
220248
return
221249

222250
# Set project level context
223251
self.config.api_context.set_project_level_context(
224-
scope.organization_id, scope.project_id
252+
str(scope.organization_id), str(scope.project_id)
225253
)
226254
return
227255

228256
# Set org level context
229257
self.config.api_context.set_organization_level_context(
230-
scope.organization_id
258+
str(scope.organization_id)
231259
)
232260
return
233261

234262
raise PermitContextError("Could not set API context level")
235263

236-
async def ensure_context(self, call_level: ApiKeyLevel) -> None:
264+
async def _ensure_access_level(
265+
self, required_access_level: ApiKeyAccessLevel
266+
) -> None:
237267
"""
238-
Ensure that the API context matches the required endpoint context.
268+
Ensure that the API Key has the necessary permissions to successfully call the API endpoint.
269+
270+
Note that this check is not full proof, and the API may still throw 401.
239271
240272
Args:
241-
call_level: The required API key level for the endpoint.
273+
required_access_level: The required API Key Access level for the endpoint.
242274
243275
Raises:
244-
PermitContextError: If the API context does not match the required endpoint context.
276+
PermitContextError: If the currently set API key access level does not match the required access level.
245277
"""
246-
if self.config.api_context.level == ApiKeyLevel.WAIT_FOR_INIT:
278+
# should only happen once in the lifetime of the sdk
279+
if (
280+
self.config.api_context.level == ApiContextLevel.WAIT_FOR_INIT
281+
or self.config.api_context.permitted_access_level
282+
== ApiKeyAccessLevel.WAIT_FOR_INIT
283+
):
247284
await self._set_context_from_api_key()
248285

249-
if call_level != self.config.api_context.level:
250-
if API_ACCESS_LEVELS.index(call_level) < API_ACCESS_LEVELS.index(
251-
self.config.api_context.level
286+
if required_access_level != self.config.api_context.permitted_access_level:
287+
if API_ACCESS_LEVELS.index(required_access_level) < API_ACCESS_LEVELS.index(
288+
self.config.api_context.permitted_access_level
252289
):
253290
raise PermitContextError(
254-
f"You're trying to use an SDK method that requires an API Key with level: {call_level}, "
255-
+ f"however the SDK is running with an API key with level {self.config.api_context.level}."
291+
f"You're trying to use an SDK method that requires an API Key with access level: {required_access_level}, "
292+
+ f"however the SDK is running with an API key with level {self.config.api_context.permitted_access_level}."
256293
)
257294
return
258295

259296
if (
260-
call_level == ApiKeyLevel.PROJECT_LEVEL_API_KEY
261-
and self.config.api_context.project is None
297+
self.config.api_context.permitted_access_level.value
298+
< required_access_level.value
262299
):
263300
raise PermitContextError(
264-
"You're trying to use an SDK method that's specific to a project, "
265-
+ "but you haven't set the current project in your client's context yet, "
266-
+ "or you are using an organization level API key. "
267-
+ "Please set the context to a specific "
268-
+ "project using `permit.set_context()` method."
301+
f"You're trying to use an SDK method that requires an api context of {required_context.name}, "
302+
+ f"however the SDK is running in a less specific context level: {self.config.api_context.level}."
269303
)
270304

271-
if call_level == ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY and (
272-
self.config.api_context.project is None
273-
or self.config.api_context.environment is None
305+
async def _ensure_context(self, required_context: ApiContextLevel) -> None:
306+
"""
307+
Ensure that the API context matches the required endpoint context.
308+
309+
Args:
310+
context: The required API context level for the endpoint.
311+
312+
Raises:
313+
PermitContextError: If the currently set API context level does not match the required context level.
314+
"""
315+
# should only happen once in the lifetime of the sdk
316+
if (
317+
self.config.api_context.level == ApiContextLevel.WAIT_FOR_INIT
318+
or self.config.api_context.permitted_access_level
319+
== ApiKeyAccessLevel.WAIT_FOR_INIT
274320
):
321+
await self._set_context_from_api_key()
322+
323+
if self.config.api_context.level.value < required_context.value:
275324
raise PermitContextError(
276-
"You're trying to use an SDK method that's specific to an environment, "
277-
+ "but you haven't set the current environment in your client's context yet, "
278-
+ "or you are using an organization/project level API key. "
279-
+ "Please set the context to a specific "
280-
+ "environment using `permit.set_context()` method."
325+
f"You're trying to use an SDK method that requires an api context of {required_context.name}, "
326+
+ f"however the SDK is running in a less specific context level: {self.config.api_context.level}."
281327
)

permit/api/condition_set_rules.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
from pydantic import validate_arguments
44

5-
from .base import BasePermitApi, SimpleHttpClient, ensure_context, pagination_params
6-
from .context import ApiKeyLevel
5+
from .base import (
6+
BasePermitApi,
7+
SimpleHttpClient,
8+
pagination_params,
9+
required_context,
10+
required_permissions,
11+
)
12+
from .context import ApiContextLevel, ApiKeyAccessLevel
713
from .models import ConditionSetRuleCreate, ConditionSetRuleRead, ConditionSetRuleRemove
814

915

@@ -17,7 +23,8 @@ def __condition_set_rules(self) -> SimpleHttpClient:
1723
)
1824
)
1925

20-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
26+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
27+
@required_context(ApiContextLevel.ENVIRONMENT)
2128
@validate_arguments
2229
async def list(
2330
self,
@@ -58,7 +65,8 @@ async def list(
5865
params=params,
5966
)
6067

61-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
68+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
69+
@required_context(ApiContextLevel.ENVIRONMENT)
6270
@validate_arguments
6371
async def create(self, rule: ConditionSetRuleCreate) -> List[ConditionSetRuleRead]:
6472
"""
@@ -78,7 +86,8 @@ async def create(self, rule: ConditionSetRuleCreate) -> List[ConditionSetRuleRea
7886
"", model=List[ConditionSetRuleRead], json=rule
7987
)
8088

81-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
89+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
90+
@required_context(ApiContextLevel.ENVIRONMENT)
8291
@validate_arguments
8392
async def delete(self, rule: ConditionSetRuleRemove) -> None:
8493
"""

permit/api/condition_sets.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
from pydantic import validate_arguments
44

5-
from .base import BasePermitApi, SimpleHttpClient, ensure_context, pagination_params
6-
from .context import ApiKeyLevel
5+
from .base import (
6+
BasePermitApi,
7+
SimpleHttpClient,
8+
pagination_params,
9+
required_context,
10+
required_permissions,
11+
)
12+
from .context import ApiContextLevel, ApiKeyAccessLevel
713
from .models import ConditionSetCreate, ConditionSetRead, ConditionSetUpdate
814

915

@@ -17,7 +23,8 @@ def __condition_sets(self) -> SimpleHttpClient:
1723
)
1824
)
1925

20-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
26+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
27+
@required_context(ApiContextLevel.ENVIRONMENT)
2128
@validate_arguments
2229
async def list(self, page: int = 1, per_page: int = 100) -> List[ConditionSetRead]:
2330
"""
@@ -38,7 +45,13 @@ async def list(self, page: int = 1, per_page: int = 100) -> List[ConditionSetRea
3845
"", model=List[ConditionSetRead], params=pagination_params(page, per_page)
3946
)
4047

41-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
48+
async def _get(self, condition_set_key: str) -> ConditionSetRead:
49+
return await self.__condition_sets.get(
50+
f"/{condition_set_key}", model=ConditionSetRead
51+
)
52+
53+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
54+
@required_context(ApiContextLevel.ENVIRONMENT)
4255
@validate_arguments
4356
async def get(self, condition_set_key: str) -> ConditionSetRead:
4457
"""
@@ -54,11 +67,10 @@ async def get(self, condition_set_key: str) -> ConditionSetRead:
5467
PermitApiError: If the API returns an error HTTP status code.
5568
PermitContextError: If the configured ApiContext does not match the required endpoint context.
5669
"""
57-
return await self.__condition_sets.get(
58-
f"/{condition_set_key}", model=ConditionSetRead
59-
)
70+
return await self._get(condition_set_key)
6071

61-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
72+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
73+
@required_context(ApiContextLevel.ENVIRONMENT)
6274
@validate_arguments
6375
async def get_by_key(self, condition_set_key: str) -> ConditionSetRead:
6476
"""
@@ -75,9 +87,10 @@ async def get_by_key(self, condition_set_key: str) -> ConditionSetRead:
7587
PermitApiError: If the API returns an error HTTP status code.
7688
PermitContextError: If the configured ApiContext does not match the required endpoint context.
7789
"""
78-
return await self.get(condition_set_key)
90+
return await self._get(condition_set_key)
7991

80-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
92+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
93+
@required_context(ApiContextLevel.ENVIRONMENT)
8194
@validate_arguments
8295
async def get_by_id(self, condition_set_id: str) -> ConditionSetRead:
8396
"""
@@ -94,9 +107,10 @@ async def get_by_id(self, condition_set_id: str) -> ConditionSetRead:
94107
PermitApiError: If the API returns an error HTTP status code.
95108
PermitContextError: If the configured ApiContext does not match the required endpoint context.
96109
"""
97-
return await self.get(condition_set_id)
110+
return await self._get(condition_set_id)
98111

99-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
112+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
113+
@required_context(ApiContextLevel.ENVIRONMENT)
100114
@validate_arguments
101115
async def create(self, condition_set_data: ConditionSetCreate) -> ConditionSetRead:
102116
"""
@@ -116,7 +130,8 @@ async def create(self, condition_set_data: ConditionSetCreate) -> ConditionSetRe
116130
"", model=ConditionSetRead, json=condition_set_data
117131
)
118132

119-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
133+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
134+
@required_context(ApiContextLevel.ENVIRONMENT)
120135
@validate_arguments
121136
async def update(
122137
self, condition_set_key: str, condition_set_data: ConditionSetUpdate
@@ -141,7 +156,8 @@ async def update(
141156
json=condition_set_data,
142157
)
143158

144-
@ensure_context(ApiKeyLevel.ENVIRONMENT_LEVEL_API_KEY)
159+
@required_permissions(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
160+
@required_context(ApiContextLevel.ENVIRONMENT)
145161
@validate_arguments
146162
async def delete(self, condition_set_key: str) -> None:
147163
"""

0 commit comments

Comments
 (0)