diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx index affab3145..919bb7f7b 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx @@ -112,6 +112,8 @@ function sourceLabel(connectorProvider: string | null | undefined): string { return "Linear"; case "SLACK": return "Slack"; + case "METABASE": + return "Metabase"; default: return connectorProvider; } diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx index b6d853d01..836e8921c 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -119,6 +119,9 @@ function mapAPIKeyExtraSettingToField( case "ONE_PASSWORD": if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl"; break; + case "METABASE": + if (settingKey === "instanceUrl") return "metabaseInstanceUrl"; + break; } return null; } diff --git a/pkg/accessreview/drivers/metabase.go b/pkg/accessreview/drivers/metabase.go new file mode 100644 index 000000000..fe0f16d1d --- /dev/null +++ b/pkg/accessreview/drivers/metabase.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS 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"` +} + +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), + Role: metabaseRole(u.IsSuperuser), + Active: new(u.IsActive), + IsAdmin: u.IsSuperuser, + ExternalID: strconv.Itoa(u.ID), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + } + + 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) + } + + endpoint := baseURL.JoinPath("api", "user") + q := endpoint.Query() + q.Set("status", "all") + 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 users []metabaseUser + if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil { + return nil, fmt.Errorf("cannot decode metabase users response: %w", err) + } + + return users, nil +} + +func metabaseFullName(u metabaseUser) string { + if u.CommonName != "" { + return u.CommonName + } + + return strings.TrimSpace(strings.Join([]string{u.FirstName, u.LastName}, " ")) +} + +func metabaseRole(isSuperuser bool) string { + if isSuperuser { + return "Admin" + } + + return "User" +} + +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 +} diff --git a/pkg/accessreview/drivers/metabase_test.go b/pkg/accessreview/drivers/metabase_test.go new file mode 100644 index 000000000..8e7ee16b4 --- /dev/null +++ b/pkg/accessreview/drivers/metabase_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetabaseDriverListAccounts(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/user", r.URL.Path) + assert.Equal(t, "all", r.URL.Query().Get("status")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + { + "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 + } + ]`)) + })) + defer srv.Close() + + driver := NewMetabaseDriver(srv.Client(), srv.URL) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + assert.Equal(t, "alice@example.com", records[0].Email) + assert.Equal(t, "Alice A.", records[0].FullName) + assert.Equal(t, "Admin", records[0].Role) + assert.True(t, records[0].IsAdmin) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + assert.Equal(t, "1", records[0].ExternalID) + require.NotNil(t, records[0].LastLogin) + require.NotNil(t, records[0].CreatedAt) + + assert.Equal(t, "bob@example.com", records[1].Email) + assert.Equal(t, "Bob Builder", records[1].FullName) + assert.Equal(t, "User", records[1].Role) + assert.False(t, records[1].IsAdmin) + require.NotNil(t, records[1].Active) + assert.False(t, *records[1].Active) + assert.Equal(t, "2", records[1].ExternalID) + assert.Nil(t, records[1].LastLogin) + require.NotNil(t, records[1].CreatedAt) +} + +func TestMetabaseDriverListAccountsUnexpectedStatus(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"unauthorized"}`)) + })) + defer srv.Close() + + driver := NewMetabaseDriver(srv.Client(), srv.URL) + _, err := driver.ListAccounts(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 401") +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 6b76336a4..46f7b66e0 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -38,6 +38,7 @@ func NewBuiltinRegistry() *Registry { hubspotRegistration(), intercomRegistration(), linearRegistration(), + metabaseRegistration(), microsoft365Registration(), mondayRegistration(), netlifyRegistration(), diff --git a/pkg/connector/provider/metabase.go b/pkg/connector/provider/metabase.go new file mode 100644 index 000000000..cd913817b --- /dev/null +++ b/pkg/connector/provider/metabase.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func metabaseRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderMetabase, + DisplayName: "Metabase", + SupportsAPIKey: true, + APIKeyHeader: "x-api-key", + ExtraSettings: []ExtraSetting{ + {Key: "instanceUrl", Label: "Instance URL", Required: true}, + }, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + settings, err := coredata.ConnectorSettings[coredata.MetabaseConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read metabase connector settings: %w", err) + } + + instanceURL := strings.TrimSpace(settings.InstanceURL) + if instanceURL == "" { + return nil, fmt.Errorf("cannot create metabase driver: instance_url is required") + } + + if err := validateMetabaseInstanceURL(instanceURL); err != nil { + return nil, err + } + + return drivers.NewMetabaseDriver(c, instanceURL), nil + }, + } +} + +func validateMetabaseInstanceURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("cannot create metabase driver: instance_url is invalid: %w", err) + } + + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("cannot create metabase driver: instance_url must be an http(s) URL") + } + + return nil +} diff --git a/pkg/connector/provider/metabase_test.go b/pkg/connector/provider/metabase_test.go new file mode 100644 index 000000000..a6c9370d6 --- /dev/null +++ b/pkg/connector/provider/metabase_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +func TestMetabaseRegistrationMetadata(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderMetabase) + require.True(t, ok, "metabase provider must be registered") + + assert.Equal(t, "Metabase", reg.DisplayName) + assert.True(t, reg.SupportsAPIKey) + assert.Equal(t, "x-api-key", reg.APIKeyHeader) + require.Len(t, reg.ExtraSettings, 1) + assert.Equal(t, "instanceUrl", reg.ExtraSettings[0].Key) + assert.Equal(t, "Instance URL", reg.ExtraSettings[0].Label) + assert.True(t, reg.ExtraSettings[0].Required) +} + +func TestMetabaseNewDriver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderMetabase) + require.True(t, ok, "metabase provider must be registered") + require.NotNil(t, reg.NewDriver, "metabase NewDriver closure must be wired") + + t.Run("creates driver with valid instance_url", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.MetabaseConnectorSettings{ + InstanceURL: "https://metabase.example.test", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderMetabase, + RawSettings: raw, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.MetabaseDriver{}, drv) + }) + + t.Run("errors when instance_url is missing", func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderMetabase, + RawSettings: []byte(`{}`), + } + + _, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "instance_url is required") + }) + + t.Run("errors when instance_url is invalid", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.MetabaseConnectorSettings{ + InstanceURL: "ftp://metabase.example.test", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderMetabase, + RawSettings: raw, + } + + _, err = reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "instance_url must be an http(s) URL") + }) +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 3ab4370c3..c6da291a3 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -50,6 +50,7 @@ const ( ConnectorProviderClickUp ConnectorProvider = "CLICKUP" ConnectorProviderVercel ConnectorProvider = "VERCEL" ConnectorProviderMonday ConnectorProvider = "MONDAY" + ConnectorProviderMetabase ConnectorProvider = "METABASE" ConnectorProviderTailscale ConnectorProvider = "TAILSCALE" ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC" ConnectorProviderCursor ConnectorProvider = "CURSOR" @@ -90,6 +91,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderClickUp, ConnectorProviderVercel, ConnectorProviderMonday, + ConnectorProviderMetabase, ConnectorProviderTailscale, ConnectorProviderAnthropic, ConnectorProviderCursor, @@ -126,6 +128,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderClickUp, ConnectorProviderVercel, ConnectorProviderMonday, + ConnectorProviderMetabase, ConnectorProviderTailscale, ConnectorProviderAnthropic, ConnectorProviderCursor: diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 57dae55ea..985d9630d 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -87,6 +87,10 @@ type ( VercelConnectorSettings struct { TeamID string `json:"team_id"` } + + MetabaseConnectorSettings struct { + InstanceURL string `json:"instance_url"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260528T214100Z.sql b/pkg/coredata/migrations/20260528T214100Z.sql new file mode 100644 index 000000000..b34bbe8fb --- /dev/null +++ b/pkg/coredata/migrations/20260528T214100Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'METABASE'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index 76f7c9c9f..44baa8fb5 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -82,6 +82,17 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.OnePasswordConnectorSettings{SCIMBridgeURL: *input.OnePasswordScimBridgeURL}) + case coredata.ConnectorProviderMetabase: + if input.MetabaseInstanceURL == nil || *input.MetabaseInstanceURL == "" { + return nil, fmt.Errorf("cannot create metabase connector: metabaseInstanceUrl is required") + } + + u, err := url.Parse(*input.MetabaseInstanceURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("cannot create metabase connector: metabaseInstanceUrl must be an http(s) URL") + } + + return json.Marshal(&coredata.MetabaseConnectorSettings{InstanceURL: *input.MetabaseInstanceURL}) } return nil, nil diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 21b1179d2..4a867b506 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -46,6 +46,8 @@ enum ConnectorProvider @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderClickUp") VERCEL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderVercel") MONDAY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMonday") + METABASE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMetabase") TAILSCALE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderTailscale") ANTHROPIC @@ -125,6 +127,7 @@ input CreateAPIKeyConnectorInput { githubOrganization: String grafanaBaseUrl: String onePasswordScimBridgeUrl: String + metabaseInstanceUrl: String } type CreateAPIKeyConnectorPayload {