From d579879707379edbffd0720cbd1370b4319c6967 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Thu, 28 May 2026 11:52:22 -0700 Subject: [PATCH] Add tailscale driver Signed-off-by: Bryan Frimin --- .../ui/src/Atoms/ThirdParties/Tailscale.tsx | 12 + .../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 2 + packages/ui/src/Atoms/ThirdParties/index.ts | 1 + pkg/accessreview/access_source_service.go | 47 +++- pkg/accessreview/drivers/name_resolver.go | 1 + .../drivers/name_resolver_test.go | 63 +++++ pkg/accessreview/drivers/tailscale.go | 231 ++++++++++++++++++ pkg/accessreview/drivers/tailscale_test.go | 42 ++++ .../drivers/testdata/tailscale.yaml | 28 +++ pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/tailscale.go | 38 +++ pkg/coredata/connector.go | 7 + pkg/coredata/connector_provider.go | 5 +- pkg/coredata/migrations/20260528T172500Z.sql | 15 ++ pkg/coredata/scim_bridge.go | 26 ++ .../api/console/v1/graphql/connector.graphql | 2 + 16 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/Atoms/ThirdParties/Tailscale.tsx create mode 100644 pkg/accessreview/drivers/tailscale.go create mode 100644 pkg/accessreview/drivers/tailscale_test.go create mode 100644 pkg/accessreview/drivers/testdata/tailscale.yaml create mode 100644 pkg/connector/provider/tailscale.go create mode 100644 pkg/coredata/migrations/20260528T172500Z.sql diff --git a/packages/ui/src/Atoms/ThirdParties/Tailscale.tsx b/packages/ui/src/Atoms/ThirdParties/Tailscale.tsx new file mode 100644 index 000000000..43caf091d --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Tailscale.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Tailscale(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index 0e6f2bf1a..852de3631 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -40,6 +40,7 @@ import { Sentry } from "./Sentry"; import { Slack } from "./Slack"; import { Supabase } from "./Supabase"; import { Tally } from "./Tally"; +import { Tailscale } from "./Tailscale"; import { Vercel } from "./Vercel"; const thirdParties: Record>> = { @@ -71,6 +72,7 @@ const thirdParties: Record>> = { SENTRY: Sentry, SLACK: Slack, SUPABASE: Supabase, + TAILSCALE: Tailscale, TALLY: Tally, VERCEL: Vercel, }; diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index b10f4f840..642b426c6 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -24,5 +24,6 @@ export { Sentry } from "./Sentry"; export { Slack } from "./Slack"; export { Supabase } from "./Supabase"; export { Tally } from "./Tally"; +export { Tailscale } from "./Tailscale"; export { ThirdPartyLogo } from "./ThirdPartyLogo"; export { Vercel } from "./Vercel"; diff --git a/pkg/accessreview/access_source_service.go b/pkg/accessreview/access_source_service.go index d10697636..6bfe48a52 100644 --- a/pkg/accessreview/access_source_service.go +++ b/pkg/accessreview/access_source_service.go @@ -218,12 +218,55 @@ func (s AccessSourceService) Delete( ctx context.Context, accessSourceID gid.GID, ) error { - source := &coredata.AccessSource{ID: accessSourceID} + source := &coredata.AccessSource{} return s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - return source.Delete(ctx, conn, s.scope) + if err := source.LoadByID(ctx, conn, s.scope, accessSourceID); err != nil { + return fmt.Errorf("cannot load access source: %w", err) + } + + if err := source.Delete(ctx, conn, s.scope); err != nil { + return fmt.Errorf("cannot delete access source: %w", err) + } + + // Garbage-collect the underlying connector once nothing else + // references it. The connectors table is unique per + // (organization_id, provider), so leaving an orphaned connector + // behind would block re-adding a source for the same provider. + if source.ConnectorID == nil { + return nil + } + + accessSources := &coredata.AccessSources{} + + sourceCount, err := accessSources.CountByConnectorID(ctx, conn, s.scope, *source.ConnectorID) + if err != nil { + return fmt.Errorf("cannot count access sources for connector: %w", err) + } + + if sourceCount > 0 { + return nil + } + + bridges := &coredata.SCIMBridges{} + + bridgeCount, err := bridges.CountByConnectorID(ctx, conn, s.scope, *source.ConnectorID) + if err != nil { + return fmt.Errorf("cannot count scim bridges for connector: %w", err) + } + + if bridgeCount > 0 { + return nil + } + + cnnctr := &coredata.Connector{ID: *source.ConnectorID} + if err := cnnctr.Delete(ctx, conn, s.scope); err != nil { + return fmt.Errorf("cannot delete connector: %w", err) + } + + return nil }, ) } diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index a92f33548..c9ff927dd 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -1170,3 +1170,4 @@ func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (str return "", nil } + diff --git a/pkg/accessreview/drivers/name_resolver_test.go b/pkg/accessreview/drivers/name_resolver_test.go index 8a1a95763..02bd68c2d 100644 --- a/pkg/accessreview/drivers/name_resolver_test.go +++ b/pkg/accessreview/drivers/name_resolver_test.go @@ -185,6 +185,69 @@ func TestSentryNameResolver(t *testing.T) { } } +func TestTailscaleNameResolver(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status int + body string + want string + wantErr bool + }{ + { + name: "custom domain tailnet", + status: http.StatusOK, + body: `{"users":[{"loginName":"jane@acme.example.com"},{"loginName":"bob@acme.example.com"}]}`, + want: "acme.example.com", + }, + { + name: "most common domain wins", + status: http.StatusOK, + body: `{"users":[{"loginName":"a@one.com"},{"loginName":"b@two.com"},{"loginName":"c@two.com"}]}`, + want: "two.com", + }, + { + name: "no usable login names", + status: http.StatusOK, + body: `{"users":[{"loginName":""},{"loginName":"tagged-device"}]}`, + want: "", + }, + { + name: "server error", + status: http.StatusInternalServerError, + body: `{"message":"boom"}`, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(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/v2/tailnet/-/users", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + client := &http.Client{Transport: &hostRewriter{target: srv.URL}} + + got, err := NewTailscaleNameResolver(client).ResolveInstanceName(context.Background()) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + // roundTripperFunc adapts a function into an http.RoundTripper, useful for // asserting that a resolver short-circuits before making any HTTP call. type roundTripperFunc func(*http.Request) (*http.Response, error) diff --git a/pkg/accessreview/drivers/tailscale.go b/pkg/accessreview/drivers/tailscale.go new file mode 100644 index 000000000..8c66741a1 --- /dev/null +++ b/pkg/accessreview/drivers/tailscale.go @@ -0,0 +1,231 @@ +// 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" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +// tailscaleDefaultTailnet is the "-" shorthand Tailscale accepts in the +// tailnet path segment; it resolves to the access token's own tailnet, so +// the connector never needs to know the organization name up front. +const tailscaleDefaultTailnet = "-" + +// TailscaleDriver fetches tailnet users from the Tailscale API via Bearer +// token-authenticated REST requests. It always targets the access token's +// default tailnet, so no tailnet identifier is required. +type TailscaleDriver struct { + httpClient *http.Client +} + +var _ Driver = (*TailscaleDriver)(nil) + +type tailscaleUser struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LoginName string `json:"loginName"` + Created string `json:"created"` + Role string `json:"role"` + Status string `json:"status"` + LastSeen string `json:"lastSeen"` + CurrentlyConnected bool `json:"currentlyConnected"` +} + +type tailscaleUsersResponse struct { + Users []tailscaleUser `json:"users"` +} + +func NewTailscaleDriver(httpClient *http.Client) *TailscaleDriver { + return &TailscaleDriver{ + httpClient: httpClient, + } +} + +func (d *TailscaleDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + users, err := d.fetchUsers(ctx) + if err != nil { + return nil, err + } + + records := make([]AccountRecord, 0, len(users)) + + for _, u := range users { + email := u.LoginName + if email == "" { + continue + } + + record := AccountRecord{ + Email: email, + FullName: u.DisplayName, + Role: u.Role, + Active: tailscaleUserActive(u.Status), + IsAdmin: tailscaleUserIsAdmin(u.Role), + ExternalID: u.ID, + MFAStatus: coredata.MFAStatusUnknown, + // Tailscale has no local credentials; it always delegates + // authentication to an upstream identity provider, so every + // account is SSO regardless of which IdP backs the tailnet. + AuthMethod: coredata.AccessEntryAuthMethodSSO, + AccountType: coredata.AccessEntryAccountTypeUser, + } + + if u.Created != "" { + if t, err := time.Parse(time.RFC3339, u.Created); err == nil { + record.CreatedAt = &t + } + } + + if u.LastSeen != "" { + if t, err := time.Parse(time.RFC3339, u.LastSeen); err == nil { + record.LastLogin = &t + } + } + + records = append(records, record) + } + + return records, nil +} + +func (d *TailscaleDriver) fetchUsers(ctx context.Context) ([]tailscaleUser, error) { + endpoint, err := url.JoinPath( + "https://api.tailscale.com", + "api", + "v2", + "tailnet", + tailscaleDefaultTailnet, + "users", + ) + if err != nil { + return nil, fmt.Errorf("cannot build tailscale users URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create tailscale 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 tailscale users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch tailscale users: unexpected status %d", httpResp.StatusCode) + } + + var resp tailscaleUsersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode tailscale users response: %w", err) + } + + return resp.Users, nil +} + +func tailscaleUserActive(status string) *bool { + switch strings.ToLower(status) { + case "active", "idle": + return new(true) + case "suspended": + return new(false) + default: + return nil + } +} + +func tailscaleUserIsAdmin(role string) bool { + switch role { + case "owner", "admin", "it-admin", "network-admin", "billing-admin": + return true + default: + return false + } +} + +// tailscaleNameResolver derives the tailnet name from the email domain shared +// by the tailnet's users. Tailscale exposes no API endpoint that returns the +// tailnet/organization name directly, and the connector targets the "-" +// default tailnet so the identifier is never captured up front. For tailnets +// backed by a custom domain the user login domain matches the tailnet ID +// exactly (e.g. "example.com"); for shared-domain tailnets it degrades to the +// provider domain, which is still a useful label. +type tailscaleNameResolver struct { + httpClient *http.Client +} + +var _ NameResolver = (*tailscaleNameResolver)(nil) + +func NewTailscaleNameResolver(httpClient *http.Client) NameResolver { + return &tailscaleNameResolver{httpClient: httpClient} +} + +func (r *tailscaleNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + driver := &TailscaleDriver{httpClient: r.httpClient} + + users, err := driver.fetchUsers(ctx) + if err != nil { + return "", err + } + + return tailscaleTailnetName(users), nil +} + +// tailscaleTailnetName returns the most common email domain among the tailnet +// users, preserving first-seen order to break ties deterministically. +func tailscaleTailnetName(users []tailscaleUser) string { + counts := make(map[string]int, len(users)) + order := make([]string, 0, len(users)) + + for _, u := range users { + at := strings.LastIndex(u.LoginName, "@") + if at < 0 || at == len(u.LoginName)-1 { + continue + } + + domain := strings.ToLower(u.LoginName[at+1:]) + if _, seen := counts[domain]; !seen { + order = append(order, domain) + } + + counts[domain]++ + } + + best := "" + bestCount := 0 + + for _, domain := range order { + if counts[domain] > bestCount { + best = domain + bestCount = counts[domain] + } + } + + return best +} diff --git a/pkg/accessreview/drivers/tailscale_test.go b/pkg/accessreview/drivers/tailscale_test.go new file mode 100644 index 000000000..b60b5d84e --- /dev/null +++ b/pkg/accessreview/drivers/tailscale_test.go @@ -0,0 +1,42 @@ +// 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" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTailscaleDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/tailscale", "TAILSCALE_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("TAILSCALE_TOKEN"))) + + driver := NewTailscaleDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, records) + + r := records[0] + assert.NotEmpty(t, r.Email) + assert.NotEmpty(t, r.ExternalID) + assert.NotEmpty(t, r.Role) + assert.NotNil(t, r.Active) +} diff --git a/pkg/accessreview/drivers/testdata/tailscale.yaml b/pkg/accessreview/drivers/testdata/tailscale.yaml new file mode 100644 index 000000000..03cfffe34 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/tailscale.yaml @@ -0,0 +1,28 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.tailscale.com + headers: + Accept: + - application/json + url: https://api.tailscale.com/api/v2/tailnet/-/users + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"users":[{"id":"u1abc123","displayName":"Jane Doe","loginName":"jane@acme.example.com","profilePicUrl":"","tailnetId":"tn1","created":"2024-01-15T10:00:00Z","type":"member","role":"admin","status":"active","deviceCount":2,"lastSeen":"2025-03-01T08:30:00Z","currentlyConnected":true},{"id":"u2def456","displayName":"Bob Smith","loginName":"bob@acme.example.com","profilePicUrl":"","tailnetId":"tn1","created":"2024-06-20T14:00:00Z","type":"member","role":"member","status":"suspended","deviceCount":0,"lastSeen":"2024-12-01T12:00:00Z","currentlyConnected":false}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index d6eab56fd..c86414431 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -46,6 +46,7 @@ func NewBuiltinRegistry() *Registry { sentryRegistration(), slackRegistration(), supabaseRegistration(), + tailscaleRegistration(), tallyRegistration(), vercelRegistration(), } { diff --git a/pkg/connector/provider/tailscale.go b/pkg/connector/provider/tailscale.go new file mode 100644 index 000000000..9298a598f --- /dev/null +++ b/pkg/connector/provider/tailscale.go @@ -0,0 +1,38 @@ +// 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" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func tailscaleRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderTailscale, + DisplayName: "Tailscale", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewTailscaleDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewTailscaleNameResolver(c) + }, + } +} diff --git a/pkg/coredata/connector.go b/pkg/coredata/connector.go index 98a2599e0..4e89f5da7 100644 --- a/pkg/coredata/connector.go +++ b/pkg/coredata/connector.go @@ -24,6 +24,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/crypto/cipher" @@ -401,6 +402,12 @@ INSERT INTO connectors ( _, err = conn.Exec(ctx, q, args) if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_connectors_organization_id_provider" { + return ErrResourceAlreadyExists + } + } + return fmt.Errorf("cannot insert connector: %w", err) } diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index bd786753f..5a59a8b39 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -49,6 +49,7 @@ const ( ConnectorProviderClickUp ConnectorProvider = "CLICKUP" ConnectorProviderVercel ConnectorProvider = "VERCEL" ConnectorProviderMonday ConnectorProvider = "MONDAY" + ConnectorProviderTailscale ConnectorProvider = "TAILSCALE" ) var ( @@ -85,6 +86,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderClickUp, ConnectorProviderVercel, ConnectorProviderMonday, + ConnectorProviderTailscale, } } @@ -116,7 +118,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderNetlify, ConnectorProviderClickUp, ConnectorProviderVercel, - ConnectorProviderMonday: + ConnectorProviderMonday, + ConnectorProviderTailscale: return true } diff --git a/pkg/coredata/migrations/20260528T172500Z.sql b/pkg/coredata/migrations/20260528T172500Z.sql new file mode 100644 index 000000000..d81509834 --- /dev/null +++ b/pkg/coredata/migrations/20260528T172500Z.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 'TAILSCALE'; diff --git a/pkg/coredata/scim_bridge.go b/pkg/coredata/scim_bridge.go index efefeec10..9e7d675a3 100644 --- a/pkg/coredata/scim_bridge.go +++ b/pkg/coredata/scim_bridge.go @@ -265,6 +265,32 @@ LIMIT 1; return nil } +func (s *SCIMBridges) CountByConnectorID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + connectorID gid.GID, +) (int, error) { + q := ` +SELECT COUNT(id) +FROM iam_scim_bridges +WHERE + %s + AND connector_id = @connector_id; +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"connector_id": connectorID} + maps.Copy(args, scope.SQLArguments()) + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count iam_scim_bridges by connector ID: %w", err) + } + + return count, nil +} + func (s *SCIMBridge) Insert( ctx context.Context, conn pg.Tx, diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 3de9c2aa9..aabbb07ca 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -44,6 +44,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") + TAILSCALE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderTailscale") } type ConnectorProviderInfo {