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
1 change: 0 additions & 1 deletion backend/modules/iam/handler/bulk_idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ func NewBulkIDPHandler(uc connectors.IdentityProviderUsecase, tenantLister func(
return &BulkIDPHandler{uc: uc, tenantLister: tenantLister}
}

// ponytail: resolveTenants duplicated from eventprocessing — same package boundary, not worth a shared pkg
func resolveIDPTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) {
if sel.AllTenants {
return lister(ctx)
Expand Down
17 changes: 16 additions & 1 deletion backend/modules/iam/usecase/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ func (u *identityProviderUsecase) prepareSettings(
if blank(s.MetadataURL, s.SpEntityID, s.SpACSURL, s.SpCertificatePem) {
return nil, domain.ErrIDPSettingsInvalid
}
// Format first line; real X.509/PKCS8 parse stays at login time.
if err := validateSAMLFormat(s); err != nil {
return nil, err
}
kept, err := u.keepOrEncrypt(s.SpPrivateKeyPem, previous, func(p *domain.IdentityProviderConfig) string {
old, _ := samlSettings(p)
return old.SpPrivateKeyPem
Expand All @@ -111,6 +115,9 @@ func (u *identityProviderUsecase) prepareSettings(
if blank(s.Issuer, s.ClientID, s.RedirectURL) {
return nil, domain.ErrIDPSettingsInvalid
}
if err := validateOIDCFormat(s); err != nil {
return nil, err
}
kept, err := u.keepOrEncrypt(s.ClientSecret, previous, func(p *domain.IdentityProviderConfig) string {
old, _ := oidcSettings(p)
return old.ClientSecret
Expand All @@ -122,6 +129,7 @@ func (u *identityProviderUsecase) prepareSettings(
return nil, domain.ErrIDPSettingsInvalid
}
s.ClientSecret = kept

return json.Marshal(s)

case domain.ProviderLDAP:
Expand All @@ -134,6 +142,9 @@ func (u *identityProviderUsecase) prepareSettings(
if blank(s.Host, s.BindDN, s.BaseDN, s.UserFilter) || !strings.Contains(s.UserFilter, "%s") {
return nil, domain.ErrIDPSettingsInvalid
}
if err := validateLDAPFormat(s); err != nil {
return nil, err
}
kept, err := u.keepOrEncrypt(s.BindPassword, previous, func(p *domain.IdentityProviderConfig) string {
old, _ := ldapSettings(p)
return old.BindPassword
Expand All @@ -144,7 +155,6 @@ func (u *identityProviderUsecase) prepareSettings(
if kept == "" {
return nil, domain.ErrIDPSettingsInvalid
}
s.BindPassword = kept
return json.Marshal(s)
}
return nil, domain.ErrIDPTypeUnsupported
Expand Down Expand Up @@ -235,6 +245,11 @@ func (u *identityProviderUsecase) build(
if strings.TrimSpace(req.Name) == "" {
return nil, domain.ErrIDPInvalidInput
}
// Name sits in /sso/<name>/login; on create it must be URL-safe. Edit leaves
// the input disabled, so a legacy name must not lock the form out of saving.
if previous == nil && (len(req.Name) > 64 || !idpNameRe.MatchString(req.Name)) {
return nil, domain.ErrIDPInvalidInput
}
settings, err := u.prepareSettings(kind, req.Settings, previous)
if err != nil {
return nil, err
Expand Down
117 changes: 117 additions & 0 deletions backend/modules/iam/usecase/idp_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package usecase

import (
"encoding/json"
"net/url"
"regexp"
"strings"

"github.com/utmstack/utmstack/backend/modules/iam/domain"
)

// idpNameRe keeps names URL-safe: they sit in /sso/<name>/login.
var idpNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

// idpHostRe matches a bare hostname or IP: no scheme, no path, no spaces.
var idpHostRe = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)

// Presence + secrets are decided by prepareSettings; this only rejects malformed values.
func validateIDPSettingsFormat(kind domain.ProviderType, raw json.RawMessage) error {
switch kind {
case domain.ProviderSAML:
var s domain.SAMLSettings
if err := json.Unmarshal(raw, &s); err != nil {
return domain.ErrIDPSettingsInvalid
}
return validateSAMLFormat(s)
case domain.ProviderOIDC:
var s domain.OIDCSettings
if err := json.Unmarshal(raw, &s); err != nil {
return domain.ErrIDPSettingsInvalid
}
return validateOIDCFormat(s)
case domain.ProviderLDAP:
var s domain.LDAPSettings
if err := json.Unmarshal(raw, &s); err != nil {
return domain.ErrIDPSettingsInvalid
}
return validateLDAPFormat(s)
default:
return domain.ErrIDPTypeUnsupported
}
}

func validateSAMLFormat(s domain.SAMLSettings) error {
if !idpIsHTTPURL(s.MetadataURL) || !idpIsHTTPURL(s.SpACSURL) ||
!idpIsURI(s.SpEntityID) ||
!strings.Contains(s.SpCertificatePem, "-----BEGIN CERTIFICATE-----") ||
!strings.Contains(s.SpCertificatePem, "-----END CERTIFICATE-----") {
return domain.ErrIDPSettingsInvalid
}
return nil
}

func validateOIDCFormat(s domain.OIDCSettings) error {
if !idpIsHTTPSURL(s.Issuer) || !idpIsRedirectURL(s.RedirectURL) {
return domain.ErrIDPSettingsInvalid
}
return nil
}

func validateLDAPFormat(s domain.LDAPSettings) error {
host := strings.TrimSpace(s.Host)
filter := strings.TrimSpace(s.UserFilter)
if host == "" || s.Port < 0 || s.Port > 65535 ||
!idpHostRe.MatchString(host) ||
strings.Count(filter, "(") != strings.Count(filter, ")") {
return domain.ErrIDPSettingsInvalid
}
return nil
}

func idpIsHTTPURL(v string) bool {
u, err := url.Parse(strings.TrimSpace(v))
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}

func idpIsHTTPSURL(v string) bool {
u, err := url.Parse(strings.TrimSpace(v))
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "https"
}

// idpIsURI accepts http(s) URLs or urn: identifiers (SAML entity IDs).
func idpIsURI(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
if strings.HasPrefix(strings.ToLower(v), "urn:") && len(v) > 4 {
return true
}
return idpIsHTTPURL(v)
}

func idpIsRedirectURL(v string) bool {
u, err := url.Parse(strings.TrimSpace(v))
if err != nil || u.Host == "" {
return false
}
if u.Scheme == "https" {
return true
}
return u.Scheme == "http" && isLoopbackHost(u.Hostname())
}

func isLoopbackHost(h string) bool {
switch h {
case "localhost", "127.0.0.1", "::1":
return true
}
return false
}
124 changes: 124 additions & 0 deletions frontend/src/features/settings/lib/idp-form-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Reglas de validación — Formulario de Identity Provider

Documento de referencia de las validaciones del frontend que se aplican sobre el
formulario **Add / Edit identity provider** (`/settings/identity-providers`).

- **Dónde corre:** `src/features/settings/lib/idp-form-validation.ts`
(`validateIdpForm`), consumido por `UpsertDialog` en
`src/features/settings/pages/IdentityProvidersPage.tsx`.
- **Cuándo:** en vivo, en cada render del formulario (los errores se pintan bajo
cada campo y bloquean el botón de guardar). No es asincrónico: no hace peticiones.
- **Qué devuelve:** un objeto `campo → clave i18n` (namespace `idp.form.errors.*`).
Objeto vacío = formulario válido. El componente traduce la clave al texto.

## Por qué existe

El backend solo comprueba **presencia** al guardar y **formato** al hacer login
(`modules/iam/usecase/idp.go` → `prepareSettings` y `buildSP`/`oidcConfig`/`ldapBind`).
Sin validación en el frontend, un admin puede guardar una URL mal escrita, un PEM
roto o un puerto fuera de rango y el error le llega a un usuario cuando intenta
iniciar sesión, no al admin cuando configura. Este módulo intercede ese gap.

> **La validación de formato aquí es de primera línea, no única.** El backend
> sigue siendo la última defensa y hace el parseo real (X.509 / PKCS8, discovery
> OIDC, dial LDAP) en el momento del login.

## Semántica del secreto (aplica a los tres protocolos)

Cada protocolo lleva exactamente **un campo secreto write-only** que **no** vuelve
del backend:

| Protocolo | Campo secreto |
|---|---|
| saml | `spPrivateKeyPem` |
| oidc | `clientSecret` |
| ldap | `bindPassword` |

Regla:

- **Create** → el secreto es **obligatorio** (sin él no hay nada que guardar).
- **Edit** → vacío significa **"mantener el guardado"**; no se valida formato.
- Si tiene valor en create, **sí** se valida su formato (armadura PEM, en SAML).

## Reglas comunes

| Campo | Tipo | Qué maneja | Validación | Error key |
|---|---|---|---|---|
| `name` | `string` | Nombre del provider; va en la URL de SSO (`/api/v1/sso/<name>/login`) | Obligatorio. Solo en **create**: ≤ 64 chars y `^[A-Za-z0-9][A-Za-z0-9._-]*$` (sin espacios). En **edit** el input está deshabilitado, así que su formato no bloquea guardar | `idp.form.errors.required` / `idp.form.errors.nameFormat` |

En edit, `providerType` también es inmutable (no validable por el usuario).

## SAML

| Campo | Tipo | Qué maneja | Validación | Error key |
|---|---|---|---|---|
| `metadataUrl` | `string` | URL de metadatos del IdP | Obligatorio. URL `http(s)` válida (parseo con `URL`) | `required` / `idp.form.errors.url` |
| `spEntityId` | `string` | Entity ID del Service Provider | Obligatorio. Solo en **create**: URI válida (http, https o `urn:`) — coge espacios y esquemas raros que el IdP rechazaría en login. En **edit** solo presencia (un valor heredado no bloquea el save) | `required` / `idp.form.errors.entityId` |
| `spAcsUrl` | `string` | Endpoint ACS que recibe la respuesta SAML | Obligatorio. URL `http(s)` válida | `required` / `idp.form.errors.url` |
| `spCertificatePem` | `string` | Certificado del SP | Obligatorio. Contiene armadura `-----BEGIN/END CERTIFICATE-----` | `required` / `idp.form.errors.pemCertificate` |
| `spPrivateKeyPem` | `string` | Clave privada del SP (secreto) | Ver sección de secreto | `required` / `idp.form.errors.pemKey` |

## OIDC

| Campo | Tipo | Qué maneja | Validación | Error key |
|---|---|---|---|---|
| `issuer` | `string` | Issuer contra el que corre la discovery | Obligatorio. URL **`https`** válida (https estricto: la discovery expone el secreto) | `required` / `idp.form.errors.httpsUrl` |
| `clientId` | `string` | Client ID de la app | Obligatorio (solo presencia) | `idp.form.errors.required` |
| `redirectUrl` | `string` | Callback registrado en el provider | Obligatorio. URL **https** válida; excepción: loopback `http` (`localhost`, `127.0.0.1`, `[::1]`) para clientes nativos, según RFC 8252 §7.3 — el token exchange no sale de la máquina | `required` / `idp.form.errors.redirectUrl` |
| `clientSecret` | `string` | Client secret (secreto) | Ver sección de secreto | `idp.form.errors.required` |

## LDAP

| Campo | Tipo | Qué maneja | Validación | Error key |
|---|---|---|---|---|
| `host` | `string` | Host del servidor LDAP | Obligatorio. Hostname/IP **sin esquema** ni ruta: `^(?!.*[\/\s])[A-Za-z0-9.-]+$` | `required` / `idp.form.errors.hostname` |
| `port` | `number` | Puerto de conexión | Opcional en formato. Si está, entero sin ceros a la izquierda y ≤ 65535. `0` es válido = default 389 (el backend diala `0` como `389`) | `idp.form.errors.port` |
| `bindDn` | `string` | Servicio que busca en el directorio | Obligatorio (solo presencia; no se valida la sintaxis DN, sería brittle) | `idp.form.errors.required` |
| `baseDn` | `string` | Raíz de la búsqueda | Obligatorio (solo presencia) | `idp.form.errors.required` |
| `userFilter` | `string` | Filtro LDAP donde entra la dirección de login | Obligatorio. Debe contener `%s` (placeholder). Paréntesis balanceados | `required` / `idp.form.errors.userFilterPlaceholder` / `idp.form.errors.userFilterUnbalanced` |
| `bindPassword` | `string` | Password del servicio (secreto) | Ver sección de secreto | `idp.form.errors.required` |

Los atributos LDAP `emailAttribute`, `nameAttribute`, `groupAttribute` y el
toggle `startTls` **no** se validan: el backend los tolera vacíos / usa defaults.

## Decisiones de diseño

- **PEM por armadura, no por parseo.** Se verifica la presencia de las líneas
`-----BEGIN/END …-----`. Un admin que pega una clave/cert real siempre trae los
marcadores, y su ausencia es justo el error de dedo que esto debe atrapar. No se
usa `crypto.subtle` para parsear porque no hay API de "parsear sin usar la clave"
y solo engordaría la lib. El parseo real (X.509 / PKCS8) lo hace el backend al
login.
- **`issuer` exige `https`**, mientras que `metadataUrl`/`redirectUrl` aceptan
`http`: la discovery OIDC viaja al issuer con las credenciales, así que el
canal debe ser cifrado.
- **`name` estricto solo en create.** En edit el input está deshabilitado; validar
su formato ahí bloquearía guardar cualquier otro cambio sobre un nombre heredado.
- **`port = 0` es válido.** Borrar el campo produce `0`, que es el default, no un
typo; no debe atrancar el form.

## Fuentes de las reglas

Las reglas de formato (URL, `https` estricto para `issuer`, loopback para
`redirectUrl`, URI para `spEntityId`, placeholder `%s` y paréntesis para
`userFilter`) se cruzaron contra las specs de cada protocolo:

- **OIDC** — [OpenID Connect Core 1.0 §3.1.2.1](https://openid.net/specs/openid-connect-core-1_0.html)
(`client_id`, `redirect_uri` required en la auth request) y
[RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3)
(loopback redirect URIs sobre http para clientes nativos).
- **SAML** — guías de proveedores (Cisco IPR, OneStream, Vendasta): el entity ID
del SP puede ser `http(s)` o `urn:`; ACS y metadata siempre son URLs
navegables, y la cert/keys son X.509 / PEM.
- **LDAP** — flujo `service bind → search → user bind` del propio backend
(`modules/iam/usecase/idp.go`): por eso `bindDn` y `bindPassword` son
obligatorios aquí aunque algunas fuentes los marquen opcionales (simple bind).

El set de *required* por protocolo (presencia) no lo define esta lib, sino el
backend en `prepareSettings`. Esta lib añade el *formato* por encima, de forma
aditiva.

## Cubertura

`src/features/settings/lib/idp-form-validation.test.ts` — 41 casos, uno por regla,
corriendo con `npx vitest run`.
Loading
Loading