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 an environment flag for LILAC_AUTH_ENABLED and turn off features. #452

Merged
merged 4 commits into from
Jul 19, 2023
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
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ LILAC_DATA_PATH=./data
# Set to 1 for duckdb to use views instead of materialized tables (lower memory usage, but slower).
DUCKDB_USE_VIEWS=0

# Set to true to enable read-only mode, disabling the ability to add datasets & compute dataset
# signals.
# LILAC_AUTH_ENABLED=true
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add this variable in .env.demo in deploy_hf.py ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't do this yet on purpose -- for our staging demos I don't want this bit on, and I'm not sure yet if this should be controlled only through the UI (since it's nice for discoverability when forking)


# Variables that can be set in .env.local
#
# Get key from https://www.cohere.ai/api-keys
Expand Down
50 changes: 50 additions & 0 deletions src/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Authentication and ACL configuration."""

from pydantic import BaseModel

from .config import CONFIG


class DatasetUserAccess(BaseModel):
"""User access for datasets."""
# Whether the user can compute a signal.
compute_signals: bool
# Whether the user can delete a dataset.
delete_dataset: bool
# Whether the user can delete a signal.
delete_signals: bool
# Whether the user can update settings.
update_settings: bool


class ConceptUserAccess(BaseModel):
"""User access for concepts."""
# Whether the user can delete any concept (not their own).
delete_any_concept: bool


class UserAccess(BaseModel):
"""User access."""
create_dataset: bool

# TODO(nsthorat): Make this keyed to each dataset and concept.
dataset: DatasetUserAccess
concept: ConceptUserAccess


def get_user_access() -> UserAccess:
"""Get the user access."""
auth_enabled = CONFIG.get('LILAC_AUTH_ENABLED', False)
if isinstance(auth_enabled, str):
auth_enabled = auth_enabled.lower() == 'true'
if auth_enabled:
return UserAccess(
create_dataset=False,
dataset=DatasetUserAccess(
compute_signals=False, delete_dataset=False, delete_signals=False, update_settings=False),
concept=ConceptUserAccess(delete_any_concept=False))
return UserAccess(
create_dataset=True,
dataset=DatasetUserAccess(
compute_signals=True, delete_dataset=True, delete_signals=True, update_settings=True),
concept=ConceptUserAccess(delete_any_concept=True))
4 changes: 4 additions & 0 deletions src/router_concept.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from openai_function_call import OpenAISchema
from pydantic import BaseModel, Field

from .auth import get_user_access
from .concepts.concept import (
DRAFT_MAIN,
Concept,
Expand Down Expand Up @@ -81,6 +82,9 @@ def edit_concept(namespace: str, concept_name: str, change: ConceptUpdate) -> Co
@router.delete('/{namespace}/{concept_name}')
def delete_concept(namespace: str, concept_name: str) -> None:
"""Deletes the concept from the database."""
if not get_user_access().concept.delete_any_concept:
raise HTTPException(401, 'User does not have access to delete this concept.')

DISK_CONCEPT_DB.remove(namespace, concept_name)
# Delete concept models from all datasets that are using this concept.
DISK_CONCEPT_MODEL_DB.remove_all(namespace, concept_name)
Expand Down
6 changes: 5 additions & 1 deletion src/router_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
"""
from typing import Any

from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from .auth import get_user_access
from .config import data_path
from .data.sources.default_sources import register_default_sources
from .data.sources.source_registry import get_source_cls, registered_sources
Expand Down Expand Up @@ -67,6 +68,9 @@ class LoadDatasetResponse(BaseModel):
async def load(source_name: str, options: LoadDatasetOptions,
request: Request) -> LoadDatasetResponse:
"""Load a dataset."""
if not get_user_access().create_dataset:
raise HTTPException(401, 'User does not have access to load a dataset.')

source_cls = get_source_cls(source_name)
source = source_cls(**options.config)

Expand Down
14 changes: 13 additions & 1 deletion src/router_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
from typing import Optional, Sequence, Union, cast
from urllib.parse import unquote

from fastapi import APIRouter, Response
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel, validator

from .auth import get_user_access
from .config import data_path
from .data.dataset import BinaryOp
from .data.dataset import Column as DBColumn
Expand Down Expand Up @@ -82,6 +83,9 @@ def parse_signal(cls, signal: dict) -> Signal:
@router.delete('/{namespace}/{dataset_name}')
def delete_dataset(namespace: str, dataset_name: str) -> None:
"""Delete the dataset."""
if not get_user_access().dataset.delete_dataset:
raise HTTPException(401, 'User does not have access to delete this dataset.')

dataset = get_dataset(namespace, dataset_name)
dataset.delete()
remove_dataset_from_cache(namespace, dataset_name)
Expand All @@ -96,6 +100,8 @@ class ComputeSignalResponse(BaseModel):
def compute_signal(namespace: str, dataset_name: str,
options: ComputeSignalOptions) -> ComputeSignalResponse:
"""Compute a signal for a dataset."""
if not get_user_access().dataset.compute_signals:
raise HTTPException(401, 'User does not have access to compute signals over this dataset.')

def _task_compute_signal(namespace: str, dataset_name: str, options_dict: dict,
task_id: TaskId) -> None:
Expand Down Expand Up @@ -130,6 +136,9 @@ class DeleteSignalResponse(BaseModel):
def delete_signal(namespace: str, dataset_name: str,
options: DeleteSignalOptions) -> DeleteSignalResponse:
"""Delete a signal from a dataset."""
if not get_user_access().dataset.delete_signals:
raise HTTPException(401, 'User does not have access to delete this signal.')

dataset = get_dataset(namespace, dataset_name)
dataset.delete_signal(options.signal_path)
return DeleteSignalResponse(completed=True)
Expand Down Expand Up @@ -293,6 +302,9 @@ def get_settings(namespace: str, dataset_name: str) -> DatasetSettings:
@router.post('/{namespace}/{dataset_name}/settings', response_model_exclude_none=True)
def update_settings(namespace: str, dataset_name: str, settings: DatasetSettings) -> None:
"""Get the media for the dataset."""
if not get_user_access().dataset.compute_signals:
raise HTTPException(401, 'User does not have access to update the settings of this dataset.')

dataset = get_dataset(namespace, dataset_name)
dataset.update_settings(settings)
return None
11 changes: 11 additions & 0 deletions src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from fastapi.staticfiles import StaticFiles

from . import router_concept, router_data_loader, router_dataset, router_signal, router_tasks
from .auth import UserAccess, get_user_access
from .concepts.db_concept import DiskConceptDB, get_concept_output_dir
from .config import CONFIG, data_path
from .router_utils import RouteErrorHandler
Expand Down Expand Up @@ -52,6 +53,16 @@ def custom_generate_unique_id(route: APIRoute) -> str:
v1_router.include_router(router_signal.router, prefix='/signals', tags=['signals'])
v1_router.include_router(router_tasks.router, prefix='/tasks', tags=['tasks'])


@v1_router.get('/acl')
def user_acls() -> UserAccess:
"""Returns the user's ACLs.

NOTE: Validation happens server-side as well. This is just used for UI treatment.
"""
return get_user_access()


app.include_router(v1_router, prefix='/api/v1')


Expand Down
37 changes: 37 additions & 0 deletions src/server_concept_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,43 @@ def test_concept_create() -> None:
]


def test_concept_delete() -> None:
# Create a concept.
response = client.post(
'/api/v1/concepts/create',
json=CreateConceptOptions(
namespace='concept_namespace', name='concept', type=SignalInputType.TEXT).dict())

assert len(client.get('/api/v1/concepts/').json()) == 1

# Delete the concept.
url = '/api/v1/concepts/concept_namespace/concept'
response = client.delete(url)
assert response.status_code == 200

# Make sure list shows no concepts.
assert parse_obj_as(list[ConceptInfo], client.get('/api/v1/concepts/').json()) == []


def test_concept_delete_auth(mocker: MockerFixture) -> None:
mocker.patch.dict(CONFIG, {'LILAC_AUTH_ENABLED': True})

# Create a concept.
response = client.post(
'/api/v1/concepts/create',
json=CreateConceptOptions(
namespace='concept_namespace', name='concept', type=SignalInputType.TEXT).dict())

assert len(client.get('/api/v1/concepts/').json()) == 1

# Delete the concept.
url = '/api/v1/concepts/concept_namespace/concept'
response = client.delete(url)
assert response.status_code == 401
assert response.is_error is True
assert 'User does not have access to delete this concept' in response.text


def test_concept_edits(mocker: MockerFixture) -> None:
mock_uuid = mocker.patch.object(uuid, 'uuid4', autospec=True)

Expand Down
35 changes: 35 additions & 0 deletions src/server_test.py → src/server_dataset_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
Column,
Dataset,
DatasetManifest,
DatasetSettings,
SelectRowsSchemaResult,
SelectRowsSchemaUDF,
)
from .data.dataset_duckdb import DatasetDuckDB
from .data.dataset_test_utils import TEST_DATASET_NAME, TEST_NAMESPACE, enriched_item, make_dataset
from .router_dataset import (
ComputeSignalOptions,
DeleteSignalOptions,
SelectRowsOptions,
SelectRowsResponse,
SelectRowsSchemaOptions,
Expand Down Expand Up @@ -284,3 +287,35 @@ def test_select_rows_schema_no_cols() -> None:
}]
}]
}))


def test_compute_signal_auth(mocker: MockerFixture) -> None:
mocker.patch.dict(CONFIG, {'LILAC_AUTH_ENABLED': True})

url = f'/api/v1/datasets/{TEST_NAMESPACE}/{TEST_DATASET_NAME}/compute_signal'
response = client.post(
url, json=ComputeSignalOptions(signal=LengthSignal(), leaf_path=['people', 'name']).dict())
assert response.status_code == 401
assert response.is_error is True
assert 'User does not have access to compute signals over this dataset.' in response.text


def test_delete_signal_auth(mocker: MockerFixture) -> None:
mocker.patch.dict(CONFIG, {'LILAC_AUTH_ENABLED': True})

url = f'/api/v1/datasets/{TEST_NAMESPACE}/{TEST_DATASET_NAME}/delete_signal'
response = client.request(
'DELETE', url, json=DeleteSignalOptions(signal_path=['doesnt', 'matter']).dict())
assert response.status_code == 401
assert response.is_error is True
assert 'User does not have access to delete this signal.' in response.text


def test_update_settings_auth(mocker: MockerFixture) -> None:
mocker.patch.dict(CONFIG, {'LILAC_AUTH_ENABLED': True})

url = f'/api/v1/datasets/{TEST_NAMESPACE}/{TEST_DATASET_NAME}/settings'
response = client.post(url, json=DatasetSettings().dict())
assert response.status_code == 401
assert response.is_error is True
assert 'User does not have access to update the settings of this dataset.' in response.text
Loading