Skip to content

Commit

Permalink
Feature/organizations auth secret code (#8)
Browse files Browse the repository at this point in the history
* Update ingester docs for serperdev

* Organization management

* Mock frontend auth

* gen sdk

* Working auth on front

* Working authentication

* Filter workspace for organizatino

* Improved login

* Update docs

* fix imports
  • Loading branch information
SuperMuel authored Jan 3, 2025
1 parent fce8e35 commit 4d64456
Show file tree
Hide file tree
Showing 24 changed files with 841 additions and 49 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ The Analyzer component handles:
### Backend

The Backend provides:
- RESTful API endpoints for data access and management on the frontend
- RESTful API endpoints for authentication and data access and management on the frontend

### Frontend

The Frontend offers a Streamlit interface for:

- Logging in an organization
- Managing workspaces and search queries
- Configuring data sources (web search and RSS feeds)
- Manually triggering ingestion and analysis tasks
Expand Down
16 changes: 16 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,19 @@ For development purposes, you might want to run the application outside of Docke
4. Install dependencies: `poetry install`
5. Set up your environment variables in a `.env` file in the `backend` directory.
6. Run the application: `poetry run uvicorn src.api:app --reload`

# Authentication

The SO Insights backend requires organization-level authentication for accessing most of its resources. This is done using the X-Organization-ID header in the request.

### Authentication Flow
SoInsights Administrators create Organizations and their access codes for the clients.
Users receive the code and use it to login on the platform.
Internally, the code is exchanged against the organization ID, which is subsequently sent in each request as `X-Organization-ID` header.
We wrongly assume that the org ID can't be guessed, and dangerously use it for authentication.

### Important Notes

This authentication mechanism is not highly secure and is designed for demonstration purposes. Ensure that access codes are kept private.

Future updates may introduce more robust authentication mechanisms
16 changes: 14 additions & 2 deletions backend/src/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
from contextlib import asynccontextmanager
from dotenv import load_dotenv

from fastapi import FastAPI
from fastapi import Depends, FastAPI

from shared.db import get_client, my_init_beanie

from src.api_settings import api_settings
from src.dependencies import get_organization
from src.routers import (
clustering,
ingestion_configs,
ingestion_runs,
organizations,
starters,
workspaces,
)
Expand Down Expand Up @@ -59,26 +61,36 @@ async def root():
return {"message": "Welcome to so_insights API"}


app.include_router(workspaces.router, prefix="/workspaces")
app.include_router(organizations.router, prefix="/organizations")
app.include_router(
workspaces.router,
prefix="/workspaces",
dependencies=[Depends(get_organization)],
)
app.include_router(
ingestion_configs.router,
prefix="/workspaces/{workspace_id}/ingestion-configs",
dependencies=[Depends(get_organization)],
)
app.include_router(
ingestion_runs.router,
prefix="/workspaces/{workspace_id}/ingestion-runs",
dependencies=[Depends(get_organization)],
)

app.include_router(
clustering.router,
prefix="/workspaces/{workspace_id}/clustering",
dependencies=[Depends(get_organization)],
)

app.include_router(
starters.router,
prefix="/workspaces/{workspace_id}/starters",
dependencies=[Depends(get_organization)],
)


if __name__ == "__main__":
import uvicorn

Expand Down
21 changes: 18 additions & 3 deletions backend/src/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
from typing import Annotated

from beanie import PydanticObjectId
from fastapi import Depends, HTTPException
from fastapi import Depends, HTTPException, Header

from shared.models import (
Cluster,
ClusteringSession,
IngestionConfig,
IngestionRun,
Organization,
RssIngestionConfig,
SearchIngestionConfig,
Workspace,
)


async def get_workspace(workspace_id: str | PydanticObjectId) -> Workspace:
async def get_organization(x_organization_id: Annotated[str, Header()]) -> Organization:
organization = await Organization.get(x_organization_id)
if not organization:
raise HTTPException(status_code=400, detail="Invalid X-Organization-ID header")
assert organization.id
return organization


ExistingOrganization = Annotated[Organization, Depends(get_organization)]


async def get_workspace(
organization: ExistingOrganization, workspace_id: str | PydanticObjectId
) -> Workspace:
workspace = await Workspace.get(workspace_id)
if not workspace:
if not workspace or workspace.organization_id != organization.id:
raise HTTPException(status_code=404, detail="Workspace not found")

assert workspace.id
return workspace

Expand Down
88 changes: 88 additions & 0 deletions backend/src/routers/organizations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import SecretStr
from pymongo.errors import DuplicateKeyError

from shared.models import Organization
from src.dependencies import ExistingOrganization
from src.schemas import OrganizationCreate

router = APIRouter(tags=["organizations"])


# Disabled because only admins should be able to access this, and we don't have
# roles implemented yet.
# @router.post(
# "/",
# response_model=Organization,
# status_code=status.HTTP_201_CREATED,
# operation_id="create_organization",
# )
# async def create_organization(body: OrganizationCreate):
# """
# Create a new organization.

# - Fails with 409 if 'name' or 'secret_code' is already in use
# """
# new_org = Organization(
# name=body.name.strip(),
# secret_code=SecretStr(body.secret_code.strip()),
# )

# try:
# return await new_org.insert()
# except DuplicateKeyError:
# raise HTTPException(
# status_code=status.HTTP_409_CONFLICT,
# detail="Organization name or secret_code already in use.",
# )


# Disabled because only admins should be able to access this, and we don't have
# roles implemented yet.
# @router.get(
# "/",
# response_model=list[Organization],
# operation_id="list_organizations",
# )
# async def list_organizations():
# """
# List all organizations.
# (Admins only in real scenario, but minimal for now)
# """
# return (
# await Organization.find_all()
# .sort(
# -Organization.created_at, # type: ignore
# )
# .to_list()
# )


@router.get(
"/by-secret-code",
response_model=Organization,
operation_id="get_organization_by_secret_code",
)
async def get_organization_by_secret_code(
code: str = Query(..., description="Organization secret code"),
):
"""
Retrieve an organization by its secret code.
Returns 404 if none found.
"""
org = await Organization.find_one(Organization.secret_code == code)
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
return org


@router.get(
"/{organization_id}",
response_model=Organization,
operation_id="get_organization",
)
async def get_organization(organization: ExistingOrganization):
"""
Retrieve a single organization by ID.
"""
return organization
25 changes: 17 additions & 8 deletions backend/src/routers/workspaces.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import APIRouter, status
from src.dependencies import ExistingWorkspace
from src.dependencies import ExistingWorkspace, ExistingOrganization
from shared.models import Workspace, utc_datetime_factory
from src.schemas import WorkspaceUpdate, WorkspaceCreate
from logging import getLogger
Expand All @@ -15,8 +15,15 @@
status_code=status.HTTP_201_CREATED,
operation_id="create_workspace",
)
async def create_workspace(workspace: WorkspaceCreate):
return await Workspace(**workspace.model_dump()).insert()
async def create_workspace(
organization: ExistingOrganization, workspace: WorkspaceCreate
):
assert organization.id

return await Workspace(
organization_id=organization.id,
**workspace.model_dump(),
).insert()


@router.get(
Expand All @@ -25,15 +32,17 @@ async def create_workspace(workspace: WorkspaceCreate):
operation_id="list_workspaces",
)
async def list_workspaces(
organization: ExistingOrganization,
enabled: bool | None = None,
):
workspaces = (
Workspace.find(Workspace.enabled == enabled)
if enabled is not None
else Workspace.find_all()
workspaces = Workspace.find(
Workspace.organization_id == organization.id,
*[Workspace.enabled == enabled] if enabled is not None else [],
)

return await workspaces.sort(Workspace.created_at).to_list()
return await workspaces.sort(
Workspace.created_at, # type: ignore
).to_list()


@router.get(
Expand Down
29 changes: 28 additions & 1 deletion backend/src/schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
from datetime import datetime
from typing import Annotated
from beanie import PydanticObjectId
from pydantic import BaseModel, Field, HttpUrl, PastDatetime
from pydantic import (
BaseModel,
Field,
HttpUrl,
PastDatetime,
SecretStr,
StringConstraints,
)

from shared.models import (
Cluster,
Expand All @@ -14,6 +22,25 @@
from shared.region import Region


class OrganizationCreate(BaseModel):
name: ModelTitle = Field(..., description="Unique name of the organization")

secret_code: Annotated[
str,
StringConstraints(
min_length=8,
max_length=64,
strip_whitespace=True,
),
] = Field(
...,
description="Secret code for organization access. Will be stored in plain text.",
)

class Config:
extra = "forbid"


class WorkspaceCreate(BaseModel):
name: ModelTitle
description: ModelDescription = ""
Expand Down
18 changes: 18 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,21 @@ To run the docker image, run :
docker run -p 8501:8501 -e STREAMLIT_SERVER_PORT=8501 -e STREAMLIT_SERVER_ADDRESS=localhost -e SO_INSIGHTS_API_URL="<api_url>" --env-file .\frontend\.env -t so-insights-frontend
```


# Authentication

The frontend application requires authentication to interact with the backend API. This is achieved using the organization's `X-Organization-ID` header.

### Login Flow

1. **Access Code Input:**
- When accessing the application for the first time, users are prompted to enter a secret access code provided by the administrator.

2. **Exchange for Organization ID:**
- The access code is sent to the backend API endpoint `/organizations/by-secret-code` to retrieve the corresponding `organization_id`.

3. **Session Persistence:**
- The `organization_id` is stored in the user's session and used to authenticate subsequent API calls.

4. **Headers in API Requests:**
- All API requests made by the frontend include the `X-Organization-ID` header to authenticate the user.
Loading

0 comments on commit 4d64456

Please sign in to comment.