// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package drivers import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" "go.probo.inc/probo/pkg/coredata" ) type MetabaseDriver struct { httpClient *http.Client instanceURL string } var _ Driver = (*MetabaseDriver)(nil) type metabaseUser struct { ID int `json:"id"` Email string `json:"email"` FirstName string `json:"first_name"` LastName string `json:"last_name"` CommonName string `json:"common_name"` IsActive bool `json:"is_active"` IsSuperuser bool `json:"is_superuser"` LastLogin string `json:"last_login"` 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, instanceURL: instanceURL, } } func (d *MetabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { users, err := d.queryUsers(ctx) if err != nil { return nil, err } records := make([]AccountRecord, 0, len(users)) for _, u := range users { if u.Email == "" { continue } record := AccountRecord{ Email: u.Email, FullName: metabaseFullName(u), Roles: metabaseRoles(u.IsSuperuser), Active: new(u.IsActive), IsAdmin: u.IsSuperuser, ExternalID: strconv.Itoa(u.ID), MFAStatus: coredata.MFAStatusUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: coredata.AccessReviewEntryAccountTypeUser, } if t, ok := parseMetabaseTimestamp(u.LastLogin); ok { record.LastLogin = &t } if t, ok := parseMetabaseTimestamp(u.DateJoined); ok { record.CreatedAt = &t } records = append(records, record) } return records, nil } func (d *MetabaseDriver) queryUsers(ctx context.Context) ([]metabaseUser, error) { baseURL, err := url.Parse(d.instanceURL) if err != nil { 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) if err != nil { return nil, fmt.Errorf("cannot create metabase users request: %w", err) } req.Header.Set("Accept", "application/json") httpResp, err := d.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("cannot execute metabase users request: %w", err) } defer func() { _ = httpResp.Body.Close() }() if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { return nil, fmt.Errorf("cannot fetch metabase users: unexpected status %d", httpResp.StatusCode) } 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 &resp, nil } func metabaseFullName(u metabaseUser) string { if u.CommonName != "" { return u.CommonName } return strings.TrimSpace(strings.Join([]string{u.FirstName, u.LastName}, " ")) } func metabaseRoles(isSuperuser bool) []string { if isSuperuser { return []string{"Admin"} } return []string{"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 "", nameStatusError("metabase session properties", 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 } for _, layout := range []string{ time.RFC3339Nano, time.RFC3339, } { t, err := time.Parse(layout, value) if err == nil { return t, true } } return time.Time{}, false }