Skip to content
Open
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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,20 @@ LEGAL_PRIVACY_VERSION=2026-02-08

# ----------------------------------------------------------------------------
# Enterprise License
# RSA-signed license key to activate enterprise modules (SSO, On-Call).
# RSA-signed license key to activate enterprise modules (SAML SSO, On-Call).
# Without this, only the open-source core is active.
# Enterprise modules are subject to the Moneat Enterprise License (see ee/LICENSE).
# Contact licensing@moneat.io or visit https://moneat.io/pricing to obtain a key.
# ----------------------------------------------------------------------------
MONEAT_LICENSE_KEY=

# ----------------------------------------------------------------------------
# SSO (OIDC)
# OpenID Connect single sign-on. Works with any OIDC provider (Authentik,
# Authelia, Keycloak, Okta, Azure AD, etc.). No license key required.
# Configure via the dashboard under Settings > SSO.
# ----------------------------------------------------------------------------

# ----------------------------------------------------------------------------
# Slack Integration
# ----------------------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ cd dashboard && npm install && npm run dev

### Working on Enterprise Features

Enterprise modules (On-Call, SSO) live in the `ee/` directory and are licensed under the [Moneat Enterprise License](ee/LICENSE). The `ee/` directory is a Gradle subproject included as a `runtimeOnly` dependency — enterprise classes are on the classpath at runtime but never referenced at compile time from core code (except the `EnterpriseModule` interface in `FeatureRegistry`).
Enterprise modules (On-Call, SAML SSO) live in the `ee/` directory and are licensed under the [Moneat Enterprise License](ee/LICENSE). OIDC SSO is part of the open core and always available without a license key. The `ee/` directory is a Gradle subproject included as a `runtimeOnly` dependency — enterprise classes are on the classpath at runtime but never referenced at compile time from core code (except the `EnterpriseModule` interface in `FeatureRegistry`).

Enterprise modules are always built with the project. At runtime, the `FeatureRegistry` uses Java `ServiceLoader` to discover `EnterpriseModule` implementations. Licensed modules (SSO, On-Call) only activate when a valid `MONEAT_LICENSE_KEY` is set.
Enterprise modules are always built with the project. At runtime, the `FeatureRegistry` uses Java `ServiceLoader` to discover `EnterpriseModule` implementations. Licensed modules (SAML SSO, On-Call) only activate when a valid `MONEAT_LICENSE_KEY` is set.

```bash
# 1. Start databases
Expand All @@ -93,7 +93,7 @@ cd dashboard && npm install && npm run dev

| Variable | Required for |
|----------|-------------|
| `MONEAT_LICENSE_KEY` | Activating licensed enterprise modules (SSO, On-Call) |
| `MONEAT_LICENSE_KEY` | Activating licensed enterprise modules (SAML SSO, On-Call) |
| `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` / `TWILIO_FROM_NUMBER` | On-Call voice alerts |
| `SAML_CERT` / `SAML_KEY` / `SAML_ENTITY_ID` | SSO (SAML) |

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ Moneat is Sentry SDK, Datadog Agent, and OpenTelemetry (OTLP) compatible. Point
| User Feedback | Sentry-compatible feedback ingestion with status workflows | [Docs](https://moneat.io/docs) |
| Datadog Compatibility | Ingest from existing Datadog agents with no code changes | [Docs](https://moneat.io/docs) |
| On-Call & Incidents | PagerDuty-style escalations *(Enterprise)* | [Pricing](https://moneat.io/pricing) |
| SSO (OIDC) | Sign in with any OpenID Connect provider | [Docs](https://moneat.io/docs) |
| SSO (SAML) & Enforcement | SAML 2.0 and mandatory SSO *(Enterprise)* | [Pricing](https://moneat.io/pricing) |
| Terraform Provider | Manage Moneat resources as code | [Registry](https://registry.terraform.io/providers/moneat-io/moneat/latest) |

### Sentry SDK Compatibility
Expand Down Expand Up @@ -268,7 +270,7 @@ Copyright © 2026 Moneat
| `ee/` | [Moneat Enterprise License](ee/LICENSE) |
| Everything else | [GNU AGPLv3](LICENSE) |

Enterprise modules are gated by a signed license key (`MONEAT_LICENSE_KEY`). Without a valid key, only the open-source core is active. The AGPL does not apply to files in `ee/`.
Enterprise modules are gated by a signed license key (`MONEAT_LICENSE_KEY`). Without a valid key, only the open-source core is active. OIDC SSO is part of the open core and always available. SAML SSO and SSO enforcement require an enterprise license. The AGPL does not apply to files in `ee/`.

For licensing questions: [licensing@moneat.io](mailto:licensing@moneat.io)

Expand Down
1 change: 1 addition & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ tasks.jacocoTestReport {
"**/models/**", // data classes — no logic to test
"**/config/**", // infrastructure wiring (DB clients, Redis, Sentry, env)
"**/plugins/**", // Ktor plugin bootstrap — framework wiring, not business logic
"**/sso/**", // OIDC/OAuth routes & service — integration-heavy; gate via detekt + manual/E2E
"**/logging/**", // log appender setup
"**/enterprise/**", // on-call/feature-flag integration stubs
"**/Application*", // entry point
Expand Down
45 changes: 32 additions & 13 deletions backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,21 @@ class AuthService(

val tokenPair = refreshTokenService.generateRefreshToken(userId, normalizedEmail, orgId, orgRole)
SentryUtils.breadcrumb("auth", "Signup completed", mapOf("user_id" to userId))
val organizationSlug = organizationRepository.findById(orgId)?.slug
return AuthResponse(
token = tokenPair.accessToken,
refreshToken = tokenPair.refreshToken,
expiresIn = tokenPair.expiresIn,
user = UserResponse(userId, normalizedEmail, request.name, emailVerified, false, isAdmin)
user = UserResponse(
id = userId,
email = normalizedEmail,
name = request.name,
emailVerified = emailVerified,
onboardingCompleted = false,
isAdmin = isAdmin,
organizationSlug = organizationSlug,
orgRole = orgRole,
)
)
}

Expand Down Expand Up @@ -367,17 +377,20 @@ class AuthService(
}

val tokenPair = refreshTokenService.generateRefreshToken(user.id, user.email, orgId, orgRole)
val organizationSlug = organizationRepository.findById(orgId)?.slug
return AuthResponse(
token = tokenPair.accessToken,
refreshToken = tokenPair.refreshToken,
expiresIn = tokenPair.expiresIn,
user = UserResponse(
user.id,
user.email,
user.name,
user.emailVerified,
user.onboardingCompleted,
user.isAdmin
id = user.id,
email = user.email,
name = user.name,
emailVerified = user.emailVerified,
onboardingCompleted = user.onboardingCompleted,
isAdmin = user.isAdmin,
organizationSlug = organizationSlug,
orgRole = orgRole,
)
)
}
Expand Down Expand Up @@ -543,6 +556,7 @@ class AuthService(
true,
user.isAdmin,
finalSlug,
membership.role,
null,
hiddenItems
)
Expand Down Expand Up @@ -570,13 +584,18 @@ class AuthService(

val user = run {
val userRow = userRepository.findById(userId) ?: return null
val membership = membershipRepository.getFirstMembershipForUser(userId)
val organizationSlug =
membership?.let { organizationRepository.findById(it.organizationId)?.slug }
UserResponse(
userId,
email,
userRow.name,
userRow.emailVerified,
userRow.onboardingCompleted,
userRow.isAdmin
id = userId,
email = email,
name = userRow.name,
emailVerified = userRow.emailVerified,
onboardingCompleted = userRow.onboardingCompleted,
isAdmin = userRow.isAdmin,
organizationSlug = organizationSlug,
orgRole = membership?.role,
Comment on lines +587 to +598
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

Derive the refresh response from the rotated token's org context.

This now uses getFirstMembershipForUser(userId), so a multi-org user can receive organizationSlug / orgRole for a different org than the tokenPair.accessToken you just issued. After refresh, the client can end up rendering the wrong org context or role until the next full reload.

🔧 Suggested fix
         val decodedJWT = jwtVerifier.verify(tokenPair.accessToken)
         val userId = decodedJWT.getClaim("userId").asInt()
         val email = decodedJWT.getClaim("email").asString()
+        val orgId = decodedJWT.getClaim("orgId").asInt()
+        val orgRole = decodedJWT.getClaim("orgRole").asString()

         val user = run {
             val userRow = userRepository.findById(userId) ?: return null
-            val membership = membershipRepository.getFirstMembershipForUser(userId)
-            val organizationSlug =
-                membership?.let { organizationRepository.findById(it.organizationId)?.slug }
+            val organizationSlug = organizationRepository.findById(orgId)?.slug
             UserResponse(
                 id = userId,
                 email = email,
                 name = userRow.name,
                 emailVerified = userRow.emailVerified,
                 onboardingCompleted = userRow.onboardingCompleted,
                 isAdmin = userRow.isAdmin,
                 organizationSlug = organizationSlug,
-                orgRole = membership?.role,
+                orgRole = orgRole,
             )
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt` around lines
587 - 598, The UserResponse is built using
membershipRepository.getFirstMembershipForUser(userId), which can pick the wrong
org for multi-org users after a token refresh; instead derive the organization
context from the rotated token you just issued (tokenPair.accessToken) and use
that to fetch membership and organization data. Parse the organization
identifier (e.g., organizationId or organizationSlug) from the rotated access
token's claims, then call the membership lookup that accepts both userId and
that org id (instead of getFirstMembershipForUser) and use
organizationRepository.findById/findBySlug based on that org id to populate
organizationSlug and orgRole so the refresh response matches the token's org
context.

)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ data class UserResponse(
val onboardingCompleted: Boolean = false,
val isAdmin: Boolean = false,
val organizationSlug: String? = null,
val orgRole: String? = null,
val demoEpochMs: Long? = null,
val sidebarHiddenItems: List<String> = emptyList(),
val phoneNumber: String? = null,
Expand Down
28 changes: 25 additions & 3 deletions backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package com.moneat.events.routes

import com.moneat.auth.routes.accountDeletionRoutes
import com.moneat.auth.services.Quadruple
import com.moneat.billing.routes.billingRoutes
import com.moneat.billing.routes.publicBillingRoutes
import com.moneat.billing.services.PricingTierService
import com.moneat.events.models.AddTargetRequest
import com.moneat.events.models.AlertNotificationPreferencesResponse
import com.moneat.events.models.CreateProjectRequest
Expand Down Expand Up @@ -94,6 +96,24 @@ fun Route.apiRoutes() {
// Protected billing routes
billingRoutes()

// Subscription tier (for SSO visibility, etc.)
get("/subscription") {
val principal = call.principal<JWTPrincipal>()
val userId = principal!!.payload.getClaim("userId").asInt()
val pricingTierService = koin.get<PricingTierService>()
val orgId =
pricingTierService.getPrimaryOrganizationIdForUser(userId) ?: run {
call.respond(HttpStatusCode.NotFound, ErrorResponse("No organization access"))
return@get
}
val context = pricingTierService.getEffectiveTierForOrganization(orgId)
Comment on lines +104 to +109
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

Scope /subscription to the org in the JWT, not the user's primary org.

This endpoint is used for tier-gated SSO UI, but getPrimaryOrganizationIdForUser(userId) ignores the org currently selected in the access token. For multi-org users, that can return the wrong tier and expose or hide SSO settings for the wrong workspace.

🔧 Suggested fix
                 get("/subscription") {
                     val principal = call.principal<JWTPrincipal>()
                     val userId = principal!!.payload.getClaim("userId").asInt()
+                    val orgIdClaim = principal.payload.getClaim("orgId").asInt()
                     val pricingTierService = koin.get<PricingTierService>()
                     val orgId =
-                        pricingTierService.getPrimaryOrganizationIdForUser(userId) ?: run {
+                        orgIdClaim ?: pricingTierService.getPrimaryOrganizationIdForUser(userId) ?: run {
                             call.respond(HttpStatusCode.NotFound, ErrorResponse("No organization access"))
                             return@get
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt` around lines
104 - 109, The /subscription handler currently calls
pricingTierService.getPrimaryOrganizationIdForUser(userId) which ignores the
organization present in the access token; change it to read the organization id
from the JWT/claims (the org selected in the token) and use that orgId when
calling pricingTierService.getEffectiveTierForOrganization(orgId), returning
HttpStatusCode.NotFound only if the token has no org claim or the org is
invalid; update any references in ApiRoutes.kt to stop deriving orgId from
getPrimaryOrganizationIdForUser and instead pull it from the request
principal/claims before calling getEffectiveTierForOrganization.

call.respond(
mapOf(
"tier" to mapOf("tierName" to context.tier.tierName)
)
)
}

// Integrations
integrationRoutes()

Expand All @@ -103,11 +123,11 @@ fun Route.apiRoutes() {
val userId = principal!!.payload.getClaim("userId").asInt()
val demoEpochMs = call.getDemoEpochMs()

val (user, orgSlug, sidebarHiddenItems) =
val (user, orgSlug, orgRole, sidebarHiddenItems) =
transaction {
val userRow =
Users.selectAll().where { Users.id eq userId }.firstOrNull()
?: return@transaction Triple(null, null, emptyList())
?: return@transaction Quadruple(null, null, null, emptyList())

val membership =
Memberships
Expand All @@ -124,9 +144,10 @@ fun Route.apiRoutes() {
?.get(Organizations.slug)
}

val role = membership?.get(Memberships.role)
val hiddenItems = membership?.get(Memberships.sidebar_hidden_items) ?: emptyList()

Triple(userRow, slug, hiddenItems)
Quadruple(userRow, slug, role, hiddenItems)
}

if (user == null) {
Expand All @@ -141,6 +162,7 @@ fun Route.apiRoutes() {
user[Users.onboarding_completed],
user[Users.is_admin],
orgSlug,
orgRole,
demoEpochMs,
sidebarHiddenItems,
user[Users.phone_number],
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main/kotlin/com/moneat/plugins/Monitoring.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.moneat.plugins

import com.moneat.config.SentryConfig
import com.moneat.sso.SsoForbiddenException
import com.moneat.utils.ErrorResponse
import com.moneat.utils.SentryUtils
import io.ktor.http.HttpStatusCode
Expand Down Expand Up @@ -116,13 +117,23 @@ fun Application.configureMonitoring() {

install(StatusPages) {
exception<BadRequestException> { call, cause ->
logger.debug(cause) { "Bad request: ${cause.message}" }
if (!call.response.isCommitted) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(cause.message ?: "Bad request"),
)
}
}
exception<SsoForbiddenException> { call, cause ->
logger.debug(cause) { "SSO forbidden: ${cause.message}" }
if (!call.response.isCommitted) {
call.respond(
HttpStatusCode.Forbidden,
ErrorResponse(cause.message ?: "Forbidden"),
)
}
}
exception<Throwable> { call, cause ->
logger.error(cause) { "Unhandled exception: ${cause.message}" }

Expand Down
25 changes: 25 additions & 0 deletions backend/src/main/kotlin/com/moneat/sso/SsoForbiddenException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Moneat - observability platform
// Copyright (C) 2026 Moneat
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package com.moneat.sso

/**
* Thrown when the caller is authenticated but not permitted to configure SSO
* (e.g. not an organization owner, or licensing / plan constraints).
*/
class SsoForbiddenException(
message: String,
) : RuntimeException(message)
45 changes: 45 additions & 0 deletions backend/src/main/kotlin/com/moneat/sso/SsoModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Moneat - observability platform
// Copyright (C) 2026 Moneat
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package com.moneat.sso

import com.moneat.enterprise.EnterpriseModule
import com.moneat.sso.routes.ssoRoutes
import io.ktor.server.application.Application
import io.ktor.server.routing.Route

/**
* Core SSO module providing OIDC single sign-on for all deployments.
* No license required (licenseFeature = null, always loaded).
*
* SAML 2.0 and SSO enforcement ("Require SSO") require the enterprise
* SamlModule (licenseFeature = "sso").
*/
class SsoModule : EnterpriseModule {
override val name: String = "SSO"

override fun registerRoutes(route: Route) {
route.ssoRoutes()
}

override fun startBackgroundJobs(application: Application) {
// SSO has no background jobs
}

override fun stopBackgroundJobs() {
// No-op
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
// Moneat Enterprise - proprietary module
// Copyright (c) 2026 Moneat. All rights reserved.
// See ee/LICENSE for license terms.
// Moneat - observability platform
// Copyright (C) 2026 Moneat
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package com.moneat.enterprise.sso.models
package com.moneat.sso.models

import kotlinx.serialization.Serializable

Expand Down
Loading
Loading