Add tailscale driver

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-05-28 11:52:22 -07:00
parent dd547471e6
commit d579879707
16 changed files with 518 additions and 3 deletions

View File

@@ -0,0 +1,12 @@
import type { ComponentProps } from "react";
export function Tailscale(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"
fill="#242424"
/>
</svg>
);
}

View File

@@ -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<string, FC<ComponentProps<"svg">>> = {
@@ -71,6 +72,7 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
SENTRY: Sentry,
SLACK: Slack,
SUPABASE: Supabase,
TAILSCALE: Tailscale,
TALLY: Tally,
VERCEL: Vercel,
};

View File

@@ -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";

View File

@@ -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
},
)
}

View File

@@ -1170,3 +1170,4 @@ func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (str
return "", nil
}

View File

@@ -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)

View File

@@ -0,0 +1,231 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -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

View File

@@ -46,6 +46,7 @@ func NewBuiltinRegistry() *Registry {
sentryRegistration(),
slackRegistration(),
supabaseRegistration(),
tailscaleRegistration(),
tallyRegistration(),
vercelRegistration(),
} {

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
},
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- 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';

View File

@@ -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,

View File

@@ -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 {