diff --git a/pkg/server/api/console/v1/connector_provider_info.go b/pkg/server/api/console/v1/connector_provider_info.go new file mode 100644 index 000000000..af39c55d9 --- /dev/null +++ b/pkg/server/api/console/v1/connector_provider_info.go @@ -0,0 +1,79 @@ +// 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 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{} +} diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index ce61de61f..c63b6c3e9 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -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, }, diff --git a/pkg/server/api/console/v1/provider_organizations.go b/pkg/server/api/console/v1/provider_organizations.go new file mode 100644 index 000000000..096e07a36 --- /dev/null +++ b/pkg/server/api/console/v1/provider_organizations.go @@ -0,0 +1,139 @@ +// 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 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 +} diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index d96510fe1..f3f57c883 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -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) diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 08e8aec4e..4b435a1bc 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -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! +} diff --git a/pkg/server/api/console/v1/types/access_review.go b/pkg/server/api/console/v1/types/access_review.go new file mode 100644 index 000000000..355c7cfdd --- /dev/null +++ b/pkg/server/api/console/v1/types/access_review.go @@ -0,0 +1,302 @@ +// 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 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, + } +} diff --git a/pkg/server/api/console/v1/types/access_review_campaign_scope_source.go b/pkg/server/api/console/v1/types/access_review_campaign_scope_source.go new file mode 100644 index 000000000..bb1d45709 --- /dev/null +++ b/pkg/server/api/console/v1/types/access_review_campaign_scope_source.go @@ -0,0 +1,35 @@ +// 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 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"` +} diff --git a/pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go b/pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go new file mode 100644 index 000000000..31b07e0f4 --- /dev/null +++ b/pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go @@ -0,0 +1,82 @@ +// 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 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) + } +} diff --git a/pkg/server/api/console/v1/types/connector.go b/pkg/server/api/console/v1/types/connector.go new file mode 100644 index 000000000..1b620eff3 --- /dev/null +++ b/pkg/server/api/console/v1/types/connector.go @@ -0,0 +1,34 @@ +// 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 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, + } +} diff --git a/pkg/server/api/console/v1/types/slack_connection.go b/pkg/server/api/console/v1/types/slack_connection.go index 624197722..a62202a89 100644 --- a/pkg/server/api/console/v1/types/slack_connection.go +++ b/pkg/server/api/console/v1/types/slack_connection.go @@ -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 diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 990e3086e..7ae9b61a1 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -17,6 +17,8 @@ import ( pgx "github.com/jackc/pgx/v5" "github.com/vikstrous/dataloadgen" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview" + "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" @@ -32,6 +34,513 @@ import ( "go.probo.inc/probo/pkg/validator" ) +// Campaign is the resolver for the campaign field. +func (r *accessEntryResolver) Campaign(ctx context.Context, obj *types.AccessEntry) (*types.AccessReviewCampaign, error) { + if err := r.authorize(ctx, obj.Campaign.ID, probo.ActionAccessReviewCampaignGet); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.Campaign.ID) + + campaign, err := r.accessReview.Campaigns(scope).Get(ctx, obj.Campaign.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot get access review campaign: %w", err)) + } + + return types.NewAccessReviewCampaign(campaign), nil +} + +// AccessSource is the resolver for the accessSource field. +func (r *accessEntryResolver) AccessSource(ctx context.Context, obj *types.AccessEntry) (*types.AccessSource, error) { + if err := r.authorize(ctx, obj.AccessSource.ID, probo.ActionAccessSourceGet); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.AccessSource.ID) + + source, err := r.accessReview.Sources(scope).Get(ctx, obj.AccessSource.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot get access source: %w", err)) + } + + return types.NewAccessSource(source), nil +} + +// DecisionHistory is the resolver for the decisionHistory field. +func (r *accessEntryResolver) DecisionHistory(ctx context.Context, obj *types.AccessEntry) ([]*types.AccessEntryDecisionHistoryEntry, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryGet); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + histories, err := r.accessReview.Entries(scope).DecisionHistory(ctx, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot get decision history: %w", err)) + } + + result := make([]*types.AccessEntryDecisionHistoryEntry, len(histories)) + for i, h := range histories { + result[i] = types.NewAccessEntryDecisionHistoryEntry(h) + } + + return result, nil +} + +// Permission is the resolver for the permission field. +func (r *accessEntryResolver) Permission(ctx context.Context, obj *types.AccessEntry, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *accessEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessEntryConnection) (int, error) { + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + switch obj.Resolver.(type) { + case *accessReviewCampaignResolver: + if obj.SourceID != nil { + count, err := r.accessReview.Entries(scope).CountForCampaignIDAndSourceID(ctx, obj.ParentID, *obj.SourceID, obj.Filter) + if err != nil { + panic(fmt.Errorf("cannot count access entries: %w", err)) + } + return count, nil + } + count, err := r.accessReview.Entries(scope).CountForCampaignID(ctx, obj.ParentID, obj.Filter) + if err != nil { + panic(fmt.Errorf("cannot count access entries: %w", err)) + } + return count, nil + } + + panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) +} + +// Organization is the resolver for the organization field. +func (r *accessReviewResolver) Organization(ctx context.Context, obj *types.AccessReview) (*types.Organization, error) { + return obj.Organization, nil +} + +// IdentitySource is the resolver for the identitySource field. +func (r *accessReviewResolver) IdentitySource(ctx context.Context, obj *types.AccessReview) (*types.AccessSource, error) { + return obj.IdentitySource, nil +} + +// AccessSources is the resolver for the accessSources field. +func (r *accessReviewResolver) AccessSources(ctx context.Context, obj *types.AccessReview, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessSourceOrder) (*types.AccessSourceConnection, error) { + if err := r.authorize(ctx, obj.Organization.ID, probo.ActionAccessSourceList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.Organization.ID) + + pageOrderBy := page.OrderBy[coredata.AccessSourceOrderField]{ + Field: coredata.AccessSourceOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessSourceOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.accessReview.Sources(scope).ListForOrganizationID(ctx, obj.Organization.ID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list access sources: %w", err)) + } + + return types.NewAccessSourceConnection(p, r, obj.Organization.ID), nil +} + +// Campaigns is the resolver for the campaigns field. +func (r *accessReviewResolver) Campaigns(ctx context.Context, obj *types.AccessReview, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignOrder) (*types.AccessReviewCampaignConnection, error) { + if err := r.authorize(ctx, obj.Organization.ID, probo.ActionAccessReviewCampaignList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.Organization.ID) + + pageOrderBy := page.OrderBy[coredata.AccessReviewCampaignOrderField]{ + Field: coredata.AccessReviewCampaignOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessReviewCampaignOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.accessReview.Campaigns(scope).ListForOrganizationID(ctx, obj.Organization.ID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list access review campaigns: %w", err)) + } + + return types.NewAccessReviewCampaignConnection(p, r, obj.Organization.ID), nil +} + +// Permission is the resolver for the permission field. +func (r *accessReviewResolver) Permission(ctx context.Context, obj *types.AccessReview, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// Organization is the resolver for the organization field. +func (r *accessReviewCampaignResolver) Organization(ctx context.Context, obj *types.AccessReviewCampaign) (*types.Organization, error) { + return obj.Organization, nil +} + +// ScopeSources is the resolver for the scopeSources field. +func (r *accessReviewCampaignResolver) ScopeSources(ctx context.Context, obj *types.AccessReviewCampaign) ([]*types.AccessReviewCampaignScopeSource, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + sources, err := r.accessReview.Sources(scope).ListScopeSourcesForCampaignID(ctx, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot list scope sources: %w", err)) + } + + fetches, err := r.accessReview.Campaigns(scope).ListSourceFetches(ctx, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot list source fetch states: %w", err)) + } + + fetchBySourceID := make(map[gid.GID]*coredata.AccessReviewCampaignSourceFetch, len(fetches)) + for _, fetch := range fetches { + fetchBySourceID[fetch.AccessSourceID] = fetch + } + + result := make([]*types.AccessReviewCampaignScopeSource, len(sources)) + for i, s := range sources { + result[i] = types.NewAccessReviewCampaignScopeSource(obj.ID, s, fetchBySourceID[s.ID]) + } + + return result, nil +} + +// Entries is the resolver for the entries field. +func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessEntryOrder, accessSourceID *gid.GID, filter *coredata.AccessEntryFilter) (*types.AccessEntryConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{ + Field: coredata.AccessEntryOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + var ( + p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField] + err error + ) + + if accessSourceID != nil { + p, err = r.accessReview.Entries(scope).ListForCampaignIDAndSourceID(ctx, obj.ID, *accessSourceID, cursor, filter) + } else { + p, err = r.accessReview.Entries(scope).ListForCampaignID(ctx, obj.ID, cursor, filter) + } + if err != nil { + panic(fmt.Errorf("cannot list access entries: %w", err)) + } + + return types.NewAccessEntryConnection(p, r, obj.ID, accessSourceID, filter), nil +} + +// PendingEntryCount is the resolver for the pendingEntryCount field. +func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, obj *types.AccessReviewCampaign) (int, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList); err != nil { + return 0, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + count, err := r.accessReview.Entries(scope).CountPendingForCampaignID(ctx, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot count pending access entries: %w", err)) + } + + return count, nil +} + +// Statistics is the resolver for the statistics field. +func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaign) (*types.AccessReviewCampaignStatistics, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + stats, err := r.accessReview.Entries(scope).Statistics(ctx, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot get campaign statistics: %w", err)) + } + + return types.NewAccessReviewCampaignStatistics(stats), nil +} + +// Permission is the resolver for the permission field. +func (r *accessReviewCampaignResolver) Permission(ctx context.Context, obj *types.AccessReviewCampaign, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewCampaignConnection) (int, error) { + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + switch obj.Resolver.(type) { + case *organizationResolver: + count, err := r.accessReview.Campaigns(scope).CountForOrganizationID(ctx, obj.ParentID) + if err != nil { + panic(fmt.Errorf("cannot count access review campaigns: %w", err)) + } + return count, nil + } + + panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) +} + +// Entries is the resolver for the entries field. +func (r *accessReviewCampaignScopeSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignScopeSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessEntryOrder, filter *coredata.AccessEntryFilter) (*types.AccessEntryConnection, error) { + if err := r.authorize(ctx, obj.CampaignID, probo.ActionAccessEntryList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.CampaignID) + + pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{ + Field: coredata.AccessEntryOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.accessReview.Entries(scope).ListForCampaignIDAndSourceID(ctx, obj.CampaignID, obj.ID, cursor, filter) + if err != nil { + panic(fmt.Errorf("cannot list access entries: %w", err)) + } + + sourceID := obj.ID + return types.NewAccessEntryConnection(p, r, obj.CampaignID, &sourceID, filter), nil +} + +// Statistics is the resolver for the statistics field. +func (r *accessReviewCampaignScopeSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignScopeSource) (*types.AccessReviewCampaignStatistics, error) { + if err := r.authorize(ctx, obj.CampaignID, probo.ActionAccessEntryList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.CampaignID) + + stats, err := r.accessReview.Entries(scope).StatisticsForSource(ctx, obj.CampaignID, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot get source statistics: %w", err)) + } + + return types.NewAccessReviewCampaignStatistics(stats), nil +} + +// Organization is the resolver for the organization field. +func (r *accessSourceResolver) Organization(ctx context.Context, obj *types.AccessSource) (*types.Organization, error) { + return obj.Organization, nil +} + +// Connector is the resolver for the connector field. +func (r *accessSourceResolver) Connector(ctx context.Context, obj *types.AccessSource) (*types.Connector, error) { + if obj.ConnectorID == nil { + return nil, nil + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + connector, err := prb.Connectors.Get(ctx, *obj.ConnectorID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + panic(fmt.Errorf("cannot get connector: %w", err)) + } + + return types.NewConnector(connector), nil +} + +// ProviderOrganizations is the resolver for the providerOrganizations field. +func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *types.AccessSource) ([]*types.ProviderOrganization, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet); err != nil { + return nil, err + } + + if obj.ConnectorID == nil { + return []*types.ProviderOrganization{}, nil + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + httpClient, dbConnector, err := r.accessReview.Sources(scope).ConnectorHTTPClient(ctx, *obj.ConnectorID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return []*types.ProviderOrganization{}, nil + } + return nil, fmt.Errorf("cannot get connector HTTP client: %w", err) + } + + switch dbConnector.Provider { + case coredata.ConnectorProviderGitHub: + orgs, err := fetchGitHubOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch github organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderSentry: + orgs, err := fetchSentryOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err) + } + return orgs, nil + default: + return []*types.ProviderOrganization{}, nil + } +} + +// NeedsConfiguration is the resolver for the needsConfiguration field. +func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessSource) (bool, error) { + if obj.ConnectorID == nil { + return false, nil + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + dbConnector, err := prb.Connectors.Get(ctx, *obj.ConnectorID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return false, nil + } + panic(fmt.Errorf("cannot get connector: %w", err)) + } + + switch dbConnector.Provider { + case coredata.ConnectorProviderGitHub: + settings, _ := dbConnector.GitHubSettings() + return settings.Organization == "", nil + case coredata.ConnectorProviderSentry: + settings, _ := dbConnector.SentrySettings() + return settings.OrganizationSlug == "", nil + default: + return false, nil + } +} + +// ConnectionStatus is the resolver for the connectionStatus field. +func (r *accessSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessSource) (types.AccessSourceConnectionStatus, error) { + if obj.ConnectorID == nil { + return types.AccessSourceConnectionStatusNotApplicable, nil + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + httpClient, dbConnector, err := r.accessReview.Sources(scope).ConnectorHTTPClient(ctx, *obj.ConnectorID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return types.AccessSourceConnectionStatusNotApplicable, nil + } + return types.AccessSourceConnectionStatusDisconnected, nil + } + + if dbConnector.Protocol != coredata.ConnectorProtocolOAuth2 { + return types.AccessSourceConnectionStatusConnected, nil + } + + // Creating an HTTP client may succeed even with an expired token + // (e.g. no refresh token available). Make a lightweight probe + // request to verify the token is actually valid. + probeURL := r.connectorRegistry.GetProbeURL(string(dbConnector.Provider)) + if err := probeConnection(ctx, httpClient, probeURL); err != nil { + return types.AccessSourceConnectionStatusDisconnected, nil + } + + return types.AccessSourceConnectionStatusConnected, nil +} + +// SelectedOrganization is the resolver for the selectedOrganization field. +func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *types.AccessSource) (*string, error) { + if obj.ConnectorID == nil { + return nil, nil + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + dbConnector, err := prb.Connectors.Get(ctx, *obj.ConnectorID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + panic(fmt.Errorf("cannot get connector: %w", err)) + } + + switch dbConnector.Provider { + case coredata.ConnectorProviderGitHub: + settings, _ := dbConnector.GitHubSettings() + if settings.Organization != "" { + return &settings.Organization, nil + } + case coredata.ConnectorProviderSentry: + settings, _ := dbConnector.SentrySettings() + if settings.OrganizationSlug != "" { + return &settings.OrganizationSlug, nil + } + } + + return nil, nil +} + +// Permission is the resolver for the permission field. +func (r *accessSourceResolver) Permission(ctx context.Context, obj *types.AccessSource, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *accessSourceConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessSourceConnection) (int, error) { + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + switch obj.Resolver.(type) { + case *organizationResolver: + count, err := r.accessReview.Sources(scope).CountForOrganizationID(ctx, obj.ParentID) + if err != nil { + panic(fmt.Errorf("cannot count access sources: %w", err)) + } + return count, nil + } + + panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) +} + // StateOfApplicability is the resolver for the stateOfApplicability field. func (r *applicabilityStatementResolver) StateOfApplicability(ctx context.Context, obj *types.ApplicabilityStatement) (*types.StateOfApplicability, error) { if err := r.authorize(ctx, obj.StateOfApplicability.ID, probo.ActionStateOfApplicabilityGet); err != nil { @@ -6921,6 +7430,550 @@ func (r *mutationResolver) DeleteCustomDomain(ctx context.Context, input types.D }, nil } +// CreateAccessSource is the resolver for the createAccessSource field. +func (r *mutationResolver) CreateAccessSource(ctx context.Context, input types.CreateAccessSourceInput) (*types.CreateAccessSourcePayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionAccessSourceCreate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + + source, err := r.accessReview.Sources(scope).Create(ctx, accessreview.CreateAccessSourceRequest{ + OrganizationID: input.OrganizationID, + ConnectorID: input.ConnectorID, + Name: input.Name, + Category: coredata.AccessSourceCategorySaaS, + CsvData: input.CSVData, + }) + if err != nil { + panic(fmt.Errorf("cannot create access source: %w", err)) + } + + return &types.CreateAccessSourcePayload{ + AccessSourceEdge: types.NewAccessSourceEdge(source, coredata.AccessSourceOrderFieldCreatedAt), + }, nil +} + +// UpdateAccessSource is the resolver for the updateAccessSource field. +func (r *mutationResolver) UpdateAccessSource(ctx context.Context, input types.UpdateAccessSourceInput) (*types.UpdateAccessSourcePayload, error) { + if err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessSourceID) + + req := accessreview.UpdateAccessSourceRequest{ + AccessSourceID: input.AccessSourceID, + } + if input.Name.IsSet() { + req.Name = input.Name.Value() + } + if input.ConnectorID.IsSet() { + req.ConnectorID = gqlutils.UnwrapOmittable(input.ConnectorID) + } + if input.CSVData.IsSet() { + req.CsvData = gqlutils.UnwrapOmittable(input.CSVData) + } + + source, err := r.accessReview.Sources(scope).Update(ctx, req) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot update access source: %w", err)) + } + + return &types.UpdateAccessSourcePayload{ + AccessSource: types.NewAccessSource(source), + }, nil +} + +// DeleteAccessSource is the resolver for the deleteAccessSource field. +func (r *mutationResolver) DeleteAccessSource(ctx context.Context, input types.DeleteAccessSourceInput) (*types.DeleteAccessSourcePayload, error) { + if err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceDelete); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessSourceID) + + if err := r.accessReview.Sources(scope).Delete(ctx, input.AccessSourceID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot delete access source: %w", err)) + } + + return &types.DeleteAccessSourcePayload{ + DeletedAccessSourceID: input.AccessSourceID, + }, nil +} + +// CreateAccessReviewCampaign is the resolver for the createAccessReviewCampaign field. +func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input types.CreateAccessReviewCampaignInput) (*types.CreateAccessReviewCampaignPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignCreate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.OrganizationID) + + var description string + if input.Description != nil { + description = *input.Description + } + + campaign, err := r.accessReview.Campaigns(scope).Create(ctx, accessreview.CreateAccessReviewCampaignRequest{ + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: description, + FrameworkControls: input.FrameworkControls, + AccessSourceIDs: input.AccessSourceIds, + }) + if err != nil { + panic(fmt.Errorf("cannot create access review campaign: %w", err)) + } + + return &types.CreateAccessReviewCampaignPayload{ + AccessReviewCampaignEdge: types.NewAccessReviewCampaignEdge(campaign, coredata.AccessReviewCampaignOrderFieldCreatedAt), + }, nil +} + +// UpdateAccessReviewCampaign is the resolver for the updateAccessReviewCampaign field. +func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input types.UpdateAccessReviewCampaignInput) (*types.UpdateAccessReviewCampaignPayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + req := accessreview.UpdateAccessReviewCampaignRequest{ + CampaignID: input.AccessReviewCampaignID, + } + if input.Name.IsSet() { + req.Name = input.Name.Value() + } + if input.Description.IsSet() { + req.Description = input.Description.Value() + } + if input.FrameworkControls.IsSet() { + controls := input.FrameworkControls.Value() + req.FrameworkControls = &controls + } + + campaign, err := r.accessReview.Campaigns(scope).Update(ctx, req) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot update access review campaign: %w", err)) + } + + return &types.UpdateAccessReviewCampaignPayload{ + AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// DeleteAccessReviewCampaign is the resolver for the deleteAccessReviewCampaign field. +func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input types.DeleteAccessReviewCampaignInput) (*types.DeleteAccessReviewCampaignPayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignDelete); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + if err := r.accessReview.Campaigns(scope).Delete(ctx, input.AccessReviewCampaignID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot delete access review campaign: %w", err)) + } + + return &types.DeleteAccessReviewCampaignPayload{ + DeletedAccessReviewCampaignID: input.AccessReviewCampaignID, + }, nil +} + +// StartAccessReviewCampaign is the resolver for the startAccessReviewCampaign field. +func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input types.StartAccessReviewCampaignInput) (*types.StartAccessReviewCampaignPayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignStart); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + campaign, err := r.accessReview.Campaigns(scope).Start(ctx, input.AccessReviewCampaignID) + if err != nil { + panic(fmt.Errorf("cannot start access review campaign: %w", err)) + } + + return &types.StartAccessReviewCampaignPayload{ + AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// CloseAccessReviewCampaign is the resolver for the closeAccessReviewCampaign field. +func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input types.CloseAccessReviewCampaignInput) (*types.CloseAccessReviewCampaignPayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignClose); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + campaign, err := r.accessReview.Campaigns(scope).Close(ctx, input.AccessReviewCampaignID) + if err != nil { + panic(fmt.Errorf("cannot close access review campaign: %w", err)) + } + + return &types.CloseAccessReviewCampaignPayload{ + AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// CancelAccessReviewCampaign is the resolver for the cancelAccessReviewCampaign field. +func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input types.CancelAccessReviewCampaignInput) (*types.CancelAccessReviewCampaignPayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignCancel); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + campaign, err := r.accessReview.Campaigns(scope).Cancel(ctx, input.AccessReviewCampaignID) + if err != nil { + panic(fmt.Errorf("cannot cancel access review campaign: %w", err)) + } + + return &types.CancelAccessReviewCampaignPayload{ + AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// AddAccessReviewCampaignScopeSource is the resolver for the addAccessReviewCampaignScopeSource field. +func (r *mutationResolver) AddAccessReviewCampaignScopeSource(ctx context.Context, input types.AddAccessReviewCampaignScopeSourceInput) (*types.AddAccessReviewCampaignScopeSourcePayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignAddScopeSource); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + campaign, err := r.accessReview.Campaigns(scope).AddScopeSource(ctx, accessreview.AddCampaignScopeSourceRequest{ + CampaignID: input.AccessReviewCampaignID, + AccessSourceID: input.AccessSourceID, + }) + if err != nil { + panic(fmt.Errorf("cannot add scope source to access review campaign: %w", err)) + } + + return &types.AddAccessReviewCampaignScopeSourcePayload{ + AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// RemoveAccessReviewCampaignScopeSource is the resolver for the removeAccessReviewCampaignScopeSource field. +func (r *mutationResolver) RemoveAccessReviewCampaignScopeSource(ctx context.Context, input types.RemoveAccessReviewCampaignScopeSourceInput) (*types.RemoveAccessReviewCampaignScopeSourcePayload, error) { + if err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignRemoveScopeSource); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessReviewCampaignID) + + campaign, err := r.accessReview.Campaigns(scope).RemoveScopeSource(ctx, accessreview.RemoveCampaignScopeSourceRequest{ + CampaignID: input.AccessReviewCampaignID, + AccessSourceID: input.AccessSourceID, + }) + if err != nil { + panic(fmt.Errorf("cannot remove scope source from access review campaign: %w", err)) + } + + return &types.RemoveAccessReviewCampaignScopeSourcePayload{ + AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), + }, nil +} + +// RecordAccessEntryDecision is the resolver for the recordAccessEntryDecision field. +func (r *mutationResolver) RecordAccessEntryDecision(ctx context.Context, input types.RecordAccessEntryDecisionInput) (*types.RecordAccessEntryDecisionPayload, error) { + if err := r.authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryDecide); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessEntryID) + + // Resolve the profile ID from the session's identity. + // The profile may not exist for every identity, in which + // case decided_by will be left nil. + identity := authn.IdentityFromContext(ctx) + if identity == nil { + return nil, fmt.Errorf("no identity in context") + } + + req := accessreview.RecordAccessEntryDecisionRequest{ + EntryID: input.AccessEntryID, + Decision: input.Decision, + DecisionNote: input.DecisionNote, + } + + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessEntryID) + if err == nil { + profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID) + if err == nil { + req.DecidedByID = &profile.ID + } + } + + entry, err := r.accessReview.Entries(scope).RecordDecision(ctx, req) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot record access entry decision: %w", err)) + } + + return &types.RecordAccessEntryDecisionPayload{ + AccessEntry: types.NewAccessEntry(entry), + }, nil +} + +// RecordAccessEntryDecisions is the resolver for the recordAccessEntryDecisions field. +func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input types.RecordAccessEntryDecisionsInput) (*types.RecordAccessEntryDecisionsPayload, error) { + if len(input.Decisions) == 0 { + return &types.RecordAccessEntryDecisionsPayload{ + AccessEntries: []*types.AccessEntry{}, + }, nil + } + + const maxBatchSize = 100 + if len(input.Decisions) > maxBatchSize { + return nil, fmt.Errorf("cannot record decisions: batch size %d exceeds maximum of %d", len(input.Decisions), maxBatchSize) + } + + // Authorize each entry individually to prevent cross-org bypass. + for _, d := range input.Decisions { + if err := r.authorize(ctx, d.AccessEntryID, probo.ActionAccessEntryDecide); err != nil { + return nil, err + } + } + + identity := authn.IdentityFromContext(ctx) + if identity == nil { + return nil, fmt.Errorf("no identity in context") + } + + tenantID := input.Decisions[0].AccessEntryID.TenantID() + scope := coredata.NewScope(tenantID) + + // Cache profile lookups per organization so we resolve the correct + // decidedByID for each entry even when a batch spans multiple orgs. + profileCache := make(map[gid.GID]*gid.GID) + + decisions := make([]accessreview.RecordAccessEntryDecisionRequest, len(input.Decisions)) + for i, d := range input.Decisions { + var decidedByID *gid.GID + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessEntryID) + if err == nil { + if cached, ok := profileCache[organizationID]; ok { + decidedByID = cached + } else { + profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID) + if err == nil { + decidedByID = &profile.ID + } + profileCache[organizationID] = decidedByID + } + } + + decisions[i] = accessreview.RecordAccessEntryDecisionRequest{ + EntryID: d.AccessEntryID, + Decision: d.Decision, + DecisionNote: d.DecisionNote, + DecidedByID: decidedByID, + } + } + + entries, err := r.accessReview.Entries(scope).RecordDecisions(ctx, decisions) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot record access entry decisions: %w", err)) + } + + accessEntries := make([]*types.AccessEntry, len(entries)) + for i, e := range entries { + accessEntries[i] = types.NewAccessEntry(e) + } + + return &types.RecordAccessEntryDecisionsPayload{ + AccessEntries: accessEntries, + }, nil +} + +// FlagAccessEntry is the resolver for the flagAccessEntry field. +func (r *mutationResolver) FlagAccessEntry(ctx context.Context, input types.FlagAccessEntryInput) (*types.FlagAccessEntryPayload, error) { + if err := r.authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryFlag); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessEntryID) + + entry, err := r.accessReview.Entries(scope).FlagEntry(ctx, accessreview.FlagAccessEntryRequest{ + EntryID: input.AccessEntryID, + Flags: input.Flags, + FlagReasons: input.FlagReasons, + }) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot flag access entry: %w", err)) + } + + return &types.FlagAccessEntryPayload{ + AccessEntry: types.NewAccessEntry(entry), + }, nil +} + +// CreateAPIKeyConnector is the resolver for the createAPIKeyConnector field. +func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input types.CreateAPIKeyConnectorInput) (*types.CreateAPIKeyConnectorPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionConnectorCreate); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, input.OrganizationID.TenantID()) + + req := probo.CreateConnectorRequest{ + OrganizationID: input.OrganizationID, + Provider: input.Provider, + Protocol: coredata.ConnectorProtocolAPIKey, + Connection: &connector.APIKeyConnection{APIKey: input.APIKey}, + } + + if input.TallyOrganizationID != nil { + req.TallySettings = &coredata.TallyConnectorSettings{ + OrganizationID: *input.TallyOrganizationID, + } + } + if input.SentryOrganizationSlug != nil { + req.SentrySettings = &coredata.SentryConnectorSettings{ + OrganizationSlug: *input.SentryOrganizationSlug, + } + } + if input.SupabaseOrganizationSlug != nil { + req.SupabaseSettings = &coredata.SupabaseConnectorSettings{ + OrganizationSlug: *input.SupabaseOrganizationSlug, + } + } + if input.GithubOrganization != nil { + req.GitHubSettings = &coredata.GitHubConnectorSettings{ + Organization: *input.GithubOrganization, + } + } + if input.OnePasswordScimBridgeURL != nil { + req.OnePasswordSettings = &coredata.OnePasswordConnectorSettings{ + SCIMBridgeURL: *input.OnePasswordScimBridgeURL, + } + } + cnnctr, err := prb.Connectors.Create(ctx, req) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + + panic(fmt.Errorf("cannot create API key connector: %w", err)) + } + + return &types.CreateAPIKeyConnectorPayload{ + Connector: types.NewConnector(cnnctr), + }, nil +} + +// CreateClientCredentialsConnector is the resolver for the createClientCredentialsConnector field. +func (r *mutationResolver) CreateClientCredentialsConnector(ctx context.Context, input types.CreateClientCredentialsConnectorInput) (*types.CreateClientCredentialsConnectorPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionConnectorCreate); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, input.OrganizationID.TenantID()) + + oauth2Conn := &connector.OAuth2Connection{ + GrantType: connector.OAuth2GrantTypeClientCredentials, + ClientID: input.ClientID, + ClientSecret: input.ClientSecret, + TokenURL: input.TokenURL, + } + if input.Scope != nil { + oauth2Conn.Scope = *input.Scope + } + + req := probo.CreateConnectorRequest{ + OrganizationID: input.OrganizationID, + Provider: input.Provider, + Protocol: coredata.ConnectorProtocolOAuth2, + Connection: oauth2Conn, + } + + if input.OnePasswordAccountID != nil && input.OnePasswordRegion != nil { + req.OnePasswordUsersAPISettings = &coredata.OnePasswordUsersAPISettings{ + AccountID: *input.OnePasswordAccountID, + Region: *input.OnePasswordRegion, + } + } + + cnnctr, err := prb.Connectors.Create(ctx, req) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + + panic(fmt.Errorf("cannot create client credentials connector: %w", err)) + } + + return &types.CreateClientCredentialsConnectorPayload{ + Connector: types.NewConnector(cnnctr), + }, nil +} + +// DeleteConnector is the resolver for the deleteConnector field. +func (r *mutationResolver) DeleteConnector(ctx context.Context, input types.DeleteConnectorInput) (*types.DeleteConnectorPayload, error) { + if err := r.authorize(ctx, input.ConnectorID, probo.ActionConnectorDelete); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, input.ConnectorID.TenantID()) + + if err := prb.Connectors.Delete(ctx, input.ConnectorID); err != nil { + panic(fmt.Errorf("cannot delete connector: %w", err)) + } + + return &types.DeleteConnectorPayload{ + DeletedConnectorID: input.ConnectorID, + }, nil +} + +// ConfigureAccessSource is the resolver for the configureAccessSource field. +func (r *mutationResolver) ConfigureAccessSource(ctx context.Context, input types.ConfigureAccessSourceInput) (*types.ConfigureAccessSourcePayload, error) { + if err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.AccessSourceID) + + source, err := r.accessReview.Sources(scope).ConfigureAccessSource( + ctx, + accessreview.ConfigureAccessSourceRequest{ + AccessSourceID: input.AccessSourceID, + OrganizationSlug: input.OrganizationSlug, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + panic(fmt.Errorf("cannot configure access source: %w", err)) + } + + return &types.ConfigureAccessSourcePayload{ + AccessSource: types.NewAccessSource(source), + }, nil +} + // DeleteSlackConnection is the resolver for the deleteSlackConnection field. func (r *mutationResolver) DeleteSlackConnection(ctx context.Context, input types.DeleteSlackConnectionInput) (*types.DeleteSlackConnectionPayload, error) { if err := r.authorize(ctx, input.SlackConnectionID, probo.ActionConnectorDelete); err != nil { @@ -7142,6 +8195,59 @@ func (r *organizationResolver) SlackConnections(ctx context.Context, obj *types. return types.NewSlackConnectionConnection(page), nil } +// Connectors is the resolver for the connectors field. +func (r *organizationResolver) Connectors(ctx context.Context, obj *types.Organization, filter *types.ConnectorFilter) ([]*types.Connector, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionConnectorList); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + connectors, err := prb.Connectors.ListAllForOrganizationID(ctx, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot list organization connectors: %w", err)) + } + + if filter != nil && len(filter.Providers) > 0 { + allowed := make(map[coredata.ConnectorProvider]struct{}, len(filter.Providers)) + for _, provider := range filter.Providers { + allowed[provider] = struct{}{} + } + + filtered := make(coredata.Connectors, 0, len(connectors)) + for _, cnnctr := range connectors { + if _, ok := allowed[cnnctr.Provider]; ok { + filtered = append(filtered, cnnctr) + } + } + connectors = filtered + } + + return types.NewConnectors(connectors), nil +} + +// ConnectorProviderInfos is the resolver for the connectorProviderInfos field. +func (r *organizationResolver) ConnectorProviderInfos(ctx context.Context, obj *types.Organization) ([]*types.ConnectorProviderInfo, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionConnectorList); err != nil { + return nil, err + } + + var infos []*types.ConnectorProviderInfo + for _, provider := range coredata.ConnectorProviders() { + _, oauthErr := r.connectorRegistry.Get(string(provider)) + info := &types.ConnectorProviderInfo{ + Provider: provider, + DisplayName: providerDisplayName(provider), + OauthConfigured: oauthErr == nil, + APIKeySupported: providerSupportsAPIKey(provider), + ClientCredentialsSupported: providerSupportsClientCredentials(provider), + ExtraSettings: providerExtraSettings(provider), + } + infos = append(infos, info) + } + return infos, nil +} + // Frameworks is the resolver for the frameworks field. func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.FrameworkOrderBy) (*types.FrameworkConnection, error) { if err := r.authorize(ctx, obj.ID, probo.ActionFrameworkList); err != nil { @@ -7967,6 +9073,64 @@ func (r *organizationResolver) AuditLogEntries(ctx context.Context, obj *types.O return types.NewAuditLogEntryConnection(p, r, obj.ID, coredataFilter), nil } +// AccessSources is the resolver for the accessSources field. +func (r *organizationResolver) AccessSources(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessSourceOrder) (*types.AccessSourceConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + pageOrderBy := page.OrderBy[coredata.AccessSourceOrderField]{ + Field: coredata.AccessSourceOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessSourceOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.accessReview.Sources(scope).ListForOrganizationID(ctx, obj.ID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list access sources: %w", err)) + } + + return types.NewAccessSourceConnection(p, r, obj.ID), nil +} + +// AccessReviewCampaigns is the resolver for the accessReviewCampaigns field. +func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignOrder) (*types.AccessReviewCampaignConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessReviewCampaignList); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + pageOrderBy := page.OrderBy[coredata.AccessReviewCampaignOrderField]{ + Field: coredata.AccessReviewCampaignOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.AccessReviewCampaignOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.accessReview.Campaigns(scope).ListForOrganizationID(ctx, obj.ID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list access review campaigns: %w", err)) + } + + return types.NewAccessReviewCampaignConnection(p, r, obj.ID), nil +} + // Permission is the resolver for the permission field. func (r *organizationResolver) Permission(ctx context.Context, obj *types.Organization, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -8443,6 +9607,36 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error } return types.NewWebhookSubscription(wc), nil } + case coredata.AccessReviewCampaignEntityType: + action = probo.ActionAccessReviewCampaignGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + campaign, err := r.accessReview.Campaigns(scope).Get(ctx, id) + if err != nil { + return nil, err + } + return types.NewAccessReviewCampaign(campaign), nil + } + case coredata.AccessSourceEntityType: + action = probo.ActionAccessSourceGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + source, err := r.accessReview.Sources(scope).Get(ctx, id) + if err != nil { + return nil, err + } + return types.NewAccessSource(source), nil + } + case coredata.AccessEntryEntityType: + action = probo.ActionAccessEntryGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + scope := coredata.NewScopeFromObjectID(id) + entry, err := r.accessReview.Entries(scope).Get(ctx, id) + if err != nil { + return nil, err + } + return types.NewAccessEntry(entry), nil + } default: } @@ -10487,6 +11681,40 @@ func (r *webhookSubscriptionConnectionResolver) TotalCount(ctx context.Context, return 0, gqlutils.Internal(ctx) } +// AccessEntry returns schema.AccessEntryResolver implementation. +func (r *Resolver) AccessEntry() schema.AccessEntryResolver { return &accessEntryResolver{r} } + +// AccessEntryConnection returns schema.AccessEntryConnectionResolver implementation. +func (r *Resolver) AccessEntryConnection() schema.AccessEntryConnectionResolver { + return &accessEntryConnectionResolver{r} +} + +// AccessReview returns schema.AccessReviewResolver implementation. +func (r *Resolver) AccessReview() schema.AccessReviewResolver { return &accessReviewResolver{r} } + +// AccessReviewCampaign returns schema.AccessReviewCampaignResolver implementation. +func (r *Resolver) AccessReviewCampaign() schema.AccessReviewCampaignResolver { + return &accessReviewCampaignResolver{r} +} + +// AccessReviewCampaignConnection returns schema.AccessReviewCampaignConnectionResolver implementation. +func (r *Resolver) AccessReviewCampaignConnection() schema.AccessReviewCampaignConnectionResolver { + return &accessReviewCampaignConnectionResolver{r} +} + +// AccessReviewCampaignScopeSource returns schema.AccessReviewCampaignScopeSourceResolver implementation. +func (r *Resolver) AccessReviewCampaignScopeSource() schema.AccessReviewCampaignScopeSourceResolver { + return &accessReviewCampaignScopeSourceResolver{r} +} + +// AccessSource returns schema.AccessSourceResolver implementation. +func (r *Resolver) AccessSource() schema.AccessSourceResolver { return &accessSourceResolver{r} } + +// AccessSourceConnection returns schema.AccessSourceConnectionResolver implementation. +func (r *Resolver) AccessSourceConnection() schema.AccessSourceConnectionResolver { + return &accessSourceConnectionResolver{r} +} + // ApplicabilityStatement returns schema.ApplicabilityStatementResolver implementation. func (r *Resolver) ApplicabilityStatement() schema.ApplicabilityStatementResolver { return &applicabilityStatementResolver{r} @@ -10860,6 +12088,14 @@ func (r *Resolver) WebhookSubscriptionConnection() schema.WebhookSubscriptionCon return &webhookSubscriptionConnectionResolver{r} } +type accessEntryResolver struct{ *Resolver } +type accessEntryConnectionResolver struct{ *Resolver } +type accessReviewResolver struct{ *Resolver } +type accessReviewCampaignResolver struct{ *Resolver } +type accessReviewCampaignConnectionResolver struct{ *Resolver } +type accessReviewCampaignScopeSourceResolver struct{ *Resolver } +type accessSourceResolver struct{ *Resolver } +type accessSourceConnectionResolver struct{ *Resolver } type applicabilityStatementResolver struct{ *Resolver } type applicabilityStatementConnectionResolver struct{ *Resolver } type assetResolver struct{ *Resolver }