Add access review console GraphQL API

Add queries, mutations, and types for access review
campaigns, access sources, access entries with decisions
and flags, connector provider info, and provider org
listing. Wire accessreview.Service into the Resolver.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-04-02 11:51:41 +02:00
parent 46dee27bcf
commit ebfc0b7e31
11 changed files with 2797 additions and 43 deletions

View File

@@ -0,0 +1,79 @@
// 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 console_v1
import (
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
)
var apiKeyProviders = map[coredata.ConnectorProvider]bool{
coredata.ConnectorProviderHubSpot: true,
coredata.ConnectorProviderDocuSign: true,
coredata.ConnectorProviderNotion: true,
coredata.ConnectorProviderGitHub: true,
coredata.ConnectorProviderSentry: true,
coredata.ConnectorProviderIntercom: true,
coredata.ConnectorProviderBrex: true,
coredata.ConnectorProviderTally: true,
coredata.ConnectorProviderCloudflare: true,
coredata.ConnectorProviderOpenAI: true,
coredata.ConnectorProviderSupabase: true,
coredata.ConnectorProviderResend: true,
coredata.ConnectorProviderOnePassword: true,
}
var clientCredentialsProviders = map[coredata.ConnectorProvider]bool{
coredata.ConnectorProviderOnePassword: true,
}
var providerExtraSettingsMap = map[coredata.ConnectorProvider][]*types.ConnectorProviderSettingInfo{
coredata.ConnectorProviderGitHub: {
{Key: "organization", Label: "Organization", Required: true},
},
coredata.ConnectorProviderSentry: {
{Key: "organizationSlug", Label: "Organization Slug", Required: true},
},
coredata.ConnectorProviderTally: {
{Key: "organizationId", Label: "Organization ID", Required: true},
},
coredata.ConnectorProviderSupabase: {
{Key: "organizationSlug", Label: "Organization Slug", Required: true},
},
coredata.ConnectorProviderOnePassword: {
{Key: "accountId", Label: "Account ID", Required: true},
{Key: "region", Label: "Region", Required: true},
},
}
func providerDisplayName(provider coredata.ConnectorProvider) string {
return drivers.ProviderDisplayName(provider)
}
func providerSupportsAPIKey(provider coredata.ConnectorProvider) bool {
return apiKeyProviders[provider]
}
func providerSupportsClientCredentials(provider coredata.ConnectorProvider) bool {
return clientCredentialsProviders[provider]
}
func providerExtraSettings(provider coredata.ConnectorProvider) []*types.ConnectorProviderSettingInfo {
if settings, ok := providerExtraSettingsMap[provider]; ok {
return settings
}
return []*types.ConnectorProviderSettingInfo{}
}

View File

@@ -18,6 +18,8 @@ import (
"net/http"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman"
@@ -27,14 +29,16 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils"
)
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *esign.Service, mailmanSvc *mailman.Service, customDomainCname string, logger *log.Logger) http.Handler {
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *esign.Service, accessReviewSvc *accessreview.Service, mailmanSvc *mailman.Service, connectorRegistry *connector.ConnectorRegistry, customDomainCname string, logger *log.Logger) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
probo: proboSvc,
iam: iamSvc,
esign: esignSvc,
accessReview: accessReviewSvc,
mailman: mailmanSvc,
connectorRegistry: connectorRegistry,
customDomainCname: customDomainCname,
logger: logger,
},

View File

@@ -0,0 +1,139 @@
// 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 console_v1
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
)
// fetchGitHubOrganizations fetches the list of organizations the
// authenticated GitHub user belongs to.
func fetchGitHubOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/orgs", nil)
if err != nil {
return nil, fmt.Errorf("cannot create github organizations request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch github organizations: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cannot fetch github organizations: status %d", resp.StatusCode)
}
var orgs []struct {
Login string `json:"login"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
return nil, fmt.Errorf("cannot decode github organizations response: %w", err)
}
result := make([]*types.ProviderOrganization, len(orgs))
for i, org := range orgs {
displayName := org.Name
if displayName == "" {
displayName = org.Login
}
result[i] = &types.ProviderOrganization{
Slug: org.Login,
DisplayName: displayName,
}
}
return result, nil
}
// fetchSentryOrganizations fetches the list of organizations the
// authenticated Sentry user belongs to.
func fetchSentryOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://sentry.io/api/0/organizations/?member=true", nil)
if err != nil {
return nil, fmt.Errorf("cannot create sentry organizations request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cannot fetch sentry organizations: status %d", resp.StatusCode)
}
var orgs []struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
return nil, fmt.Errorf("cannot decode sentry organizations response: %w", err)
}
result := make([]*types.ProviderOrganization, len(orgs))
for i, org := range orgs {
displayName := org.Name
if displayName == "" {
displayName = org.Slug
}
result[i] = &types.ProviderOrganization{
Slug: org.Slug,
DisplayName: displayName,
}
}
return result, nil
}
// probeConnection makes a lightweight API call to the given URL to verify
// the OAuth token is still valid. The probe URL is configured per connector
// in the connector registry.
func probeConnection(ctx context.Context, httpClient *http.Client, probeURL string) error {
if probeURL == "" {
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
if err != nil {
return fmt.Errorf("cannot create probe request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("probe request failed: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("token rejected: status %d", resp.StatusCode)
}
return nil
}

View File

@@ -39,6 +39,7 @@ import (
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/coredata"
@@ -61,7 +62,9 @@ type (
probo *probo.Service
iam *iam.Service
esign *esign.Service
accessReview *accessreview.Service
mailman *mailman.Service
connectorRegistry *connector.ConnectorRegistry
logger *log.Logger
customDomainCname string
}
@@ -72,6 +75,7 @@ func NewMux(
proboSvc *probo.Service,
iamSvc *iam.Service,
esignSvc *esign.Service,
accessReviewSvc *accessreview.Service,
mailmanSvc *mailman.Service,
cookieConfig securecookie.Config,
tokenSecret string,
@@ -83,7 +87,7 @@ func NewMux(
safeRedirect := saferedirect.New(saferedirect.StaticHosts(baseURL.Host()))
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, esignSvc, mailmanSvc, customDomainCname, logger)
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, esignSvc, accessReviewSvc, mailmanSvc, connectorRegistry, customDomainCname, logger)
r.Group(func(r chi.Router) {
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
@@ -95,8 +99,13 @@ func NewMux(
r.Get("/connectors/initiate", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider != "SLACK" && provider != "GOOGLE_WORKSPACE" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
if provider == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing provider parameter"))
return
}
if _, err := connectorRegistry.Get(provider); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider: %q", provider))
return
}
@@ -149,51 +158,69 @@ func NewMux(
})
r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing provider parameter"))
return
}
var connectorProvider coredata.ConnectorProvider
switch provider {
case "SLACK":
connectorProvider = coredata.ConnectorProviderSlack
case "GOOGLE_WORKSPACE":
connectorProvider = coredata.ConnectorProviderGoogleWorkspace
default:
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
}
stateToken := r.URL.Query().Get("state")
if stateToken == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing state parameter"))
return
}
connection, organizationID, continueURL, err := connectorRegistry.Complete(r.Context(), provider, r)
provider, err := connector.ExtractProviderFromState(stateToken)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot extract provider from state: %w", err))
return
}
var connectorProvider coredata.ConnectorProvider
if err := connectorProvider.Scan(provider); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider: %q", provider))
return
}
connection, state, err := connectorRegistry.CompleteWithState(r.Context(), provider, r)
if err != nil {
panic(fmt.Errorf("cannot complete connector: %w", err))
}
organizationID, err := gid.ParseGID(state.OrganizationID)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot parse organization ID from state: %w", err))
return
}
svc := proboSvc.WithTenant(organizationID.TenantID())
connector, err := svc.Connectors.Create(
r.Context(),
probo.CreateConnectorRequest{
OrganizationID: *organizationID,
Provider: connectorProvider,
Protocol: coredata.ConnectorProtocol(connection.Type()),
Connection: connection,
},
)
if err != nil {
panic(fmt.Errorf("cannot create or update connector: %w", err))
var cnnctr *coredata.Connector
// If a connector_id was passed in the state, this is a
// reconnection — update the existing connector's token.
if state.ConnectorID != "" {
connectorID, err := gid.ParseGID(state.ConnectorID)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot parse connector ID from state: %w", err))
return
}
cnnctr, err = svc.Connectors.Reconnect(r.Context(), connectorID, connection)
if err != nil {
panic(fmt.Errorf("cannot reconnect connector: %w", err))
}
} else {
cnnctr, err = svc.Connectors.Create(
r.Context(),
probo.CreateConnectorRequest{
OrganizationID: organizationID,
Provider: connectorProvider,
Protocol: coredata.ConnectorProtocol(connection.Type()),
Connection: connection,
},
)
if err != nil {
panic(fmt.Errorf("cannot create connector: %w", err))
}
}
// Append connector_id to the redirect URL so frontend can create the bridge
redirectURL := continueURL
redirectURL := state.ContinueURL
if redirectURL == "" {
redirectURL = baseURL.WithPath("/organizations/" + organizationID.String()).MustString()
}
@@ -204,7 +231,8 @@ func NewMux(
parsedURL, _ = url.Parse(baseURL.WithPath("/organizations/" + organizationID.String()).MustString())
}
q := parsedURL.Query()
q.Set("connector_id", connector.ID.String())
q.Set("connector_id", cnnctr.ID.String())
q.Set("provider", string(connectorProvider))
parsedURL.RawQuery = q.Encode()
safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther)

View File

@@ -1903,6 +1903,8 @@ type Organization implements Node {
last: Int
before: CursorKey
): SlackConnectionConnection! @goField(forceResolver: true)
connectors(filter: ConnectorFilter): [Connector!]! @goField(forceResolver: true)
connectorProviderInfos: [ConnectorProviderInfo!]! @goField(forceResolver: true)
frameworks(
first: Int
@@ -2100,12 +2102,84 @@ type Organization implements Node {
filter: AuditLogEntryFilter
): AuditLogEntryConnection! @goField(forceResolver: true)
accessSources(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessSourceOrder
): AccessSourceConnection! @goField(forceResolver: true)
accessReviewCampaigns(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessReviewCampaignOrder
): AccessReviewCampaignConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
enum ConnectorProvider
@goModel(model: "go.probo.inc/probo/pkg/coredata.ConnectorProvider") {
SLACK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSlack")
GOOGLE_WORKSPACE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleWorkspace"
)
LINEAR @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderLinear")
ONE_PASSWORD
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOnePassword"
)
HUBSPOT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderHubSpot")
DOCUSIGN
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDocuSign")
NOTION @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNotion")
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")
OPENAI @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOpenAI")
SENTRY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSentry")
SUPABASE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSupabase")
GITHUB @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGitHub")
INTERCOM
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIntercom")
RESEND @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderResend")
}
type ConnectorProviderInfo {
provider: ConnectorProvider!
displayName: String!
oauthConfigured: Boolean!
apiKeySupported: Boolean!
clientCredentialsSupported: Boolean!
extraSettings: [ConnectorProviderSettingInfo!]!
}
type ConnectorProviderSettingInfo {
key: String!
label: String!
required: Boolean!
}
input ConnectorFilter {
providers: [ConnectorProvider!]
}
type Connector {
id: ID!
provider: ConnectorProvider!
createdAt: Datetime!
}
type SlackConnection implements Node {
id: ID!
channel: String
@@ -3983,6 +4057,62 @@ type Mutation {
deleteCustomDomain(
input: DeleteCustomDomainInput!
): DeleteCustomDomainPayload!
# Access Source mutations
createAccessSource(
input: CreateAccessSourceInput!
): CreateAccessSourcePayload!
updateAccessSource(
input: UpdateAccessSourceInput!
): UpdateAccessSourcePayload!
deleteAccessSource(
input: DeleteAccessSourceInput!
): DeleteAccessSourcePayload!
# Access Review Campaign mutations
createAccessReviewCampaign(
input: CreateAccessReviewCampaignInput!
): CreateAccessReviewCampaignPayload!
updateAccessReviewCampaign(
input: UpdateAccessReviewCampaignInput!
): UpdateAccessReviewCampaignPayload!
deleteAccessReviewCampaign(
input: DeleteAccessReviewCampaignInput!
): DeleteAccessReviewCampaignPayload!
startAccessReviewCampaign(
input: StartAccessReviewCampaignInput!
): StartAccessReviewCampaignPayload!
closeAccessReviewCampaign(
input: CloseAccessReviewCampaignInput!
): CloseAccessReviewCampaignPayload!
cancelAccessReviewCampaign(
input: CancelAccessReviewCampaignInput!
): CancelAccessReviewCampaignPayload!
addAccessReviewCampaignScopeSource(
input: AddAccessReviewCampaignScopeSourceInput!
): AddAccessReviewCampaignScopeSourcePayload!
removeAccessReviewCampaignScopeSource(
input: RemoveAccessReviewCampaignScopeSourceInput!
): RemoveAccessReviewCampaignScopeSourcePayload!
# Access Entry mutations
recordAccessEntryDecision(
input: RecordAccessEntryDecisionInput!
): RecordAccessEntryDecisionPayload!
recordAccessEntryDecisions(
input: RecordAccessEntryDecisionsInput!
): RecordAccessEntryDecisionsPayload!
flagAccessEntry(
input: FlagAccessEntryInput!
): FlagAccessEntryPayload!
# Connector mutations
createAPIKeyConnector(
input: CreateAPIKeyConnectorInput!
): CreateAPIKeyConnectorPayload!
createClientCredentialsConnector(
input: CreateClientCredentialsConnectorInput!
): CreateClientCredentialsConnectorPayload!
deleteConnector(input: DeleteConnectorInput!): DeleteConnectorPayload!
configureAccessSource(
input: ConfigureAccessSourceInput!
): ConfigureAccessSourcePayload!
# Slack Connection mutations
deleteSlackConnection(
input: DeleteSlackConnectionInput!
@@ -6332,6 +6462,256 @@ type AuditLogEntry implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
# ===== Access Review Types =====
enum AccessReviewCampaignStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatus"
) {
DRAFT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatusDraft"
)
IN_PROGRESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatusInProgress"
)
PENDING_ACTIONS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatusPendingActions"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatusFailed"
)
COMPLETED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatusCompleted"
)
CANCELLED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatusCancelled"
)
}
enum AccessSourceCategory
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessSourceCategory"
) {
SAAS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategorySaaS"
)
CLOUD_INFRA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategoryCloudInfra"
)
SOURCE_CODE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategorySourceCode"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategoryOther"
)
}
enum AccessReviewCampaignSourceFetchStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatus"
) {
QUEUED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatusQueued"
)
FETCHING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatusFetching"
)
SUCCESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatusSuccess"
)
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatusFailed"
)
}
enum AccessEntryFlag
@goModel(model: "go.probo.inc/probo/pkg/coredata.AccessEntryFlag") {
NONE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNone"
)
ORPHANED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagOrphaned"
)
INACTIVE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagInactive"
)
EXCESSIVE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagExcessive"
)
ROLE_MISMATCH
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagRoleMismatch"
)
NEW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNew"
)
DORMANT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagDormant"
)
TERMINATED_USER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagTerminatedUser"
)
CONTRACTOR_EXPIRED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagContractorExpired"
)
SOD_CONFLICT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagSoDConflict"
)
PRIVILEGED_ACCESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagPrivilegedAccess"
)
ROLE_CREEP
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagRoleCreep"
)
NO_BUSINESS_JUSTIFICATION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNoBusinessJustification"
)
OUT_OF_DEPARTMENT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagOutOfDepartment"
)
SHARED_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagSharedAccount"
)
}
enum AccessEntryDecision
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryDecision"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionPending"
)
APPROVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionApproved"
)
REVOKE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionRevoke"
)
DEFER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionDefer"
)
ESCALATE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionEscalate"
)
}
enum AccessEntryIncrementalTag
@goModel(model: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTag") {
NEW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagNew"
)
REMOVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagRemoved"
)
UNCHANGED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagUnchanged"
)
}
enum MfaStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.MFAStatus") {
ENABLED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MFAStatusEnabled"
)
DISABLED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MFAStatusDisabled"
)
UNKNOWN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MFAStatusUnknown"
)
}
enum AccessEntryAuthMethod
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethod"
) {
SSO
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodSSO"
)
PASSWORD
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodPassword"
)
API_KEY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodAPIKey"
)
SERVICE_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodServiceAccount"
)
UNKNOWN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodUnknown"
)
}
type AccessReview implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
identitySource: AccessSource @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
accessSources(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessSourceOrder
): AccessSourceConnection! @goField(forceResolver: true)
campaigns(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessReviewCampaignOrder
): AccessReviewCampaignConnection! @goField(forceResolver: true)
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AuditLogEntryConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AuditLogEntryConnection"
@@ -6345,3 +6725,439 @@ type AuditLogEntryEdge {
cursor: CursorKey!
node: AuditLogEntry!
}
enum AccessEntryAccountType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountType"
) {
USER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountTypeUser"
)
SERVICE_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountTypeServiceAccount"
)
}
type ProviderOrganization {
slug: String!
displayName: String!
}
enum AccessSourceConnectionStatus {
CONNECTED
DISCONNECTED
NOT_APPLICABLE
}
type AccessSource implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
connectorId: ID
connector: Connector @goField(forceResolver: true)
name: String!
csvData: String
providerOrganizations: [ProviderOrganization!]! @goField(forceResolver: true)
needsConfiguration: Boolean! @goField(forceResolver: true)
connectionStatus: AccessSourceConnectionStatus! @goField(forceResolver: true)
selectedOrganization: String @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessReviewCampaignScopeSource
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignScopeSource"
) {
id: ID!
source: AccessSource!
name: String!
fetchStatus: AccessReviewCampaignSourceFetchStatus!
fetchedAccountsCount: Int!
attemptCount: Int!
lastError: String
fetchStartedAt: Datetime
fetchCompletedAt: Datetime
entries(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessEntryOrder
filter: AccessEntryFilter
): AccessEntryConnection! @goField(forceResolver: true)
statistics: AccessReviewCampaignStatistics! @goField(forceResolver: true)
}
type AccessSourceConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessSourceConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessSourceEdge!]!
pageInfo: PageInfo!
}
type AccessSourceEdge {
cursor: CursorKey!
node: AccessSource!
}
input AccessSourceOrder {
direction: OrderDirection!
field: AccessSourceOrderField!
}
enum AccessSourceOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessSourceOrderField"
) {
CREATED_AT
}
type AccessReviewCampaign implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
name: String!
description: String!
status: AccessReviewCampaignStatus!
startedAt: Datetime
completedAt: Datetime
frameworkControls: [String!]
createdAt: Datetime!
updatedAt: Datetime!
scopeSources: [AccessReviewCampaignScopeSource!]! @goField(forceResolver: true)
entries(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessEntryOrder
accessSourceId: ID
filter: AccessEntryFilter
): AccessEntryConnection! @goField(forceResolver: true)
pendingEntryCount: Int! @goField(forceResolver: true)
statistics: AccessReviewCampaignStatistics! @goField(forceResolver: true)
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessReviewCampaignConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessReviewCampaignEdge!]!
pageInfo: PageInfo!
}
type AccessReviewCampaignEdge {
cursor: CursorKey!
node: AccessReviewCampaign!
}
input AccessReviewCampaignOrder {
direction: OrderDirection!
field: AccessReviewCampaignOrderField!
}
enum AccessReviewCampaignOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignOrderField"
) {
CREATED_AT
}
type AccessEntry implements Node {
id: ID!
campaign: AccessReviewCampaign! @goField(forceResolver: true)
accessSource: AccessSource! @goField(forceResolver: true)
email: String!
fullName: String!
role: String!
jobTitle: String!
isAdmin: Boolean!
mfaStatus: MfaStatus!
authMethod: AccessEntryAuthMethod!
accountType: AccessEntryAccountType!
lastLogin: Datetime
accountCreatedAt: Datetime
externalId: String!
incrementalTag: AccessEntryIncrementalTag!
flags: [AccessEntryFlag!]!
flagReasons: [String!]!
decision: AccessEntryDecision!
decisionNote: String
decidedBy: ID
decidedAt: Datetime
decisionHistory: [AccessEntryDecisionHistoryEntry!]!
@goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessEntryDecisionHistoryEntry {
id: ID!
decision: AccessEntryDecision!
decisionNote: String
decidedBy: ID
decidedAt: Datetime!
createdAt: Datetime!
}
type AccessEntryConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessEntryConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessEntryEdge!]!
pageInfo: PageInfo!
}
type AccessEntryEdge {
cursor: CursorKey!
node: AccessEntry!
}
input AccessEntryOrder {
direction: OrderDirection!
field: AccessEntryOrderField!
}
enum AccessEntryOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryOrderField"
) {
CREATED_AT
}
input AccessEntryFilter
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryFilter"
) {
decision: AccessEntryDecision
flag: AccessEntryFlag
incrementalTag: AccessEntryIncrementalTag
isAdmin: Boolean
authMethod: AccessEntryAuthMethod
accountType: AccessEntryAccountType
}
type AccessReviewCampaignStatistics {
totalCount: Int!
decisionCounts: [AccessEntryDecisionCount!]!
flagCounts: [AccessEntryFlagCount!]!
incrementalTagCounts: [AccessEntryIncrementalTagCount!]!
}
type AccessEntryDecisionCount {
decision: AccessEntryDecision!
count: Int!
}
type AccessEntryFlagCount {
flag: AccessEntryFlag!
count: Int!
}
type AccessEntryIncrementalTagCount {
incrementalTag: AccessEntryIncrementalTag!
count: Int!
}
# Access Review Inputs & Payloads
input CreateAccessSourceInput {
organizationId: ID!
connectorId: ID
name: String!
csvData: String
}
type CreateAccessSourcePayload {
accessSourceEdge: AccessSourceEdge!
}
input UpdateAccessSourceInput {
accessSourceId: ID!
name: String @goField(omittable: true)
connectorId: ID @goField(omittable: true)
csvData: String @goField(omittable: true)
}
type UpdateAccessSourcePayload {
accessSource: AccessSource!
}
input DeleteAccessSourceInput {
accessSourceId: ID!
}
type DeleteAccessSourcePayload {
deletedAccessSourceId: ID!
}
input ConfigureAccessSourceInput {
accessSourceId: ID!
organizationSlug: String!
}
type ConfigureAccessSourcePayload {
accessSource: AccessSource!
}
input CreateAccessReviewCampaignInput {
organizationId: ID!
name: String!
description: String
frameworkControls: [String!]
accessSourceIds: [ID!]
}
type CreateAccessReviewCampaignPayload {
accessReviewCampaignEdge: AccessReviewCampaignEdge!
}
input UpdateAccessReviewCampaignInput {
accessReviewCampaignId: ID!
name: String @goField(omittable: true)
description: String @goField(omittable: true)
frameworkControls: [String!] @goField(omittable: true)
}
type UpdateAccessReviewCampaignPayload {
accessReviewCampaign: AccessReviewCampaign!
}
input DeleteAccessReviewCampaignInput {
accessReviewCampaignId: ID!
}
type DeleteAccessReviewCampaignPayload {
deletedAccessReviewCampaignId: ID!
}
input StartAccessReviewCampaignInput {
accessReviewCampaignId: ID!
}
input AddAccessReviewCampaignScopeSourceInput {
accessReviewCampaignId: ID!
accessSourceId: ID!
}
type AddAccessReviewCampaignScopeSourcePayload {
accessReviewCampaign: AccessReviewCampaign!
}
input RemoveAccessReviewCampaignScopeSourceInput {
accessReviewCampaignId: ID!
accessSourceId: ID!
}
type RemoveAccessReviewCampaignScopeSourcePayload {
accessReviewCampaign: AccessReviewCampaign!
}
type StartAccessReviewCampaignPayload {
accessReviewCampaign: AccessReviewCampaign!
}
input CloseAccessReviewCampaignInput {
accessReviewCampaignId: ID!
}
type CloseAccessReviewCampaignPayload {
accessReviewCampaign: AccessReviewCampaign!
}
input CancelAccessReviewCampaignInput {
accessReviewCampaignId: ID!
}
type CancelAccessReviewCampaignPayload {
accessReviewCampaign: AccessReviewCampaign!
}
input RecordAccessEntryDecisionInput {
accessEntryId: ID!
decision: AccessEntryDecision!
decisionNote: String
}
type RecordAccessEntryDecisionPayload {
accessEntry: AccessEntry!
}
input RecordAccessEntryDecisionsInput {
decisions: [AccessEntryDecisionInput!]!
}
input AccessEntryDecisionInput {
accessEntryId: ID!
decision: AccessEntryDecision!
decisionNote: String
}
type RecordAccessEntryDecisionsPayload {
accessEntries: [AccessEntry!]!
}
input FlagAccessEntryInput {
accessEntryId: ID!
flags: [AccessEntryFlag!]!
flagReasons: [String!]
}
type FlagAccessEntryPayload {
accessEntry: AccessEntry!
}
input CreateAPIKeyConnectorInput {
organizationId: ID!
provider: ConnectorProvider!
apiKey: String!
tallyOrganizationId: String
sentryOrganizationSlug: String
supabaseOrganizationSlug: String
githubOrganization: String
onePasswordScimBridgeUrl: String
}
type CreateAPIKeyConnectorPayload {
connector: Connector!
}
input CreateClientCredentialsConnectorInput {
organizationId: ID!
provider: ConnectorProvider!
clientId: String!
clientSecret: String!
tokenUrl: String!
scope: String
onePasswordAccountId: String
onePasswordRegion: String
}
type CreateClientCredentialsConnectorPayload {
connector: Connector
}
input DeleteConnectorInput {
connectorId: ID!
}
type DeleteConnectorPayload {
deletedConnectorId: ID!
}

View File

@@ -0,0 +1,302 @@
// 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 types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
AccessSourceOrderBy OrderBy[coredata.AccessSourceOrderField]
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
AccessEntryOrderBy OrderBy[coredata.AccessEntryOrderField]
AccessSourceConnection struct {
TotalCount int
Edges []*AccessSourceEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
AccessReviewCampaignConnection struct {
TotalCount int
Edges []*AccessReviewCampaignEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
AccessEntryConnection struct {
TotalCount int
Edges []*AccessEntryEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
SourceID *gid.GID
Filter *coredata.AccessEntryFilter
}
)
// AccessSource helpers
func NewAccessSourceConnection(
p *page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField],
parentType any,
parentID gid.GID,
) *AccessSourceConnection {
edges := make([]*AccessSourceEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessSourceEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &AccessSourceConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewAccessSourceEdge(s *coredata.AccessSource, orderBy coredata.AccessSourceOrderField) *AccessSourceEdge {
return &AccessSourceEdge{
Cursor: s.CursorKey(orderBy),
Node: NewAccessSource(s),
}
}
func NewAccessSource(s *coredata.AccessSource) *AccessSource {
return &AccessSource{
ID: s.ID,
Organization: &Organization{
ID: s.OrganizationID,
},
ConnectorID: s.ConnectorID,
Name: s.Name,
CSVData: s.CsvData,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}
func NewAccessReviewCampaignScopeSource(
campaignID gid.GID,
source *coredata.AccessSource,
fetch *coredata.AccessReviewCampaignSourceFetch,
) *AccessReviewCampaignScopeSource {
status := coredata.AccessReviewCampaignSourceFetchStatusQueued
fetchedAccountsCount := 0
attemptCount := 0
var lastError *string
var fetchStartedAt *time.Time
var fetchCompletedAt *time.Time
if fetch != nil {
status = fetch.Status
fetchedAccountsCount = fetch.FetchedAccountsCount
attemptCount = fetch.AttemptCount
lastError = fetch.LastError
fetchStartedAt = fetch.StartedAt
fetchCompletedAt = fetch.CompletedAt
}
return &AccessReviewCampaignScopeSource{
ID: source.ID,
CampaignID: campaignID,
Source: NewAccessSource(source),
Name: source.Name,
FetchStatus: status,
FetchedAccountsCount: fetchedAccountsCount,
AttemptCount: attemptCount,
LastError: lastError,
FetchStartedAt: fetchStartedAt,
FetchCompletedAt: fetchCompletedAt,
}
}
// AccessReviewCampaign helpers
func NewAccessReviewCampaignConnection(
p *page.Page[*coredata.AccessReviewCampaign, coredata.AccessReviewCampaignOrderField],
parentType any,
parentID gid.GID,
) *AccessReviewCampaignConnection {
edges := make([]*AccessReviewCampaignEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessReviewCampaignEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &AccessReviewCampaignConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewAccessReviewCampaignEdge(c *coredata.AccessReviewCampaign, orderBy coredata.AccessReviewCampaignOrderField) *AccessReviewCampaignEdge {
return &AccessReviewCampaignEdge{
Cursor: c.CursorKey(orderBy),
Node: NewAccessReviewCampaign(c),
}
}
func NewAccessReviewCampaign(c *coredata.AccessReviewCampaign) *AccessReviewCampaign {
campaign := &AccessReviewCampaign{
ID: c.ID,
Organization: &Organization{
ID: c.OrganizationID,
},
Name: c.Name,
Description: c.Description,
Status: c.Status,
StartedAt: c.StartedAt,
CompletedAt: c.CompletedAt,
FrameworkControls: c.FrameworkControls,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
return campaign
}
func NewAccessEntryDecisionHistoryEntry(h *coredata.AccessEntryDecisionHistory) *AccessEntryDecisionHistoryEntry {
entry := &AccessEntryDecisionHistoryEntry{
ID: h.ID,
Decision: h.Decision,
DecisionNote: h.DecisionNote,
DecidedAt: h.DecidedAt,
CreatedAt: h.CreatedAt,
}
if h.DecidedBy != nil {
entry.DecidedBy = h.DecidedBy
}
return entry
}
// AccessEntry helpers
func NewAccessEntryConnection(
p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField],
parentType any,
parentID gid.GID,
sourceID *gid.GID,
filter *coredata.AccessEntryFilter,
) *AccessEntryConnection {
edges := make([]*AccessEntryEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &AccessEntryConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
SourceID: sourceID,
Filter: filter,
}
}
func NewAccessEntryEdge(e *coredata.AccessEntry, orderBy coredata.AccessEntryOrderField) *AccessEntryEdge {
return &AccessEntryEdge{
Cursor: e.CursorKey(orderBy),
Node: NewAccessEntry(e),
}
}
func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry {
entry := &AccessEntry{
ID: e.ID,
Campaign: &AccessReviewCampaign{
ID: e.AccessReviewCampaignID,
},
AccessSource: &AccessSource{
ID: e.AccessSourceID,
},
Email: e.Email,
FullName: e.FullName,
Role: e.Role,
JobTitle: e.JobTitle,
IsAdmin: e.IsAdmin,
MfaStatus: e.MFAStatus,
AuthMethod: e.AuthMethod,
AccountType: e.AccountType,
LastLogin: e.LastLogin,
AccountCreatedAt: e.AccountCreatedAt,
ExternalID: e.ExternalID,
IncrementalTag: e.IncrementalTag,
Flags: e.Flags,
FlagReasons: e.FlagReasons,
Decision: e.Decision,
DecisionNote: e.DecisionNote,
DecidedAt: e.DecidedAt,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.DecidedBy != nil {
entry.DecidedBy = e.DecidedBy
}
return entry
}
func NewAccessReviewCampaignStatistics(stats *coredata.AccessEntryStatistics) *AccessReviewCampaignStatistics {
decisionCounts := make([]*AccessEntryDecisionCount, 0, len(stats.DecisionCounts))
for decision, count := range stats.DecisionCounts {
decisionCounts = append(
decisionCounts,
&AccessEntryDecisionCount{Decision: decision, Count: count},
)
}
flagCounts := make([]*AccessEntryFlagCount, 0, len(stats.FlagCounts))
for flag, count := range stats.FlagCounts {
flagCounts = append(
flagCounts,
&AccessEntryFlagCount{Flag: flag, Count: count},
)
}
incrementalTagCounts := make([]*AccessEntryIncrementalTagCount, 0, len(stats.IncrementalTagCounts))
for tag, count := range stats.IncrementalTagCounts {
incrementalTagCounts = append(
incrementalTagCounts,
&AccessEntryIncrementalTagCount{IncrementalTag: tag, Count: count},
)
}
return &AccessReviewCampaignStatistics{
TotalCount: stats.TotalCount,
DecisionCounts: decisionCounts,
FlagCounts: flagCounts,
IncrementalTagCounts: incrementalTagCounts,
}
}

View File

@@ -0,0 +1,35 @@
// 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 types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type AccessReviewCampaignScopeSource struct {
ID gid.GID `json:"id"`
CampaignID gid.GID `json:"-"`
Source *AccessSource `json:"source"`
Name string `json:"name"`
FetchStatus coredata.AccessReviewCampaignSourceFetchStatus `json:"fetchStatus"`
FetchedAccountsCount int `json:"fetchedAccountsCount"`
AttemptCount int `json:"attemptCount"`
LastError *string `json:"lastError,omitempty"`
FetchStartedAt *time.Time `json:"fetchStartedAt,omitempty"`
FetchCompletedAt *time.Time `json:"fetchCompletedAt,omitempty"`
}

View File

@@ -0,0 +1,82 @@
// 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 types
import (
"testing"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
func TestNewAccessReviewCampaignScopeSource_DefaultFetchState(t *testing.T) {
t.Parallel()
tenantID := gid.NewTenantID()
source := &coredata.AccessSource{
ID: gid.New(tenantID, coredata.AccessSourceEntityType),
OrganizationID: gid.New(tenantID, coredata.OrganizationEntityType),
Name: "Google Workspace",
}
campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType)
got := NewAccessReviewCampaignScopeSource(campaignID, source, nil)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued {
t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus)
}
if got.FetchedAccountsCount != 0 {
t.Fatalf("fetched accounts count = %d, want 0", got.FetchedAccountsCount)
}
if got.AttemptCount != 0 {
t.Fatalf("attempt count = %d, want 0", got.AttemptCount)
}
}
func TestNewAccessReviewCampaignScopeSource_UsesFetchState(t *testing.T) {
t.Parallel()
now := time.Now()
errMsg := "connector timeout"
tenantID := gid.NewTenantID()
source := &coredata.AccessSource{
ID: gid.New(tenantID, coredata.AccessSourceEntityType),
OrganizationID: gid.New(tenantID, coredata.OrganizationEntityType),
Name: "Linear",
}
fetch := &coredata.AccessReviewCampaignSourceFetch{
Status: coredata.AccessReviewCampaignSourceFetchStatusFailed,
FetchedAccountsCount: 42,
AttemptCount: 3,
LastError: &errMsg,
StartedAt: &now,
CompletedAt: &now,
}
campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType)
got := NewAccessReviewCampaignScopeSource(campaignID, source, fetch)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusFailed {
t.Fatalf("fetch status = %q, want FAILED", got.FetchStatus)
}
if got.FetchedAccountsCount != 42 {
t.Fatalf("fetched accounts count = %d, want 42", got.FetchedAccountsCount)
}
if got.AttemptCount != 3 {
t.Fatalf("attempt count = %d, want 3", got.AttemptCount)
}
if got.LastError == nil || *got.LastError != errMsg {
t.Fatalf("last error = %v, want %q", got.LastError, errMsg)
}
}

View File

@@ -0,0 +1,34 @@
// 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 types
import "go.probo.inc/probo/pkg/coredata"
func NewConnectors(connectors coredata.Connectors) []*Connector {
items := make([]*Connector, 0, len(connectors))
for _, cnnctr := range connectors {
items = append(items, NewConnector(cnnctr))
}
return items
}
func NewConnector(c *coredata.Connector) *Connector {
return &Connector{
ID: c.ID,
Provider: c.Provider,
CreatedAt: c.CreatedAt,
}
}

View File

@@ -46,14 +46,13 @@ func NewSlackConnection(c *coredata.Connector) *SlackConnection {
UpdatedAt: c.UpdatedAt,
}
// Extract channel information from settings
if len(c.Settings) > 0 {
if channel, ok := c.Settings["channel"].(string); ok && channel != "" {
conn.Channel = &channel
}
if channelID, ok := c.Settings["channel_id"].(string); ok && channelID != "" {
conn.ChannelID = &channelID
}
// Extract channel information from typed settings
settings, _ := c.SlackSettings()
if settings.Channel != "" {
conn.Channel = &settings.Channel
}
if settings.ChannelID != "" {
conn.ChannelID = &settings.ChannelID
}
return conn

File diff suppressed because it is too large Load Diff