Skip to content
This repository has been archived by the owner on Oct 11, 2024. It is now read-only.

Commit

Permalink
feat(auth): add Supabase auth utils & clean JWT (#154)
Browse files Browse the repository at this point in the history
* docs(docker): add compose name

* feat(auth): add supabase as auth provider

* refactor(config): renamed SECRET_KEY to JWT_SECRET

* docs(env.example): update env example

* feat(docker): add auth service to docker orchestration

* style(ruff): update config

* refactor(auth): update token security

* refactor(auth): update supabase

* refactor(docker): remove auth from docker orchestration

* fix(docker): update healthcheck

* fix(schemas): update token schema

* refactor(dependencies): remove legacy token resolver

* test(dependencies): update tests

* revert(dependencies): update legacy token resolution

* refactor(jwt): update jwt utils

* test(pytest): update settings attribute
  • Loading branch information
frgfm authored Apr 25, 2024
1 parent bd07f84 commit a707f06
Show file tree
Hide file tree
Showing 21 changed files with 398 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ OLLAMA_MODEL='dolphin-mistral:7b-v2.6-dpo-laser-q4_K_M'
# OLLAMA_MODEL='tinydolphin:1.1b-v2.8-q4_K_M'
OLLAMA_TIMEOUT=120
LLM_TEMPERATURE=0
SECRET_KEY=
JWT_SECRET=
SENTRY_DSN=
SERVER_NAME=
POSTHOG_HOST='https://eu.posthog.com'
Expand Down
105 changes: 105 additions & 0 deletions auth/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: quack
version: '3.8'

services:
auth:
image: supabase/gotrue:v2.143.0
depends_on:
auth_db:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health"
]
timeout: 5s
interval: 5s
retries: 3
restart: unless-stopped
ports:
- 9999:9999
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}

GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${AUTH_PG_PW}@auth_db:${AUTH_PG_PORT}/${AUTH_PG_DB}

GOTRUE_SITE_URL: ${GOTRUE_SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: false

GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}

GOTRUE_EXTERNAL_EMAIL_ENABLED: true
GOTRUE_MAILER_AUTOCONFIRM: true
# GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true
# GOTRUE_SMTP_MAX_FREQUENCY: 1s
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}

GOTRUE_EXTERNAL_PHONE_ENABLED: false
GOTRUE_SMS_AUTOCONFIRM: true

auth_db:
image: supabase/postgres:15.1.0.147
healthcheck:
test: pg_isready -U postgres -h localhost
interval: 5s
timeout: 5s
retries: 10
command:
- postgres
- -c
- config_file=/etc/postgresql/postgresql.conf
- -c
- log_min_messages=fatal # prevents Realtime polling queries from appearing in logs
restart: unless-stopped
ports:
# Pass down internal port because it's set dynamically by other services
- ${AUTH_PG_PORT}:${AUTH_PG_PORT}
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: ${AUTH_PG_PORT}
POSTGRES_PORT: ${AUTH_PG_PORT}
PGPASSWORD: ${AUTH_PG_PW}
POSTGRES_PASSWORD: ${AUTH_PG_PW}
PGDATABASE: ${AUTH_PG_DB}
POSTGRES_DB: ${AUTH_PG_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
volumes:
- ./auth/volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
# Must be superuser to create event trigger
- ./auth/volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
# Must be superuser to alter reserved role
- ./auth/volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
# Initialize the database settings with JWT_SECRET and JWT_EXP
- ./auth/volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
# PGDATA directory is persisted between restarts
- ./auth/volumes/db/data:/var/lib/postgresql/data:Z
# Changes required for Analytics support
- ./auth/volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
# Use named volume to persist pgsodium decryption key between restarts
- db-config:/etc/postgresql-custom

volumes:
db-config:
1 change: 1 addition & 0 deletions demo/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ COPY demo/requirements.txt /app/requirements.txt

# install dependencies
RUN set -eux \
&& apk add --no-cache curl \
&& pip install --no-cache-dir uv \
&& uv pip install --no-cache --system -r /app/requirements.txt \
&& rm -rf /root/.cache
Expand Down
6 changes: 2 additions & 4 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name: quack
version: '3.8'

services:
Expand Down Expand Up @@ -55,10 +56,7 @@ services:
- ./src/:/app/
command: "sh -c 'python app/db.py && uvicorn app.main:app --reload --host 0.0.0.0 --port 5050 --proxy-headers'"
healthcheck:
test: ["CMD-SHELL", "nc -vz localhost 5050"]
test: ["CMD-SHELL", "curl http://localhost:5050/status"]
interval: 10s
timeout: 3s
retries: 3

volumes:
ollama:
1 change: 1 addition & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name: quack
version: '3.8'

services:
Expand Down
8 changes: 3 additions & 5 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name: quack
version: '3.8'

services:
Expand Down Expand Up @@ -91,7 +92,7 @@ services:
- "traefik.http.services.backend.loadbalancer.server.port=5050"
command: "sh -c 'alembic upgrade head && python app/db.py && uvicorn app.main:app --reload --host 0.0.0.0 --port 5050 --proxy-headers'"
healthcheck:
test: ["CMD-SHELL", "nc -vz localhost 5050"]
test: ["CMD-SHELL", "curl http://localhost:5050/status"]
interval: 10s
timeout: 3s
retries: 3
Expand All @@ -117,7 +118,7 @@ services:
- "traefik.http.services.grafana.loadbalancer.server.port=7860"
command: python main.py --server-name 0.0.0.0 --auth
healthcheck:
test: ["CMD-SHELL", "nc -vz localhost 7860"]
test: ["CMD-SHELL", "curl http://localhost:7860"]
interval: 10s
timeout: 3s
retries: 3
Expand Down Expand Up @@ -159,6 +160,3 @@ services:
interval: 10s
timeout: 3s
retries: 3

volumes:
ollama:
8 changes: 4 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name: quack
version: '3.8'

services:
Expand Down Expand Up @@ -49,7 +50,7 @@ services:
- POSTGRES_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}
- SUPERADMIN_LOGIN=${SUPERADMIN_LOGIN}
- SUPERADMIN_PWD=${SUPERADMIN_PWD}
- SECRET_KEY=${SECRET_KEY}
- JWT_SECRET=${JWT_SECRET}
- OLLAMA_ENDPOINT=http://ollama:11434
- OLLAMA_MODEL=${OLLAMA_MODEL}
- OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-60}
Expand All @@ -60,7 +61,7 @@ services:
- ./src/:/app/
command: "sh -c 'alembic upgrade head && python app/db.py && uvicorn app.main:app --reload --host 0.0.0.0 --port 5050 --proxy-headers'"
healthcheck:
test: ["CMD-SHELL", "nc -vz localhost 5050"]
test: ["CMD-SHELL", "curl http://localhost:5050/status"]
interval: 10s
timeout: 3s
retries: 3
Expand All @@ -83,7 +84,7 @@ services:
- ./demo/:/app/
command: python main.py --server-name 0.0.0.0
healthcheck:
test: ["CMD-SHELL", "nc -vz localhost 7860"]
test: ["CMD-SHELL", "curl http://localhost:7860"]
interval: 10s
timeout: 3s
retries: 3
Expand Down Expand Up @@ -126,4 +127,3 @@ services:

volumes:
postgres_data:
ollama:
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ known-third-party = ["fastapi"]
"**/__init__.py" = ["I001", "F401", "CPY001"]
"scripts/**.py" = ["D", "T201", "S101", "ANN"]
".github/**.py" = ["D", "T201", "ANN"]
"client/docs/**.py" = ["E402"]
"src/tests/**.py" = ["D103", "CPY001", "S101", "T201", "ANN001", "ANN201", "ARG001"]
"src/migrations/versions/**.py" = ["CPY001"]
"src/migrations/**.py" = ["ANN"]
Expand Down
1 change: 1 addition & 0 deletions src/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ COPY requirements.txt /app/requirements.txt

# install dependencies
RUN set -eux \
&& apk add --no-cache curl \
&& pip install --no-cache-dir uv \
&& uv pip install --no-cache --system -r /app/requirements.txt \
&& rm -rf /root/.cache
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/api_v1/endpoints/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, Security, status
from fastapi.responses import StreamingResponse

from app.api.dependencies import get_guideline_crud, get_token_payload
from app.api.dependencies import get_guideline_crud, get_quack_jwt
from app.core.config import settings
from app.crud.crud_guideline import GuidelineCRUD
from app.models import UserScope
Expand All @@ -23,11 +23,11 @@
async def chat(
payload: ChatHistory,
guidelines: GuidelineCRUD = Depends(get_guideline_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN, UserScope.USER]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN, UserScope.USER]),
) -> StreamingResponse:
telemetry_client.capture(token_payload.user_id, event="compute-chat")
telemetry_client.capture(token_payload.sub, event="compute-chat")
# Retrieve the guidelines of this user
user_guidelines = [g.content for g in await guidelines.fetch_all(filter_pair=("creator_id", token_payload.user_id))]
user_guidelines = [g.content for g in await guidelines.fetch_all(filter_pair=("creator_id", token_payload.sub))]
# Run analysis
return StreamingResponse(
ollama_client.chat(
Expand Down
32 changes: 15 additions & 17 deletions src/app/api/api_v1/endpoints/guidelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from fastapi import APIRouter, Depends, HTTPException, Path, Security, status

from app.api.dependencies import get_guideline_crud, get_token_payload
from app.api.dependencies import get_guideline_crud, get_quack_jwt
from app.crud import GuidelineCRUD
from app.models import Guideline, UserScope
from app.schemas.guidelines import (
Expand All @@ -24,29 +24,29 @@
async def create_guideline(
payload: GuidelineContent,
guidelines: GuidelineCRUD = Depends(get_guideline_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN, UserScope.USER]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN, UserScope.USER]),
) -> Guideline:
telemetry_client.capture(token_payload.user_id, event="guideline-creation")
return await guidelines.create(Guideline(creator_id=token_payload.user_id, **payload.model_dump()))
telemetry_client.capture(token_payload.sub, event="guideline-creation")
return await guidelines.create(Guideline(creator_id=token_payload.sub, **payload.model_dump()))


@router.get("/{guideline_id}", status_code=status.HTTP_200_OK, summary="Read a specific guideline")
async def get_guideline(
guideline_id: int = Path(..., gt=0),
guidelines: GuidelineCRUD = Depends(get_guideline_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN, UserScope.USER]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN, UserScope.USER]),
) -> Guideline:
telemetry_client.capture(token_payload.user_id, event="guideline-get", properties={"guideline_id": guideline_id})
telemetry_client.capture(token_payload.sub, event="guideline-get", properties={"guideline_id": guideline_id})
return cast(Guideline, await guidelines.get(guideline_id, strict=True))


@router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all the guidelines")
async def fetch_guidelines(
guidelines: GuidelineCRUD = Depends(get_guideline_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.USER, UserScope.ADMIN]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.USER, UserScope.ADMIN]),
) -> List[Guideline]:
telemetry_client.capture(token_payload.user_id, event="guideline-fetch")
filter_pair = ("creator_id", token_payload.user_id) if UserScope.ADMIN not in token_payload.scopes else None
telemetry_client.capture(token_payload.sub, event="guideline-fetch")
filter_pair = ("creator_id", token_payload.sub) if UserScope.ADMIN not in token_payload.scopes else None
return [elt for elt in await guidelines.fetch_all(filter_pair=filter_pair)]


Expand All @@ -55,13 +55,13 @@ async def update_guideline_content(
payload: GuidelineContent,
guideline_id: int = Path(..., gt=0),
guidelines: GuidelineCRUD = Depends(get_guideline_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN, UserScope.USER]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN, UserScope.USER]),
) -> Guideline:
telemetry_client.capture(
token_payload.user_id, event="guideline-update-content", properties={"guideline_id": guideline_id}
token_payload.sub, event="guideline-update-content", properties={"guideline_id": guideline_id}
)
guideline = cast(Guideline, await guidelines.get(guideline_id, strict=True))
if UserScope.ADMIN not in token_payload.scopes and token_payload.user_id != guideline.creator_id:
if UserScope.ADMIN not in token_payload.scopes and token_payload.sub != guideline.creator_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions.")
return await guidelines.update(guideline_id, ContentUpdate(**payload.model_dump()))

Expand All @@ -70,13 +70,11 @@ async def update_guideline_content(
async def delete_guideline(
guideline_id: int = Path(..., gt=0),
guidelines: GuidelineCRUD = Depends(get_guideline_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN, UserScope.USER]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN, UserScope.USER]),
) -> None:
telemetry_client.capture(
token_payload.user_id, event="guideline-deletion", properties={"guideline_id": guideline_id}
)
telemetry_client.capture(token_payload.sub, event="guideline-deletion", properties={"guideline_id": guideline_id})
guideline = cast(Guideline, await guidelines.get(guideline_id, strict=True))
if UserScope.ADMIN not in token_payload.scopes and token_payload.user_id != guideline.creator_id:
if UserScope.ADMIN not in token_payload.scopes and token_payload.sub != guideline.creator_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions.")
await guidelines.delete(guideline_id)

Expand Down
4 changes: 2 additions & 2 deletions src/app/api/api_v1/endpoints/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import HttpUrl

from app.api.api_v1.endpoints.users import _create_user
from app.api.dependencies import get_token_payload, get_user_crud
from app.api.dependencies import get_quack_jwt, get_user_crud
from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.crud import UserCRUD
Expand Down Expand Up @@ -96,6 +96,6 @@ async def login_with_github_token(

@router.get("/validate", status_code=status.HTTP_200_OK, summary="Check token validity")
def check_token_validity(
payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.USER, UserScope.ADMIN]),
payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.USER, UserScope.ADMIN]),
) -> TokenPayload:
return payload
14 changes: 7 additions & 7 deletions src/app/api/api_v1/endpoints/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from fastapi import APIRouter, Depends, HTTPException, Path, Security, status

from app.api.dependencies import get_current_user, get_repo_crud, get_token_payload
from app.api.dependencies import get_current_user, get_quack_jwt, get_repo_crud
from app.crud import RepositoryCRUD
from app.models import Provider, Repository, User, UserScope
from app.schemas.login import TokenPayload
Expand Down Expand Up @@ -80,28 +80,28 @@ async def register_repo(
async def get_repo(
repo_id: int = Path(..., gt=0),
repos: RepositoryCRUD = Depends(get_repo_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN, UserScope.USER]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN, UserScope.USER]),
) -> Repository:
telemetry_client.capture(token_payload.user_id, event="repo-get", properties={"repo_id": repo_id})
telemetry_client.capture(token_payload.sub, event="repo-get", properties={"repo_id": repo_id})
return cast(Repository, await repos.get(repo_id, strict=True))


@router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all repositories")
async def fetch_repos(
repos: RepositoryCRUD = Depends(get_repo_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN]),
) -> List[Repository]:
telemetry_client.capture(token_payload.user_id, event="repo-fetch")
telemetry_client.capture(token_payload.sub, event="repo-fetch")
return [elt for elt in await repos.fetch_all()]


@router.delete("/{repo_id}", status_code=status.HTTP_200_OK, summary="Delete a specific repository")
async def delete_repo(
repo_id: int = Path(..., gt=0),
repos: RepositoryCRUD = Depends(get_repo_crud),
token_payload: TokenPayload = Security(get_token_payload, scopes=[UserScope.ADMIN]),
token_payload: TokenPayload = Security(get_quack_jwt, scopes=[UserScope.ADMIN]),
) -> None:
telemetry_client.capture(token_payload.user_id, event="repo-delete", properties={"repo_id": repo_id})
telemetry_client.capture(token_payload.sub, event="repo-delete", properties={"repo_id": repo_id})
await repos.delete(repo_id)


Expand Down
Loading

0 comments on commit a707f06

Please sign in to comment.