From ebfc0b7e314f9a047e18d8974a292bd2fea8f1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:51:41 +0200 Subject: [PATCH] Add access review console GraphQL API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add queries, mutations, and types for access review campaigns, access sources, access entries with decisions and flags, connector provider info, and provider org listing. Wire accessreview.Service into the Resolver. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .../api/console/v1/connector_provider_info.go | 79 ++ pkg/server/api/console/v1/graphql_handler.go | 6 +- .../api/console/v1/provider_organizations.go | 139 ++ pkg/server/api/console/v1/resolver.go | 96 +- pkg/server/api/console/v1/schema.graphql | 816 +++++++++++ .../api/console/v1/types/access_review.go | 302 ++++ .../access_review_campaign_scope_source.go | 35 + ...ccess_review_campaign_scope_source_test.go | 82 ++ pkg/server/api/console/v1/types/connector.go | 34 + .../api/console/v1/types/slack_connection.go | 15 +- pkg/server/api/console/v1/v1_resolver.go | 1236 +++++++++++++++++ 11 files changed, 2797 insertions(+), 43 deletions(-) create mode 100644 pkg/server/api/console/v1/connector_provider_info.go create mode 100644 pkg/server/api/console/v1/provider_organizations.go create mode 100644 pkg/server/api/console/v1/types/access_review.go create mode 100644 pkg/server/api/console/v1/types/access_review_campaign_scope_source.go create mode 100644 pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go create mode 100644 pkg/server/api/console/v1/types/connector.go 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 }