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

feat: setup wizard api + authentication frontend #29371

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open

Conversation

joshsny
Copy link
Contributor

@joshsny joshsny commented Feb 28, 2025

Problem

We need an API for our setup wizard CLI and a method to temporarily authenticate users when they are using the CLI to setup their project.

Changes

  • Creates a SetupWizard view set
  • An endpoint for generating temporary hashes for a user + a team endpoint for the user to login and authenticate those hashes
  • An endpoint for the CLI to fetch authenticated data for that user's hash (in this case just a project api token and host)
  • An endpoint to proxy requests to OpenAI from the setup wizard, which is authenticated using the temporary hash
  • Add a frontend page for authenticating the wizard

The query endpoint is strongly rate limited to avoid abuse, and the hashes are authed for 15 minutes only after which they expire in the redis cache.

Does this work well for both Cloud and self-hosted?

It is only available on Cloud

How did you test this code?

Added unit tests for the endpoints, manually tested the frontend wizard page authed and unauthed

@joshsny joshsny requested a review from a team February 28, 2025 18:09
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

PR Summary

This PR adds a setup wizard API and authentication frontend for PostHog, enabling temporary authentication for CLI users during project setup.

  • Added SetupWizardViewSet in posthog/api/wizard.py with endpoints for initializing, retrieving data, and proxying OpenAI queries
  • Implemented temporary authentication using Redis-cached hashes with 10-minute expiration via SETUP_WIZARD_CACHE_PREFIX and SETUP_WIZARD_CACHE_TIMEOUT
  • Created frontend authentication flow in frontend/src/scenes/wizard/Wizard.tsx with pending, success, and error states
  • Added rate limiting via SetupWizardAuthenticationRateThrottle and SetupWizardQueryRateThrottle classes to prevent abuse
  • Integrated new wizard scene into application routing with corresponding URL and scene configuration

14 file(s) reviewed, 10 comment(s)
Edit PR Review Bot Settings | Greptile

Comment on lines +36 to +38
} catch (e: any) {
actions.setView('invalid')
return { success: false, errorCode: e.code, errorDetail: e.detail }
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Error handling accesses e.code and e.detail directly, but these properties might not exist on all error objects. Consider using optional chaining (e?.code, e?.detail) or providing fallback values.

Suggested change
} catch (e: any) {
actions.setView('invalid')
return { success: false, errorCode: e.code, errorDetail: e.detail }
} catch (e: any) {
actions.setView('invalid')
return { success: false, errorCode: e?.code, errorDetail: e?.detail }

Comment on lines +814 to +818
wizard_data = {
"project_api_key": request.user.team.api_token,
"host": get_api_host(),
"user_distinct_id": request.user.distinct_id,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Consider adding a check to verify if request.user.team exists before accessing api_token to prevent potential errors if a user doesn't have a team assigned.

Suggested change
wizard_data = {
"project_api_key": request.user.team.api_token,
"host": get_api_host(),
"user_distinct_id": request.user.distinct_id,
}
wizard_data = {
"project_api_key": request.user.team.api_token if hasattr(request.user, 'team') and request.user.team else None,
"host": get_api_host(),
"user_distinct_id": request.user.distinct_id,
}

Comment on lines +30 to +33
def test_data_endpoint_returns_data(self):
response = self.client.get(self.data_url, HTTP_X_POSTHOG_WIZARD_HASH=self.hash)
assert response.status_code == status.HTTP_200_OK
assert response.data["project_api_key"] == "test-key"
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Test should also verify the 'host' field is returned correctly in the response.

Suggested change
def test_data_endpoint_returns_data(self):
response = self.client.get(self.data_url, HTTP_X_POSTHOG_WIZARD_HASH=self.hash)
assert response.status_code == status.HTTP_200_OK
assert response.data["project_api_key"] == "test-key"
def test_data_endpoint_returns_data(self):
response = self.client.get(self.data_url, HTTP_X_POSTHOG_WIZARD_HASH=self.hash)
assert response.status_code == status.HTTP_200_OK
assert response.data["project_api_key"] == "test-key"
assert response.data["host"] == "http://localhost:8010"

Comment on lines +47 to +74
@patch("posthog.api.wizard.OpenAI")
def test_query_endpoint_rate_limit(self, mock_openai):
mock_openai_instance = mock_openai.return_value
# Simulate an OpenAI response with JSON {"foo": "bar"}
mock_openai_instance.beta.chat.completions.parse.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=json.dumps({"foo": "bar"})))]
)

for _ in range(20):
response = self.client.post(
self.query_url,
data=json.dumps(
{"message": "test", "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}}}
),
content_type="application/json",
HTTP_X_POSTHOG_WIZARD_HASH=self.hash,
)
assert response.status_code == status.HTTP_200_OK

response = self.client.post(
self.query_url,
data=json.dumps(
{"message": "test", "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}}}
),
content_type="application/json",
HTTP_X_POSTHOG_WIZARD_HASH=self.hash,
)
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
Copy link
Contributor

Choose a reason for hiding this comment

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

style: The rate limit test is hardcoded to 20 requests, but it should reference the actual rate limit value from the SetupWizardQueryRateThrottle class to ensure the test remains valid if the rate limit changes.

Comment on lines +88 to +102
@patch("posthog.api.wizard.OpenAI")
def test_query_endpoint(self, mock_openai):
mock_openai_instance = mock_openai.return_value
mock_openai_instance.beta.chat.completions.parse.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=json.dumps({"foo": "bar"})))]
)
response = self.client.post(
self.query_url,
data=json.dumps(
{"message": "test", "json_schema": {"type": "object", "properties": {"name": {"type": "number"}}}}
),
content_type="application/json",
HTTP_X_POSTHOG_WIZARD_HASH=self.hash,
)
assert response.status_code == status.HTTP_200_OK
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: This test doesn't verify the structure of the response data. Add an assertion to check that response.data contains the expected 'data' field with the mocked response.

Comment on lines +363 to +365
hash = request.headers.get("X-PostHog-Wizard-Hash")
if not hash:
return None
Copy link
Contributor

Choose a reason for hiding this comment

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

style: If hash is missing, returning None will bypass rate limiting entirely. Consider if this is the intended behavior or if it should throw an authentication error instead.

return {"hash": instance}

def create(self) -> dict[str, str]:
hash = get_random_string(64, allowed_chars="abcdefghijklmnopqrstuvwxyz012345679")
Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: The allowed_chars string is missing the digit '8' in the character set

Suggested change
hash = get_random_string(64, allowed_chars="abcdefghijklmnopqrstuvwxyz012345679")
hash = get_random_string(64, allowed_chars="abcdefghijklmnopqrstuvwxyz0123456789")

hash = get_random_string(64, allowed_chars="abcdefghijklmnopqrstuvwxyz012345679")
key = f"{SETUP_WIZARD_CACHE_PREFIX}{hash}"

cache.set(key, {"project_api_key": None, "host": None}, SETUP_WIZARD_CACHE_TIMEOUT)
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Consider refreshing the cache expiration when the data is accessed to prevent expiration during active use

Comment on lines +76 to +77
if wizard_data is None:
return Response(status=404)
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Return a more descriptive error message when the hash is invalid or expired

Copy link
Contributor

Size Change: +305 B (0%)

Total Size: 9.73 MB

ℹ️ View Unchanged
Filename Size Change
frontend/dist/toolbar.js 9.73 MB +305 B (0%)

compressed-size-action

@posthog-bot
Copy link
Contributor

📸 UI snapshots have been updated

3 snapshot changes in total. 0 added, 3 modified, 0 deleted:

Triggered by this commit.

👉 Review this PR's diff of snapshots.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants