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
|
- name: Run linter
|
||||||
uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
version: v1.61
|
version: v1.63.4
|
||||||
|
|
|
@ -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",
|
||||||
|
|
5
Tiltfile
5
Tiltfile
|
@ -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'
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
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:
|
||||||
|
|
|
@ -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
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
|
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
|
||||||
|
|
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 |
|
| 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
2
go.mod
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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"
|
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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue