diff --git a/.gitignore b/.gitignore index caf27bc6d..e65d0c421 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ compose/keycloak/probo-realm.json # Generated files (codegen) __generated__/ pkg/server/api/*/v1/types/types.go +cfg/dev_local.yaml diff --git a/cfg/dev.yaml b/cfg/dev.yaml index d62b51f44..07056d638 100644 --- a/cfg/dev.yaml +++ b/cfg/dev.yaml @@ -109,29 +109,124 @@ probod: - provider: "SLACK" protocol: "oauth2" config: - client-id: "slack-client-id" - client-secret: "thisisnotasecret" + client-id: "your-slack-client-id" + client-secret: "your-slack-client-secret" redirect-uri: "https://localhost:8080/api/console/v1/connectors/complete" auth-url: "https://slack.com/oauth/v2/authorize" token-url: "https://slack.com/api/oauth.v2.access" + scopes: - "chat:write" - "channels:join" - "incoming-webhook" + - "users:read" + - "users:read.email" settings: - signing-secret: "this-is-not-a-secret-for-slack-signing" + signing-secret: "your-slack-signing-secret" - provider: "GOOGLE_WORKSPACE" protocol: "oauth2" config: - client-id: "google-workspace-client-id" - client-secret: "thisisnotasecret" + client-id: "your-google-client-id.apps.googleusercontent.com" + client-secret: "your-google-client-secret" redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" auth-url: "https://accounts.google.com/o/oauth2/v2/auth" token-url: "https://oauth2.googleapis.com/token" + scopes: - "https://www.googleapis.com/auth/admin.directory.user.readonly" - "https://www.googleapis.com/auth/admin.directory.userschema.readonly" - "https://www.googleapis.com/auth/admin.directory.group.member.readonly" + - "https://www.googleapis.com/auth/admin.directory.customer.readonly" extra-auth-params: access_type: "offline" prompt: "consent" + - provider: "LINEAR" + protocol: "oauth2" + config: + client-id: "your-linear-client-id" + client-secret: "your-linear-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://linear.app/oauth/authorize" + token-url: "https://api.linear.app/oauth/token" + + scopes: + - "read" + - "write" + - provider: "BREX" + protocol: "oauth2" + config: + client-id: "your-brex-client-id" + client-secret: "your-brex-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://accounts-api.brex.com/oauth2/default/v1/authorize" + token-url: "https://accounts-api.brex.com/oauth2/default/v1/token" + + scopes: + - "openid" + - "offline_access" + - provider: "HUBSPOT" + protocol: "oauth2" + config: + client-id: "your-hubspot-client-id" + client-secret: "your-hubspot-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://app.hubspot.com/oauth/authorize" + token-url: "https://api.hubapi.com/oauth/v1/token" + + scopes: + - "settings.users.read" + - provider: "DOCUSIGN" + protocol: "oauth2" + config: + client-id: "your-docusign-client-id" + client-secret: "your-docusign-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://account-d.docusign.com/oauth/auth" + token-url: "https://account-d.docusign.com/oauth/token" + + scopes: + - "signature" + token-endpoint-auth: "basic-form" + - provider: "NOTION" + protocol: "oauth2" + config: + client-id: "your-notion-client-id" + client-secret: "your-notion-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://api.notion.com/v1/oauth/authorize" + token-url: "https://api.notion.com/v1/oauth/token" + + extra-auth-params: + owner: "user" + token-endpoint-auth: "basic-json" + - provider: "GITHUB" + protocol: "oauth2" + config: + client-id: "your-github-client-id" + client-secret: "your-github-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://github.com/login/oauth/authorize" + token-url: "https://github.com/login/oauth/access_token" + + scopes: + - "read:org" + - provider: "SENTRY" + protocol: "oauth2" + config: + client-id: "your-sentry-client-id" + client-secret: "your-sentry-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://sentry.io/oauth/authorize/" + token-url: "https://sentry.io/oauth/token/" + + scopes: + - "org:read" + - "member:read" + - provider: "INTERCOM" + protocol: "oauth2" + config: + client-id: "your-intercom-client-id" + client-secret: "your-intercom-client-secret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://app.intercom.com/oauth" + token-url: "https://api.intercom.io/auth/eagle/token" diff --git a/go.mod b/go.mod index 262080242..ec3dae28f 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( golang.org/x/oauth2 v0.35.0 golang.org/x/sync v0.20.0 google.golang.org/api v0.269.0 + gopkg.in/dnaeon/go-vcr.v4 v4.0.6 gopkg.in/yaml.v3 v3.0.1 ) @@ -70,6 +71,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect ) diff --git a/go.sum b/go.sum index 768880b5b..0ba1bdcda 100644 --- a/go.sum +++ b/go.sum @@ -366,6 +366,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= @@ -409,6 +411,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/coredata/access_entry_account_type_test.go b/pkg/coredata/access_entry_account_type_test.go new file mode 100644 index 000000000..d982232c0 --- /dev/null +++ b/pkg/coredata/access_entry_account_type_test.go @@ -0,0 +1,67 @@ +// 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 coredata + +import "testing" + +func TestAccessEntryAccountTypeScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want AccessEntryAccountType + wantErr bool + }{ + {name: "user string", input: "USER", want: AccessEntryAccountTypeUser}, + {name: "service_account bytes", input: []byte("SERVICE_ACCOUNT"), want: AccessEntryAccountTypeServiceAccount}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got AccessEntryAccountType + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAccessEntryAccountTypeValue(t *testing.T) { + t.Parallel() + + got, err := AccessEntryAccountTypeUser.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != "USER" { + t.Fatalf("Value() = %q, want %q", got, "USER") + } +} diff --git a/pkg/coredata/access_entry_decision_test.go b/pkg/coredata/access_entry_decision_test.go new file mode 100644 index 000000000..67e90e627 --- /dev/null +++ b/pkg/coredata/access_entry_decision_test.go @@ -0,0 +1,86 @@ +// 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 coredata + +import "testing" + +func TestAccessEntryDecisionScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want AccessEntryDecision + wantErr bool + }{ + {name: "pending string", input: "PENDING", want: AccessEntryDecisionPending}, + {name: "approved string", input: "APPROVED", want: AccessEntryDecisionApproved}, + {name: "revoke string", input: "REVOKE", want: AccessEntryDecisionRevoke}, + {name: "defer bytes", input: []byte("DEFER"), want: AccessEntryDecisionDefer}, + {name: "escalate string", input: "ESCALATE", want: AccessEntryDecisionEscalate}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got AccessEntryDecision + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAccessEntryDecisionValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + decision AccessEntryDecision + want string + }{ + {name: "pending", decision: AccessEntryDecisionPending, want: "PENDING"}, + {name: "approved", decision: AccessEntryDecisionApproved, want: "APPROVED"}, + {name: "revoke", decision: AccessEntryDecisionRevoke, want: "REVOKE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.decision.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != tt.want { + t.Fatalf("Value() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/coredata/access_entry_flag_test.go b/pkg/coredata/access_entry_flag_test.go new file mode 100644 index 000000000..d1be9b9a1 --- /dev/null +++ b/pkg/coredata/access_entry_flag_test.go @@ -0,0 +1,71 @@ +// 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 coredata + +import "testing" + +func TestAccessEntryFlagScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want AccessEntryFlag + wantErr bool + }{ + {name: "none string", input: "NONE", want: AccessEntryFlagNone}, + {name: "orphaned string", input: "ORPHANED", want: AccessEntryFlagOrphaned}, + {name: "inactive string", input: "INACTIVE", want: AccessEntryFlagInactive}, + {name: "excessive string", input: "EXCESSIVE", want: AccessEntryFlagExcessive}, + {name: "role_mismatch bytes", input: []byte("ROLE_MISMATCH"), want: AccessEntryFlagRoleMismatch}, + {name: "new string", input: "NEW", want: AccessEntryFlagNew}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got AccessEntryFlag + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAccessEntryFlagValue(t *testing.T) { + t.Parallel() + + got, err := AccessEntryFlagNone.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != "NONE" { + t.Fatalf("Value() = %q, want %q", got, "NONE") + } +} diff --git a/pkg/coredata/access_entry_incremental_tag_test.go b/pkg/coredata/access_entry_incremental_tag_test.go new file mode 100644 index 000000000..6133483c8 --- /dev/null +++ b/pkg/coredata/access_entry_incremental_tag_test.go @@ -0,0 +1,68 @@ +// 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 coredata + +import "testing" + +func TestAccessEntryIncrementalTagScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want AccessEntryIncrementalTag + wantErr bool + }{ + {name: "new string", input: "NEW", want: AccessEntryIncrementalTagNew}, + {name: "removed bytes", input: []byte("REMOVED"), want: AccessEntryIncrementalTagRemoved}, + {name: "unchanged string", input: "UNCHANGED", want: AccessEntryIncrementalTagUnchanged}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got AccessEntryIncrementalTag + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAccessEntryIncrementalTagValue(t *testing.T) { + t.Parallel() + + got, err := AccessEntryIncrementalTagNew.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != "NEW" { + t.Fatalf("Value() = %q, want %q", got, "NEW") + } +} diff --git a/pkg/coredata/access_review_campaign_status_test.go b/pkg/coredata/access_review_campaign_status_test.go new file mode 100644 index 000000000..1924b586b --- /dev/null +++ b/pkg/coredata/access_review_campaign_status_test.go @@ -0,0 +1,87 @@ +// 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 coredata + +import "testing" + +func TestAccessReviewCampaignStatusScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want AccessReviewCampaignStatus + wantErr bool + }{ + {name: "draft string", input: "DRAFT", want: AccessReviewCampaignStatusDraft}, + {name: "in_progress string", input: "IN_PROGRESS", want: AccessReviewCampaignStatusInProgress}, + {name: "pending_actions string", input: "PENDING_ACTIONS", want: AccessReviewCampaignStatusPendingActions}, + {name: "failed string", input: "FAILED", want: AccessReviewCampaignStatusFailed}, + {name: "completed string", input: "COMPLETED", want: AccessReviewCampaignStatusCompleted}, + {name: "cancelled bytes", input: []byte("CANCELLED"), want: AccessReviewCampaignStatusCancelled}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got AccessReviewCampaignStatus + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAccessReviewCampaignStatusValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status AccessReviewCampaignStatus + want string + }{ + {name: "draft", status: AccessReviewCampaignStatusDraft, want: "DRAFT"}, + {name: "in_progress", status: AccessReviewCampaignStatusInProgress, want: "IN_PROGRESS"}, + {name: "completed", status: AccessReviewCampaignStatusCompleted, want: "COMPLETED"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.status.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != tt.want { + t.Fatalf("Value() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/coredata/access_source_category_test.go b/pkg/coredata/access_source_category_test.go new file mode 100644 index 000000000..b62f6946e --- /dev/null +++ b/pkg/coredata/access_source_category_test.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 coredata + +import "testing" + +func TestAccessSourceCategoryScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + want AccessSourceCategory + wantErr bool + }{ + {name: "saas string", input: "SAAS", want: AccessSourceCategorySaaS}, + {name: "cloud_infra string", input: "CLOUD_INFRA", want: AccessSourceCategoryCloudInfra}, + {name: "source_code bytes", input: []byte("SOURCE_CODE"), want: AccessSourceCategorySourceCode}, + {name: "other string", input: "OTHER", want: AccessSourceCategoryOther}, + {name: "invalid value", input: "BOGUS", wantErr: true}, + {name: "unsupported type", input: 42, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got AccessSourceCategory + err := got.Scan(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("Scan(%v) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("Scan(%v) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("Scan(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAccessSourceCategoryValue(t *testing.T) { + t.Parallel() + + got, err := AccessSourceCategorySaaS.Value() + if err != nil { + t.Fatalf("Value() returned error: %v", err) + } + if got != "SAAS" { + t.Fatalf("Value() = %q, want %q", got, "SAAS") + } +} diff --git a/pkg/coredata/migrations/20260330T120000Z.sql b/pkg/coredata/migrations/20260330T120000Z.sql index 2e04a80e2..7a53ba206 100644 --- a/pkg/coredata/migrations/20260330T120000Z.sql +++ b/pkg/coredata/migrations/20260330T120000Z.sql @@ -1,40 +1,36 @@ --- 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. +-- Rename priority to rank +ALTER TABLE tasks RENAME COLUMN priority TO rank; --- Add organization_id to access_entries and access_entry_decision_history --- to avoid JOINs in AuthorizationAttributes lookups. +ALTER TABLE tasks DROP CONSTRAINT tasks_organization_id_state_priority_key; --- 1. access_entries -ALTER TABLE access_entries - ADD COLUMN organization_id TEXT REFERENCES organizations(id); +-- Add task priority enum +CREATE TYPE task_priority AS ENUM ('URGENT', 'HIGH', 'MEDIUM', 'LOW'); -UPDATE access_entries ae -SET organization_id = arc.organization_id -FROM access_review_campaigns arc -WHERE ae.access_review_campaign_id = arc.id; +ALTER TABLE tasks ADD COLUMN priority task_priority NOT NULL DEFAULT 'MEDIUM'::task_priority; -ALTER TABLE access_entries - ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE tasks ALTER COLUMN priority DROP DEFAULT; --- 2. access_entry_decision_history -ALTER TABLE access_entry_decision_history - ADD COLUMN organization_id TEXT REFERENCES organizations(id); +-- Rank is now scoped to (state, priority) — backfill ranks per group +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY organization_id, state, priority + ORDER BY rank + ) AS new_rank + FROM tasks +) +UPDATE tasks SET rank = ranked.new_rank FROM ranked WHERE tasks.id = ranked.id; -UPDATE access_entry_decision_history h -SET organization_id = ae.organization_id -FROM access_entries ae -WHERE h.access_entry_id = ae.id; +ALTER TABLE tasks +ADD CONSTRAINT tasks_organization_id_state_priority_rank_key + UNIQUE (organization_id, state, priority, rank) + DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE access_entry_decision_history - ALTER COLUMN organization_id SET NOT NULL; +-- Computed column for composite ordering (priority level then rank) +ALTER TABLE tasks ADD COLUMN priority_rank int GENERATED ALWAYS AS ( + (CASE priority + WHEN 'URGENT' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + END) * 1000000 + rank +) STORED; diff --git a/pkg/coredata/migrations/20260330T120042Z.sql b/pkg/coredata/migrations/20260330T120042Z.sql new file mode 100644 index 000000000..2e04a80e2 --- /dev/null +++ b/pkg/coredata/migrations/20260330T120042Z.sql @@ -0,0 +1,40 @@ +-- 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. + +-- Add organization_id to access_entries and access_entry_decision_history +-- to avoid JOINs in AuthorizationAttributes lookups. + +-- 1. access_entries +ALTER TABLE access_entries + ADD COLUMN organization_id TEXT REFERENCES organizations(id); + +UPDATE access_entries ae +SET organization_id = arc.organization_id +FROM access_review_campaigns arc +WHERE ae.access_review_campaign_id = arc.id; + +ALTER TABLE access_entries + ALTER COLUMN organization_id SET NOT NULL; + +-- 2. access_entry_decision_history +ALTER TABLE access_entry_decision_history + ADD COLUMN organization_id TEXT REFERENCES organizations(id); + +UPDATE access_entry_decision_history h +SET organization_id = ae.organization_id +FROM access_entries ae +WHERE h.access_entry_id = ae.id; + +ALTER TABLE access_entry_decision_history + ALTER COLUMN organization_id SET NOT NULL; diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go index 337ef87f4..ef422bef2 100644 --- a/pkg/probo/connector_service.go +++ b/pkg/probo/connector_service.go @@ -49,10 +49,16 @@ type ( } CreateConnectorRequest struct { - OrganizationID gid.GID - Provider coredata.ConnectorProvider - Protocol coredata.ConnectorProtocol - Connection connector.Connection + OrganizationID gid.GID + Provider coredata.ConnectorProvider + Protocol coredata.ConnectorProtocol + Connection connector.Connection + TallySettings *coredata.TallyConnectorSettings + OnePasswordSettings *coredata.OnePasswordConnectorSettings + SentrySettings *coredata.SentryConnectorSettings + SupabaseSettings *coredata.SupabaseConnectorSettings + GitHubSettings *coredata.GitHubConnectorSettings + OnePasswordUsersAPISettings *coredata.OnePasswordUsersAPISettings } ) @@ -94,6 +100,30 @@ func (s *ConnectorService) ListForOrganizationID( return page.NewPage(connectors, cursor), nil } +func (s *ConnectorService) ListAllForOrganizationID( + ctx context.Context, + organizationID gid.GID, +) (coredata.Connectors, error) { + var connectors coredata.Connectors + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + return connectors.LoadAllByOrganizationIDWithoutDecryptedConnection( + ctx, + conn, + s.svc.scope, + organizationID, + ) + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot list all connectors: %w", err) + } + + return connectors, nil +} + func (s *ConnectorService) GetByOrganizationIDAndProvider( ctx context.Context, organizationID gid.GID, @@ -127,6 +157,25 @@ func (s *ConnectorService) GetByOrganizationIDAndProvider( return connectors[0], nil } +func (s *ConnectorService) Get( + ctx context.Context, + connectorID gid.GID, +) (*coredata.Connector, error) { + connector := &coredata.Connector{} + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + return connector.LoadMetadataByID(ctx, conn, s.svc.scope, connectorID) + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot get connector: %w", err) + } + + return connector, nil +} + func (s *ConnectorService) Delete( ctx context.Context, connectorID gid.GID, @@ -161,6 +210,33 @@ func (s *ConnectorService) Create( UpdatedAt: now, } + switch { + case req.TallySettings != nil: + if err := newConnector.SetSettings(req.TallySettings); err != nil { + return nil, fmt.Errorf("cannot set tally settings: %w", err) + } + case req.OnePasswordSettings != nil: + if err := newConnector.SetSettings(req.OnePasswordSettings); err != nil { + return nil, fmt.Errorf("cannot set one password settings: %w", err) + } + case req.SentrySettings != nil: + if err := newConnector.SetSettings(req.SentrySettings); err != nil { + return nil, fmt.Errorf("cannot set sentry settings: %w", err) + } + case req.SupabaseSettings != nil: + if err := newConnector.SetSettings(req.SupabaseSettings); err != nil { + return nil, fmt.Errorf("cannot set supabase settings: %w", err) + } + case req.GitHubSettings != nil: + if err := newConnector.SetSettings(req.GitHubSettings); err != nil { + return nil, fmt.Errorf("cannot set github settings: %w", err) + } + case req.OnePasswordUsersAPISettings != nil: + if err := newConnector.SetSettings(req.OnePasswordUsersAPISettings); err != nil { + return nil, fmt.Errorf("cannot set one password users api settings: %w", err) + } + } + err := s.svc.pg.WithConn( ctx, func(conn pg.Conn) error { @@ -211,3 +287,30 @@ func (s *ConnectorService) Create( return newConnector, nil } + +// Reconnect updates an existing connector's connection (token) without +// changing its settings or identity. Used when an OAuth token expires +// and the user re-authenticates. +func (s *ConnectorService) Reconnect( + ctx context.Context, + connectorID gid.GID, + connection connector.Connection, +) (*coredata.Connector, error) { + cnnctr := &coredata.Connector{} + + err := s.svc.pg.WithTx(ctx, func(conn pg.Conn) error { + if err := cnnctr.LoadMetadataByID(ctx, conn, s.svc.scope, connectorID); err != nil { + return fmt.Errorf("cannot load connector: %w", err) + } + + cnnctr.Connection = connection + cnnctr.UpdatedAt = time.Now() + + return cnnctr.Update(ctx, conn, s.svc.scope, s.svc.encryptionKey) + }) + if err != nil { + return nil, fmt.Errorf("cannot reconnect connector: %w", err) + } + + return cnnctr, nil +} diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index f8f672b83..4980573f6 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -410,6 +410,10 @@ enum ConnectorProvider SLACK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSlack") GOOGLE_WORKSPACE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleWorkspace") + BREX @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBrex") + TALLY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderTally") + CLOUDFLARE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderCloudflare") } enum SCIMBridgeType