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:
parent
e9302c51be
commit
3c13eb0d6b
21 changed files with 721 additions and 276 deletions
.github/workflows
.husky.tomlTiltfileapi/v1alpha1
assets/migrations/migrations
20230529180330_alter_api_roles_for_inherit.sql20250205060043_disable_log_statement_on_internal_roles.sql
config
crd/bases
dev
samples
docs/api
go.modinternal
controlplane
oidc
webhook/v1alpha1
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
@ -28,4 +28,4 @@ jobs:
|
|||
- name: Run linter
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.61
|
||||
version: v1.63.4
|
||||
|
|
|
@ -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",
|
||||
|
|
5
Tiltfile
5
Tiltfile
|
@ -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'
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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
36
config/dev/ca.yaml
Normal 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
|
|
@ -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
|
||||
|
|
8
config/dev/studio-plaintext-users.yaml
Normal file
8
config/dev/studio-plaintext-users.yaml
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: studio-sample-basic-auth
|
||||
namespace: supabase-demo
|
||||
stringData:
|
||||
ted: not_admin
|
|
@ -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
|
|
@ -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>"
|
|
@ -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
2
go.mod
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})},
|
||||
}
|
||||
}
|
||||
|
|
60
internal/oidc/discovery.go
Normal file
60
internal/oidc/discovery.go
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue