From e62e6cce436489a1971f45a949ac6e805d602423 Mon Sep 17 00:00:00 2001 From: Steven4Hooisma <112615049+Steven4Hooisma@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:52:46 +0200 Subject: [PATCH] Add UpCloud integration with account listing and details retrieval Signed-off-by: Steven4Hooisma <112615049+Steven4Hooisma@users.noreply.github.com> --- .../drivers/testdata/upcloud.yaml | 132 ++++++++++++ pkg/accessreview/drivers/upcloud.go | 202 ++++++++++++++++++ pkg/accessreview/drivers/upcloud_test.go | 79 +++++++ pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/upcloud.go | 54 +++++ pkg/coredata/connector_provider.go | 5 +- pkg/coredata/migrations/20260723T113756Z.sql | 21 ++ .../api/console/v1/graphql/connector.graphql | 2 + 8 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 pkg/accessreview/drivers/testdata/upcloud.yaml create mode 100644 pkg/accessreview/drivers/upcloud.go create mode 100644 pkg/accessreview/drivers/upcloud_test.go create mode 100644 pkg/connector/provider/upcloud.go create mode 100644 pkg/coredata/migrations/20260723T113756Z.sql diff --git a/pkg/accessreview/drivers/testdata/upcloud.yaml b/pkg/accessreview/drivers/testdata/upcloud.yaml new file mode 100644 index 000000000..1ba8c6b5f --- /dev/null +++ b/pkg/accessreview/drivers/testdata/upcloud.yaml @@ -0,0 +1,132 @@ +# Hand-authored fixture for the UpCloud account-listing flow: GET +# /1.3/account/list returns the main account plus its sub-accounts in one +# call, then GET /1.3/account/details/{username} is called per account to +# enrich with name/email. my_temp_account's details call 404s to exercise +# the driver's fallback to list-only fields. Synthetic usernames only. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.upcloud.com + headers: + Accept: + - application/json + url: https://api.upcloud.com/1.3/account/list + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"accounts":{"account":[{"labels":[],"roles":{"role":["technical"]},"type":"mymain","username":"test"},{"labels":[],"roles":{"role":["technical"]},"type":"sub","username":"my_sub_account"},{"labels":[{"key":"to_be_removed","value":"after 2022-31-12"}],"roles":{"role":[]},"type":"sub","username":"my_temp_account"},{"labels":[],"roles":{"role":["billing"]},"type":"sub","username":"my_billing_account"}]}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 110ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.upcloud.com + headers: + Accept: + - application/json + url: https://api.upcloud.com/1.3/account/details/test + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"account":{"main_account":"","type":"mymain","username":"test","first_name":"Main","last_name":"Account","email":"main@example.com","roles":{"role":["technical"]}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 90ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.upcloud.com + headers: + Accept: + - application/json + url: https://api.upcloud.com/1.3/account/details/my_sub_account + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"account":{"main_account":"test","type":"sub","username":"my_sub_account","first_name":"Sub","last_name":"Account","email":"sub@example.com","roles":{"role":["technical"]}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 90ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.upcloud.com + headers: + Accept: + - application/json + url: https://api.upcloud.com/1.3/account/details/my_temp_account + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"error":{"error_code":"ACCOUNT_NOT_FOUND","error_message":"Account not found"}}' + headers: + Content-Type: + - application/json + status: 404 Not Found + code: 404 + duration: 90ms + - id: 4 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.upcloud.com + headers: + Accept: + - application/json + url: https://api.upcloud.com/1.3/account/details/my_billing_account + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"account":{"main_account":"test","type":"sub","username":"my_billing_account","first_name":"Billing","last_name":"Account","email":"billing@example.com","roles":{"role":["billing"]}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 90ms diff --git a/pkg/accessreview/drivers/upcloud.go b/pkg/accessreview/drivers/upcloud.go new file mode 100644 index 000000000..af5487adb --- /dev/null +++ b/pkg/accessreview/drivers/upcloud.go @@ -0,0 +1,202 @@ +// 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" + "strings" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" +) + +const upcloudAccountListURL = "https://api.upcloud.com/1.3/account/list" + +// UpCloudDriver lists the main account and its sub-accounts via UpCloud's +// account/list endpoint, then enriches each with account/details/{username} +// (email, first/last name), using a pre-authenticated HTTP client (Bearer API +// token) attached by the connection transport. +// +// Notes on data quality: +// - account/details has no explicit account-status field, so Active is +// left nil (no signal). +// - Neither endpoint exposes per-account MFA status, so MFAStatus is left +// Unknown. +// - If the details fetch for an account fails, the account is still +// returned (per Driver contract, no account may be dropped) with just +// the list fields; Email stays blank and FullName falls back to the +// username. +type UpCloudDriver struct { + httpClient *http.Client + logger *log.Logger +} + +var _ Driver = (*UpCloudDriver)(nil) + +func NewUpCloudDriver(httpClient *http.Client, logger *log.Logger) *UpCloudDriver { + return &UpCloudDriver{ + httpClient: httpClient, + logger: logger, + } +} + +type upcloudAccountListResponse struct { + Accounts struct { + Account []upcloudAccount `json:"account"` + } `json:"accounts"` +} + +type upcloudAccount struct { + Username string `json:"username"` + Type string `json:"type"` + Labels []struct { + Key string `json:"key"` + Value string `json:"value"` + } `json:"labels"` + Roles struct { + Role []string `json:"role"` + } `json:"roles"` +} + +type upcloudAccountDetailsResponse struct { + Account upcloudAccountDetails `json:"account"` +} + +type upcloudAccountDetails struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` +} + +func (d *UpCloudDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, upcloudAccountListURL, nil) + if err != nil { + return nil, fmt.Errorf("cannot create upcloud account list request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute upcloud account list request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch upcloud accounts: unexpected status %d", httpResp.StatusCode) + } + + var resp upcloudAccountListResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode upcloud account list response: %w", err) + } + + records := make([]AccountRecord, 0, len(resp.Accounts.Account)) + + for _, a := range resp.Accounts.Account { + username := strings.TrimSpace(a.Username) + if username == "" { + continue + } + + fullName := username + + details, err := d.fetchAccountDetails(ctx, username) + if err != nil { + d.logger.WarnCtx(ctx, "cannot fetch upcloud account details, using list fields only", log.Error(err)) + } else { + if name := strings.TrimSpace(details.FirstName + " " + details.LastName); name != "" { + fullName = name + } + } + + record := AccountRecord{ + FullName: fullName, + Roles: upcloudRoles(a.Roles.Role), + IsAdmin: upcloudIsMainAccount(a.Type), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: username, + } + + if details != nil { + record.Email = strings.TrimSpace(details.Email) + } + + records = append(records, record) + } + + return records, nil +} + +func (d *UpCloudDriver) fetchAccountDetails(ctx context.Context, username string) (*upcloudAccountDetails, error) { + endpoint, err := url.JoinPath("https://api.upcloud.com", "1.3", "account", "details", url.PathEscape(username)) + if err != nil { + return nil, fmt.Errorf("cannot build upcloud account details URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create upcloud account details request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute upcloud account details request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch upcloud account details: unexpected status %d", httpResp.StatusCode) + } + + var resp upcloudAccountDetailsResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode upcloud account details response: %w", err) + } + + return &resp.Account, nil +} + +// upcloudRoles copies the account's role list, mapping a missing/empty role +// list to an empty (non-nil) slice rather than nil. +func upcloudRoles(roles []string) []string { + out := make([]string, 0, len(roles)) + out = append(out, roles...) + + return out +} + +// upcloudIsMainAccount reports whether the account is the primary account on +// the contract ("mymain"), as opposed to a "sub" account. The main account +// holds full administrative access; sub-accounts are scoped by their roles. +func upcloudIsMainAccount(accountType string) bool { + return strings.EqualFold(strings.TrimSpace(accountType), "mymain") +} diff --git a/pkg/accessreview/drivers/upcloud_test.go b/pkg/accessreview/drivers/upcloud_test.go new file mode 100644 index 000000000..a871d391d --- /dev/null +++ b/pkg/accessreview/drivers/upcloud_test.go @@ -0,0 +1,79 @@ +// 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" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" +) + +func TestUpCloudDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/upcloud", "UPCLOUD_API_KEY") + // UpCloud authenticates with a Bearer API token. The matcher ignores + // Authorization, so replay needs no auth. + client := newVCRClient(rec, bearerAuth(os.Getenv("UPCLOUD_API_KEY"))) + + driver := NewUpCloudDriver(client, log.NewLogger(log.WithName("test"))) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 4) + + main := records[0] + assert.Equal(t, "test", main.ExternalID) + assert.Equal(t, "Main Account", main.FullName) + assert.Equal(t, "main@example.com", main.Email) + assert.Equal(t, []string{"technical"}, main.Roles) + assert.True(t, main.IsAdmin) + assert.Nil(t, main.Active) + assert.Equal(t, coredata.MFAStatusUnknown, main.MFAStatus) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, main.AccountType) + + sub := records[1] + assert.Equal(t, "my_sub_account", sub.ExternalID) + assert.Equal(t, "Sub Account", sub.FullName) + assert.Equal(t, "sub@example.com", sub.Email) + assert.Equal(t, []string{"technical"}, sub.Roles) + assert.False(t, sub.IsAdmin) + + // no roles assigned; details fetch fails (404), so the record falls back + // to list-only fields rather than being dropped. + temp := records[2] + assert.Equal(t, "my_temp_account", temp.ExternalID) + assert.Equal(t, "my_temp_account", temp.FullName) + assert.Empty(t, temp.Email) + assert.Equal(t, []string{}, temp.Roles) + assert.False(t, temp.IsAdmin) + + billing := records[3] + assert.Equal(t, "my_billing_account", billing.ExternalID) + assert.Equal(t, "Billing Account", billing.FullName) + assert.Equal(t, "billing@example.com", billing.Email) + assert.Equal(t, []string{"billing"}, billing.Roles) + assert.False(t, billing.IsAdmin) +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 8e55bbac7..6424f8a51 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -83,6 +83,7 @@ func NewBuiltinRegistry() *Registry { supabaseRegistration(), tailscaleRegistration(), tallyRegistration(), + upcloudRegistration(), vercelRegistration(), yousignRegistration(), zendeskRegistration(), diff --git a/pkg/connector/provider/upcloud.go b/pkg/connector/provider/upcloud.go new file mode 100644 index 000000000..61828f844 --- /dev/null +++ b/pkg/connector/provider/upcloud.go @@ -0,0 +1,54 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func upcloudRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderUpCloud, + DisplayName: "UpCloud", + SupportsAPIKey: true, + // UpCloud's newer API tokens (the "ucat_..." personal access tokens + // created under People > API access) authenticate as a standard + // Bearer token, so the default APIKeyConnection mode (Authorization: + // Bearer ) applies; no Header/Scheme/BasicAuth override is + // needed. There is no OAuth2 flow; account/list already returns the + // main account plus every sub-account reachable with the token, so + // there is nothing to pick or configure: no settings struct, no + // picker. + // + // ProbeURL lets the connection-status check confirm the token with + // the same lightweight GET the driver uses; an invalid token returns + // 401. + ProbeURL: "https://api.upcloud.com/1.3/account/list", + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, logger *log.Logger) (drivers.Driver, error) { + return drivers.NewUpCloudDriver(c, logger), nil + }, + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index febabab0e..51f0ec099 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -90,6 +90,7 @@ const ( ConnectorProviderSegment ConnectorProvider = "SEGMENT" ConnectorProviderSquare ConnectorProvider = "SQUARE" ConnectorProviderGoogleAnalytics ConnectorProvider = "GOOGLE_ANALYTICS" + ConnectorProviderUpCloud ConnectorProvider = "UPCLOUD" ) var ( @@ -158,6 +159,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderSegment, ConnectorProviderSquare, ConnectorProviderGoogleAnalytics, + ConnectorProviderUpCloud, } } @@ -222,7 +224,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderDotfile, ConnectorProviderSegment, ConnectorProviderSquare, - ConnectorProviderGoogleAnalytics: + ConnectorProviderGoogleAnalytics, + ConnectorProviderUpCloud: return true } diff --git a/pkg/coredata/migrations/20260723T113756Z.sql b/pkg/coredata/migrations/20260723T113756Z.sql new file mode 100644 index 000000000..ca627cfc2 --- /dev/null +++ b/pkg/coredata/migrations/20260723T113756Z.sql @@ -0,0 +1,21 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'UPCLOUD'; diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index d8079d90c..9fbfcad1a 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -108,6 +108,8 @@ enum ConnectorProvider @goEnum( value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleAnalytics" ) + UPCLOUD + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderUpCloud") } type ConnectorProviderInfo {