feat(apigateay): add OIDC and basic auth support

- when setting an OIDC issuer URL the defaulter will fetch and set
  authorization and token endpoints
- basic auth allows to use either inline hashed credentials or plaintext
  credentials from a secret that are automatically hashed
- finish TLS support for API & dashboard listeners
This commit is contained in:
Peter 2025-02-05 20:47:02 +01:00
parent e9302c51be
commit 3c13eb0d6b
Signed by: prskr
GPG key ID: F56BED6903BC5E37
21 changed files with 721 additions and 276 deletions

View file

@ -28,4 +28,4 @@ jobs:
- name: Run linter
uses: golangci/golangci-lint-action@v6
with:
version: v1.61
version: v1.63.4

View file

@ -2,7 +2,7 @@
# git hook pre commit
pre-commit = [
"go mod tidy -go=1.23.5",
"go mod tidy -go=1.23.6",
"go run mage.go GenerateAll",
"husky lint-staged",
# "golangci-lint run",

View file

@ -62,6 +62,11 @@ k8s_resource(
objects=["gateway-sample:APIGateway:supabase-demo"],
extra_pod_selectors={"app.kubernetes.io/component": "api-gateway"},
port_forwards=[3000, 8000, 19000],
links=[
link("https://localhost:3000", "Studio"),
link("http://localhost:8000", "API"),
link("http://localhost:19000", "Envoy Admin Interface")
],
new_name='API Gateway',
resource_deps=[
'supabase-controller-manager'

View file

@ -128,10 +128,13 @@ const (
)
type DashboardOAuth2Spec struct {
// OpenIDIssuer - if set the defaulter will fetch the discovery document and fill
// TokenEndpoint and AuthorizationEndpoint based on the discovery document
OpenIDIssuer string `json:"openIdIssuer,omitempty"`
// TokenEndpoint - endpoint where Envoy will retrieve the OAuth2 access and identity token from
TokenEndpoint string `json:"tokenEndpoint"`
TokenEndpoint string `json:"tokenEndpoint,omitempty"`
// AuthorizationEndpoint - endpoint where the user will be redirected to authenticate
AuthorizationEndpoint string `json:"authorizationEndpoint"`
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
// ClientID - client ID to authenticate with the OAuth2 provider
ClientID string `json:"clientId"`
// Scopes - scopes to request from the OAuth2 provider (e.g. "openid", "profile", ...) - optional
@ -142,11 +145,24 @@ type DashboardOAuth2Spec struct {
ClientSecretRef *corev1.SecretKeySelector `json:"clientSecretRef"`
}
type DashboardBasicAuthSpec struct{}
type DashboardBasicAuthSpec struct {
// UsersInline - [htpasswd format](https://httpd.apache.org/docs/2.4/programs/htpasswd.html)
// +kubebuilder:validation:items:Pattern="^[\\w_.]+:\\{SHA\\}[A-z0-9]+=*$"
UsersInline []string `json:"usersInline,omitempty"`
// PlaintextUsersSecretRef - name of a secret that contains plaintext credentials in key-value form
// if not empty, credentials will be merged with inline users
PlaintextUsersSecretRef string `json:"plaintextUsersSecretRef,omitempty"`
}
type DashboardAuthSpec struct {
OAuth2 *DashboardOAuth2Spec `json:"oauth2,omitempty"`
Basic *DashboardBasicAuthSpec `json:"basic,omitempty"`
// OAuth2 - configure oauth2 authentication for the dashhboard listener
// if configured, will be preferred over Basic authentication configuration
// effectively disabling basic auth
OAuth2 *DashboardOAuth2Spec `json:"oauth2,omitempty"`
// Basic - HTTP basic auth configuration, this should only be used in exceptions
// e.g. during evaluations or for local development
// only used if no other authentication is configured
Basic *DashboardBasicAuthSpec `json:"basic,omitempty"`
}
type DashboardEndpointSpec struct {

View file

@ -21,7 +21,7 @@ limitations under the License.
package v1alpha1
import (
"k8s.io/api/core/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@ -506,7 +506,7 @@ func (in *DashboardAuthSpec) DeepCopyInto(out *DashboardAuthSpec) {
if in.Basic != nil {
in, out := &in.Basic, &out.Basic
*out = new(DashboardBasicAuthSpec)
**out = **in
(*in).DeepCopyInto(*out)
}
}
@ -523,6 +523,11 @@ func (in *DashboardAuthSpec) DeepCopy() *DashboardAuthSpec {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardBasicAuthSpec) DeepCopyInto(out *DashboardBasicAuthSpec) {
*out = *in
if in.UsersInline != nil {
in, out := &in.UsersInline, &out.UsersInline
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardBasicAuthSpec.

View file

@ -4,5 +4,7 @@ ALTER ROLE authenticated inherit;
ALTER ROLE anon inherit;
ALTER ROLE service_role inherit;
GRANT pgsodium_keyholder to service_role;
-- migrate:down

View file

@ -0,0 +1,6 @@
-- migrate:up
alter role supabase_admin set log_statement = none;
alter role supabase_auth_admin set log_statement = none;
alter role supabase_storage_admin set log_statement = none;
-- migrate:down

View file

@ -121,8 +121,28 @@ spec:
endpoint
properties:
basic:
description: |-
Basic - HTTP basic auth configuration, this should only be used in exceptions
e.g. during evaluations or for local development
only used if no other authentication is configured
properties:
plaintextUsersSecretRef:
description: |-
PlaintextUsersSecretRef - name of a secret that contains plaintext credentials in key-value form
if not empty, credentials will be merged with inline users
type: string
usersInline:
description: UsersInline - [htpasswd format](https://httpd.apache.org/docs/2.4/programs/htpasswd.html)
items:
pattern: ^[\w_.]+:\{SHA\}[A-z0-9]+=*$
type: string
type: array
type: object
oauth2:
description: |-
OAuth2 - configure oauth2 authentication for the dashhboard listener
if configured, will be preferred over Basic authentication configuration
effectively disabling basic auth
properties:
authorizationEndpoint:
description: AuthorizationEndpoint - endpoint where the
@ -157,6 +177,11 @@ spec:
- key
type: object
x-kubernetes-map-type: atomic
openIdIssuer:
description: |-
OpenIDIssuer - if set the defaulter will fetch the discovery document and fill
TokenEndpoint and AuthorizationEndpoint based on the discovery document
type: string
resources:
description: Resources - resources to request from the
OAuth2 provider (e.g. "user", "email", ...) - optional
@ -174,10 +199,8 @@ spec:
retrieve the OAuth2 access and identity token from
type: string
required:
- authorizationEndpoint
- clientId
- clientSecretRef
- tokenEndpoint
type: object
type: object
tls:

View file

@ -9,19 +9,18 @@ metadata:
namespace: supabase-demo
spec:
envoy:
debugging:
componentLogLevels:
- component: oauth2
level: debug
disableIPv6: true
apiEndpoint:
jwks:
name: core-sample-jwt
key: jwks.json
dashboardEndpoint:
tls:
cert:
secretName: dashboard-tls-cert
auth:
oauth2:
tokenEndpoint: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/oauth2/token"
authorizationEndpoint: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/oauth2/authorize"
openIdIssuer: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/"
clientId: 3528016b-f6e3-49be-8fb3-f9a9a2ab6c3f
scopes:
- openid
@ -49,6 +48,6 @@ spec:
- gateway-sample-envoy.supabase-demo.svc.cluster.local
- localhost:3000
issuerRef:
kind: Issuer
name: selfsigned-issuer
kind: ClusterIssuer
name: cluster-pki
secretName: dashboard-tls-cert

36
config/dev/ca.yaml Normal file
View file

@ -0,0 +1,36 @@
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: cluster-pki-bootstrapper
namespace: cert-manager
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: cluster-pki-ca
namespace: cert-manager
spec:
commonName: cluster-pki
isCA: true
issuerRef:
kind: Issuer
name: cluster-pki-bootstrapper
secretName: cluster-pki-ca-cert
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: cluster-pki
namespace: cert-manager
spec:
ca:
secretName: cluster-pki-ca-cert

View file

@ -2,12 +2,14 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- https://github.com/cert-manager/cert-manager/releases/download/v1.16.3/cert-manager.yaml
- https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml
- https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.25.0/cnpg-1.25.0.yaml
- ca.yaml
- namespace.yaml
- cnpg-cluster.yaml
- minio.yaml
- ../default
- studio-plaintext-users.yaml
- studio-credentials-secret.yaml
- core.yaml
- apigateway.yaml

View file

@ -0,0 +1,8 @@
---
apiVersion: v1
kind: Secret
metadata:
name: studio-sample-basic-auth
namespace: supabase-demo
stringData:
ted: not_admin

View file

@ -0,0 +1,30 @@
---
apiVersion: supabase.k8s.icb4dc0.de/v1alpha1
kind: APIGateway
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: gateway-sample
spec:
apiEndpoint:
jwks:
# will be created by Core resource operator if not present
# just make sure the secret name is either based on the name of the core resource or explicitly set
name: core-sample-jwt
key: jwks.json
dashboardEndpoint:
auth:
basic:
usersInline:
# admin:admin
- admin:{SHA}0DPiKuNIrrVmD8IUCuw1hQxNqZc=
plaintextUsersSecretRef: studio-sample-basic-auth
---
apiVersion: v1
kind: Secret
metadata:
name: studio-sample-basic-auth
namespace: supabase-demo
stringData:
ted: not_admin

View file

@ -0,0 +1,36 @@
---
apiVersion: supabase.k8s.icb4dc0.de/v1alpha1
kind: APIGateway
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: gateway-sample
spec:
apiEndpoint:
jwks:
# will be created by Core resource operator if not present
# just make sure the secret name is either based on the name of the core resource or explicitly set
name: core-sample-jwt
key: jwks.json
dashboardEndpoint:
auth:
oauth2:
openIdIssuer: "https://idp.your-domain.com/"
clientId: "<your-client-id>"
# if not set, 'user' will be used
scopes:
- openid
- profile
- email
clientSecretRef:
name: studio-sample-oauth2
key: clientSecret
---
apiVersion: v1
kind: Secret
metadata:
name: studio-sample-oauth2
namespace: supabase-demo
stringData:
clientSecret: "<your-client-secret>"

View file

@ -331,8 +331,8 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `oauth2` _[DashboardOAuth2Spec](#dashboardoauth2spec)_ | | | |
| `basic` _[DashboardBasicAuthSpec](#dashboardbasicauthspec)_ | | | |
| `oauth2` _[DashboardOAuth2Spec](#dashboardoauth2spec)_ | OAuth2 - configure oauth2 authentication for the dashhboard listener<br />if configured, will be preferred over Basic authentication configuration<br />effectively disabling basic auth | | |
| `basic` _[DashboardBasicAuthSpec](#dashboardbasicauthspec)_ | Basic - HTTP basic auth configuration, this should only be used in exceptions<br />e.g. during evaluations or for local development<br />only used if no other authentication is configured | | |
@ -348,6 +348,10 @@ _Appears in:_
_Appears in:_
- [DashboardAuthSpec](#dashboardauthspec)
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `usersInline` _string array_ | UsersInline - [htpasswd format](https://httpd.apache.org/docs/2.4/programs/htpasswd.html) | | items:Pattern: ^[\w_.]+:\\{SHA\\}[A-z0-9]+=*$ <br /> |
| `plaintextUsersSecretRef` _string_ | PlaintextUsersSecretRef - name of a secret that contains plaintext credentials in key-value form<br />if not empty, credentials will be merged with inline users | | |
#### DashboardDbSpec
@ -417,6 +421,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `openIdIssuer` _string_ | OpenIDIssuer - if set the defaulter will fetch the discovery document and fill<br />TokenEndpoint and AuthorizationEndpoint based on the discovery document | | |
| `tokenEndpoint` _string_ | TokenEndpoint - endpoint where Envoy will retrieve the OAuth2 access and identity token from | | |
| `authorizationEndpoint` _string_ | AuthorizationEndpoint - endpoint where the user will be redirected to authenticate | | |
| `clientId` _string_ | ClientID - client ID to authenticate with the OAuth2 provider | | |

2
go.mod
View file

@ -1,6 +1,6 @@
module code.icb4dc0.de/prskr/supabase-operator
go 1.23.5
go 1.23.6
require (
github.com/alecthomas/kong v1.7.0

View file

@ -24,4 +24,5 @@ const (
FilterNameHttpConnectionManager = "envoy.filters.network.http_connection_manager"
FilterNameBasicAuth = "envoy.filters.http.basic_auth"
FilterNameOAuth2 = "envoy.filters.http.oauth2"
SocketNameTLS = "envoy.transport_sockets.tls"
)

View file

@ -20,28 +20,20 @@ import (
"context"
"crypto/sha256"
"fmt"
"net/url"
"slices"
"strconv"
"time"
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
oauth2v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/oauth2/v3"
routerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3"
hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
"github.com/envoyproxy/go-control-plane/pkg/cache/types"
"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/durationpb"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -94,7 +86,7 @@ func (s *EnvoyServices) UpsertEndpointSlices(endpointSlices ...discoveryv1.Endpo
s.PGMeta.AddOrUpdateEndpoints(eps)
case supabase.ServiceConfig.Studio.Name:
if s.Studio == nil {
s.Studio = new(StudioCluster)
s.Studio = &StudioCluster{Client: s.Client}
}
s.Studio.AddOrUpdateEndpoints(eps)
@ -128,7 +120,15 @@ func (s EnvoyServices) Targets() map[string][]string {
return targets
}
func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string) (snapshot *cache.Snapshot, snapshotHash []byte, err error) {
func (s *EnvoyServices) snapshot(
ctx context.Context,
instance, version string,
) (snapshot *cache.Snapshot, snapshotHash []byte, err error) {
socket, err := s.apiTransportSocket(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to setup API TLS listener: %w", err)
}
listeners := []*listenerv3.Listener{{
Name: apilistenerName,
Address: &corev3.Address{
@ -152,37 +152,37 @@ func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string)
},
},
},
TransportSocket: socket,
},
},
}}
if studioListener := s.studioListener(instance); studioListener != nil {
if studioListener, err := s.Studio.Listener(ctx, instance, s.Gateway); err != nil {
return nil, nil, err
} else if studioListener != nil {
listeners = append(listeners, studioListener)
}
routes := []types.Resource{s.apiRouteConfiguration(instance)}
if studioRouteCfg := s.studioRoute(instance); studioRouteCfg != nil {
if studioRouteCfg := s.Studio.RouteConfiguration(instance); studioRouteCfg != nil {
routes = append(routes, studioRouteCfg)
}
studioClusters, err := s.Studio.Cluster(instance, s.Gateway)
if err != nil {
return nil, nil, err
}
clusters := castResources(
slices.Concat(
s.Postgrest.Cluster(instance),
s.GoTrue.Cluster(instance),
s.StorageApi.Cluster(instance),
s.PGMeta.Cluster(instance),
s.Studio.Cluster(instance),
studioClusters,
)...)
if oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
if oauth2TokenEndpointCluster, err := s.oauth2TokenEndpointCluster(instance); err != nil {
return nil, nil, err
} else {
clusters = append(clusters, oauth2TokenEndpointCluster)
}
}
sdsSecrets, err := s.secrets(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to collect dynamic secrets: %w", err)
@ -350,231 +350,57 @@ func (s *EnvoyServices) apiRouteConfiguration(instance string) *routev3.RouteCon
}
}
func (s *EnvoyServices) studioListener(instance string) *listenerv3.Listener {
if s.Studio == nil {
return nil
func (s *EnvoyServices) apiTransportSocket(ctx context.Context) (*corev3.TransportSocket, error) {
tlsSpec := s.Gateway.Spec.ApiEndpoint.TLSSpec()
if tlsSpec == nil {
return nil, nil
}
var (
httpFilters []*hcm.HttpFilter
serviceCfg = supabase.ServiceConfig.Envoy
)
if oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
httpFilters = append(httpFilters, &hcm.HttpFilter{
Name: FilterNameOAuth2,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(&oauth2v3.OAuth2{
Config: &oauth2v3.OAuth2Config{
TokenEndpoint: &corev3.HttpUri{
HttpUpstreamType: &corev3.HttpUri_Cluster{
Cluster: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance),
},
Uri: s.Gateway.Spec.DashboardEndpoint.Auth.OAuth2.TokenEndpoint,
Timeout: durationpb.New(3 * time.Second),
},
AuthorizationEndpoint: s.Gateway.Spec.DashboardEndpoint.Auth.OAuth2.AuthorizationEndpoint,
RedirectUri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/callback",
RedirectPathMatcher: &matcherv3.PathMatcher{
Rule: &matcherv3.PathMatcher_Path{
Path: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "/callback",
},
},
},
},
SignoutPath: &matcherv3.PathMatcher{
Rule: &matcherv3.PathMatcher_Path{
Path: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "/signout",
},
},
},
},
Credentials: &oauth2v3.OAuth2Credentials{
ClientId: oauth2Spec.ClientID,
TokenSecret: &tlsv3.SdsSecretConfig{
Name: serviceCfg.Defaults.OAuth2ClientSecretKey,
SdsConfig: &corev3.ConfigSource{
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
Ads: new(corev3.AggregatedConfigSource),
},
},
},
TokenFormation: &oauth2v3.OAuth2Credentials_HmacSecret{
HmacSecret: &tlsv3.SdsSecretConfig{
Name: serviceCfg.Defaults.HmacSecretKey,
SdsConfig: &corev3.ConfigSource{
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
Ads: new(corev3.AggregatedConfigSource),
},
},
},
},
},
AuthScopes: oauth2Spec.Scopes,
Resources: oauth2Spec.Resources,
},
})},
})
}
studioConnetionManager := &hcm.HttpConnectionManager{
CodecType: hcm.HttpConnectionManager_AUTO,
StatPrefix: "supbase_studio",
AccessLog: []*accesslogv3.AccessLog{AccessLog("supbase_studio_access_log")},
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
Rds: &hcm.Rds{
ConfigSource: &corev3.ConfigSource{
ResourceApiVersion: resource.DefaultAPIVersion,
ConfigSourceSpecifier: &corev3.ConfigSource_ApiConfigSource{
ApiConfigSource: &corev3.ApiConfigSource{
TransportApiVersion: resource.DefaultAPIVersion,
ApiType: corev3.ApiConfigSource_GRPC,
SetNodeOnFirstMessageOnly: true,
GrpcServices: []*corev3.GrpcService{{
TargetSpecifier: &corev3.GrpcService_EnvoyGrpc_{
EnvoyGrpc: &corev3.GrpcService_EnvoyGrpc{ClusterName: "supabase-control-plane"},
},
}},
},
},
},
RouteConfigName: studioRouteName,
},
},
HttpFilters: append(httpFilters, &hcm.HttpFilter{
Name: FilterNameHttpRouter,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(routerv3.Router))},
}),
}
return &listenerv3.Listener{
Name: "studio",
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Protocol: corev3.SocketAddress_TCP,
Address: "0.0.0.0",
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: 3000,
},
},
},
},
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
{
Name: FilterNameHttpConnectionManager,
ConfigType: &listenerv3.Filter_TypedConfig{
TypedConfig: MustAny(studioConnetionManager),
},
},
},
},
apiTlsSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: tlsSpec.Cert.SecretName,
Namespace: s.Gateway.Namespace,
},
}
}
func (s *EnvoyServices) studioRoute(instance string) *routev3.RouteConfiguration {
if s.Studio == nil {
return nil
}
return &routev3.RouteConfiguration{
Name: studioRouteName,
VirtualHosts: []*routev3.VirtualHost{{
Name: "supabase-studio",
Domains: []string{"*"},
Routes: s.Studio.Routes(instance),
}},
}
}
func (s *EnvoyServices) oauth2TokenEndpointCluster(instance string) (*clusterv3.Cluster, error) {
oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2()
parsedTokenEndpoint, err := url.Parse(oauth2Spec.TokenEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse token endpoint: %w", err)
}
var (
endpointPort uint32
tls bool
)
switch parsedTokenEndpoint.Scheme {
case "http":
endpointPort = 80
case "https":
endpointPort = 443
tls = true
default:
return nil, fmt.Errorf("unsupported token endpoint scheme: %s", parsedTokenEndpoint.Scheme)
}
if tokenEndpointPort := parsedTokenEndpoint.Port(); tokenEndpointPort != "" {
if parsedPort, err := strconv.ParseUint(tokenEndpointPort, 10, 32); err != nil {
return nil, fmt.Errorf("failed to parse token endpoint port: %w", err)
} else {
endpointPort = uint32(parsedPort)
if err := s.Get(ctx, client.ObjectKeyFromObject(&apiTlsSecret), &apiTlsSecret); err != nil {
if client.IgnoreNotFound(err) == nil {
return nil, nil
}
return nil, err
}
cluster := &clusterv3.Cluster{
Name: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance),
ConnectTimeout: durationpb.New(3 * time.Second),
ClusterDiscoveryType: &clusterv3.Cluster_Type{
Type: clusterv3.Cluster_LOGICAL_DNS,
},
LbPolicy: clusterv3.Cluster_ROUND_ROBIN,
LoadAssignment: &endpointv3.ClusterLoadAssignment{
ClusterName: dashboardOAuth2ClusterName,
Endpoints: []*endpointv3.LocalityLbEndpoints{{
LbEndpoints: []*endpointv3.LbEndpoint{{
HostIdentifier: &endpointv3.LbEndpoint_Endpoint{
Endpoint: &endpointv3.Endpoint{
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Address: parsedTokenEndpoint.Hostname(),
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: endpointPort,
},
Protocol: corev3.SocketAddress_TCP,
},
return &corev3.TransportSocket{
Name: SocketNameTLS,
ConfigType: &corev3.TransportSocket_TypedConfig{
TypedConfig: MustAny(&tlsv3.DownstreamTlsContext{
CommonTlsContext: &tlsv3.CommonTlsContext{
TlsCertificates: []*tlsv3.TlsCertificate{{
CertificateChain: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: apiTlsSecret.Data[corev1.TLSCertKey],
},
},
PrivateKey: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: apiTlsSecret.Data[corev1.TLSPrivateKeyKey],
},
},
}},
ValidationContextType: &tlsv3.CommonTlsContext_ValidationContext{
ValidationContext: &tlsv3.CertificateValidationContext{
TrustedCa: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: apiTlsSecret.Data["ca.crt"],
},
},
},
},
}},
}},
},
}),
},
}
if s.Gateway.Spec.Envoy != nil && s.Gateway.Spec.Envoy.DisableIPv6 {
cluster.DnsLookupFamily = clusterv3.Cluster_V4_ONLY
}
if tls {
cluster.TransportSocket = &corev3.TransportSocket{
Name: "envoy.transport_sockets.tls",
ConfigType: &corev3.TransportSocket_TypedConfig{
TypedConfig: MustAny(&tlsv3.UpstreamTlsContext{
Sni: parsedTokenEndpoint.Hostname(),
AllowRenegotiation: true,
CommonTlsContext: &tlsv3.CommonTlsContext{
TlsParams: &tlsv3.TlsParameters{
TlsMinimumProtocolVersion: tlsv3.TlsParameters_TLSv1_2,
},
},
}),
},
}
}
return cluster, nil
}, nil
}
func castResources[T types.Resource](from ...T) []types.Resource {

View file

@ -17,49 +17,418 @@ limitations under the License.
package controlplane
import (
"context"
"crypto/sha1"
"encoding/base64"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"time"
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
basic_authv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3"
oauth2v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/oauth2/v3"
routerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3"
hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
"google.golang.org/protobuf/types/known/durationpb"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
type StudioCluster struct {
client.Client
ServiceCluster
}
func (c *StudioCluster) Cluster(instance string) []*clusterv3.Cluster {
func (c *StudioCluster) Cluster(instance string, gateway *supabasev1alpha1.APIGateway) ([]*clusterv3.Cluster, error) {
if c == nil {
return nil
return nil, nil
}
serviceCfg := supabase.ServiceConfig.Studio
return []*clusterv3.Cluster{
clusters := []*clusterv3.Cluster{
c.ServiceCluster.Cluster(fmt.Sprintf("%s@%s", serviceCfg.Name, instance), uint32(serviceCfg.Defaults.APIPort)),
}
if gateway.Spec.DashboardEndpoint.AuthType() == supabasev1alpha1.DashboardAuthTypeOAuth2 {
if tokenEndpointCluster, err := c.oauth2TokenEndpointCluster(instance, gateway); err != nil {
return nil, err
} else {
clusters = append(clusters, tokenEndpointCluster)
}
}
return clusters, nil
}
func (c *StudioCluster) Routes(instance string) []*routev3.Route {
if c == nil {
func (s *StudioCluster) Listener(ctx context.Context, instance string, gateway *supabasev1alpha1.APIGateway) (*listenerv3.Listener, error) {
if s == nil {
return nil, nil
}
var httpFilters []*hcm.HttpFilter
switch gateway.Spec.DashboardEndpoint.AuthType() {
case supabasev1alpha1.DashboardAuthTypeOAuth2:
httpFilters = append(httpFilters, s.oauth2HttpFilter(instance, gateway))
case supabasev1alpha1.DashboardAuthTypeBasic:
if filter, err := s.basicAuthHttpFilter(ctx, gateway); err != nil {
return nil, err
} else if filter != nil {
httpFilters = append(httpFilters, filter)
}
}
studioConnetionManager := &hcm.HttpConnectionManager{
CodecType: hcm.HttpConnectionManager_AUTO,
StatPrefix: "supbase_studio",
AccessLog: []*accesslogv3.AccessLog{AccessLog("supbase_studio_access_log")},
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
Rds: &hcm.Rds{
ConfigSource: &corev3.ConfigSource{
ResourceApiVersion: resource.DefaultAPIVersion,
ConfigSourceSpecifier: &corev3.ConfigSource_ApiConfigSource{
ApiConfigSource: &corev3.ApiConfigSource{
TransportApiVersion: resource.DefaultAPIVersion,
ApiType: corev3.ApiConfigSource_GRPC,
SetNodeOnFirstMessageOnly: true,
GrpcServices: []*corev3.GrpcService{{
TargetSpecifier: &corev3.GrpcService_EnvoyGrpc_{
EnvoyGrpc: &corev3.GrpcService_EnvoyGrpc{ClusterName: "supabase-control-plane"},
},
}},
},
},
},
RouteConfigName: studioRouteName,
},
},
HttpFilters: append(httpFilters, &hcm.HttpFilter{
Name: FilterNameHttpRouter,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(routerv3.Router))},
}),
}
socket, err := s.dashboardTransportSocket(ctx, gateway)
if err != nil {
return nil, fmt.Errorf("failed to setup dashboard TLS listener: %w", err)
}
return &listenerv3.Listener{
Name: "studio",
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Protocol: corev3.SocketAddress_TCP,
Address: "0.0.0.0",
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: 3000,
},
},
},
},
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
{
Name: FilterNameHttpConnectionManager,
ConfigType: &listenerv3.Filter_TypedConfig{
TypedConfig: MustAny(studioConnetionManager),
},
},
},
TransportSocket: socket,
},
},
}, nil
}
func (s *StudioCluster) RouteConfiguration(instance string) *routev3.RouteConfiguration {
if s == nil {
return nil
}
return []*routev3.Route{{
Name: "Studio: /* -> http://studio:3000/*",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/",
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Studio.Name, instance),
return &routev3.RouteConfiguration{
Name: studioRouteName,
VirtualHosts: []*routev3.VirtualHost{{
Name: "supabase-studio",
Domains: []string{"*"},
Routes: []*routev3.Route{{
Name: "Studio: /* -> http://studio:3000/*",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/",
},
},
PrefixRewrite: "/",
},
},
}}
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Studio.Name, instance),
},
PrefixRewrite: "/",
},
},
}},
}},
}
}
func (s *StudioCluster) oauth2TokenEndpointCluster(
instance string,
gateway *supabasev1alpha1.APIGateway,
) (*clusterv3.Cluster, error) {
parsedTokenEndpoint, err := url.Parse(gateway.Spec.DashboardEndpoint.OAuth2().TokenEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse token endpoint: %w", err)
}
var (
endpointPort uint32
tls bool
)
switch parsedTokenEndpoint.Scheme {
case "http":
endpointPort = 80
case "https":
endpointPort = 443
tls = true
default:
return nil, fmt.Errorf("unsupported token endpoint scheme: %s", parsedTokenEndpoint.Scheme)
}
if tokenEndpointPort := parsedTokenEndpoint.Port(); tokenEndpointPort != "" {
if parsedPort, err := strconv.ParseUint(tokenEndpointPort, 10, 32); err != nil {
return nil, fmt.Errorf("failed to parse token endpoint port: %w", err)
} else {
endpointPort = uint32(parsedPort)
}
}
cluster := &clusterv3.Cluster{
Name: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance),
ConnectTimeout: durationpb.New(3 * time.Second),
ClusterDiscoveryType: &clusterv3.Cluster_Type{
Type: clusterv3.Cluster_LOGICAL_DNS,
},
LbPolicy: clusterv3.Cluster_ROUND_ROBIN,
LoadAssignment: &endpointv3.ClusterLoadAssignment{
ClusterName: dashboardOAuth2ClusterName,
Endpoints: []*endpointv3.LocalityLbEndpoints{{
LbEndpoints: []*endpointv3.LbEndpoint{{
HostIdentifier: &endpointv3.LbEndpoint_Endpoint{
Endpoint: &endpointv3.Endpoint{
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Address: parsedTokenEndpoint.Hostname(),
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: endpointPort,
},
Protocol: corev3.SocketAddress_TCP,
},
},
},
},
},
}},
}},
},
}
if gateway.Spec.Envoy != nil && gateway.Spec.Envoy.DisableIPv6 {
cluster.DnsLookupFamily = clusterv3.Cluster_V4_ONLY
}
if tls {
cluster.TransportSocket = &corev3.TransportSocket{
Name: "envoy.transport_sockets.tls",
ConfigType: &corev3.TransportSocket_TypedConfig{
TypedConfig: MustAny(&tlsv3.UpstreamTlsContext{
CommonTlsContext: new(tlsv3.CommonTlsContext),
}),
},
}
}
return cluster, nil
}
func (s *StudioCluster) dashboardTransportSocket(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
) (*corev3.TransportSocket, error) {
tlsSpec := gateway.Spec.DashboardEndpoint.TLSSpec()
if tlsSpec == nil {
return nil, nil
}
dashboardTlsSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: tlsSpec.Cert.SecretName,
Namespace: gateway.Namespace,
},
}
if err := s.Get(ctx, client.ObjectKeyFromObject(&dashboardTlsSecret), &dashboardTlsSecret); err != nil {
if client.IgnoreNotFound(err) == nil {
return nil, nil
}
return nil, err
}
return &corev3.TransportSocket{
Name: SocketNameTLS,
ConfigType: &corev3.TransportSocket_TypedConfig{
TypedConfig: MustAny(&tlsv3.DownstreamTlsContext{
CommonTlsContext: &tlsv3.CommonTlsContext{
TlsCertificates: []*tlsv3.TlsCertificate{{
CertificateChain: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: dashboardTlsSecret.Data[corev1.TLSCertKey],
},
},
PrivateKey: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: dashboardTlsSecret.Data[corev1.TLSPrivateKeyKey],
},
},
}},
ValidationContextType: &tlsv3.CommonTlsContext_ValidationContext{
ValidationContext: &tlsv3.CertificateValidationContext{
TrustedCa: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: dashboardTlsSecret.Data["ca.crt"],
},
},
},
},
},
}),
},
}, nil
}
func (s *StudioCluster) basicAuthHttpFilter(ctx context.Context, gateway *supabasev1alpha1.APIGateway) (*hcm.HttpFilter, error) {
users := gateway.Spec.DashboardEndpoint.Auth.Basic.UsersInline
usersSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: gateway.Spec.DashboardEndpoint.Auth.Basic.PlaintextUsersSecretRef,
Namespace: gateway.Namespace,
},
}
if err := s.Client.Get(ctx, client.ObjectKeyFromObject(&usersSecret), &usersSecret); err != nil {
if client.IgnoreNotFound(err) != nil {
return nil, fmt.Errorf("failed to fetch credentials secret: %w", err)
}
}
hash := sha1.New()
for username, passwd := range usersSecret.Data {
_, _ = hash.Write(passwd)
pwHash := base64.StdEncoding.EncodeToString(hash.Sum(nil))
users = append(users, fmt.Sprintf("%s:{SHA}%s", username, pwHash))
hash.Reset()
}
if len(users) == 0 {
return nil, nil
}
slices.Sort(users)
return &hcm.HttpFilter{
Name: FilterNameBasicAuth,
ConfigType: &hcm.HttpFilter_TypedConfig{
TypedConfig: MustAny(&basic_authv3.BasicAuth{
Users: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineString{
InlineString: strings.Join(users, "\n"),
},
},
}),
},
}, nil
}
func (s *StudioCluster) oauth2HttpFilter(instance string, gateway *supabasev1alpha1.APIGateway) *hcm.HttpFilter {
var (
serviceCfg = supabase.ServiceConfig.Envoy
oauth2Spec = gateway.Spec.DashboardEndpoint.OAuth2()
)
return &hcm.HttpFilter{
Name: FilterNameOAuth2,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(&oauth2v3.OAuth2{
Config: &oauth2v3.OAuth2Config{
TokenEndpoint: &corev3.HttpUri{
HttpUpstreamType: &corev3.HttpUri_Cluster{
Cluster: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance),
},
Uri: gateway.Spec.DashboardEndpoint.Auth.OAuth2.TokenEndpoint,
Timeout: durationpb.New(3 * time.Second),
},
AuthorizationEndpoint: gateway.Spec.DashboardEndpoint.Auth.OAuth2.AuthorizationEndpoint,
RedirectUri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/callback",
RedirectPathMatcher: &matcherv3.PathMatcher{
Rule: &matcherv3.PathMatcher_Path{
Path: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "/callback",
},
},
},
},
SignoutPath: &matcherv3.PathMatcher{
Rule: &matcherv3.PathMatcher_Path{
Path: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "/signout",
},
},
},
},
Credentials: &oauth2v3.OAuth2Credentials{
ClientId: oauth2Spec.ClientID,
TokenSecret: &tlsv3.SdsSecretConfig{
Name: serviceCfg.Defaults.OAuth2ClientSecretKey,
SdsConfig: &corev3.ConfigSource{
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
Ads: new(corev3.AggregatedConfigSource),
},
},
},
TokenFormation: &oauth2v3.OAuth2Credentials_HmacSecret{
HmacSecret: &tlsv3.SdsSecretConfig{
Name: serviceCfg.Defaults.HmacSecretKey,
SdsConfig: &corev3.ConfigSource{
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
Ads: new(corev3.AggregatedConfigSource),
},
},
},
},
},
AuthScopes: oauth2Spec.Scopes,
Resources: oauth2Spec.Resources,
},
})},
}
}

View file

@ -0,0 +1,60 @@
/*
Copyright 2025 Peter Kurfer.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package oidc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)
var ErrUnexpectedStatusCode = errors.New("unexpected status code")
type DiscoveryDocument struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
}
func IssuerConfiguration(ctx context.Context, issuerUrl string) (dd DiscoveryDocument, err error) {
const oidcDiscoveryEndpoint = "/.well-known/openid-configuration"
if !strings.HasSuffix(issuerUrl, oidcDiscoveryEndpoint) {
issuerUrl = strings.TrimSuffix(issuerUrl, "/") + oidcDiscoveryEndpoint
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuerUrl, nil)
if err != nil {
return dd, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return dd, err
}
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if resp.StatusCode != http.StatusOK {
return dd, fmt.Errorf("%w: %d - %s", ErrUnexpectedStatusCode, resp.StatusCode, resp.Status)
}
return dd, json.NewDecoder(resp.Body).Decode(&dd)
}

View file

@ -23,9 +23,11 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/oidc"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
@ -49,6 +51,7 @@ func (d *APIGatewayCustomDefaulter) Default(ctx context.Context, obj runtime.Obj
defaultManagerNamespace = "supabase-system"
)
logger := log.FromContext(ctx)
apiGateway, ok := obj.(*supabasev1alpha1.APIGateway)
if !ok {
@ -106,5 +109,18 @@ func (d *APIGatewayCustomDefaulter) Default(ctx context.Context, obj runtime.Obj
}
}
if oauth2Spec := apiGateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
if oauth2Spec.OpenIDIssuer != "" {
logger.Info("Fetching OIDC discovery document", "discovery_url", oauth2Spec.OpenIDIssuer)
discoveryDoc, err := oidc.IssuerConfiguration(ctx, oauth2Spec.OpenIDIssuer)
if err != nil {
return fmt.Errorf("failed to fetch OIDC configuration: %w", err)
}
oauth2Spec.TokenEndpoint = discoveryDoc.TokenEndpoint
oauth2Spec.AuthorizationEndpoint = discoveryDoc.AuthorizationEndpoint
}
}
return nil
}