Skip to content
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
28 changes: 22 additions & 6 deletions .github/workflows/pr-desktop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ jobs:
build-desktop:
name: Build Desktop (${{ matrix.platform }})
runs-on: ${{ matrix.os }}
timeout-minutes: 60
# Windows runners are the slow leg: a cold cache compiles the whole Tauri
# dep tree twice (Clippy, then the release build), incl. native aws-lc-sys.
# Matches the desktop build timeout in deployment.yml.
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
Expand All @@ -38,11 +41,10 @@ jobs:
os: macos-latest
target: universal-apple-darwin
args: "--target universal-apple-darwin"
# TODO: Fix and enable the Windows build.
#- platform: windows
# os: windows-latest
# target: x86_64-pc-windows-msvc
# args: ""
- platform: windows
os: windows-latest
target: x86_64-pc-windows-msvc
args: ""

steps:
- name: Checkout code
Expand Down Expand Up @@ -106,6 +108,20 @@ jobs:
working-directory: ./desktop/src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings

# `tauri build` bundles an MSI on Windows, and WiX's ProductVersion only
# accepts numeric `major.minor.patch[.build]` -- the checked-in
# `0.0.0-dev` fails with "optional pre-release identifier in app version
# must be numeric-only ... for msi target". The release workflow injects a
# real numeric version at this point; presubmit has none to inject, so
# pin a throwaway one to keep MSI bundling exercised on PRs.
- name: Pin an MSI-compatible version (Windows)
if: matrix.platform == 'windows'
shell: bash
working-directory: ./desktop/src-tauri
run: |
jq '.version = "0.0.0"' tauri.conf.json > tauri.conf.json.tmp
mv tauri.conf.json.tmp tauri.conf.json

- name: Build desktop app
working-directory: ./desktop
run: bunx tauri build ${{ matrix.args }}
Expand Down
10 changes: 6 additions & 4 deletions .greptile/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ When hardcoding a boolean variable to a constant value, remove the variable enti

Code changes must consider both multi-tenant and single-tenant deployments. In multi-tenant mode, preserve tenant isolation, ensure tenant context is propagated correctly, and avoid assumptions that only hold for a single shared schema or globally shared state. In single-tenant mode, avoid introducing unnecessary tenant-specific requirements or cloud-only control-plane dependencies.

## Nginx Routing New Backend Routes
## Routing for New Non-/api Backend Routes

Whenever a new backend route is added that does NOT start with `/api`, it must also be explicitly added to ALL nginx configs:
Whenever a new backend route is added that does NOT start with `/api`, it must be explicitly routed in ALL nginx configs:

- `deployment/helm/charts/onyx/templates/nginx-conf.yaml` (Helm/k8s)
- `deployment/helm/charts/onyx/templates/nginx-conf.yaml` (Helm/k8s, bundled nginx)
- `deployment/data/nginx/app.conf.template` (docker-compose dev)
- `deployment/data/nginx/app.conf.template.prod` (docker-compose prod)
- `deployment/data/nginx/app.conf.template.no-letsencrypt` (docker-compose no-letsencrypt)

Routes not starting with `/api` are not caught by the existing `^/(api|openapi\.json)` location block and will fall through to `location /`, which proxies to the Next.js web server and returns an HTML 404. The new location block must be placed before the `/api` block. Examples of routes that need this treatment: `/scim`, `/mcp`.
Routes not starting with `/api` are not caught by the existing `^/(api|openapi\.json)` location block and will fall through to `location /`, which proxies to the Next.js web server and returns an HTML 404. In the nginx configs, the new location block must be placed before the `/api` block. Examples of routes that need this treatment: `/scim`, `/mcp`.

The route must ALSO be covered in Helm ingress mode (`ingress.enabled=true`), which does not use the bundled nginx. Add a dedicated ingress template for the route (see `deployment/helm/charts/onyx/templates/ingress-scim.yaml`). `ingress-api.yaml` cannot host it, since that resource only routes `/api` and its resource-wide rewrite annotation strips other prefixes. If the web app owns a sub-path of the route (for example an IdP callback page), carve that sub-path back out to the webserver with `pathType: Exact`, following `ingress-mcp-oauth-callback.yaml`.

## Full vs Lite Deployments

Expand Down
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,20 @@ pre-commit run --files <path> [<path> ...]

NOTE: Always make sure everything is strictly typed (both in Python and Typescript).

NOTE: Keep comments brief and focused on information that stays relevant long-term. Don't write
comments that only describe the instantaneous change (e.g. what was just added/removed/refactored).
NOTE: Keep code comments brief and focused on information that stays relevant long-term.

## Writing

These rules apply to all prose you write: docs, commit messages, PR descriptions, reports, and replies.

Follow ASD-STE100 Simplified Technical English for technical text:

- Use approved words only. Each word has one meaning.
- Use one word for one idea. Do not use two words for the same thing.
- Write short sentences. Use 20 words or less for instructions.
- Use active voice. Write "Turn the switch", not "The switch must be turned".
- Write short paragraphs. Keep one topic in each paragraph.
- Keep code comments focused on information that is relevant long-term or for future readers.

## Testing

Expand Down
3 changes: 3 additions & 0 deletions backend/onyx/external_apps/providers/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,6 @@ class EndpointSpec(BaseModel):
# The policy a freshly-created built-in app starts this action at, unless the
# admin overrides it.
default_policy: EndpointPolicy = EndpointPolicy.ASK
# Set when the action needs a scope only a self-hosted deployment requests,
# which drops it from the cloud catalog (``registry.get_endpoint_catalog``).
requires_self_hosted_scope: bool = False
29 changes: 25 additions & 4 deletions backend/onyx/external_apps/providers/gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from onyx.external_apps.providers.base import OnyxManagedExtApp
from onyx.external_apps.providers.google_base import GoogleOAuthProvider
from shared_configs.configs import MULTI_TENANT


# Gmail API v1 (https://gmail.googleapis.com/gmail/v1/users/{userId}/...); the
Expand Down Expand Up @@ -56,20 +57,25 @@ class GmailAction(ExternalAppAction):
RestRoute(method="GET", path=_MESSAGE_ITEM),
),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.LABELS_READ,
normalised_name="List labels",
description="List the labels in the mailbox.",
matches=(RestRoute(method="GET", path=f"{_USER}/labels"),),
default_policy=EndpointPolicy.ALWAYS,
# `gmail.labels` isn't restricted, but labels are only useful alongside
# the message reads that are.
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.PROFILE_READ,
normalised_name="Read profile",
description="Read the connected account's Gmail profile.",
matches=(RestRoute(method="GET", path=f"{_USER}/profile"),),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.MESSAGES_SEND,
Expand All @@ -82,12 +88,14 @@ class GmailAction(ExternalAppAction):
normalised_name="Modify message labels",
description="Add or remove labels on a message (mark read, archive, …).",
matches=(RestRoute(method="POST", path=f"{_MESSAGE_ITEM}/modify"),),
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.MESSAGES_TRASH,
normalised_name="Trash a message",
description="Move a message to the trash.",
matches=(RestRoute(method="POST", path=f"{_MESSAGE_ITEM}/trash"),),
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.THREADS_READ,
Expand All @@ -98,6 +106,7 @@ class GmailAction(ExternalAppAction):
RestRoute(method="GET", path=_THREAD_ITEM),
),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.ATTACHMENTS_READ,
Expand All @@ -109,6 +118,7 @@ class GmailAction(ExternalAppAction):
),
),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.DRAFTS_READ,
Expand All @@ -119,6 +129,7 @@ class GmailAction(ExternalAppAction):
RestRoute(method="GET", path=_DRAFT_ITEM),
),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.DRAFTS_CREATE,
Expand All @@ -128,37 +139,47 @@ class GmailAction(ExternalAppAction):
description="Save a new draft email (not sent).",
matches=(RestRoute(method="POST", path=_DRAFTS),),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.DRAFTS_UPDATE,
normalised_name="Update a draft",
description="Replace the contents of an existing draft (not sent).",
matches=(RestRoute(method="PUT", path=_DRAFT_ITEM),),
default_policy=EndpointPolicy.ALWAYS,
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.DRAFTS_DELETE,
normalised_name="Delete a draft",
description="Permanently delete a draft.",
matches=(RestRoute(method="DELETE", path=_DRAFT_ITEM),),
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GmailAction.DRAFTS_SEND,
normalised_name="Send a draft",
description="Send an existing draft as an email.",
matches=(RestRoute(method="POST", path=f"{_DRAFTS}/send"),),
requires_self_hosted_scope=True,
),
]


# gmail.modify covers read, send, label, trash, threads, attachments, and the
# full draft lifecycle — but not permanent message delete, which keeps the
# integration safer by default.
_SELF_HOSTED_SCOPE = "https://www.googleapis.com/auth/gmail.modify"
# Every Gmail scope that can read mail or touch drafts is restricted, so on
# cloud the app is send-only.
_CLOUD_SCOPE = "https://www.googleapis.com/auth/gmail.send"


class GmailProvider(GoogleOAuthProvider, OnyxManagedExtApp):
spec = GoogleOAuthProvider.build_spec(
app_type=ExternalAppType.GMAIL,
app_name="Gmail",
# gmail.modify covers read, send, label, trash, threads, attachments, and
# the full draft lifecycle — but not permanent message delete, which keeps
# the integration safer by default.
scope="https://www.googleapis.com/auth/gmail.modify",
scope=_CLOUD_SCOPE if MULTI_TENANT else _SELF_HOSTED_SCOPE,
upstream_url_patterns=["https://gmail\\.googleapis\\.com/gmail/.*"],
google_api_name="Gmail API",
endpoint_catalog=_ENDPOINTS,
Expand Down
6 changes: 6 additions & 0 deletions backend/onyx/external_apps/providers/google_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_TOKEN_URL = "https://oauth2.googleapis.com/token"

# Google's restricted scopes (all of Gmail bar `gmail.send`/`gmail.labels`, all
# of Drive bar `drive.file`) put the OAuth client under an annual third-party
# security assessment. Cloud runs Onyx's verified client, so each provider picks
# a restricted-free scope there and marks the actions it can't cover
# `requires_self_hosted_scope`.

# Every Google provider authenticates with the same Cloud Console OAuth client.
_CLIENT_CREDENTIAL_FIELDS = [
OrgCredentialField(
Expand Down
13 changes: 12 additions & 1 deletion backend/onyx/external_apps/providers/google_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from onyx.external_apps.providers.base import OnyxManagedExtApp
from onyx.external_apps.providers.google_base import GoogleOAuthProvider
from shared_configs.configs import MULTI_TENANT


# Google Calendar REST v3 (https://www.googleapis.com/calendar/v3/...); the
Expand Down Expand Up @@ -79,11 +80,21 @@ class GoogleCalendarAction(ExternalAppAction):
]


_SELF_HOSTED_SCOPE = "https://www.googleapis.com/auth/calendar"
# No Calendar scope is restricted, so this is just the least-privilege spelling
# of the above — the whole catalog survives.
_CLOUD_SCOPE = (
"https://www.googleapis.com/auth/calendar.events "
"https://www.googleapis.com/auth/calendar.calendarlist.readonly "
"https://www.googleapis.com/auth/calendar.freebusy"
)


class GoogleCalendarProvider(GoogleOAuthProvider, OnyxManagedExtApp):
spec = GoogleOAuthProvider.build_spec(
app_type=ExternalAppType.GOOGLE_CALENDAR,
app_name="Google Calendar",
scope="https://www.googleapis.com/auth/calendar",
scope=_CLOUD_SCOPE if MULTI_TENANT else _SELF_HOSTED_SCOPE,
upstream_url_patterns=["https://www\\.googleapis\\.com/calendar/.*"],
google_api_name="Google Calendar API",
endpoint_catalog=_ENDPOINTS,
Expand Down
19 changes: 16 additions & 3 deletions backend/onyx/external_apps/providers/google_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from onyx.external_apps.providers.base import OnyxManagedExtApp
from onyx.external_apps.providers.google_base import GoogleOAuthProvider
from shared_configs.configs import MULTI_TENANT


# Google Drive API v3. Reads (GET) live under `/drive/v3/...`; content uploads
Expand Down Expand Up @@ -71,6 +72,8 @@ class GoogleDriveAction(ExternalAppAction):
RestRoute(method="GET", path="/drive/v3/drives/{driveId}"),
),
default_policy=EndpointPolicy.ALWAYS,
# drives.list needs a Drive-wide scope; `drive.file` can't reach it.
requires_self_hosted_scope=True,
),
EndpointSpec(
id=GoogleDriveAction.FILES_CREATE,
Expand Down Expand Up @@ -124,13 +127,23 @@ class GoogleDriveAction(ExternalAppAction):
]


# Full drive scope: read, search, create, edit, and delete any of the user's
# files. Mutations are gated by per-action ASK approval.
_SELF_HOSTED_SCOPE = "https://www.googleapis.com/auth/drive"
# Every Drive-wide scope is restricted, so cloud pairs per-file access (files
# Onyx created or the user opened with it) with the Docs API, which reaches any
# Google Doc by id.
_CLOUD_SCOPE = (
"https://www.googleapis.com/auth/drive.file "
"https://www.googleapis.com/auth/documents"
)


class GoogleDriveProvider(GoogleOAuthProvider, OnyxManagedExtApp):
spec = GoogleOAuthProvider.build_spec(
app_type=ExternalAppType.GOOGLE_DRIVE,
app_name="Google Drive",
# Full drive scope: read, search, create, edit, and delete any of the
# user's files. Mutations are gated by per-action ASK approval.
scope="https://www.googleapis.com/auth/drive",
scope=_CLOUD_SCOPE if MULTI_TENANT else _SELF_HOSTED_SCOPE,
upstream_url_patterns=[
"https://www\\.googleapis\\.com/drive/.*",
# Content uploads use the separate /upload host path.
Expand Down
23 changes: 20 additions & 3 deletions backend/onyx/external_apps/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from onyx.external_apps.providers.linear import LinearProvider
from onyx.external_apps.providers.notion import NotionProvider
from onyx.external_apps.providers.slack import SlackProvider
from shared_configs.configs import MULTI_TENANT

_PROVIDER_CLASSES: list[type[ExternalAppProvider]] = [
SlackProvider,
Expand Down Expand Up @@ -61,6 +62,14 @@ def get_onyx_managed_provider(app_type: ExternalAppType) -> OnyxManagedExtApp |
return provider if isinstance(provider, OnyxManagedExtApp) else None


def uses_cloud_scope(app_type: ExternalAppType) -> bool:
"""Whether this app connects with Onyx's cloud OAuth client rather than
credentials the deployment owns. That client is verified with the upstream
provider, so providers narrow their scope there (see ``GoogleOAuthProvider``)
and the actions it can't cover drop out of the catalog."""
return MULTI_TENANT and get_onyx_managed_provider(app_type) is not None


def get_provider_or_raise(app: ExternalApp) -> ExternalAppProvider:
provider = get_provider_for_app(app)
if provider is None:
Expand Down Expand Up @@ -98,15 +107,23 @@ def _descriptor_for(
description=e.description,
default_policy=e.default_policy,
)
for e in spec.endpoint_catalog
for e in get_endpoint_catalog(spec.app_type)
],
)


def get_endpoint_catalog(app_type: ExternalAppType) -> list[EndpointSpec]:
"""The action catalog for an app_type (empty for CUSTOM / unregistered)."""
"""The action catalog for an app_type (empty for CUSTOM / unregistered),
minus the actions the OAuth grant in force can't cover. Every consumer
(admin view, policy resolution, the runtime gate) funnels through here, so
none of them can offer an action the grant won't authorize."""
provider = PROVIDERS.get(app_type)
return list(provider.spec.endpoint_catalog) if provider is not None else []
if provider is None:
return []
catalog = provider.spec.endpoint_catalog
if uses_cloud_scope(app_type):
return [e for e in catalog if not e.requires_self_hosted_scope]
return list(catalog)


def effective_policy(
Expand Down
1 change: 1 addition & 0 deletions backend/onyx/server/features/mcp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,7 @@ async def _connect_oauth(
connection_headers=connection_config_dict.get("headers", {}),
transport=mcp_server.transport,
is_authenticated=is_authenticated,
force_reauthentication=request.force_reauthentication,
)
except OnyxError:
raise
Expand Down
4 changes: 4 additions & 0 deletions backend/onyx/server/features/mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@ class MCPUserOAuthConnectRequest(BaseModel):
server_id: int = Field(..., description="ID of the MCP server")
return_path: str = Field(..., description="Path to redirect to after callback")
include_resource_param: bool = Field(..., description="Include resource parameter")
force_reauthentication: bool = Field(
default=False,
description="Ignore stored OAuth tokens and start a fresh authorization flow",
)
oauth_client_id: str | None = Field(
None, description="OAuth client ID (optional for DCR)"
)
Expand Down
Loading
Loading