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
35 changes: 35 additions & 0 deletions api/v1/nebariapp_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package v1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)

// NebariAppSpec defines the desired state of NebariApp
Expand Down Expand Up @@ -104,6 +105,15 @@ type RoutingConfig struct {
// Each entry uses the same RouteMatch format as routes, supporting both
// PathPrefix (default) and Exact matching via the pathType field.
// Example: [{pathPrefix: "/api/v1/health", pathType: "Exact"}]
//
// PublicRoutes is for carving out a *subset* of paths that should bypass
// auth while the rest stays protected. It is not a way to disable auth for
// the whole app: if a public path also matches the main route (including the
// default "/" match when routes is empty) while auth.enforceAtGateway is
// true, both a protected and an unprotected HTTPRoute claim that path and
// the effective auth posture is undefined. To turn gateway auth off entirely,
// set auth.enforceAtGateway: false (keep provisioning the Keycloak client) or
// auth.enabled: false (no client at all) instead of listing every path here.
// +optional
PublicRoutes []RouteMatch `json:"publicRoutes,omitempty"`

Expand All @@ -120,6 +130,27 @@ type RoutingConfig struct {
// annotations always take precedence to avoid breaking internal behaviour.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`

// Filters are appended, in order, to every HTTPRouteRule the operator
// generates for this NebariApp (both the protected route and the public
// route built from publicRoutes). This is a pass-through to the upstream
// gateway.networking.k8s.io/v1 HTTPRouteFilter spec, mirroring the
// Annotations escape hatch: the operator does not interpret, reorder, or
// strip the filters, it only forwards them. Use it to adjust request or
// response headers at the gateway (for example, stripping an overpermissive
// CORS header an upstream sets, or adding security headers) without forking
// the operator or rebuilding the backend image.
//
// The field is schemaless in the CRD: inlining the full HTTPRouteFilter
// OpenAPI schema would copy its nested x-kubernetes-validations (CEL) rules
// into the NebariApp CRD and blow the per-CRD CEL cost budget. As a
// pass-through, the filters are instead validated by Gateway API when the
// operator applies them to the generated HTTPRoute. Each entry must still be
// a valid HTTPRouteFilter object (it is decoded into the typed Go struct).
// +optional
// +kubebuilder:validation:Schemaless
// +kubebuilder:pruning:PreserveUnknownFields
Filters []gatewayv1.HTTPRouteFilter `json:"filters,omitempty"`
}

// RouteMatch defines a path-based routing rule.
Expand Down Expand Up @@ -230,6 +261,10 @@ type AuthConfig struct {
// When false, the operator provisions the OIDC client and stores credentials
// in a Secret, but does NOT create a SecurityPolicy - the application is
// expected to handle OAuth natively (e.g., Grafana's built-in generic_oauth).
// This is the correct switch for "no gateway-level auth"; do not try to
// achieve that by listing every path under routing.publicRoutes, which
// instead produces overlapping protected and unprotected routes for the same
// path (see PublicRoutes).
// +kubebuilder:default=true
// +optional
EnforceAtGateway *bool `json:"enforceAtGateway,omitempty"`
Expand Down
8 changes: 8 additions & 0 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ spec:
When false, the operator provisions the OIDC client and stores credentials
in a Secret, but does NOT create a SecurityPolicy - the application is
expected to handle OAuth natively (e.g., Grafana's built-in generic_oauth).
This is the correct switch for "no gateway-level auth"; do not try to
achieve that by listing every path under routing.publicRoutes, which
instead produces overlapping protected and unprotected routes for the same
path (see PublicRoutes).
type: boolean
forwardAccessToken:
description: |-
Expand Down Expand Up @@ -442,6 +446,25 @@ spec:
These annotations are merged with any operator-managed annotations; operator
annotations always take precedence to avoid breaking internal behaviour.
type: object
filters:
description: |-
Filters are appended, in order, to every HTTPRouteRule the operator
generates for this NebariApp (both the protected route and the public
route built from publicRoutes). This is a pass-through to the upstream
gateway.networking.k8s.io/v1 HTTPRouteFilter spec, mirroring the
Annotations escape hatch: the operator does not interpret, reorder, or
strip the filters, it only forwards them. Use it to adjust request or
response headers at the gateway (for example, stripping an overpermissive
CORS header an upstream sets, or adding security headers) without forking
the operator or rebuilding the backend image.

The field is schemaless in the CRD: inlining the full HTTPRouteFilter
OpenAPI schema would copy its nested x-kubernetes-validations (CEL) rules
into the NebariApp CRD and blow the per-CRD CEL cost budget. As a
pass-through, the filters are instead validated by Gateway API when the
operator applies them to the generated HTTPRoute. Each entry must still be
a valid HTTPRouteFilter object (it is decoded into the typed Go struct).
x-kubernetes-preserve-unknown-fields: true
publicRoutes:
description: |-
PublicRoutes specifies paths that should bypass OIDC authentication.
Expand All @@ -450,6 +473,15 @@ spec:
Each entry uses the same RouteMatch format as routes, supporting both
PathPrefix (default) and Exact matching via the pathType field.
Example: [{pathPrefix: "/api/v1/health", pathType: "Exact"}]

PublicRoutes is for carving out a *subset* of paths that should bypass
auth while the rest stays protected. It is not a way to disable auth for
the whole app: if a public path also matches the main route (including the
default "/" match when routes is empty) while auth.enforceAtGateway is
true, both a protected and an unprotected HTTPRoute claim that path and
the effective auth posture is undefined. To turn gateway auth off entirely,
set auth.enforceAtGateway: false (keep provisioning the Keycloak client) or
auth.enabled: false (no client at all) instead of listing every path here.
items:
description: RouteMatch defines a path-based routing rule.
properties:
Expand Down
5 changes: 3 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ _Appears in:_
| `scopes` _string array_ | Scopes defines the OIDC scopes to request during authentication.<br />Common scopes: openid, profile, email, roles, groups<br />If not specified, defaults to: ["openid", "profile", "email"] | | Optional: \{\} <br /> |
| `groups` _string array_ | Groups specifies the list of groups that should have access to this application.<br />When specified, only users belonging to these groups will be authorized.<br />Group matching is case-sensitive and depends on the OIDC provider's group claim. | | Optional: \{\} <br /> |
| `provisionClient` _boolean_ | ProvisionClient determines whether the operator should automatically provision<br />an OIDC client in the provider. When true, the operator will create a client<br />(e.g., in Keycloak) and store the credentials in a Secret.<br />Only supported for provider="keycloak".<br />Defaults to true if not specified. | true | Optional: \{\} <br /> |
| `enforceAtGateway` _boolean_ | EnforceAtGateway determines whether the operator should create an Envoy Gateway<br />SecurityPolicy to enforce authentication at the gateway level.<br />When true (default), the operator creates a SecurityPolicy that handles<br />the OIDC flow at the gateway before requests reach the application.<br />When false, the operator provisions the OIDC client and stores credentials<br />in a Secret, but does NOT create a SecurityPolicy - the application is<br />expected to handle OAuth natively (e.g., Grafana's built-in generic_oauth). | true | Optional: \{\} <br /> |
| `enforceAtGateway` _boolean_ | EnforceAtGateway determines whether the operator should create an Envoy Gateway<br />SecurityPolicy to enforce authentication at the gateway level.<br />When true (default), the operator creates a SecurityPolicy that handles<br />the OIDC flow at the gateway before requests reach the application.<br />When false, the operator provisions the OIDC client and stores credentials<br />in a Secret, but does NOT create a SecurityPolicy - the application is<br />expected to handle OAuth natively (e.g., Grafana's built-in generic_oauth).<br />This is the correct switch for "no gateway-level auth"; do not try to<br />achieve that by listing every path under routing.publicRoutes, which<br />instead produces overlapping protected and unprotected routes for the same<br />path (see PublicRoutes). | true | Optional: \{\} <br /> |
| `forwardAccessToken` _boolean_ | ForwardAccessToken instructs the gateway-enforced OIDC filter to forward<br />the user's OAuth2 access token to the upstream service via the<br />`Authorization: Bearer <token>` header. Use this when the application<br />needs to read the JWT itself - for example to extract the user's groups<br />claim and apply per-user authorization decisions on top of the gateway's<br />authentication. By default the gateway only stores the token in an<br />encrypted session cookie that backends cannot decode.<br />Only applies when enforceAtGateway is true. | | Optional: \{\} <br /> |
| `denyRedirect` _[DenyRedirectHeader](#denyredirectheader) array_ | DenyRedirect configures headers that, when matched, prevent the OIDC filter<br />from redirecting to the identity provider. Instead, matching requests receive<br />a 401 response. This prevents PKCE race conditions when SPAs fire multiple<br />requests on page load (e.g., the main page and AJAX calls simultaneously),<br />each of which would otherwise start a separate OAuth flow and overwrite<br />each other's state cookies.<br />Only applies when enforceAtGateway is true. | | Optional: \{\} <br /> |
| `issuerURL` _string_ | IssuerURL specifies the OIDC issuer URL for generic-oidc provider.<br />Required when provider="generic-oidc", ignored for other providers.<br />Example: https://accounts.google.com, https://login.microsoftonline.com/<tenant>/v2.0 | | Optional: \{\} <br /> |
Expand Down Expand Up @@ -309,9 +309,10 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `routes` _[RouteMatch](#routematch) array_ | Routes defines path-based routing rules for the application.<br />If not specified, all traffic to the hostname will be routed to the service.<br />When specified, only traffic matching these path prefixes will be routed.<br />Example: ["/app-1", "/api/v1"] | | Optional: \{\} <br /> |
| `publicRoutes` _[RouteMatch](#routematch) array_ | PublicRoutes specifies paths that should bypass OIDC authentication.<br />When auth is enabled and these are specified, these paths will be routed<br />via a separate HTTPRoute that is not protected by the SecurityPolicy.<br />Each entry uses the same RouteMatch format as routes, supporting both<br />PathPrefix (default) and Exact matching via the pathType field.<br />Example: [\{pathPrefix: "/api/v1/health", pathType: "Exact"\}] | | Optional: \{\} <br /> |
| `publicRoutes` _[RouteMatch](#routematch) array_ | PublicRoutes specifies paths that should bypass OIDC authentication.<br />When auth is enabled and these are specified, these paths will be routed<br />via a separate HTTPRoute that is not protected by the SecurityPolicy.<br />Each entry uses the same RouteMatch format as routes, supporting both<br />PathPrefix (default) and Exact matching via the pathType field.<br />Example: [\{pathPrefix: "/api/v1/health", pathType: "Exact"\}]<br />PublicRoutes is for carving out a *subset* of paths that should bypass<br />auth while the rest stays protected. It is not a way to disable auth for<br />the whole app: if a public path also matches the main route (including the<br />default "/" match when routes is empty) while auth.enforceAtGateway is<br />true, both a protected and an unprotected HTTPRoute claim that path and<br />the effective auth posture is undefined. To turn gateway auth off entirely,<br />set auth.enforceAtGateway: false (keep provisioning the Keycloak client) or<br />auth.enabled: false (no client at all) instead of listing every path here. | | Optional: \{\} <br /> |
| `tls` _[RoutingTLSConfig](#routingtlsconfig)_ | TLS configures TLS certificate management and termination behavior.<br />When TLS is enabled (the default), the operator creates a cert-manager Certificate<br />for the application's hostname and adds a per-app HTTPS listener to the shared Gateway. | | Optional: \{\} <br /> |
| `annotations` _object (keys:string, values:string)_ | Annotations defines additional annotations to merge onto the generated HTTPRoute.<br />Useful for tools like ArgoCD that track resources via annotations<br />(e.g. argocd.argoproj.io/tracking-id).<br />These annotations are merged with any operator-managed annotations; operator<br />annotations always take precedence to avoid breaking internal behaviour. | | Optional: \{\} <br /> |
| `filters` _HTTPRouteFilter array_ | Filters are appended, in order, to every HTTPRouteRule the operator<br />generates for this NebariApp (both the protected route and the public<br />route built from publicRoutes). This is a pass-through to the upstream<br />gateway.networking.k8s.io/v1 HTTPRouteFilter spec, mirroring the<br />Annotations escape hatch: the operator does not interpret, reorder, or<br />strip the filters, it only forwards them. Use it to adjust request or<br />response headers at the gateway (for example, stripping an overpermissive<br />CORS header an upstream sets, or adding security headers) without forking<br />the operator or rebuilding the backend image.<br />The field is schemaless in the CRD: inlining the full HTTPRouteFilter<br />OpenAPI schema would copy its nested x-kubernetes-validations (CEL) rules<br />into the NebariApp CRD and blow the per-CRD CEL cost budget. As a<br />pass-through, the filters are instead validated by Gateway API when the<br />operator applies them to the generated HTTPRoute. Each entry must still be<br />a valid HTTPRouteFilter object (it is decoded into the typed Go struct). | | Schemaless: \{\} <br />Optional: \{\} <br /> |


---
Expand Down
40 changes: 39 additions & 1 deletion docs/reconcilers/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,50 @@ spec:
```

**Key details:**
- Public routes always use `PathPrefix` matching
- Public routes default to `Exact` matching (safer for auth bypass); set `pathType: PathPrefix` per entry to widen
- The public HTTPRoute has the label `nebari.dev/route-type: public`
- Both routes point to the same backend service
- If auth is disabled, no public route is created (all routes are already public)
- Cleanup removes both HTTPRoutes when the NebariApp is deleted

> **`publicRoutes` is for a subset of paths, not "disable auth".** It carves
> specific paths out of the authenticated app. If a public path also matches the
> main route (including the default `/` match when `routes` is empty) while
> `auth.enforceAtGateway` is `true`, both a protected and an unprotected HTTPRoute
> claim that path and the effective auth posture is left to Gateway API conflict
> resolution. To turn gateway auth off for the whole app, set
> `auth.enforceAtGateway: false` (keep provisioning the Keycloak client) or
> `auth.enabled: false` (no client at all) — do not list every path under
> `publicRoutes`.

### Route Filters

`spec.routing.filters` is a pass-through to the upstream Gateway API
[`HTTPRouteFilter`](https://gateway-api.sigs.k8s.io/api-types/httproute/#filters)
list. The operator appends the filters you supply, in order, to the rule of
**both** the protected and the public HTTPRoute; it does not interpret, reorder,
or strip them. This mirrors the `annotations` escape hatch and is the supported
way to adjust request/response headers at the gateway without forking the
operator or rebuilding the backend image — for example, stripping an
overpermissive CORS header an upstream sets, or adding security headers.

```yaml
spec:
routing:
filters:
- type: ResponseHeaderModifier
responseHeaderModifier:
remove:
- Access-Control-Allow-Credentials
set:
- name: Strict-Transport-Security
value: "max-age=63072000; includeSubDomains"
```

Because the operator owns the generated HTTPRoute (`ownerReferences.controller:
true`), a manual `kubectl patch` of these fields is reverted on the next
reconcile — `routing.filters` is the durable way to express them.

### Backend References

The operator creates backend references using the service details from the NebariApp spec:
Expand Down
Loading
Loading