@@ -46,6 +46,15 @@ type metabaseUser struct {
|
||||
DateJoined string `json:"date_joined"`
|
||||
}
|
||||
|
||||
type metabaseUsersResponse struct {
|
||||
Data []metabaseUser `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Limit *int `json:"limit"`
|
||||
Offset *int `json:"offset"`
|
||||
}
|
||||
|
||||
const metabaseUsersPageLimit = 50
|
||||
|
||||
func NewMetabaseDriver(httpClient *http.Client, instanceURL string) *MetabaseDriver {
|
||||
return &MetabaseDriver{
|
||||
httpClient: httpClient,
|
||||
@@ -98,9 +107,36 @@ func (d *MetabaseDriver) queryUsers(ctx context.Context) ([]metabaseUser, error)
|
||||
return nil, fmt.Errorf("cannot parse metabase instance url: %w", err)
|
||||
}
|
||||
|
||||
var users []metabaseUser
|
||||
offset := 0
|
||||
|
||||
for {
|
||||
page, err := d.queryUsersPage(ctx, baseURL, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users = append(users, page.Data...)
|
||||
|
||||
offset += len(page.Data)
|
||||
if len(page.Data) == 0 || offset >= page.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *MetabaseDriver) queryUsersPage(
|
||||
ctx context.Context,
|
||||
baseURL *url.URL,
|
||||
offset int,
|
||||
) (*metabaseUsersResponse, error) {
|
||||
endpoint := baseURL.JoinPath("api", "user")
|
||||
q := endpoint.Query()
|
||||
q.Set("status", "all")
|
||||
q.Set("limit", strconv.Itoa(metabaseUsersPageLimit))
|
||||
q.Set("offset", strconv.Itoa(offset))
|
||||
endpoint.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
@@ -123,12 +159,12 @@ func (d *MetabaseDriver) queryUsers(ctx context.Context) ([]metabaseUser, error)
|
||||
return nil, fmt.Errorf("cannot fetch metabase users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var users []metabaseUser
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil {
|
||||
var resp metabaseUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode metabase users response: %w", err)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func metabaseFullName(u metabaseUser) string {
|
||||
@@ -147,6 +183,63 @@ func metabaseRole(isSuperuser bool) string {
|
||||
return "User"
|
||||
}
|
||||
|
||||
// metabaseNameResolver resolves the Metabase site name by querying
|
||||
// /api/session/properties on the configured Metabase instance, which
|
||||
// exposes the site-name setting to any authenticated session.
|
||||
type metabaseNameResolver struct {
|
||||
httpClient *http.Client
|
||||
instanceURL string
|
||||
}
|
||||
|
||||
var _ NameResolver = (*metabaseNameResolver)(nil)
|
||||
|
||||
type metabaseSessionProperties struct {
|
||||
SiteName string `json:"site-name"`
|
||||
}
|
||||
|
||||
func NewMetabaseNameResolver(httpClient *http.Client, instanceURL string) NameResolver {
|
||||
return &metabaseNameResolver{
|
||||
httpClient: httpClient,
|
||||
instanceURL: instanceURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *metabaseNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
baseURL, err := url.Parse(r.instanceURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot parse metabase instance url: %w", err)
|
||||
}
|
||||
|
||||
endpoint := baseURL.JoinPath("api", "session", "properties")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create metabase session properties request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute metabase session properties request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch metabase session properties: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var props metabaseSessionProperties
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&props); err != nil {
|
||||
return "", fmt.Errorf("cannot decode metabase session properties response: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(props.SiteName), nil
|
||||
}
|
||||
|
||||
func parseMetabaseTimestamp(value string) (time.Time, bool) {
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
|
||||
@@ -9,12 +9,16 @@ interactions:
|
||||
content_length: 0
|
||||
host: k7.metabaseapp.com
|
||||
form:
|
||||
limit:
|
||||
- "50"
|
||||
offset:
|
||||
- "0"
|
||||
status:
|
||||
- all
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://k7.metabaseapp.com/api/user?status=all
|
||||
url: https://k7.metabaseapp.com/api/user?limit=50&offset=0&status=all
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
@@ -22,7 +26,7 @@ interactions:
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '[{"id":1,"email":"alice@example.com","first_name":"Alice","last_name":"Admin","common_name":"Alice A.","is_active":true,"is_superuser":true,"last_login":"2026-05-20T10:11:12.345678Z","date_joined":"2026-01-02T03:04:05Z"},{"id":2,"email":"bob@example.com","first_name":"Bob","last_name":"Builder","is_active":false,"is_superuser":false,"last_login":"","date_joined":"2026-02-03T04:05:06Z"},{"id":3,"email":"","first_name":"No","last_name":"Email","is_active":true,"is_superuser":false}]'
|
||||
body: '{"data":[{"id":1,"email":"alice@example.com","first_name":"Alice","last_name":"Admin","common_name":"Alice A.","is_active":true,"is_superuser":true,"last_login":"2026-05-20T10:11:12.345678Z","date_joined":"2026-01-02T03:04:05Z"},{"id":2,"email":"bob@example.com","first_name":"Bob","last_name":"Builder","is_active":false,"is_superuser":false,"last_login":"","date_joined":"2026-02-03T04:05:06Z"},{"id":3,"email":"","first_name":"No","last_name":"Email","is_active":true,"is_superuser":false}],"total":3,"limit":50,"offset":0}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
|
||||
@@ -52,6 +52,26 @@ func metabaseRegistration() *Registration {
|
||||
|
||||
return drivers.NewMetabaseDriver(c, instanceURL), nil
|
||||
},
|
||||
NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver {
|
||||
settings, err := coredata.ConnectorSettings[coredata.MetabaseConnectorSettings](conn)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot read metabase connector settings", log.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
instanceURL := strings.TrimSpace(settings.InstanceURL)
|
||||
if instanceURL == "" {
|
||||
logger.ErrorCtx(ctx, "missing metabase instance url in connector settings")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateMetabaseInstanceURL(instanceURL); err != nil {
|
||||
logger.ErrorCtx(ctx, "invalid metabase instance url in connector settings", log.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
return drivers.NewMetabaseNameResolver(c, instanceURL)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user