Skip to content
This repository has been archived by the owner on Feb 15, 2025. It is now read-only.

Commit

Permalink
feat(API): Add authentication (#533)
Browse files Browse the repository at this point in the history
* Adds migration for RLS for existing tables
* Modified Supabase session with auth credentials
* Updates all endpoints with auth
* Adds auth to relevant API tests
* Refactored base CRUD operations to handle DB connection in constructor
  • Loading branch information
CollectiveUnicorn authored Jun 5, 2024
1 parent 884761b commit a634a59
Show file tree
Hide file tree
Showing 27 changed files with 694 additions and 161 deletions.
10 changes: 10 additions & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ jobs:
docker image prune -af
uds zarf package deploy packages/api/zarf-package-leapfrogai-api-amd64-e2e-test.tar.zst --set=EXPOSE_OPENAPI_SCHEMA=true --confirm
rm packages/api/zarf-package-leapfrogai-api-amd64-e2e-test.tar.zst
- name: Set environment variable
id: set-env-var
run: |
echo "ANON_KEY=$(uds zarf tools kubectl get secret supabase-bootstrap-jwt -n leapfrogai -o jsonpath='{.data.anon-key}' | base64 -d)" >> "$GITHUB_ENV"
- name: Test API
run: |
python -m pip install requests
python -m pytest ./tests/e2e/test_api.py -v
##########
# llama
Expand Down
4 changes: 2 additions & 2 deletions packages/api/chart/templates/api/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ spec:
value: "{{ .Values.api.port }}"
- name: SUPABASE_URL
value: "{{ .Values.supabase.url }}"
- name: SUPABASE_SERVICE_KEY
- name: SUPABASE_ANON_KEY
valueFrom:
secretKeyRef:
name: supabase-bootstrap-jwt
key: service-key
key: anon-key
optional: true
ports:
- containerPort: 8080
Expand Down
2 changes: 1 addition & 1 deletion packages/repeater/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ python repeater.py
Now the basic API tests can be run in full. In a new terminal, starting from the root of the project repository:
```
export LFAI_RUN_REPEATER_TESTS=true # this is needed to run the tests that require the repeater model, otherwise they get skipped
pytest tests/pytest/test_api.py
pytest tests/pytest/test_api_auth.py
```
4 changes: 4 additions & 0 deletions packages/supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,9 @@ Go to `https://supabase-kong.uds.dev`. The login is `supabase-admin` the passwor
* If you cannot reach `https://supabase-kong.uds.dev`, check if the `Packages` CRDs and `VirtualServices` contain `supabase-kong.uds.dev`. If they do not, try restarting the `pepr-uds-core-watcher` pod.
* If logging in to the UI through keycloak returns a `500`, check and see if the `sql` migrations have been run in Supabase.
* You can find those in `leapfrogai/src/leapfrogai_ui/supabase/migrations`. They can be run in the studios SQL Editor.
* To obtain a jwt token for testing, create a test user and run the following:
```
curl -X POST 'https://supabase-kong.uds.dev/auth/v1/token?grant_type=password' \-H "apikey: <anon-key>" \-H "Content-Type: application/json" \-d '{ "email": "<test-email>", "password": "<test-password>"}'
```

By following these steps, you'll have successfully set up Keycloak for your application, allowing secure authentication and authorization for your users.
1 change: 1 addition & 0 deletions src/leapfrogai_api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.jwt
32 changes: 30 additions & 2 deletions src/leapfrogai_api/Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
SHELL := /bin/bash


export SUPABASE_URL=$(shell supabase status | grep -oP '(?<=API URL: ).*')
export SUPABASE_ANON_KEY=$(shell supabase status | grep -oP '(?<=anon key: ).*')

install:
python -m pip install ../../src/leapfrogai_sdk
python -m pip install -e .

dev:
make install
python -m uvicorn main:app --port 3000 --reload
python -m uvicorn main:app --port 3000 --reload --log-level info

test-integration:
cd ../../ && python -m pytest tests/integration/api

define get_jwt_token
echo "Getting JWT token from ${SUPABASE_URL}..."; \
TOKEN_RESPONSE=$$(curl -s -X POST $(1) \
-H "apikey: ${SUPABASE_ANON_KEY}" \
-H "Content-Type: application/json" \
-d '{ "email": "admin@localhost", "password": "$$SUPABASE_PASS"}'); \
echo "Extracting token from $(TOKEN_RESPONSE)"; \
JWT=$$(echo $$TOKEN_RESPONSE | grep -oP '(?<="access_token":")[^"]*'); \
echo -n "$$JWT" | xclip -selection clipboard; \
echo "export SUPABASE_USER_JWT=$$JWT" > .jwt; \
echo "DONE - JWT token copied to clipboard"
endef

supabase-user:
@read -s -p "Enter your Supabase password: " SUPABASE_PASS; echo; \
echo "Creating new supabase user..."; \
$(call get_jwt_token,"${SUPABASE_URL}/auth/v1/signup")

supabase-jwt-token:
@read -s -p "Enter your Supabase password: " SUPABASE_PASS; echo; \
$(call get_jwt_token,"${SUPABASE_URL}/auth/v1/token?grant_type=password")

58 changes: 43 additions & 15 deletions src/leapfrogai_api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,44 @@

A mostly OpenAI compliant API surface.

## Requirements
## Local Development Setup

- Supabase
### Requirements

## Local Development
1. Install dependencies
```bash
make install
```

Create a local Supabase instance (requires [[Supabase CLI](https://supabase.com/docs/guides/cli/getting-started)):
2. Create a local Supabase instance (requires [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started)):
```bash
brew install supabase/tap/supabases
``` bash
supabase start # from /leapfrogai
supabase start # from /leapfrogai
supabase db reset # clears all data and reinitializes migrations
supabase stop --project-id leapfrogai # stop api containers
supabase status # to check status and see your keys
```
supabase db reset # clears all data and reinitializes migrations
Setup environment variables:
supabase status # to check status and see your keys
```

3. Create a local api user
```bash
make supabase-user
```

### Session Authentication

4. Create a JWT token
```bash
make supabase-jwt-token
```
This will copy the JWT token to your clipboard.

``` bash
export SUPABASE_URL="http://localhost:54321" # or whatever you configured it as in your Supabase config.toml
export SUPABASE_SERVICE_KEY="<YOUR_KEY>" # supabase status will show you the keys
```

5. Make calls to the api swagger endpoint at `http://localhost:8080/docs` using your JWT token as the `HTTPBearer` token.
* Hit `Authorize` on the swagger page to enter your JWT token

## Integration Tests

Expand All @@ -34,10 +50,22 @@ The integration tests serve to identify any mismatches between components:
- DB CRUD operations
- Schema mismatches

### Prerequisites

Integration tests require a Supabase instance and environment variables configured (see [Local Development](#local-development)).

From this directory run:
### Authentication
Tests require a JWT token environment variable `SUPABASE_USER_JWT`:

``` bash
make supabase-jwt-token
source .jwt
```
### Running the tests
```
make test-integration
```

## Notes

* All API calls must be authenticated via a Supabase JWT token in the message's `Authorization` header, including swagger docs.
49 changes: 33 additions & 16 deletions src/leapfrogai_api/data/crud_assistant_object.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
"""CRUD Operations for Assistant."""

from pydantic import Field
from openai.types.beta import Assistant
from supabase_py_async import AsyncClient
from leapfrogai_api.data.crud_base import CRUDBase

from leapfrogai_api.data.crud_base import CRUDBase, ModelType

class CRUDAssistant(CRUDBase[Assistant]):

class AuthAssistant(Assistant):
"""A wrapper for the Assistant that includes a user_id for auth"""

user_id: str = Field(default="")


class CRUDAssistant(CRUDBase[AuthAssistant]):
"""CRUD Operations for Assistant"""

def __init__(self, model: type[Assistant], table_name: str = "assistant_objects"):
super().__init__(model=model, table_name=table_name)
def __init__(
self,
db: AsyncClient,
model: type[ModelType] = AuthAssistant,
table_name: str = "assistant_objects",
):
super().__init__(db, model, table_name)

async def create(self, db: AsyncClient, object_: Assistant) -> Assistant | None:
async def create(self, object_: Assistant) -> AuthAssistant | None:
"""Create a new assistant."""
return await super().create(db=db, object_=object_)
user_id: str = (await self.db.auth.get_user()).user.id
return await super().create(
object_=AuthAssistant(user_id=user_id, **object_.model_dump())
)

async def get(self, id_: str, db: AsyncClient) -> Assistant | None:
async def get(self, id_: str) -> AuthAssistant | None:
"""Get an assistant by its ID."""
return await super().get(db=db, id_=id_)
return await super().get(id_=id_)

async def list(self, db: AsyncClient) -> list[Assistant] | None:
async def list(self) -> list[AuthAssistant] | None:
"""List all assistants."""
return await super().list(db=db)
return await super().list()

async def update(
self, id_: str, db: AsyncClient, object_: Assistant
) -> Assistant | None:
async def update(self, id_: str, object_: Assistant) -> AuthAssistant | None:
"""Update an assistant by its ID."""
return await super().update(id_=id_, db=db, object_=object_)
user_id: str = (await self.db.auth.get_user()).user.id
return await super().update(
id_=id_, object_=AuthAssistant(user_id=user_id, **object_.model_dump())
)

async def delete(self, id_: str, db: AsyncClient) -> bool:
async def delete(self, id_: str) -> bool:
"""Delete an assistant by its ID."""
return await super().delete(id_=id_, db=db)
return await super().delete(id_=id_)
27 changes: 14 additions & 13 deletions src/leapfrogai_api/data/crud_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,28 @@
class CRUDBase(Generic[ModelType]):
"""CRUD Operations"""

def __init__(self, model: type[ModelType], table_name: str):
def __init__(self, db: AsyncClient, model: type[ModelType], table_name: str):
self.model = model
self.table_name = table_name
self.db = db

async def create(self, db: AsyncClient, object_: ModelType) -> ModelType | None:
async def create(self, object_: ModelType) -> ModelType | None:
"""Create new row."""
dict_ = object_.model_dump()
del dict_["id"] # Ensure this is created by the database
del dict_["created_at"] # Ensure this is created by the database
data, _count = await db.table(self.table_name).insert(dict_).execute()
data, _count = await self.db.table(self.table_name).insert(dict_).execute()

_, response = data

if response:
return self.model(**response[0])
return None

async def get(self, id_: str, db: AsyncClient) -> ModelType | None:
async def get(self, id_: str) -> ModelType | None:
"""Get row by ID."""
data, _count = (
await db.table(self.table_name).select("*").eq("id", id_).execute()
await self.db.table(self.table_name).select("*").eq("id", id_).execute()
)

_, response = data
Expand All @@ -39,22 +40,20 @@ async def get(self, id_: str, db: AsyncClient) -> ModelType | None:
return self.model(**response[0])
return None

async def list(self, db: AsyncClient) -> list[ModelType] | None:
async def list(self) -> list[ModelType] | None:
"""List all rows."""
data, _count = await db.table(self.table_name).select("*").execute()
data, _count = await self.db.table(self.table_name).select("*").execute()

_, response = data

if response:
return [self.model(**item) for item in response]
return None

async def update(
self, id_: str, db: AsyncClient, object_: ModelType
) -> ModelType | None:
async def update(self, id_: str, object_: ModelType) -> ModelType | None:
"""Update a vector store by its ID."""
data, _count = (
await db.table(self.table_name)
await self.db.table(self.table_name)
.update(object_.model_dump())
.eq("id", id_)
.execute()
Expand All @@ -66,9 +65,11 @@ async def update(
return self.model(**response[0])
return None

async def delete(self, id_: str, db: AsyncClient) -> bool:
async def delete(self, id_: str) -> bool:
"""Delete a vector store by its ID."""
data, _count = await db.table(self.table_name).delete().eq("id", id_).execute()
data, _count = (
await self.db.table(self.table_name).delete().eq("id", id_).execute()
)

_, response = data

Expand Down
17 changes: 9 additions & 8 deletions src/leapfrogai_api/data/crud_file_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,23 @@
class CRUDFileBucket:
"""CRUD Operations for FileBucket."""

def __init__(self, model: type[UploadFile]):
self.model = model
def __init__(self, db: AsyncClient, model: type[UploadFile]):
self.client: AsyncClient = db
self.model: type[UploadFile] = model

async def upload(self, client: AsyncClient, file: UploadFile, id_: str):
async def upload(self, file: UploadFile, id_: str):
"""Upload a file to the file bucket."""

return await client.storage.from_("file_bucket").upload(
return await self.client.storage.from_("file_bucket").upload(
file=file.file.read(), path=f"{id_}"
)

async def download(self, client: AsyncClient, id_: str):
async def download(self, id_: str):
"""Get a file from the file bucket."""

return await client.storage.from_("file_bucket").download(path=f"{id_}")
return await self.client.storage.from_("file_bucket").download(path=f"{id_}")

async def delete(self, client: AsyncClient, id_: str):
async def delete(self, id_: str):
"""Delete a file from the file bucket."""

return await client.storage.from_("file_bucket").remove(paths=f"{id_}")
return await self.client.storage.from_("file_bucket").remove(paths=f"{id_}")
Loading

0 comments on commit a634a59

Please sign in to comment.