Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Insecure Client for Convenience #193

Merged
merged 20 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
- name: "Pytest"
run: |
source ~/.cache/virtualenv/authzedpy/bin/activate
pytest -vv .
pytest -vv

protobuf:
name: "Generate & Diff Protobuf"
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,21 @@ resp = client.CheckPermission(CheckPermissionRequest(
))
assert resp.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION
```

### Insecure Client Usage
When running in a context like `docker compose`, because of Docker's virtual networking,
the gRPC client sees the SpiceDB container as "remote." It has built-in safeguards to prevent
calling a remote client in an insecure manner, such as using client credentials without TLS.

However, this is a pain when setting up a development or testing environment, so we provide
the `InsecureClient` as a convenience:

```py
from authzed.api.v1 import Client
from grpcutil import bearer_token_credentials

client = Client(
"spicedb:50051",
"my super secret token"
)
```
89 changes: 70 additions & 19 deletions authzed/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import asyncio
from typing import Any, Callable

import grpc
import grpc.aio
from grpc_interceptor import ClientCallDetails, ClientInterceptor

from authzed.api.v1.core_pb2 import (
AlgebraicSubjectSet,
Expand Down Expand Up @@ -70,47 +72,95 @@ class Client(SchemaServiceStub, PermissionsServiceStub, ExperimentalServiceStub,
"""

def __init__(self, target, credentials, options=None, compression=None):
channel = self.create_channel(target, credentials, options, compression)
self.init_stubs(channel)

def init_stubs(self, channel):
SchemaServiceStub.__init__(self, channel)
PermissionsServiceStub.__init__(self, channel)
ExperimentalServiceStub.__init__(self, channel)
WatchServiceStub.__init__(self, channel)

def create_channel(self, target, credentials, options=None, compression=None):
try:
asyncio.get_running_loop()
channelfn = grpc.aio.secure_channel
except RuntimeError:
channelfn = grpc.secure_channel

channel = channelfn(target, credentials, options, compression)
SchemaServiceStub.__init__(self, channel)
PermissionsServiceStub.__init__(self, channel)
ExperimentalServiceStub.__init__(self, channel)
WatchServiceStub.__init__(self, channel)
return channelfn(target, credentials, options, compression)


class AsyncClient(
SchemaServiceStub, PermissionsServiceStub, ExperimentalServiceStub, WatchServiceStub
):
class AsyncClient(Client):
"""
v1 Authzed gRPC API client, for use with asyncio.
"""

def __init__(self, target, credentials, options=None, compression=None):
channel = grpc.aio.secure_channel(target, credentials, options, compression)
SchemaServiceStub.__init__(self, channel)
PermissionsServiceStub.__init__(self, channel)
ExperimentalServiceStub.__init__(self, channel)
WatchServiceStub.__init__(self, channel)
self.init_stubs(channel)


class SyncClient(
SchemaServiceStub, PermissionsServiceStub, ExperimentalServiceStub, WatchServiceStub
):
class SyncClient(Client):
"""
v1 Authzed gRPC API client, running synchronously.
"""

def __init__(self, target, credentials, options=None, compression=None):
channel = grpc.secure_channel(target, credentials, options, compression)
SchemaServiceStub.__init__(self, channel)
PermissionsServiceStub.__init__(self, channel)
ExperimentalServiceStub.__init__(self, channel)
WatchServiceStub.__init__(self, channel)
self.init_stubs(channel)


class TokenAuthorization(ClientInterceptor):
def __init__(self, token: str):
self._token = token

def intercept(
self,
method: Callable,
request_or_iterator: Any,
call_details: grpc.ClientCallDetails,
):
metadata: list[tuple[str, str | bytes]] = [("authorization", f"Bearer {self._token}")]
if call_details.metadata is not None:
metadata = [*metadata, *call_details.metadata]

new_details = ClientCallDetails(
call_details.method,
call_details.timeout,
metadata,
call_details.credentials,
call_details.wait_for_ready,
call_details.compression,
)

return method(request_or_iterator, new_details)


class InsecureClient(Client):
"""
An insecure client variant for non-TLS contexts.

The default behavior of the python gRPC client is to restrict non-TLS
calls to `localhost` only, which is frustrating in contexts like docker-compose,
so we provide this as a convenience.
"""

def __init__(
self,
target: str,
token: str,
options=None,
compression=None,
):
fake_credentials = grpc.local_channel_credentials()
channel = self.create_channel(target, fake_credentials, options, compression)
auth_interceptor = TokenAuthorization(token)

insecure_channel = grpc.insecure_channel(target, options, compression)
channel = grpc.intercept_channel(insecure_channel, auth_interceptor)

self.init_stubs(channel)


__all__ = [
Expand Down Expand Up @@ -140,6 +190,7 @@ def __init__(self, target, credentials, options=None, compression=None):
"DeleteRelationshipsResponse",
"ExpandPermissionTreeRequest",
"ExpandPermissionTreeResponse",
"InsecureClient",
"LookupResourcesRequest",
"LookupResourcesResponse",
"LookupSubjectsRequest",
Expand Down
19 changes: 18 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ grpcio = "^1.63"
protobuf = ">=5.26,<6"
python = "^3.8"
typing-extensions = ">=3.7.4,<5"
grpc-interceptor = "^0.15.4"

[tool.poetry.group.dev.dependencies]
black = ">=23.3,<25.0"
Expand Down
Empty file added tests/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions tests/calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from authzed.api.v1 import WriteSchemaRequest

from .utils import maybe_await


async def write_test_schema(client):
schema = """
caveat likes_harry_potter(likes bool) {
likes == true
}

definition post {
relation writer: user
relation reader: user
relation caveated_reader: user with likes_harry_potter

permission write = writer
permission view = reader + writer
permission view_as_fan = caveated_reader + writer
}
definition user {}
"""
await maybe_await(client.WriteSchema(WriteSchemaRequest(schema=schema)))
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import uuid

import pytest


@pytest.fixture(scope="function")
def token():
return str(uuid.uuid4())
40 changes: 40 additions & 0 deletions tests/insecure_client_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import grpc
import pytest

from authzed.api.v1 import AsyncClient, InsecureClient, SyncClient
from grpcutil import insecure_bearer_token_credentials

from .calls import write_test_schema

## NOTE: these tests aren't usually run. They theoretically could and theoretically
# should be run in CI, but getting an appropriate "remote" container is difficult with
# github actions; this will happen at some point in the future.
# To run them: `poetry run pytest -m ""`
vroldanbet marked this conversation as resolved.
Show resolved Hide resolved

# NOTE: this is the name of the "remote" binding of the service container
# in CI. These tests are only run in CI because otherwise setup is fiddly.
# If you want to see these tests run locally, figure out your computer's
# network-local IP address (typically 192.168.x.x) and make that the `remote_host`
# string below, and then start up a testing container bound to that interface:
# docker run --rm -p 192.168.x.x:50051:50051 authzed/spicedb serve-testing
vroldanbet marked this conversation as resolved.
Show resolved Hide resolved
remote_host = "192.168.something.something"


@pytest.mark.skip(reason="Makes a remote call that we haven't yet supported in CI")
async def test_normal_async_client_raises_error_on_insecure_remote_call(token):
with pytest.raises(grpc.RpcError):
client = AsyncClient(f"{remote_host}:50051", insecure_bearer_token_credentials(token))
await write_test_schema(client)


@pytest.mark.skip(reason="Makes a remote call that we haven't yet supported in CI")
async def test_normal_sync_client_raises_error_on_insecure_remote_call(token):
with pytest.raises(grpc.RpcError):
client = SyncClient(f"{remote_host}:50051", insecure_bearer_token_credentials(token))
await write_test_schema(client)


@pytest.mark.skip(reason="Makes a remote call that we haven't yet supported in CI")
async def test_insecure_client_makes_insecure_remote_call(token):
insecure_client = InsecureClient(f"{remote_host}:50051", token)
await write_test_schema(insecure_client)
21 changes: 21 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from inspect import isawaitable
from typing import AsyncIterable, Iterable, List, TypeVar, Union

T = TypeVar("T")


async def maybe_async_iterable_to_list(iterable: Union[Iterable[T], AsyncIterable[T]]) -> List[T]:
items = []
if isinstance(iterable, AsyncIterable):
async for item in iterable:
items.append(item)
else:
for item in iterable:
items.append(item)
return items


async def maybe_await(resp: T) -> T:
if isawaitable(resp):
resp = await resp
return resp
49 changes: 3 additions & 46 deletions tests/v1_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import uuid
from inspect import isawaitable
from typing import Any, AsyncIterable, Iterable, List, Literal, TypeVar, Union
from typing import Any, List, Literal

import pytest
from google.protobuf.struct_pb2 import Struct
Expand Down Expand Up @@ -30,10 +29,8 @@
)
from grpcutil import insecure_bearer_token_credentials


@pytest.fixture()
def token():
return str(uuid.uuid4())
from .calls import write_test_schema
from .utils import maybe_async_iterable_to_list, maybe_await


@pytest.fixture()
Expand Down Expand Up @@ -389,43 +386,3 @@ async def write_test_tuples(client):
)
)
return beatrice, emilia, post_one, post_two


async def write_test_schema(client):
schema = """
caveat likes_harry_potter(likes bool) {
likes == true
}

definition post {
relation writer: user
relation reader: user
relation caveated_reader: user with likes_harry_potter

permission write = writer
permission view = reader + writer
permission view_as_fan = caveated_reader + writer
}
definition user {}
"""
await maybe_await(client.WriteSchema(WriteSchemaRequest(schema=schema)))


T = TypeVar("T")


async def maybe_await(resp: T) -> T:
if isawaitable(resp):
resp = await resp
return resp


async def maybe_async_iterable_to_list(iterable: Union[Iterable[T], AsyncIterable[T]]) -> List[T]:
items = []
if isinstance(iterable, AsyncIterable):
async for item in iterable:
items.append(item)
else:
for item in iterable:
items.append(item)
return items
Loading