Skip to content

Commit d703e69

Browse files
limited role api keys (#3115)
* in progress PoC * working limited user, needs routes to be marked next * make selected endpoint available to limited user role * xfail on test_slack_prune * add comment to sync function --------- Co-authored-by: Richard Kuo <rkuo@rkuo.com>
1 parent 6066042 commit d703e69

File tree

11 files changed

+104
-23
lines changed

11 files changed

+104
-23
lines changed

backend/danswer/auth/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class UserRole(str, Enum):
1515
for all groups they are a member of
1616
"""
1717

18+
LIMITED = "limited"
1819
BASIC = "basic"
1920
ADMIN = "admin"
2021
CURATOR = "curator"

backend/danswer/auth/users.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,12 +662,26 @@ async def current_user_with_expired_token(
662662
return await double_check_user(user, include_expired=True)
663663

664664

665-
async def current_user(
665+
async def current_limited_user(
666666
user: User | None = Depends(optional_user),
667667
) -> User | None:
668668
return await double_check_user(user)
669669

670670

671+
async def current_user(
672+
user: User | None = Depends(optional_user),
673+
) -> User | None:
674+
user = await double_check_user(user)
675+
if not user:
676+
return None
677+
678+
if user.role == UserRole.LIMITED:
679+
raise BasicAuthenticationError(
680+
detail="Access denied. User role is LIMITED. BASIC or higher permissions are required.",
681+
)
682+
return user
683+
684+
671685
async def current_curator_or_admin_user(
672686
user: User | None = Depends(current_user),
673687
) -> User | None:

backend/danswer/server/auth_check.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from danswer.auth.users import current_admin_user
88
from danswer.auth.users import current_curator_or_admin_user
9+
from danswer.auth.users import current_limited_user
910
from danswer.auth.users import current_user
1011
from danswer.auth.users import current_user_with_expired_token
1112
from danswer.configs.app_configs import APP_API_PREFIX
@@ -102,7 +103,8 @@ def check_router_auth(
102103
for dependency in route_dependant_obj.dependencies:
103104
depends_fn = dependency.cache_key[0]
104105
if (
105-
depends_fn == current_user
106+
depends_fn == current_limited_user
107+
or depends_fn == current_user
106108
or depends_fn == current_admin_user
107109
or depends_fn == current_curator_or_admin_user
108110
or depends_fn == api_key_dep
@@ -118,5 +120,5 @@ def check_router_auth(
118120
# print(f"(\"{route.path}\", {set(route.methods)}),")
119121

120122
raise RuntimeError(
121-
f"Did not find current_user or current_admin_user dependency in route - {route}"
123+
f"Did not find user dependency in private route - {route}"
122124
)

backend/danswer/server/features/persona/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from danswer.auth.users import current_admin_user
1313
from danswer.auth.users import current_curator_or_admin_user
14+
from danswer.auth.users import current_limited_user
1415
from danswer.auth.users import current_user
1516
from danswer.configs.constants import FileOrigin
1617
from danswer.configs.constants import NotificationType
@@ -272,7 +273,7 @@ def list_personas(
272273
@basic_router.get("/{persona_id}")
273274
def get_persona(
274275
persona_id: int,
275-
user: User | None = Depends(current_user),
276+
user: User | None = Depends(current_limited_user),
276277
db_session: Session = Depends(get_session),
277278
) -> PersonaSnapshot:
278279
return PersonaSnapshot.from_model(

backend/danswer/server/query_and_chat/chat_backend.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pydantic import BaseModel
1919
from sqlalchemy.orm import Session
2020

21+
from danswer.auth.users import current_limited_user
2122
from danswer.auth.users import current_user
2223
from danswer.chat.chat_utils import create_chat_chain
2324
from danswer.chat.chat_utils import extract_headers
@@ -309,7 +310,7 @@ def is_connected_sync() -> bool:
309310
def handle_new_chat_message(
310311
chat_message_req: CreateChatMessageRequest,
311312
request: Request,
312-
user: User | None = Depends(current_user),
313+
user: User | None = Depends(current_limited_user),
313314
_: None = Depends(check_token_rate_limits),
314315
is_connected_func: Callable[[], bool] = Depends(is_connected),
315316
) -> StreamingResponse:
@@ -391,7 +392,7 @@ def set_message_as_latest(
391392
@router.post("/create-chat-message-feedback")
392393
def create_chat_feedback(
393394
feedback: ChatFeedbackRequest,
394-
user: User | None = Depends(current_user),
395+
user: User | None = Depends(current_limited_user),
395396
db_session: Session = Depends(get_session),
396397
) -> None:
397398
user_id = user.id if user else None

backend/danswer/server/query_and_chat/query_backend.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sqlalchemy.orm import Session
1010

1111
from danswer.auth.users import current_curator_or_admin_user
12+
from danswer.auth.users import current_limited_user
1213
from danswer.auth.users import current_user
1314
from danswer.configs.constants import DocumentSource
1415
from danswer.configs.constants import MessageType
@@ -262,7 +263,7 @@ def stream_query_validation(
262263
@basic_router.post("/stream-answer-with-quote")
263264
def get_answer_with_quote(
264265
query_request: DirectQARequest,
265-
user: User = Depends(current_user),
266+
user: User = Depends(current_limited_user),
266267
_: None = Depends(check_token_rate_limits),
267268
) -> StreamingResponse:
268269
query = query_request.messages[0].message

backend/tests/integration/common_utils/managers/cc_pair.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,10 @@ def sync(
326326
cc_pair: DATestCCPair,
327327
user_performing_action: DATestUser | None = None,
328328
) -> None:
329+
"""This function triggers a permission sync.
330+
Naming / intent of this function probably could use improvement, but currently it's letting
331+
409 Conflict pass through since if it's running that's what we were trying to do anyway.
332+
"""
329333
result = requests.post(
330334
url=f"{API_SERVER_URL}/manage/admin/cc-pair/{cc_pair.id}/sync-permissions",
331335
headers=user_performing_action.headers

backend/tests/integration/connector_job_tests/slack/test_prune.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from datetime import timezone
44
from typing import Any
55

6+
import pytest
7+
68
from danswer.connectors.models import InputType
79
from danswer.db.enums import AccessType
810
from danswer.server.documents.models import DocumentSource
@@ -22,7 +24,7 @@
2224
from tests.integration.connector_job_tests.slack.slack_api_utils import SlackManager
2325

2426

25-
# @pytest.mark.xfail(reason="flaky - see DAN-835 for example", strict=False)
27+
@pytest.mark.xfail(reason="flaky - see DAN-986 for details", strict=False)
2628
def test_slack_prune(
2729
reset: None,
2830
vespa_client: vespa_fixture,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import requests
2+
3+
from danswer.auth.schemas import UserRole
4+
from tests.integration.common_utils.constants import API_SERVER_URL
5+
from tests.integration.common_utils.managers.api_key import APIKeyManager
6+
from tests.integration.common_utils.managers.user import UserManager
7+
from tests.integration.common_utils.test_models import DATestAPIKey
8+
from tests.integration.common_utils.test_models import DATestUser
9+
10+
11+
def test_limited(reset: None) -> None:
12+
"""Verify that with a limited role key, limited endpoints are accessible and
13+
others are not."""
14+
15+
# Creating an admin user (first user created is automatically an admin)
16+
admin_user: DATestUser = UserManager.create(name="admin_user")
17+
18+
api_key: DATestAPIKey = APIKeyManager.create(
19+
api_key_role=UserRole.LIMITED,
20+
user_performing_action=admin_user,
21+
)
22+
23+
# test limited endpoint
24+
response = requests.get(
25+
f"{API_SERVER_URL}/persona/0",
26+
headers=api_key.headers,
27+
)
28+
assert response.status_code == 200
29+
30+
# test basic endpoints
31+
response = requests.get(
32+
f"{API_SERVER_URL}/input_prompt",
33+
headers=api_key.headers,
34+
)
35+
assert response.status_code == 403
36+
37+
# test admin endpoints
38+
response = requests.get(
39+
f"{API_SERVER_URL}/admin/api-key",
40+
headers=api_key.headers,
41+
)
42+
assert response.status_code == 403

web/src/app/admin/api-key/DanswerApiKeyForm.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { Form, Formik } from "formik";
22
import { PopupSpec } from "@/components/admin/connectors/Popup";
33
import {
44
BooleanFormField,
5+
SelectorFormField,
56
TextFormField,
67
} from "@/components/admin/connectors/Field";
78
import { createApiKey, updateApiKey } from "./lib";
89
import { Modal } from "@/components/Modal";
910
import { Button } from "@/components/ui/button";
1011
import { Separator } from "@/components/ui/separator";
1112
import Text from "@/components/ui/text";
12-
import { UserRole } from "@/lib/types";
13+
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
1314
import { APIKey } from "./types";
1415

1516
interface DanswerApiKeyFormProps {
@@ -39,20 +40,15 @@ export const DanswerApiKeyForm = ({
3940
<Formik
4041
initialValues={{
4142
name: apiKey?.api_key_name || "",
42-
is_admin: apiKey?.api_key_role === "admin",
43+
role: apiKey?.api_key_role || UserRole.BASIC.toString(),
4344
}}
4445
onSubmit={async (values, formikHelpers) => {
4546
formikHelpers.setSubmitting(true);
4647

47-
// Map the boolean to a UserRole string
48-
const role: UserRole = values.is_admin
49-
? UserRole.ADMIN
50-
: UserRole.BASIC;
51-
5248
// Prepare the payload with the UserRole
5349
const payload = {
5450
...values,
55-
role, // Assign the role directly as a UserRole type
51+
role: values.role as UserRole, // Assign the role directly as a UserRole type
5652
};
5753

5854
let response;
@@ -98,13 +94,28 @@ export const DanswerApiKeyForm = ({
9894
autoCompleteDisabled={true}
9995
/>
10096

101-
<BooleanFormField
102-
small
103-
removeIndent
104-
alignTop
105-
name="is_admin"
106-
label="Is Admin?"
107-
subtext="If set, this API key will have access to admin level server API's."
97+
<SelectorFormField
98+
// defaultValue is managed by Formik
99+
label="Role:"
100+
subtext="Select the role for this API key.
101+
Limited has access to simple public API's.
102+
Basic has access to regular user API's.
103+
Admin has access to admin level APIs."
104+
name="role"
105+
options={[
106+
{
107+
name: USER_ROLE_LABELS[UserRole.LIMITED],
108+
value: UserRole.LIMITED.toString(),
109+
},
110+
{
111+
name: USER_ROLE_LABELS[UserRole.BASIC],
112+
value: UserRole.BASIC.toString(),
113+
},
114+
{
115+
name: USER_ROLE_LABELS[UserRole.ADMIN],
116+
value: UserRole.ADMIN.toString(),
117+
},
118+
]}
108119
/>
109120

110121
<Button

0 commit comments

Comments
 (0)