Skip to content

feat(api): admin JWT auth + /manage Organization endpoints + platform-admin scope#1

Open
patrickleet wants to merge 40 commits into
mainfrom
feat/admin-jwt-auth
Open

feat(api): admin JWT auth + /manage Organization endpoints + platform-admin scope#1
patrickleet wants to merge 40 commits into
mainfrom
feat/admin-jwt-auth

Conversation

@patrickleet
Copy link
Copy Markdown

@patrickleet patrickleet commented May 18, 2026

Summary

Adds admin-only /manage controllers to the OpenPanel API server with two auth modes and a platform-admin tenancy scope. The goal is to make the entire OpenPanel install programmatically manageable — from inside Terraform, OpenTofu, and Crossplane — without ever clicking through the dashboard.

This is a fork-only PR for review; not intended to be merged to upstream Openpanel-dev/openpanel as-is (some shape changes would be needed first).

Commits in this branch (oldest → newest)

Commit Subject
51d2dbd6 feat(auth): opt-in OIDC admin auth for /manage routes
6639661e feat(manage): add Organization CRUD endpoints
88dc0ef1 fix(manage): enforce same-org scoping on Org GET/UPDATE/DELETE; refuse Create
47199f25 feat(manage): platform-admin scope on Organization CRUD
248cd688 fix(manage): reserve the base slug, not just the final id, for platform-admin

What's in

/manage admin endpoints

Full CRUD over Organization, Project, Client, Reference, plus list endpoints for Dashboards and Reports. Routes live under /manage/* (rewritten from /api/manage/* at the ingress) and share a single validateAdminRequest preHandler.

Two auth modes for /manage

  1. Client-pair (existing): openpanel-client-id / openpanel-client-secret headers; requires type=root Client in the DB.
  2. OIDC JWT (new): Authorization: Bearer <jwt> against a configured issuer. Validated via JWKS discovery + jose. Required role configurable (ADMIN_OIDC_REQUIRED_ROLE, default openpanel:admin). Synthesizes a request-scoped IServiceClientWithProject so the controllers don't need to branch on auth source.

Both modes produce a Client-shaped record with a single organizationId. The OIDC mode reads the org from a configurable claim (ADMIN_OIDC_ORG_CLAIM, defaults to Zitadel's urn:zitadel:iam:user:resourceowner:id).

Same-org scoping (with the getOrganization bug fixed)

The earlier shape of getOrganization built its Prisma where with an object spread that always overrode the URL-param id with client.organizationId. GET /manage/organizations/<any-id> silently returned the caller's own Org rather than 404'ing, and UPDATE/DELETE had no scoping at all. Real-world consequence: a Crossplane Organization MR aimed at a brand-new Org tag silently renamed-then-deleted the platform-admin Org during reconcile.

Fix: explicit params.id === client.organizationId checks (with platform-admin bypass — see below), and a 409 on DELETE /manage/organizations/platform-admin so the sentinel can't be cascade-deleted out from under the operator.

Platform-admin scope

Detects platform-admin callers by sentinel: client.organizationId === 'platform-admin'. The openpanel-chart's bootstrap Job seeds that Organization row + root Client at install time.

Platform-admin bypasses tenant scoping on Org handlers:

Handler Tenant caller Platform-admin caller
LIST Returns caller's own Org Returns every Org
GET 404 unless id matches caller's org Looks up by id directly
CREATE 403 with explicit message Allowed
UPDATE 404 unless id matches caller's org Allowed by id
DELETE 404 unless id matches caller's org Allowed by id (except the sentinel itself → 409)

Project / Client / Reference handlers stay strictly tenant-scoped. The flow for multi-tenant provisioning becomes: platform-admin creates the tenant Org via Crossplane → per-tenant chart install's bootstrap Job mints the tenant's first root Client scoped to the new Org → tenant credentials manage their own Projects / Clients / References.

Guardrails:

  • createOrganization refuses to mint an Org whose slug(name) === 'platform-admin'. Without this the slug helper would silently produce platform-admin-1459 on the collision path, and the operator would never see the explicit 409 they probably meant to hit.
  • deleteOrganization refuses to delete the platform-admin Org itself.

Verification on pat-local

End-to-end via Crossplane:

  1. Bootstrap Job mints the platform-admin root Client; chart pushes credentials to AWS Secrets Manager.
  2. Crossplane provider-upjet-openpanel (generated from the OpenPanel Terraform provider via upjet) consumes those credentials via a ProviderConfig.
  3. An Organization MR (smoketest-attempt-3) reconciles to Synced=True, Ready=True, atProvider.id=smoketest-attempt-3 — the new Org is real, NOT a silent rename of platform-admin.
  4. Deleting the MR removes the new Org from the DB; platform-admin Org + Bootstrap root Client + Bootstrap Secret all intact.

Direct curl against /manage (7-step suite, 6/7 pass on 4719; 7/7 on 248c image once it lands):

(1) LIST                                                → 200, all 4 orgs
(2) CREATE tenant-alpha                                 → 200, id=tenant-alpha
(3) GET    tenant-alpha                                 → 200, id=tenant-alpha (not platform-admin!)
(4) PATCH  tenant-alpha name="Tenant Alpha"             → 200
(5) CREATE "Platform Admin"                             → 200→platform-admin-1459 on 4719; 409 on 248c
(6) DELETE platform-admin                               → 409
(7) DELETE tenant-alpha                                 → 200

Known boundaries / follow-ups

  • Tenant Org → Project/Client/Reference provisioning still requires the per-tenant root Client. Platform-admin can't yet create Projects in arbitrary tenant Orgs; that would need request-body organizationId plumbing on Project/Client/Reference handlers + the matching TF provider schema additions + an upjet regen. Deliberately out of scope for this PR — the per-tenant chart install handles it cleanly today.
  • Pre-existing typecheck break in synthesizeAdminClient (IServiceClientWithProject schema picked up ignoreCorsAndSecret, dropped cors); fixed in the same commit as the same-org bug fix because it was blocking pnpm typecheck.
  • Not upstream-ready as-is: this PR depends on the bootstrap-Org sentinel ('platform-admin'), which is a convention from this fork's chart, not an upstream notion. For upstream we'd want a proper platform-admin role/claim in the data model.

Test plan

  • pnpm --filter @openpanel/api typecheck is clean
  • All 7 direct-curl checks pass on the published image
  • Crossplane Organization MR reconciles to the new Org (not platform-admin)
  • Clean teardown — platform-admin Org, Bootstrap root Client, and openpanel-bootstrap-root Secret all survive MR delete
  • CI image build green for feat-admin-jwt-auth-248c

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added organization management endpoints supporting create, read, update, and delete operations with role-based access control
    • Introduced OAuth/OIDC JWT-based authentication for administrative access
  • Chores

    • Updated dependencies

Review Change Stack

lindesvard and others added 12 commits May 18, 2026 09:57
…Openpanel-dev#365)

* Add support for base url, project and org for open ai and base url, token and version for anthropic

* Update dependencies

* update: use named import for zod
…#352)

Closes Openpanel-dev#177.

Browser tabs previously read e.g. "Dashboard | OpenPanel.dev" regardless
of which project was open, making it hard to tell tabs apart when
multiple projects are pinned in the same window.

The existing `createProjectTitle()` helper already accepts an optional
projectName argument, but no caller passed it. Threading project name
through every route's loader/head would touch ~30 files; instead, this
adds a single `useProjectDocumentTitle` hook mounted in the project
layout that patches `document.title` on the client whenever it changes.

The hook observes mutations on `<head>` so it re-applies the project
name after navigation (when TanStack's head() re-renders the title) and
when the project query first resolves. SSR title is unchanged — this is
purely a browser tab UX improvement.

Result: "Dashboard | MyProject | OpenPanel.dev"
* feat: added smtp support to email

* refactor: added SMTP_PORT check and timeout configurations to createSmtpTransport

* doc: added description for SMTP

* chore: updating .env.example

* doc: added warning for SMTP failure

* Remove SMTP options from .env.template

Removed SMTP configuration options from the .env.template file.

---------

Co-authored-by: Carl-Gerhard Lindesvärd <1987198+lindesvard@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Important

Review skipped

Too many files!

This PR contains 223 files, which is 73 over the limit of 150.

To get a review, narrow the scope:
• coderabbit review --type committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 294323f8-f407-435a-b75e-8176bf5812a3

📥 Commits

Reviewing files that changed from the base of the PR and between 248cd68 and 4b30e33.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (223)
  • .env.example
  • .github/workflows/docker-build.yml
  • apps/api/Dockerfile
  • apps/api/package.json
  • apps/api/src/controllers/export.controller.ts
  • apps/api/src/controllers/insights.controller.ts
  • apps/api/src/controllers/live.controller.ts
  • apps/api/src/controllers/manage.controller.ts
  • apps/api/src/controllers/oauth-callback.controller.tsx
  • apps/api/src/index.ts
  • apps/api/src/routes/manage.router.ts
  • apps/api/src/routes/oauth-callback.router.ts
  • apps/api/src/utils/auth.ts
  • apps/api/src/utils/graceful-shutdown.ts
  • apps/public/content/articles/how-to-self-host-openpanel.mdx
  • apps/public/content/articles/introduction-to-openpanel.mdx
  • apps/public/content/articles/mixpanel-alternatives.mdx
  • apps/public/content/articles/self-hosted-product-analytics.mdx
  • apps/public/content/articles/self-hosted-web-analytics.mdx
  • apps/public/content/compare/ackee-alternative.json
  • apps/public/content/compare/amplitude-alternative.json
  • apps/public/content/compare/appsflyer-alternative.json
  • apps/public/content/compare/cabin-analytics-alternative.json
  • apps/public/content/compare/countly-alternative.json
  • apps/public/content/compare/crazy-egg-alternative.json
  • apps/public/content/compare/fathom-alternative.json
  • apps/public/content/compare/fullstory-alternative.json
  • apps/public/content/compare/funnelio-alternative.json
  • apps/public/content/compare/goatcounter-alternative.json
  • apps/public/content/compare/google-analytics-alternative.json
  • apps/public/content/compare/matomo-alternative.json
  • apps/public/content/compare/microsoft-clarity-alternative.json
  • apps/public/content/compare/mixpanel-alternative.json
  • apps/public/content/compare/mouseflow-alternative.json
  • apps/public/content/compare/pirsch-analytics-alternative.json
  • apps/public/content/compare/plausible-alternative.json
  • apps/public/content/compare/posthog-alternative.json
  • apps/public/content/compare/simple-analytics-alternative.json
  • apps/public/content/compare/smartlook-alternative.json
  • apps/public/content/compare/umami-alternative.json
  • apps/public/content/docs/self-hosting/environment-variables.mdx
  • apps/public/content/docs/self-hosting/self-hosting.mdx
  • apps/public/content/for/agencies.json
  • apps/public/content/for/developers.json
  • apps/public/content/for/ecommerce.json
  • apps/public/content/for/gdpr-compliant.json
  • apps/public/content/for/shopify.json
  • apps/public/content/for/startups.json
  • apps/public/content/pages/dpa.mdx
  • apps/public/content/pages/privacy.mdx
  • apps/public/content/pages/terms.mdx
  • apps/public/next.config.mjs
  • apps/public/src/app/(content)/open-source-analytics/page.tsx
  • apps/public/src/app/(content)/pricing/page.tsx
  • apps/public/src/app/(home)/_sections/faq.tsx
  • apps/public/src/app/dpa/download/page.tsx
  • apps/public/src/app/tools/url-checker/layout.tsx
  • apps/start/components.json
  • apps/start/package.json
  • apps/start/src/components/auth/sign-in-oidc.tsx
  • apps/start/src/components/charts/animation.ts
  • apps/start/src/components/charts/area-chart.tsx
  • apps/start/src/components/charts/area-gradient-defs.tsx
  • apps/start/src/components/charts/area.tsx
  • apps/start/src/components/charts/bar-chart.tsx
  • apps/start/src/components/charts/bar-x-axis.tsx
  • apps/start/src/components/charts/bar-y-axis.tsx
  • apps/start/src/components/charts/bar.tsx
  • apps/start/src/components/charts/chart-config-context.tsx
  • apps/start/src/components/charts/chart-context.tsx
  • apps/start/src/components/charts/chart-defs.ts
  • apps/start/src/components/charts/chart-formatters.ts
  • apps/start/src/components/charts/chart-reveal-clip.tsx
  • apps/start/src/components/charts/chart-stat-flow.tsx
  • apps/start/src/components/charts/composed-chart.tsx
  • apps/start/src/components/charts/dash-tail-stroke.tsx
  • apps/start/src/components/charts/decimate-time-series.ts
  • apps/start/src/components/charts/fade-edges.ts
  • apps/start/src/components/charts/grid.tsx
  • apps/start/src/components/charts/highlight-segment-bounds.ts
  • apps/start/src/components/charts/highlight-segment.tsx
  • apps/start/src/components/charts/line-chart.tsx
  • apps/start/src/components/charts/line.tsx
  • apps/start/src/components/charts/markers/chart-markers.tsx
  • apps/start/src/components/charts/markers/index.ts
  • apps/start/src/components/charts/markers/marker-group.tsx
  • apps/start/src/components/charts/motion-utils.ts
  • apps/start/src/components/charts/op-dashed-tail.ts
  • apps/start/src/components/charts/op-date-pill.tsx
  • apps/start/src/components/charts/op-hover-probe.tsx
  • apps/start/src/components/charts/op-marker-layer.tsx
  • apps/start/src/components/charts/op-references.tsx
  • apps/start/src/components/charts/op-referrer-spikes.tsx
  • apps/start/src/components/charts/op-series-dots.tsx
  • apps/start/src/components/charts/op-stat-hover-bridge.tsx
  • apps/start/src/components/charts/op-tooltip.tsx
  • apps/start/src/components/charts/path-stroke-utils.ts
  • apps/start/src/components/charts/pattern-area.tsx
  • apps/start/src/components/charts/series-bar-layout.ts
  • apps/start/src/components/charts/series-bar.tsx
  • apps/start/src/components/charts/series-dash-tail-overlay.tsx
  • apps/start/src/components/charts/series-highlight-layer.tsx
  • apps/start/src/components/charts/series-hover-dim.tsx
  • apps/start/src/components/charts/series-markers.tsx
  • apps/start/src/components/charts/series-point-marker.tsx
  • apps/start/src/components/charts/time-series-chart-shell.tsx
  • apps/start/src/components/charts/tooltip/chart-tooltip.tsx
  • apps/start/src/components/charts/tooltip/date-ticker.tsx
  • apps/start/src/components/charts/tooltip/index.ts
  • apps/start/src/components/charts/tooltip/tooltip-box.tsx
  • apps/start/src/components/charts/tooltip/tooltip-content.tsx
  • apps/start/src/components/charts/tooltip/tooltip-dot.tsx
  • apps/start/src/components/charts/tooltip/tooltip-indicator.tsx
  • apps/start/src/components/charts/use-area-segment-highlight.ts
  • apps/start/src/components/charts/use-chart-interaction.ts
  • apps/start/src/components/charts/use-highlight-segment.ts
  • apps/start/src/components/charts/use-line-segment-highlight.ts
  • apps/start/src/components/charts/use-mount-progress.ts
  • apps/start/src/components/charts/use-scheduled-tooltip.ts
  • apps/start/src/components/charts/x-axis.tsx
  • apps/start/src/components/charts/y-axis.tsx
  • apps/start/src/components/chat/chat-context-widget.tsx
  • apps/start/src/components/chat/tool-results/chat-ui-apply-result.tsx
  • apps/start/src/components/cohort/cohort-criteria-builder.tsx
  • apps/start/src/components/filters/FiltersBuilder.tsx
  • apps/start/src/components/filters/TableFilterPills.tsx
  • apps/start/src/components/overview/filters/overview-filters-buttons.tsx
  • apps/start/src/components/overview/overview-line-chart-tooltip.tsx
  • apps/start/src/components/overview/overview-line-chart.tsx
  • apps/start/src/components/overview/overview-live-histogram.tsx
  • apps/start/src/components/overview/overview-metric-card.tsx
  • apps/start/src/components/overview/overview-metrics.tsx
  • apps/start/src/components/overview/overview-top-devices.tsx
  • apps/start/src/components/overview/overview-top-geo.tsx
  • apps/start/src/components/overview/overview-top-sources.tsx
  • apps/start/src/components/profiles/table/index.tsx
  • apps/start/src/components/report-chart/common/serie-icon.tsx
  • apps/start/src/components/report/ReportInterval.tsx
  • apps/start/src/components/report/sidebar/PropertiesCombobox.tsx
  • apps/start/src/components/report/sidebar/ReportBreakdowns.tsx
  • apps/start/src/components/report/sidebar/ReportSeriesItem.tsx
  • apps/start/src/components/report/sidebar/filters/CohortFilterItem.tsx
  • apps/start/src/components/report/sidebar/filters/FilterItem.tsx
  • apps/start/src/components/sessions/table/index.tsx
  • apps/start/src/components/settings/edit-project-filters.tsx
  • apps/start/src/components/time-window-picker.tsx
  • apps/start/src/components/ui/filter-dropdown.tsx
  • apps/start/src/components/ui/input-enter.tsx
  • apps/start/src/hooks/use-event-names.ts
  • apps/start/src/hooks/use-event-query-filters.ts
  • apps/start/src/hooks/use-project-document-title.ts
  • apps/start/src/hooks/use-property-values.ts
  • apps/start/src/hooks/use-session-filters.ts
  • apps/start/src/hooks/use-table-filters.ts
  • apps/start/src/integrations/tanstack-query/root-provider.tsx
  • apps/start/src/lib/utils.ts
  • apps/start/src/modals/index.tsx
  • apps/start/src/modals/overview-filters.tsx
  • apps/start/src/modals/table-filters.tsx
  • apps/start/src/router.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.anonymous.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles._tabs.identified.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.sessions.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.tsx
  • apps/start/src/routes/_login.login.tsx
  • apps/start/src/routes/_login.tsx
  • apps/start/src/routes/_public.onboarding.tsx
  • apps/start/src/routes/_public.tsx
  • apps/start/src/routes/api/config.tsx
  • apps/start/src/server/get-envs.ts
  • apps/start/src/styles.css
  • apps/worker/src/boot-workers.ts
  • apps/worker/src/jobs/cron.ts
  • apps/worker/src/metrics.ts
  • bklit-issues.md
  • bklit-upstream-issues.md
  • packages/auth/src/oauth.ts
  • packages/common/server/get-client-ip.ts
  • packages/constants/index.ts
  • packages/db/code-migrations/17-migrate-deprecated-ranges.ts
  • packages/db/index.ts
  • packages/db/package.json
  • packages/db/src/buffers/base-buffer.ts
  • packages/db/src/buffers/bot-buffer.ts
  • packages/db/src/buffers/event-buffer.test.ts
  • packages/db/src/buffers/event-buffer.ts
  • packages/db/src/buffers/group-buffer.ts
  • packages/db/src/buffers/profile-backfill-buffer.ts
  • packages/db/src/buffers/profile-buffer.test.ts
  • packages/db/src/buffers/profile-buffer.ts
  • packages/db/src/buffers/replay-buffer.ts
  • packages/db/src/buffers/session-buffer.test.ts
  • packages/db/src/buffers/session-buffer.ts
  • packages/db/src/clickhouse/client.ts
  • packages/db/src/clickhouse/round-robin.test.ts
  • packages/db/src/clickhouse/round-robin.ts
  • packages/db/src/services/chart-field-resolution.test.ts
  • packages/db/src/services/chart-sql.test.ts
  • packages/db/src/services/chart.service.ts
  • packages/db/src/services/cohort.service.ts
  • packages/db/src/services/conversion.service.ts
  • packages/db/src/services/date.service.ts
  • packages/db/src/services/event.service.ts
  • packages/db/src/services/filter-where.service.ts
  • packages/db/src/services/funnel.service.ts
  • packages/db/src/services/overview.service.ts
  • packages/db/src/services/profile.service.ts
  • packages/db/src/services/referrer-spikes.service.ts
  • packages/db/src/services/reports.service.ts
  • packages/db/src/services/session.service.ts
  • packages/email/package.json
  • packages/email/src/index.tsx
  • packages/mcp/src/tools/analytics/profiles.test.ts
  • packages/queue/src/kafka.ts
  • packages/trpc/src/agents/models.ts
  • packages/trpc/src/routers/auth.ts
  • packages/trpc/src/routers/chart.ts
  • packages/trpc/src/routers/cohort.ts
  • packages/trpc/src/routers/overview.ts
  • packages/trpc/src/routers/profile.ts
  • packages/trpc/src/routers/session.ts
  • packages/validation/src/index.ts
  • packages/validation/src/track.validation.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

PR adds OIDC JWT bearer-token admin authentication and platform-scoped organization management. New auth flow verifies JWTs against remote JWKS, extracts role/org claims, and dispatches between JWT and legacy client-secret validation. Organization CRUD endpoints enforce platform-admin access, reject reserved org slugs, and prevent sentinel deletion. Manage router switches to new auth dispatcher and registers org endpoints.

Changes

Admin Organization Management with OIDC JWT Authentication

Layer / File(s) Summary
Admin JWT authentication via OIDC
apps/api/package.json, apps/api/src/utils/auth.ts
Adds jose dependency and implements optional OIDC JWT bearer-token auth. Discovers issuer JWKS endpoint, verifies JWT signature and issuer, extracts role and organization claims, and synthesizes a request-scoped admin client. New validateAdminRequest dispatcher selects between JWT admin auth and legacy client-secret validation.
Organization CRUD endpoints and access control
apps/api/src/controllers/manage.controller.ts
Adds Zod schemas for organization creation and update, defines platform-admin sentinel constant, and implements list, get, create, update, delete handlers. Enforces platform-admin vs tenant-scoped access; rejects creation with reserved slug; returns 404 on cross-org access; prevents deletion of sentinel org with 409.
Manage router authentication dispatch and organization routes
apps/api/src/routes/manage.router.ts
Updates router preHandler to call validateAdminRequest instead of validateManageRequest, importing organization schemas. Registers organization CRUD routes under /organizations and /organizations/:id endpoints.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A platform admin emerges,
JWT tokens in hand, OIDC verges,
Organizations flow with root-scoped grace,
While sentinel slugs hold a sacred place!
~bunny 🎀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main features added: admin JWT authentication, /manage Organization endpoints, and platform-admin scope for tenancy management.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/admin-jwt-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/controllers/manage.controller.ts`:
- Around line 441-450: Before calling db.organization.delete, invalidate cached
entries for all descendant projects and clients so deleted resources can't be
served from cache: after the existence check for the org (the existing variable)
but before db.organization.delete, query the org's projects and clients (e.g.
select their ids) and call the cache invalidation helpers getProjectByIdCached
and getClientByIdCached for each id (or their batch/invalidateAll variant if
available); ensure invalidation errors are handled or logged but do not abort
the deletion flow.

In `@apps/api/src/utils/auth.ts`:
- Around line 354-360: The discovery fetch currently uses fetch(discoveryUrl)
without a timeout which can hang; update the call in auth.ts to pass an
AbortSignal with AbortSignal.timeout(MS) (e.g., const signal =
AbortSignal.timeout(ADMIN_OIDC_DISCOVERY_TIMEOUT_MS) or inline
fetch(discoveryUrl, { signal:
AbortSignal.timeout(ADMIN_OIDC_DISCOVERY_TIMEOUT_MS) })) so the request is
bounded, and ensure you handle the abort/error path consistently where
discoveryRes and discovery are used (keep the existing error throw for non-ok
responses and add handling for an abort/timeout error).
- Around line 350-353: The discovery URL construction uses a root-relative path
("/.well-known/openid-configuration") which discards any path component of
config.issuer; update the code that builds discoveryUrl so it uses
".well-known/openid-configuration" (no leading slash) and ensure config.issuer
has a trailing slash when passed to new URL (e.g., append '/' if missing) so
issuers like "https://example.com/issuer1" resolve to
"https://example.com/issuer1/.well-known/openid-configuration"; locate the
discoveryUrl creation where config.issuer is used and adjust accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ae6ab6b-db40-4db7-b250-2d8887112ffb

📥 Commits

Reviewing files that changed from the base of the PR and between df9dd6e and 248cd68.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/api/package.json
  • apps/api/src/controllers/manage.controller.ts
  • apps/api/src/routes/manage.router.ts
  • apps/api/src/utils/auth.ts

Comment on lines +441 to +450
const existing = await db.organization.findFirst({
where: { id: request.params.id },
});
if (!existing) {
throw new HttpError('Organization not found', { status: 404 });
}

await db.organization.delete({
where: { id: request.params.id },
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Invalidate descendant client/project caches before cascading org deletion.

db.organization.delete() removes the org's projects and clients, but this handler never clears getProjectByIdCached or getClientByIdCached. That leaves deleted credentials and project records potentially usable until cache expiry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/controllers/manage.controller.ts` around lines 441 - 450, Before
calling db.organization.delete, invalidate cached entries for all descendant
projects and clients so deleted resources can't be served from cache: after the
existence check for the org (the existing variable) but before
db.organization.delete, query the org's projects and clients (e.g. select their
ids) and call the cache invalidation helpers getProjectByIdCached and
getClientByIdCached for each id (or their batch/invalidateAll variant if
available); ensure invalidation errors are handled or logged but do not abort
the deletion flow.

Comment on lines +350 to +353
const discoveryUrl = new URL(
'/.well-known/openid-configuration',
config.issuer,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For an OpenID Connect issuer URL that includes a path segment (for example a realm or tenant path), what is the correct discovery URL format for /.well-known/openid-configuration?

💡 Result:

For an OpenID Connect (OIDC) issuer URL that includes a path segment (such as a realm or tenant path), the correct discovery URL is formed by appending /.well-known/openid-configuration to the issuer URL, ensuring that any trailing slash in the issuer URL is removed first [1][2]. According to the OpenID Connect Discovery 1.0 specification, if the issuer value contains a path component, the Relying Party (RP) must remove any terminating slash from the issuer URL before appending the /.well-known/openid-configuration string [1][3]. For example, if the issuer URL is https://example.com/issuer1, the discovery URL is https://example.com/issuer1/.well-known/openid-configuration [1][2]. If the issuer URL were provided as https://example.com/issuer1/, the trailing slash would be removed to result in the same discovery URL [1].

Citations:


🏁 Script executed:

cd apps/api/src/utils && sed -n '340,365p' auth.ts

Repository: hops-ops/openpanel-app

Length of output: 828


🏁 Script executed:

node <<'EOF'
// Test URL constructor behavior with leading slash
const issuerWithPath = 'https://example.com/issuer1';
const issuerWithoutPath = 'https://example.com';

// Current code pattern (with leading slash)
const url1 = new URL('/.well-known/openid-configuration', issuerWithPath);
console.log('With leading slash and path-based issuer:');
console.log('  Input issuer:', issuerWithPath);
console.log('  Result:', url1.toString());

// Correct pattern (without leading slash)
const url2 = new URL('.well-known/openid-configuration', issuerWithPath);
console.log('\nWithout leading slash and path-based issuer:');
console.log('  Input issuer:', issuerWithPath);
console.log('  Result:', url2.toString());

// What about trailing slashes in issuer?
const issuerWithTrailingSlash = 'https://example.com/issuer1/';
const url3 = new URL('.well-known/openid-configuration', issuerWithTrailingSlash);
console.log('\nWithout leading slash and issuer with trailing slash:');
console.log('  Input issuer:', issuerWithTrailingSlash);
console.log('  Result:', url3.toString());

const url4 = new URL('/.well-known/openid-configuration', issuerWithTrailingSlash);
console.log('\nWith leading slash and issuer with trailing slash:');
console.log('  Input issuer:', issuerWithTrailingSlash);
console.log('  Result:', url4.toString());
EOF

Repository: hops-ops/openpanel-app

Length of output: 699


Remove the leading slash from the discovery URL path to preserve issuer paths.

Line 350 uses '/.well-known/openid-configuration' which treats the path as root-relative, discarding any path component in config.issuer. According to OIDC Discovery 1.0 specification, for an issuer like https://example.com/issuer1, the discovery URL must be https://example.com/issuer1/.well-known/openid-configuration, not https://example.com/.well-known/openid-configuration. This breaks authentication for path-scoped issuers (realms, tenants, etc.).

Fix approach

Change:

const discoveryUrl = new URL(
  '/.well-known/openid-configuration',
  config.issuer,
);

To (without leading slash and ensuring issuer has trailing slash):

const discoveryUrl = new URL(
  '.well-known/openid-configuration',
  config.issuer.endsWith('/') ? config.issuer : config.issuer + '/',
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/utils/auth.ts` around lines 350 - 353, The discovery URL
construction uses a root-relative path ("/.well-known/openid-configuration")
which discards any path component of config.issuer; update the code that builds
discoveryUrl so it uses ".well-known/openid-configuration" (no leading slash)
and ensure config.issuer has a trailing slash when passed to new URL (e.g.,
append '/' if missing) so issuers like "https://example.com/issuer1" resolve to
"https://example.com/issuer1/.well-known/openid-configuration"; locate the
discoveryUrl creation where config.issuer is used and adjust accordingly.

Comment on lines +354 to +360
const discoveryRes = await fetch(discoveryUrl);
if (!discoveryRes.ok) {
throw new Error(
`Admin OIDC: discovery fetch failed (${discoveryRes.status}) for ${discoveryUrl}`,
);
}
const discovery = (await discoveryRes.json()) as { jwks_uri?: string };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In current Node.js, does fetch()have a default request timeout, and isAbortSignal.timeout() the recommended way to bound it?

💡 Result:

In Node.js, the native fetch function does not have a built-in, configurable request timeout [1][2]. By default, it will wait indefinitely for a response unless the underlying network connection times out or is otherwise interrupted [2]. Yes, AbortSignal.timeout is the recommended, standard way to bound the duration of a fetch request [3][4][5]. This approach is part of the Web Fetch API standard, making it the cross-platform, future-proof method for handling timeouts [6][7]. To use it, pass the signal to the fetch options object [3][5]: try { const response = await fetch('https://example.com', { signal: AbortSignal.timeout(5000) // Aborts after 5 seconds }); const data = await response.json; } catch (err) { if (err.name === 'TimeoutError') { console.error('The request timed out'); } else { console.error('Fetch error:', err); } } When a timeout occurs, the signal aborts the request and throws a TimeoutError [5]. Note that while some older third-party libraries (like node-fetch) previously offered a proprietary timeout option, this is non-standard and discouraged in favor of AbortSignal [6][7].

Citations:


Add a timeout to the discovery fetch to prevent indefinite hangs on admin requests.

Node.js's native fetch() has no default timeout and will wait indefinitely if the issuer is slow or unreachable, blocking admin requests in the /manage auth preHandler. AbortSignal.timeout() is the standard, recommended way to bound fetch duration:

-  const discoveryRes = await fetch(discoveryUrl);
+  const discoveryRes = await fetch(discoveryUrl, {
+    signal: AbortSignal.timeout(5_000),
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const discoveryRes = await fetch(discoveryUrl);
if (!discoveryRes.ok) {
throw new Error(
`Admin OIDC: discovery fetch failed (${discoveryRes.status}) for ${discoveryUrl}`,
);
}
const discovery = (await discoveryRes.json()) as { jwks_uri?: string };
const discoveryRes = await fetch(discoveryUrl, {
signal: AbortSignal.timeout(5_000),
});
if (!discoveryRes.ok) {
throw new Error(
`Admin OIDC: discovery fetch failed (${discoveryRes.status}) for ${discoveryUrl}`,
);
}
const discovery = (await discoveryRes.json()) as { jwks_uri?: string };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/utils/auth.ts` around lines 354 - 360, The discovery fetch
currently uses fetch(discoveryUrl) without a timeout which can hang; update the
call in auth.ts to pass an AbortSignal with AbortSignal.timeout(MS) (e.g., const
signal = AbortSignal.timeout(ADMIN_OIDC_DISCOVERY_TIMEOUT_MS) or inline
fetch(discoveryUrl, { signal:
AbortSignal.timeout(ADMIN_OIDC_DISCOVERY_TIMEOUT_MS) })) so the request is
bounded, and ensure you handle the abort/error path consistently where
discoveryRes and discovery are used (keep the existing error throw for non-ok
responses and add handling for an abort/timeout error).

lindesvard and others added 13 commits May 28, 2026 07:08
* init

* update

* performance changes

* bump bklit

* update bklit

* bklit update
Fork-only commit; do NOT cherry-pick upstream.

Single env var change so the existing multi-arch build + manifest
merge pipeline publishes to `ghcr.io/hops-ops/{api,worker,dashboard}`
instead of `ghcr.io/openpanel-dev/{api,worker,dashboard}`. Everything
else — Dockerfiles, build matrix, GHA cache, tag scheme — is
unchanged.

Followup commits will add OIDC + SMTP support and a semver tag step.
Adds a generic OIDC sign-in path that works with any compliant
Identity Provider (Zitadel, Keycloak, Authentik, Okta, etc.) by
configuring endpoint URLs directly rather than per-provider
library support.

Provider is enabled when these env vars are all set:
  OIDC_CLIENT_ID
  OIDC_CLIENT_SECRET
  OIDC_REDIRECT_URI                — <dashboard>/api/oauth/oidc/callback
  OIDC_AUTHORIZATION_ENDPOINT
  OIDC_TOKEN_ENDPOINT

Optional:
  OIDC_DISPLAY_NAME                — label shown on sign-in button
                                      (defaults to "Single Sign-On")

Implementation mirrors the existing Google flow:
- PKCE (S256) for the auth code exchange
- User identity from the ID token (sub, email, given_name,
  family_name; falls back to `name` claim if given_name absent)
- email_verified honoured when present; defaults to verified when
  the IdP doesn't emit the claim (operators choose trusted IdPs)

Files:
- packages/auth/src/oauth.ts — new `oidc` Arctic.OAuth2Client +
  endpoint exports + `isOidcEnabled()` helper.
- packages/trpc/src/routers/auth.ts — zProvider enum gains 'oidc';
  signInOAuth branch creates the authorization URL.
- apps/api/src/controllers/oauth-callback.controller.tsx — new
  oidcCallback + fetchOidcUser; validateOAuthCallback generalised
  to handle any PKCE provider via cookie naming convention
  (<provider>_code_verifier).
- apps/api/src/routes/oauth-callback.router.ts — registers
  GET /oidc/callback.

Dashboard sign-in button rendering still needs a small UI change
(separate commit) so end-users see an OIDC option. tRPC route is
fully wired regardless.
Adds a SignInOidc component that renders on the /login and
/onboarding pages whenever the server's getServerEnvs reports
oidc.enabled=true (i.e. when the OIDC_CLIENT_ID/SECRET/REDIRECT_URI/
AUTHORIZATION_ENDPOINT/TOKEN_ENDPOINT env vars are all set).

- packages/auth flag isOidcEnabled already gates the tRPC route
  and the callback; this commit just surfaces the same check to
  the client via the existing getServerEnvs server function.
- Button label uses OIDC_DISPLAY_NAME, defaulting to "Single Sign-On".
- Falls back to null render when OIDC is not configured so existing
  installs (Google/GitHub only) see no UI change.
- last-auth-provider cookie path extended so an OIDC user lands
  back on the OIDC button after sign-out.

Pair: /api/config now returns oidc.{enabled,displayName} so the
debug endpoint reflects what the UI is seeing.
The dashboard's getServerEnvs previously gated `oidc.enabled` on all
five OIDC_* env vars being set. That forced operators to either pass
OIDC_CLIENT_SECRET / endpoint URLs to the dashboard pod (where they
aren't needed — the OAuth flow runs in apps/api) or accept that the
SignInOidc button never renders.

Tightened to check only OIDC_CLIENT_ID. The api pod still validates
the full env when handling /api/oauth/oidc/callback (oidcCallback in
apps/api/src/controllers/oauth-callback.controller.tsx) so an
incomplete config on the api side still errors loudly at sign-in
time — just not in a way that hides the button.
Adds a JWT-bearer admin auth path for /manage routes, gated behind
ADMIN_OIDC_ISSUER configuration. When unset, /manage continues to
accept the existing openpanel-client-id / openpanel-client-secret
Client-pair auth unchanged.

When configured, /manage accepts `Authorization: Bearer <jwt>` from
any token signed by the configured OIDC issuer (Zitadel, Keycloak,
Authentik, etc.) that:
  - validates against the issuer's JWKS (discovered via
    /.well-known/openid-configuration)
  - matches the configured audience (ADMIN_OIDC_AUDIENCE)
  - carries the required role claim (ADMIN_OIDC_REQUIRED_ROLE,
    defaults to 'openpanel:admin'), tolerantly looking at
    `roles[]`, `scope`, and Zitadel's nested
    `urn:zitadel:iam:org:project:roles` shape
  - carries an organization claim (ADMIN_OIDC_ORG_CLAIM, defaults
    to the Zitadel resourceowner-id claim)

The JWT-validated request synthesizes a Client-shaped record with
`type: root`, `secret: null`, `id: jwt:<sub>`, and the
organizationId from the claim — so existing controllers in
manage.controller.ts work without branching on auth source.

Implementation:
- jose@^6 added to apps/api for JWKS-based JWT verification
- validateAdminJwtRequest + validateAdminRequest wrapper in
  apps/api/src/utils/auth.ts
- manage.router.ts switches its preHandler from
  validateManageRequest to validateAdminRequest

Backwards-compatible. Off by default. No DB schema changes. No new
auth tier inside OpenPanel — identity / orgs / roles stay in the
configured IdP.

Refs the openpanel-admin-jwt-auth spec.
Adds GET / POST / PATCH / DELETE /manage/organizations[/:id] routes
mirroring the existing /manage/projects shape. Use cases:

- Platform-admin OIDC callers (the new JWT auth path) provisioning
  new Organizations as part of tenant onboarding workflows
- Root-Client callers reading/updating/deleting their own
  Organization

v1 deliberately scopes list/get to the caller's bound organization.
Cross-org listing for a true instance-admin claim would require a
richer claim model than v1 ships; the endpoints just trust the auth
context's organizationId.

Member and Invite admin endpoints are deferred — the
JWT-auth-bootstrap-then-OIDC-sign-in flow makes them less load-bearing
than they'd be for a session-cookie admin UX. Adding them later is
additive.

Refs the openpanel-admin-jwt-auth spec.
…e Create

The original `getOrganization` built its Prisma `where` with an object
spread that always overrode `id` with `request.client.organizationId`,
so GET `/manage/organizations/<any-id>` silently returned the caller's
own Organization rather than 404'ing. The same gap let UPDATE and
DELETE target the caller's org even when the URL param pointed at a
different ID.

Crossplane / Terraform reconciles surfaced this loudly: an
`openpanel_organization` MR with `forProvider.name = smoketest-org`
caused a successful POST (new Org row in DB) followed by a READ that
returned the caller's anchored Org. TF treated that as drift, called
UPDATE on the wrong row, then DELETE on tear-down — wiping the
bootstrap Org and cascading to its root Client.

Fixes:

- `getOrganization`: explicit `params.id === client.organizationId`
  check; 404 otherwise. No more spread override.
- `updateOrganization` / `deleteOrganization`: same scoping check
  before mutating.
- `createOrganization`: 403 for now. Both client-pair root auth and
  the synthesized OIDC-JWT admin client are anchored to one
  organizationId; a successful Create would produce an Org the same
  caller can never READ back. Until a real platform-admin scope
  exists (multi-org JWT claim, or a reserved `organizationId =
  "platform"`), Org provisioning is operator-driven via the
  chart bootstrap Job. TenantStack will route through that platform
  scope once it lands.

Also fixes a pre-existing typecheck break in `synthesizeAdminClient`
(IServiceClientWithProject schema changed under the JWT auth PR —
drop `cors`, add `ignoreCorsAndSecret`).

Local verification on pat-local pending image rebuild.
Detects platform-admin callers by sentinel `client.organizationId ===
'platform-admin'` (the row + root Client the openpanel-chart bootstrap
Job seeds at install time). Bypasses same-org filtering on:

  LIST   — returns every Organization in the install
  GET    — fetches by URL id without org-match
  UPDATE — updates by URL id without org-match
  DELETE — deletes by URL id without org-match
  CREATE — allowed (was 403 for everyone in the previous commit)

Tenant-scoped callers still get the strict same-org behavior.

Guardrails:

- `createOrganization` refuses to mint a new Org whose slugified id
  collides with the `platform-admin` sentinel. The bootstrap Job
  INSERTs that row directly via SQL and isn't subject to the gate.
- `deleteOrganization` refuses to delete the `platform-admin` Org
  itself (would cascade-delete the platform-admin root Client and
  lock the operator out of /manage until the chart bootstrap re-runs).

Unblocks TenantStack: a Crossplane Organization MR using the
platform-admin client's credentials can now provision a fresh tenant
Org, after which the per-tenant chart install's own bootstrap Job
mints that tenant's first root Client scoped to the new Org. Project /
Client / Reference handlers stay tenant-scoped — the per-tenant
credentials manage those, not the platform-admin.
…rm-admin

 already appends a random -NNNN suffix on
id collision, so naming a tenant 'Platform Admin' silently produced
`platform-admin-1459` (or similar) rather than 409'ing. That's a
confusing UX even though the suffixed id doesn't grant god-mode (the
isPlatformAdmin sentinel check requires an exact match).

Reject the request whenever `slug(name) === 'platform-admin'`, before
calling getId. Tenants who name themselves 'Platform Admin' now get
the explicit 409 they meant to see.
A JWT that carries the configured `platformAdminRole` (default
`openpanel:platform-admin`) is synthesized with the sentinel
`organizationId='platform-admin'`. The manage controllers'
`isPlatformAdmin` check then bypasses tenant scoping — same code path
as the chart-managed bootstrap root Client, just with rotation +
audit-trail provided by the IdP instead of a static shared secret.

Existing behavior preserved for tenant-scoped admin JWTs: tokens
without the platform-admin role get the org claim treatment as
before.

This lets Crossplane and TF callers reach the upjet provider via
the `oidc {}` provider config block — Zitadel mints a fresh token
per reconcile via client_credentials, the token's role grant
authorizes platform-admin scope, the server elevates accordingly.

Env:
  ADMIN_OIDC_PLATFORM_ADMIN_ROLE  defaults openpanel:platform-admin

Set this to the same value as ADMIN_OIDC_REQUIRED_ROLE if every admin
JWT should imply platform-admin. Leave as default to keep a separation
between tenant-admins and platform-admins.
Zitadel's `client_credentials` grant for MachineUsers does NOT include
project role claims in the access JWT — those are only retrievable via
the introspection endpoint. Role-based elevation works fine for
Keycloak / Auth0 / generic IdPs that DO assert roles in the access
token, but our primary IdP is Zitadel.

Add `ADMIN_OIDC_PLATFORM_ADMIN_AUDIENCE`: when the token's `aud` claim
contains the configured value, elevate to the `platform-admin`
sentinel scope. Operators restrict MachineUser membership of that
Zitadel Project to platform-admins; the audience-bound JWT then is
sufficient signal on its own.

Either path (`platformAdminRole` OR `platformAdminAudience`) is
independently sufficient. Both elevate to the sentinel; both bypass
the `requiredRole` check (platform-admin ≥ admin). Neither configured
falls back to the existing role-based admin / tenant-scoped flow.

`aud` is parsed correctly as either a string or string array per RFC
7519 §4.1.3.
@patrickleet patrickleet force-pushed the feat/admin-jwt-auth branch from 276ae72 to 4b30e33 Compare May 31, 2026 20:44
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.

5 participants