Initial cloud-services repo - gateway service + pkg modules
This commit is contained in:
28
pkg/superset/cache_acc_token.go
Normal file
28
pkg/superset/cache_acc_token.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package superset
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fiskerinc.com/modules/redis"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var errTokenNotFound = errors.New("token isn't found")
|
||||
|
||||
func getCachedAccToken(c redis.Client) (string, error) {
|
||||
res, err := c.Get(redis.SupersetAccTokenKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res == nil {
|
||||
return "", errors.WithStack(errTokenNotFound)
|
||||
}
|
||||
|
||||
tokenBytes, ok := res.([]byte)
|
||||
if !ok {
|
||||
return "", errors.WithStack(fmt.Errorf("invalid superset access token type (expected []byte]); access token: %v", res))
|
||||
}
|
||||
|
||||
return string(tokenBytes), nil
|
||||
}
|
||||
130
pkg/superset/dashboards.go
Normal file
130
pkg/superset/dashboards.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package superset
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"fiskerinc.com/modules/logger"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func GetEmbeddableDashboards(accToken string) (embeddableDashboards []EmbeddableDashboard, err error) {
|
||||
publishedDashboards, err := getPublishedDashboards(accToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
embeddableDashboards = make([]EmbeddableDashboard, 0, len(publishedDashboards))
|
||||
for _, dashboard := range publishedDashboards {
|
||||
tempId, err := getDashboardsEmbeddedID(dashboard.ID, accToken)
|
||||
|
||||
// If one of the dashboards gets an error, that could be fine, as long as not all of them do
|
||||
// The dashboard failed to have embedding data on it
|
||||
if err != nil || tempId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
embeddableDashboards = append(embeddableDashboards, EmbeddableDashboard{Title: dashboard.DashboardTitle, EmbeddingId: tempId})
|
||||
}
|
||||
|
||||
return embeddableDashboards, nil
|
||||
}
|
||||
|
||||
func getPublishedDashboards(accToken string) (publishedDashboard []publishedDashboard, err error) {
|
||||
// Host already has the /api/v1
|
||||
targetURL, err := url.JoinPath(host, "dashboard")
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
// ulr.JoinPath with url escape the ?
|
||||
// Filter that published == true
|
||||
targetURL += "/?q=%7B%0A%20%20%22filters%22%3A%20%5B%0A%20%20%20%20%7B%0A%20%20%20%20%20%20%22col%22%3A%20%22published%22%2C%0A%20%20%20%20%20%20%22opr%22%3A%20%22eq%22%2C%0A%20%20%20%20%20%20%22value%22%3A%20true%0A%20%20%20%20%7D%0A%20%20%5D%0A%7D"
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+accToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
logger.Warn().Msgf("404 while trying to access superset api, code: %s. Trying to get published dashboards\n", resp.Status)
|
||||
} else if resp.StatusCode >= 300 {
|
||||
logger.Error().Msgf("Failed to access superset api, code: %s. Trying to get published dashboards\n", resp.Status)
|
||||
return nil, errors.New("failed to access superset api")
|
||||
}
|
||||
var dashboardResponse getDashboardResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&dashboardResponse)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
publishedDashboard = dashboardResponse.Result
|
||||
return
|
||||
}
|
||||
|
||||
func getDashboardsEmbeddedID(dashboardId int, accToken string) (embeddedId string, err error) {
|
||||
targetURL, err := url.JoinPath(host, "dashboard/", strconv.Itoa(dashboardId), "embedded")
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+accToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
logger.Warn().Msgf("Superset dashboard id %s is public, but is not embedable", embeddedId)
|
||||
return "", errors.New("failed to get embedable id")
|
||||
} else if resp.StatusCode >= 300 {
|
||||
logger.Error().Msgf("Failed to access superset api, code: %s. Trying to get embeded dashboard id\n", resp.Status)
|
||||
return "", errors.New("failed to access superset api")
|
||||
}
|
||||
|
||||
var embeddedResponse getEmbeddedDashboardInfoResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&embeddedResponse)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return embeddedResponse.Result.UUID, nil
|
||||
}
|
||||
|
||||
type getDashboardResponse struct {
|
||||
Count int `json:"count"`
|
||||
Ids []int `json:"ids"`
|
||||
Result []publishedDashboard `json:"result"`
|
||||
}
|
||||
|
||||
type publishedDashboard struct {
|
||||
DashboardTitle string `json:"dashboard_title"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type getEmbeddedDashboardInfoResponse struct {
|
||||
Result struct {
|
||||
UUID string `json:"uuid"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type EmbeddableDashboard struct {
|
||||
Title string `json:"title"`
|
||||
EmbeddingId string `json:"embedded_id"`
|
||||
}
|
||||
136
pkg/superset/guest_token.go
Normal file
136
pkg/superset/guest_token.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package superset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"fiskerinc.com/modules/httpclient"
|
||||
"fiskerinc.com/modules/redis"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var errUnauthorized = errors.New("superset unauthorized")
|
||||
|
||||
type (
|
||||
guestTokenRequest struct {
|
||||
User user `json:"user"`
|
||||
Resources []resource `json:"resources"`
|
||||
Rls []rule `json:"rls"`
|
||||
}
|
||||
user struct {
|
||||
UserName string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
resource struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
rule struct {
|
||||
Clause string `json:"clause"`
|
||||
}
|
||||
|
||||
guestTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
)
|
||||
|
||||
func GetGuestToken(r redis.Client, accToken string) (string, error) {
|
||||
token, err := getGuestToken(accToken)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, errUnauthorized) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// if unauthorized
|
||||
accToken, err = loginFunc(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return getGuestToken(accToken)
|
||||
}
|
||||
|
||||
func getGuestToken(accToken string) (string, error) {
|
||||
req, err := getGuestTokenReq(accToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := httpclient.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", errors.WithStack(errUnauthorized)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.Errorf("superset guest token answered with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
var structResp guestTokenResponse
|
||||
err = json.Unmarshal(body, &structResp)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return structResp.Token, nil
|
||||
}
|
||||
|
||||
func getGuestTokenReq(accToken string) (*http.Request, error) {
|
||||
body, err := json.Marshal(guestTokenRequest{
|
||||
User: user{
|
||||
UserName: guestUserName,
|
||||
FirstName: guestFirstName,
|
||||
LastName: guestLastName,
|
||||
},
|
||||
Resources: compileResources(accToken),
|
||||
Rls: []rule{},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, host+"/security/guest_token", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+accToken)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func compileResources(accToken string) []resource {
|
||||
var res []resource
|
||||
|
||||
dashes, err := GetEmbeddableDashboards(accToken)
|
||||
dashIDs := make([]string, 0)
|
||||
if err == nil {
|
||||
for _, d := range dashes {
|
||||
dashIDs = append(dashIDs, d.EmbeddingId)
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range dashIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, resource{
|
||||
ID: id,
|
||||
Type: "dashboard",
|
||||
})
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
115
pkg/superset/guest_token_test.go
Normal file
115
pkg/superset/guest_token_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package superset_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"fiskerinc.com/modules/httpclient"
|
||||
"fiskerinc.com/modules/httpclient/mock"
|
||||
"fiskerinc.com/modules/redis"
|
||||
"fiskerinc.com/modules/redis/tester"
|
||||
"fiskerinc.com/modules/superset"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
validAccToken = "acc_token"
|
||||
unauthAccToken = "not_authorized_acc_token"
|
||||
validGuestToken = "valid_guest_token"
|
||||
)
|
||||
|
||||
func TestGetGuestToken(t *testing.T) {
|
||||
redisMock := tester.NewRedisMock()
|
||||
tests := map[string]struct {
|
||||
accToken string
|
||||
httpClientDoFunc func(req *http.Request) (*http.Response, error)
|
||||
loginFunc func(r redis.Client) (string, error)
|
||||
expToken string
|
||||
expErr error
|
||||
}{
|
||||
"success": {
|
||||
accToken: validAccToken,
|
||||
httpClientDoFunc: successGuestHttpDo,
|
||||
expToken: validGuestToken,
|
||||
},
|
||||
"success_unauthorized": {
|
||||
accToken: unauthAccToken,
|
||||
httpClientDoFunc: successGuestHttpDo,
|
||||
expToken: validGuestToken,
|
||||
loginFunc: validLoginFunc,
|
||||
},
|
||||
"err_http": {
|
||||
httpClientDoFunc: errorHttpDo,
|
||||
expErr: someErr,
|
||||
},
|
||||
"unknown_http_error": {
|
||||
httpClientDoFunc: successGuestHttpDo,
|
||||
expErr: errors.New("superset guest token answered with status: Internal server error"),
|
||||
},
|
||||
"login_failed": {
|
||||
accToken: unauthAccToken,
|
||||
httpClientDoFunc: successGuestHttpDo,
|
||||
loginFunc: invalidLoginFunc,
|
||||
expErr: someErr,
|
||||
},
|
||||
"login_wrong_token": {
|
||||
accToken: unauthAccToken,
|
||||
httpClientDoFunc: successGuestHttpDo,
|
||||
loginFunc: wrongTokenLoginFunc,
|
||||
expErr: errors.New("superset guest token answered with status: Internal server error"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
httpclient.Client = &mock.Client{DoFunc: tt.httpClientDoFunc}
|
||||
superset.SetLoginFunc(tt.loginFunc)
|
||||
|
||||
got, err := superset.GetGuestToken(redisMock, tt.accToken)
|
||||
if err != nil && tt.expErr != nil {
|
||||
assert.Equal(t, tt.expErr.Error(), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expErr, err)
|
||||
assert.Equal(t, tt.expToken, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func successGuestHttpDo(req *http.Request) (*http.Response, error) {
|
||||
accToken := req.Header.Get("Authorization")
|
||||
switch accToken {
|
||||
case "Bearer " + validAccToken:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"token":"valid_guest_token"}`))),
|
||||
}, nil
|
||||
case "Bearer " + unauthAccToken:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte(``))),
|
||||
}, nil
|
||||
default:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Status: "Internal server error",
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte(``))),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validLoginFunc(r redis.Client) (string, error) {
|
||||
return validAccToken, nil
|
||||
}
|
||||
|
||||
func invalidLoginFunc(r redis.Client) (string, error) {
|
||||
return "", someErr
|
||||
}
|
||||
|
||||
func wrongTokenLoginFunc(r redis.Client) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
87
pkg/superset/login.go
Normal file
87
pkg/superset/login.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package superset
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"fiskerinc.com/modules/httpclient"
|
||||
"fiskerinc.com/modules/redis"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var loginFunc = login
|
||||
|
||||
type supersetRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Provider string `json:"provider"`
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
|
||||
type supersetResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func GetAccessToken(r redis.Client) (string, error) {
|
||||
token, err := getCachedAccToken(r)
|
||||
if err != nil && !errors.Is(err, errTokenNotFound) {
|
||||
return "", err
|
||||
}
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
token, err = login(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func login(r redis.Client) (string, error) {
|
||||
headers := http.Header{}
|
||||
headers.Add("Content-Type", "application/json")
|
||||
resp, err := httpclient.Post(
|
||||
host+"/security/login",
|
||||
supersetRequest{
|
||||
Username: accUserName,
|
||||
Password: password,
|
||||
Provider: "db",
|
||||
Refresh: true,
|
||||
},
|
||||
headers,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.Errorf("superset login answered with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
sresp := supersetResponse{}
|
||||
err = json.Unmarshal(b, &sresp)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = r.Set(redis.SupersetAccTokenKey, sresp.AccessToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sresp.AccessToken, nil
|
||||
}
|
||||
|
||||
// SetLoginFunc must be useful for testing.
|
||||
func SetLoginFunc(login func(r redis.Client) (string, error)) {
|
||||
loginFunc = login
|
||||
}
|
||||
104
pkg/superset/login_test.go
Normal file
104
pkg/superset/login_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package superset_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"fiskerinc.com/modules/httpclient"
|
||||
"fiskerinc.com/modules/httpclient/mock"
|
||||
"fiskerinc.com/modules/redis"
|
||||
"fiskerinc.com/modules/redis/tester"
|
||||
"fiskerinc.com/modules/superset"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var someErr = errors.New("some err")
|
||||
|
||||
func TestGetAccessToken(t *testing.T) {
|
||||
const validToken = "valid_token"
|
||||
|
||||
redisMock := tester.NewRedisMock()
|
||||
type test struct {
|
||||
redisGetResults interface{}
|
||||
httpClientDoFunc func(req *http.Request) (*http.Response, error)
|
||||
redisErr error
|
||||
expErr error
|
||||
expResp string
|
||||
expSetValues map[string]tester.ExpiringCache
|
||||
}
|
||||
|
||||
tests := map[string]test{
|
||||
"success_cached": {
|
||||
redisGetResults: []byte(validToken),
|
||||
expErr: nil,
|
||||
expResp: validToken,
|
||||
expSetValues: map[string]tester.ExpiringCache{},
|
||||
},
|
||||
"success": {
|
||||
httpClientDoFunc: successAccHttpDo,
|
||||
expErr: nil,
|
||||
expResp: validToken,
|
||||
expSetValues: map[string]tester.ExpiringCache{
|
||||
redis.SupersetAccTokenKey: {
|
||||
Value: validToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
"failed_cache_func": {
|
||||
redisErr: someErr,
|
||||
expErr: someErr,
|
||||
},
|
||||
"invalid_token": {
|
||||
redisGetResults: 12,
|
||||
expErr: errors.New("invalid superset access token type (expected []byte]); access token: 12"),
|
||||
},
|
||||
"error_request": {
|
||||
httpClientDoFunc: errorHttpDo,
|
||||
expErr: someErr,
|
||||
},
|
||||
"forbidden_request": {
|
||||
httpClientDoFunc: forbiddenAccHttpDo,
|
||||
expErr: errors.New("superset login answered with status: 403 Forbidden"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
httpclient.Client = &mock.Client{DoFunc: tt.httpClientDoFunc}
|
||||
redisMock.Reset()
|
||||
redisMock.GetResults = tt.redisGetResults
|
||||
redisMock.Error = tt.redisErr
|
||||
got, err := superset.GetAccessToken(redisMock)
|
||||
if err != nil && tt.expErr != nil {
|
||||
assert.Equal(t, tt.expErr.Error(), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expErr, err)
|
||||
assert.Equal(t, tt.expResp, got)
|
||||
assert.Equal(t, tt.expSetValues, redisMock.SetValues)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func successAccHttpDo(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"access_token":"valid_token","refresh_token":""}`))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func errorHttpDo(req *http.Request) (*http.Response, error) {
|
||||
return nil, someErr
|
||||
}
|
||||
|
||||
func forbiddenAccHttpDo(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Status: "403 Forbidden",
|
||||
Body: ioutil.NopCloser(bytes.NewReader([]byte(``))),
|
||||
}, nil
|
||||
}
|
||||
12
pkg/superset/superset.go
Normal file
12
pkg/superset/superset.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package superset
|
||||
|
||||
import "fiskerinc.com/modules/utils/envtool"
|
||||
|
||||
var (
|
||||
host = envtool.GetEnv("SUPERSET_URL", "REPLACE_ME")
|
||||
accUserName = envtool.GetEnv("SUPERSET_ACCESS_ACCOUNT_USERNAME", "REPLACE_ME")
|
||||
password = envtool.GetEnv("SUPERSET_ACCESS_ACCOUNT_PASSWORD", "REPLACE_ME")
|
||||
guestUserName = envtool.GetEnv("SUPERSET_GUEST_ACCOUNT_USERNAME", "REPLACE_ME")
|
||||
guestFirstName = envtool.GetEnv("SUPERSET_GUEST_ACCOUNT_FIRSTNAME", "REPLACE_ME")
|
||||
guestLastName = envtool.GetEnv("SUPERSET_GUEST_ACCOUNT_LASTNAME", "REPLACE_ME")
|
||||
)
|
||||
Reference in New Issue
Block a user