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 - name: Run linter
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
with: with:
version: v1.61 version: v1.63.4

View file

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

View file

@ -62,6 +62,11 @@ k8s_resource(
objects=["gateway-sample:APIGateway:supabase-demo"], objects=["gateway-sample:APIGateway:supabase-demo"],
extra_pod_selectors={"app.kubernetes.io/component": "api-gateway"}, extra_pod_selectors={"app.kubernetes.io/component": "api-gateway"},
port_forwards=[3000, 8000, 19000], 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', new_name='API Gateway',
resource_deps=[ resource_deps=[
'supabase-controller-manager' 'supabase-controller-manager'

View file

@ -128,10 +128,13 @@ const (
) )
type DashboardOAuth2Spec struct { 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 - 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 - 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 - client ID to authenticate with the OAuth2 provider
ClientID string `json:"clientId"` ClientID string `json:"clientId"`
// Scopes - scopes to request from the OAuth2 provider (e.g. "openid", "profile", ...) - optional // 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"` 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 { type DashboardAuthSpec struct {
OAuth2 *DashboardOAuth2Spec `json:"oauth2,omitempty"` // OAuth2 - configure oauth2 authentication for the dashhboard listener
Basic *DashboardBasicAuthSpec `json:"basic,omitempty"` // 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 { type DashboardEndpointSpec struct {

View file

@ -21,7 +21,7 @@ limitations under the License.
package v1alpha1 package v1alpha1
import ( import (
"k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime" runtime "k8s.io/apimachinery/pkg/runtime"
) )
@ -506,7 +506,7 @@ func (in *DashboardAuthSpec) DeepCopyInto(out *DashboardAuthSpec) {
if in.Basic != nil { if in.Basic != nil {
in, out := &in.Basic, &out.Basic in, out := &in.Basic, &out.Basic
*out = new(DashboardBasicAuthSpec) *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. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardBasicAuthSpec) DeepCopyInto(out *DashboardBasicAuthSpec) { func (in *DashboardBasicAuthSpec) DeepCopyInto(out *DashboardBasicAuthSpec) {
*out = *in *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. // 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 anon inherit;
ALTER ROLE service_role inherit; ALTER ROLE service_role inherit;
GRANT pgsodium_keyholder to service_role;
-- migrate:down -- 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 endpoint
properties: properties:
basic: 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 type: object
oauth2: oauth2:
description: |-
OAuth2 - configure oauth2 authentication for the dashhboard listener
if configured, will be preferred over Basic authentication configuration
effectively disabling basic auth
properties: properties:
authorizationEndpoint: authorizationEndpoint:
description: AuthorizationEndpoint - endpoint where the description: AuthorizationEndpoint - endpoint where the
@ -157,6 +177,11 @@ spec:
- key - key
type: object type: object
x-kubernetes-map-type: atomic 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: resources:
description: Resources - resources to request from the description: Resources - resources to request from the
OAuth2 provider (e.g. "user", "email", ...) - optional OAuth2 provider (e.g. "user", "email", ...) - optional
@ -174,10 +199,8 @@ spec:
retrieve the OAuth2 access and identity token from retrieve the OAuth2 access and identity token from
type: string type: string
required: required:
- authorizationEndpoint
- clientId - clientId
- clientSecretRef - clientSecretRef
- tokenEndpoint
type: object type: object
type: object type: object
tls: tls:

View file

@ -9,19 +9,18 @@ metadata:
namespace: supabase-demo namespace: supabase-demo
spec: spec:
envoy: envoy:
debugging: disableIPv6: true
componentLogLevels:
- component: oauth2
level: debug
apiEndpoint: apiEndpoint:
jwks: jwks:
name: core-sample-jwt name: core-sample-jwt
key: jwks.json key: jwks.json
dashboardEndpoint: dashboardEndpoint:
tls:
cert:
secretName: dashboard-tls-cert
auth: auth:
oauth2: oauth2:
tokenEndpoint: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/oauth2/token" openIdIssuer: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/"
authorizationEndpoint: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/oauth2/authorize"
clientId: 3528016b-f6e3-49be-8fb3-f9a9a2ab6c3f clientId: 3528016b-f6e3-49be-8fb3-f9a9a2ab6c3f
scopes: scopes:
- openid - openid
@ -49,6 +48,6 @@ spec:
- gateway-sample-envoy.supabase-demo.svc.cluster.local - gateway-sample-envoy.supabase-demo.svc.cluster.local
- localhost:3000 - localhost:3000
issuerRef: issuerRef:
kind: Issuer kind: ClusterIssuer
name: selfsigned-issuer name: cluster-pki
secretName: dashboard-tls-cert 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 kind: Kustomization
resources: 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 - https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.25.0/cnpg-1.25.0.yaml
- ca.yaml
- namespace.yaml - namespace.yaml
- cnpg-cluster.yaml - cnpg-cluster.yaml
- minio.yaml - minio.yaml
- ../default - ../default
- studio-plaintext-users.yaml
- studio-credentials-secret.yaml - studio-credentials-secret.yaml
- core.yaml - core.yaml
- apigateway.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 | | Field | Description | Default | Validation |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `oauth2` _[DashboardOAuth2Spec](#dashboardoauth2spec)_ | | | | | `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` _[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:_ _Appears in:_
- [DashboardAuthSpec](#dashboardauthspec) - [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 #### DashboardDbSpec
@ -417,6 +421,7 @@ _Appears in:_
| Field | Description | Default | Validation | | 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 | | | | `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 | | | | `authorizationEndpoint` _string_ | AuthorizationEndpoint - endpoint where the user will be redirected to authenticate | | |
| `clientId` _string_ | ClientID - client ID to authenticate with the OAuth2 provider | | | | `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 module code.icb4dc0.de/prskr/supabase-operator
go 1.23.5 go 1.23.6
require ( require (
github.com/alecthomas/kong v1.7.0 github.com/alecthomas/kong v1.7.0

View file

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

View file

@ -20,28 +20,20 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"net/url"
"slices" "slices"
"strconv"
"time"
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3" 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" 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" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/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" 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" 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" 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/types"
"github.com/envoyproxy/go-control-plane/pkg/cache/v3" "github.com/envoyproxy/go-control-plane/pkg/cache/v3"
"github.com/envoyproxy/go-control-plane/pkg/resource/v3" "github.com/envoyproxy/go-control-plane/pkg/resource/v3"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/durationpb"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1" discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -94,7 +86,7 @@ func (s *EnvoyServices) UpsertEndpointSlices(endpointSlices ...discoveryv1.Endpo
s.PGMeta.AddOrUpdateEndpoints(eps) s.PGMeta.AddOrUpdateEndpoints(eps)
case supabase.ServiceConfig.Studio.Name: case supabase.ServiceConfig.Studio.Name:
if s.Studio == nil { if s.Studio == nil {
s.Studio = new(StudioCluster) s.Studio = &StudioCluster{Client: s.Client}
} }
s.Studio.AddOrUpdateEndpoints(eps) s.Studio.AddOrUpdateEndpoints(eps)
@ -128,7 +120,15 @@ func (s EnvoyServices) Targets() map[string][]string {
return targets 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{{ listeners := []*listenerv3.Listener{{
Name: apilistenerName, Name: apilistenerName,
Address: &corev3.Address{ 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) listeners = append(listeners, studioListener)
} }
routes := []types.Resource{s.apiRouteConfiguration(instance)} 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) routes = append(routes, studioRouteCfg)
} }
studioClusters, err := s.Studio.Cluster(instance, s.Gateway)
if err != nil {
return nil, nil, err
}
clusters := castResources( clusters := castResources(
slices.Concat( slices.Concat(
s.Postgrest.Cluster(instance), s.Postgrest.Cluster(instance),
s.GoTrue.Cluster(instance), s.GoTrue.Cluster(instance),
s.StorageApi.Cluster(instance), s.StorageApi.Cluster(instance),
s.PGMeta.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) sdsSecrets, err := s.secrets(ctx)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to collect dynamic secrets: %w", err) 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 { func (s *EnvoyServices) apiTransportSocket(ctx context.Context) (*corev3.TransportSocket, error) {
if s.Studio == nil { tlsSpec := s.Gateway.Spec.ApiEndpoint.TLSSpec()
return nil if tlsSpec == nil {
return nil, nil
} }
var ( apiTlsSecret := corev1.Secret{
httpFilters []*hcm.HttpFilter ObjectMeta: metav1.ObjectMeta{
serviceCfg = supabase.ServiceConfig.Envoy Name: tlsSpec.Cert.SecretName,
) Namespace: s.Gateway.Namespace,
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),
},
},
},
},
}, },
} }
}
func (s *EnvoyServices) studioRoute(instance string) *routev3.RouteConfiguration { if err := s.Get(ctx, client.ObjectKeyFromObject(&apiTlsSecret), &apiTlsSecret); err != nil {
if s.Studio == nil { if client.IgnoreNotFound(err) == nil {
return nil return nil, 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)
} }
return nil, err
} }
cluster := &clusterv3.Cluster{ return &corev3.TransportSocket{
Name: fmt.Sprintf("%s@%s", dashboardOAuth2ClusterName, instance), Name: SocketNameTLS,
ConnectTimeout: durationpb.New(3 * time.Second), ConfigType: &corev3.TransportSocket_TypedConfig{
ClusterDiscoveryType: &clusterv3.Cluster_Type{ TypedConfig: MustAny(&tlsv3.DownstreamTlsContext{
Type: clusterv3.Cluster_LOGICAL_DNS, CommonTlsContext: &tlsv3.CommonTlsContext{
}, TlsCertificates: []*tlsv3.TlsCertificate{{
LbPolicy: clusterv3.Cluster_ROUND_ROBIN, CertificateChain: &corev3.DataSource{
LoadAssignment: &endpointv3.ClusterLoadAssignment{ Specifier: &corev3.DataSource_InlineBytes{
ClusterName: dashboardOAuth2ClusterName, InlineBytes: apiTlsSecret.Data[corev1.TLSCertKey],
Endpoints: []*endpointv3.LocalityLbEndpoints{{ },
LbEndpoints: []*endpointv3.LbEndpoint{{ },
HostIdentifier: &endpointv3.LbEndpoint_Endpoint{ PrivateKey: &corev3.DataSource{
Endpoint: &endpointv3.Endpoint{ Specifier: &corev3.DataSource_InlineBytes{
Address: &corev3.Address{ InlineBytes: apiTlsSecret.Data[corev1.TLSPrivateKeyKey],
Address: &corev3.Address_SocketAddress{ },
SocketAddress: &corev3.SocketAddress{ },
Address: parsedTokenEndpoint.Hostname(), }},
PortSpecifier: &corev3.SocketAddress_PortValue{ ValidationContextType: &tlsv3.CommonTlsContext_ValidationContext{
PortValue: endpointPort, ValidationContext: &tlsv3.CertificateValidationContext{
}, TrustedCa: &corev3.DataSource{
Protocol: corev3.SocketAddress_TCP, Specifier: &corev3.DataSource_InlineBytes{
}, InlineBytes: apiTlsSecret.Data["ca.crt"],
}, },
}, },
}, },
}, },
}}, },
}}, }),
}, },
} }, nil
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
} }
func castResources[T types.Resource](from ...T) []types.Resource { func castResources[T types.Resource](from ...T) []types.Resource {

View file

@ -17,49 +17,418 @@ limitations under the License.
package controlplane package controlplane
import ( import (
"context"
"crypto/sha1"
"encoding/base64"
"fmt" "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" 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" 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" "code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
) )
type StudioCluster struct { type StudioCluster struct {
client.Client
ServiceCluster ServiceCluster
} }
func (c *StudioCluster) Cluster(instance string) []*clusterv3.Cluster { func (c *StudioCluster) Cluster(instance string, gateway *supabasev1alpha1.APIGateway) ([]*clusterv3.Cluster, error) {
if c == nil { if c == nil {
return nil return nil, nil
} }
serviceCfg := supabase.ServiceConfig.Studio serviceCfg := supabase.ServiceConfig.Studio
return []*clusterv3.Cluster{ clusters := []*clusterv3.Cluster{
c.ServiceCluster.Cluster(fmt.Sprintf("%s@%s", serviceCfg.Name, instance), uint32(serviceCfg.Defaults.APIPort)), 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 { func (s *StudioCluster) Listener(ctx context.Context, instance string, gateway *supabasev1alpha1.APIGateway) (*listenerv3.Listener, error) {
if c == nil { 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 nil
} }
return []*routev3.Route{{ return &routev3.RouteConfiguration{
Name: "Studio: /* -> http://studio:3000/*", Name: studioRouteName,
Match: &routev3.RouteMatch{ VirtualHosts: []*routev3.VirtualHost{{
PathSpecifier: &routev3.RouteMatch_Prefix{ Name: "supabase-studio",
Prefix: "/", Domains: []string{"*"},
}, Routes: []*routev3.Route{{
}, Name: "Studio: /* -> http://studio:3000/*",
Action: &routev3.Route_Route{ Match: &routev3.RouteMatch{
Route: &routev3.RouteAction{ PathSpecifier: &routev3.RouteMatch_Prefix{
ClusterSpecifier: &routev3.RouteAction_Cluster{ Prefix: "/",
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Studio.Name, instance), },
}, },
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" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record" "k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1" 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" "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" defaultManagerNamespace = "supabase-system"
) )
logger := log.FromContext(ctx)
apiGateway, ok := obj.(*supabasev1alpha1.APIGateway) apiGateway, ok := obj.(*supabasev1alpha1.APIGateway)
if !ok { 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 return nil
} }