diff --git a/.gitignore b/.gitignore index 793090886..e2eb715c2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ pkg/server/api/*/v1/schema.graphql cfg/dev_local.yaml cfg/dev.yaml .env +.oauth-credentials.txt diff --git a/pkg/accessreview/drivers/organizations.go b/pkg/accessreview/drivers/organizations.go new file mode 100644 index 000000000..29b0fdeca --- /dev/null +++ b/pkg/accessreview/drivers/organizations.go @@ -0,0 +1,443 @@ +// 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 drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" +) + +// Organization represents a tenant/workspace/team/group surfaced by a +// provider's "list orgs the authenticated user can access" endpoint. +// The OAuth picker UI consumes this to let the user choose which one +// scopes the access source. +type Organization struct { + Slug string + DisplayName string +} + +// ListGitHubOrganizations fetches the organizations the authenticated +// GitHub user belongs to. +func ListGitHubOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, 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: unexpected 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([]Organization, len(orgs)) + for i, org := range orgs { + displayName := org.Name + if displayName == "" { + displayName = org.Login + } + result[i] = Organization{Slug: org.Login, DisplayName: displayName} + } + return result, nil +} + +// ListSentryOrganizations fetches the organizations the authenticated +// Sentry user belongs to. +func ListSentryOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, 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: unexpected 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([]Organization, len(orgs)) + for i, org := range orgs { + displayName := org.Name + if displayName == "" { + displayName = org.Slug + } + result[i] = Organization{Slug: org.Slug, DisplayName: displayName} + } + return result, nil +} + +// ListGitLabOrganizations fetches the GitLab groups the authenticated +// user owns. Group IDs are int64; we surface them as strings so they fit +// the Organization.Slug shape. +func ListGitLabOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://gitlab.com/api/v4/groups?min_access_level=50&per_page=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create gitlab organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch gitlab organizations: unexpected status %d", resp.StatusCode) + } + + var groups []struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullPath string `json:"full_path"` + } + if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil { + return nil, fmt.Errorf("cannot decode gitlab organizations response: %w", err) + } + + result := make([]Organization, len(groups)) + for i, g := range groups { + displayName := g.Name + if displayName == "" { + displayName = g.FullPath + } + result[i] = Organization{ + Slug: strconv.FormatInt(g.ID, 10), + DisplayName: displayName, + } + } + return result, nil +} + +// ListBitbucketOrganizations fetches the workspaces the authenticated +// Bitbucket user belongs to. The legacy /2.0/workspaces endpoint was +// sunset by CHANGE-2770 (April 2026); /2.0/user/workspaces is the +// supported cross-workspace replacement (CHANGE-3022). +func ListBitbucketOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.bitbucket.org/2.0/user/workspaces?pagelen=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create bitbucket organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch bitbucket organizations: unexpected status %d", resp.StatusCode) + } + + // We tolerate both shapes (flat and nested under `workspace`) since + // Atlassian has shipped variants of similar endpoints with both. + var body struct { + Values []struct { + Slug string `json:"slug"` + Name string `json:"name"` + Workspace struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"workspace"` + } `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode bitbucket organizations response: %w", err) + } + + result := make([]Organization, 0, len(body.Values)) + for _, v := range body.Values { + slug, name := v.Slug, v.Name + if slug == "" { + slug = v.Workspace.Slug + name = v.Workspace.Name + } + displayName := name + if displayName == "" { + displayName = slug + } + result = append(result, Organization{Slug: slug, DisplayName: displayName}) + } + return result, nil +} + +// ListHerokuOrganizations fetches the teams the authenticated Heroku +// user belongs to. +func ListHerokuOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.heroku.com/teams", nil) + if err != nil { + return nil, fmt.Errorf("cannot create heroku organizations request: %w", err) + } + req.Header.Set("Accept", "application/vnd.heroku+json; version=3") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch heroku organizations: unexpected status %d", resp.StatusCode) + } + + var teams []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil { + return nil, fmt.Errorf("cannot decode heroku organizations response: %w", err) + } + + result := make([]Organization, len(teams)) + for i, t := range teams { + displayName := t.Name + if displayName == "" { + displayName = t.ID + } + result[i] = Organization{Slug: t.ID, DisplayName: displayName} + } + return result, nil +} + +// ListAsanaOrganizations fetches the workspaces the authenticated Asana +// user belongs to. +func ListAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://app.asana.com/api/1.0/workspaces?limit=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create asana organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch asana organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch asana organizations: unexpected status %d", resp.StatusCode) + } + + var body struct { + Data []struct { + GID string `json:"gid"` + Name string `json:"name"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode asana organizations response: %w", err) + } + + result := make([]Organization, len(body.Data)) + for i, w := range body.Data { + displayName := w.Name + if displayName == "" { + displayName = w.GID + } + result[i] = Organization{Slug: w.GID, DisplayName: displayName} + } + return result, nil +} + +// ListSnykOrganizations fetches the Snyk organizations the authenticated +// user belongs to. The Snyk REST API is JSON:API; the org id is the +// unique identifier surfaced as the slug. +func ListSnykOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.snyk.io/rest/orgs?version=2024-10-15&limit=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create snyk organizations request: %w", err) + } + req.Header.Set("Accept", "application/vnd.api+json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch snyk organizations: unexpected status %d", resp.StatusCode) + } + + var body struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode snyk organizations response: %w", err) + } + + result := make([]Organization, len(body.Data)) + for i, org := range body.Data { + displayName := org.Attributes.Name + if displayName == "" { + displayName = org.Attributes.Slug + } + if displayName == "" { + displayName = org.ID + } + result[i] = Organization{Slug: org.ID, DisplayName: displayName} + } + return result, nil +} + +// ListNetlifyOrganizations fetches the Netlify accounts the authenticated +// user belongs to. +func ListNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.netlify.com/api/v1/accounts", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create netlify organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch netlify organizations: unexpected status %d", resp.StatusCode) + } + + var accounts []struct { + Slug string `json:"slug"` + Name string `json:"name"` + Type string `json:"type"` + } + if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { + return nil, fmt.Errorf("cannot decode netlify organizations response: %w", err) + } + + result := make([]Organization, len(accounts)) + for i, a := range accounts { + displayName := a.Name + if displayName == "" { + displayName = a.Slug + } + result[i] = Organization{Slug: a.Slug, DisplayName: displayName} + } + return result, nil +} + +// ListClickUpOrganizations fetches the ClickUp teams (workspaces) the +// authenticated user belongs to. +func ListClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.clickup.com/api/v2/team", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create clickup organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch clickup organizations: unexpected status %d", resp.StatusCode) + } + + var body struct { + Teams []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"teams"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode clickup organizations response: %w", err) + } + + result := make([]Organization, len(body.Teams)) + for i, t := range body.Teams { + displayName := t.Name + if displayName == "" { + displayName = t.ID + } + result[i] = Organization{Slug: t.ID, DisplayName: displayName} + } + return result, nil +} diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index fb9297aa2..221e6f9db 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -97,160 +97,6 @@ func (c *Connector) SetSettings(v any) error { return nil } -// SlackSettings unmarshals the connector's RawSettings into SlackConnectorSettings. -func (c *Connector) SlackSettings() (SlackConnectorSettings, error) { - var s SlackConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// TallySettings unmarshals the connector's RawSettings into TallyConnectorSettings. -func (c *Connector) TallySettings() (TallyConnectorSettings, error) { - var s TallyConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// OnePasswordSettings unmarshals the connector's RawSettings into OnePasswordConnectorSettings. -func (c *Connector) OnePasswordSettings() (OnePasswordConnectorSettings, error) { - var s OnePasswordConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// SentrySettings unmarshals the connector's RawSettings into SentryConnectorSettings. -func (c *Connector) SentrySettings() (SentryConnectorSettings, error) { - var s SentryConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// SupabaseSettings unmarshals the connector's RawSettings into SupabaseConnectorSettings. -func (c *Connector) SupabaseSettings() (SupabaseConnectorSettings, error) { - var s SupabaseConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// GitHubSettings unmarshals the connector's RawSettings into GitHubConnectorSettings. -func (c *Connector) GitHubSettings() (GitHubConnectorSettings, error) { - var s GitHubConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// OnePasswordUsersAPISettings unmarshals the connector's RawSettings into OnePasswordUsersAPISettings. -func (c *Connector) OnePasswordUsersAPISettings() (OnePasswordUsersAPISettings, error) { - var s OnePasswordUsersAPISettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// GitLabSettings unmarshals the connector's RawSettings into GitLabConnectorSettings. -func (c *Connector) GitLabSettings() (GitLabConnectorSettings, error) { - var s GitLabConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// BitbucketSettings unmarshals the connector's RawSettings into BitbucketConnectorSettings. -func (c *Connector) BitbucketSettings() (BitbucketConnectorSettings, error) { - var s BitbucketConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// HerokuSettings unmarshals the connector's RawSettings into HerokuConnectorSettings. -func (c *Connector) HerokuSettings() (HerokuConnectorSettings, error) { - var s HerokuConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// PagerDutySettings unmarshals the connector's RawSettings into PagerDutyConnectorSettings. -func (c *Connector) PagerDutySettings() (PagerDutyConnectorSettings, error) { - var s PagerDutyConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// AsanaSettings unmarshals the connector's RawSettings into AsanaConnectorSettings. -func (c *Connector) AsanaSettings() (AsanaConnectorSettings, error) { - var s AsanaConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// SnykSettings unmarshals the connector's RawSettings into SnykConnectorSettings. -func (c *Connector) SnykSettings() (SnykConnectorSettings, error) { - var s SnykConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// NetlifySettings unmarshals the connector's RawSettings into NetlifyConnectorSettings. -func (c *Connector) NetlifySettings() (NetlifyConnectorSettings, error) { - var s NetlifyConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// ClickUpSettings unmarshals the connector's RawSettings into ClickUpConnectorSettings. -func (c *Connector) ClickUpSettings() (ClickUpConnectorSettings, error) { - var s ClickUpConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -// VercelSettings unmarshals the connector's RawSettings into VercelConnectorSettings. -func (c *Connector) VercelSettings() (VercelConnectorSettings, error) { - var s VercelConnectorSettings - if err := c.unmarshalSettings(&s); err != nil { - return s, err - } - return s, nil -} - -func (c *Connector) unmarshalSettings(v any) error { - if len(c.RawSettings) == 0 || string(c.RawSettings) == "null" { - return nil - } - if err := json.Unmarshal(c.RawSettings, v); err != nil { - return fmt.Errorf("cannot unmarshal connector settings: %w", err) - } - return nil -} - // ConnectorSettings unmarshals the connector's RawSettings into the // requested settings struct. Empty or null RawSettings yields the zero // value with no error. Use as: @@ -258,8 +104,11 @@ func (c *Connector) unmarshalSettings(v any) error { // settings, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](dbConnector) func ConnectorSettings[T any](c *Connector) (T, error) { var s T - if err := c.unmarshalSettings(&s); err != nil { - return s, err + if len(c.RawSettings) == 0 || string(c.RawSettings) == "null" { + return s, nil + } + if err := json.Unmarshal(c.RawSettings, &s); err != nil { + return s, fmt.Errorf("cannot unmarshal connector settings: %w", err) } return s, nil } diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 88e6a30f3..cd3f12d2f 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -398,76 +398,29 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t 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 - case coredata.ConnectorProviderGitLab: - orgs, err := fetchGitLabOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderBitbucket: - orgs, err := fetchBitbucketOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderHeroku: - orgs, err := fetchHerokuOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderAsana: - orgs, err := fetchAsanaOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch asana organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderPagerDuty: - // PagerDuty uses Pattern 2-auto: the subdomain is captured during - // the OAuth callback, no picker UI is required. - return []*types.ProviderOrganization{}, nil - case coredata.ConnectorProviderSnyk: - orgs, err := fetchSnykOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderNetlify: - orgs, err := fetchNetlifyOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderClickUp: - orgs, err := fetchClickUpOrganizations(ctx, httpClient) - if err != nil { - return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err) - } - return orgs, nil - case coredata.ConnectorProviderVercel: - // Vercel uses Pattern 2-auto: the team_id is captured during the - // OAuth callback (or synthesized from /v2/user for personal - // accounts), no picker UI is required. - return []*types.ProviderOrganization{}, nil - default: + cfg, ok := providerOrgConfigs[dbConnector.Provider] + if !ok || cfg.ListOrgs == nil { return []*types.ProviderOrganization{}, nil } + + orgs, err := cfg.ListOrgs(ctx, httpClient) + if err != nil { + return nil, err + } + + result := make([]*types.ProviderOrganization, len(orgs)) + for i, o := range orgs { + result[i] = &types.ProviderOrganization{Slug: o.Slug, DisplayName: o.DisplayName} + } + return result, nil } // NeedsConfiguration is the resolver for the needsConfiguration field. +// +// True when the provider has a picker UI (NeedsPicker) AND the user has +// not yet picked an org. 2-auto providers (PagerDuty, Vercel) always +// return false: the identifier is captured during the OAuth callback, +// not via a follow-up configure mutation. func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessSource) (bool, error) { if err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet); err != nil { return false, err @@ -487,48 +440,11 @@ func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *type 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 - case coredata.ConnectorProviderGitLab: - settings, _ := dbConnector.GitLabSettings() - return settings.GroupID == "", nil - case coredata.ConnectorProviderBitbucket: - settings, _ := dbConnector.BitbucketSettings() - return settings.Workspace == "", nil - case coredata.ConnectorProviderHeroku: - settings, _ := dbConnector.HerokuSettings() - return settings.TeamID == "", nil - case coredata.ConnectorProviderAsana: - settings, _ := dbConnector.AsanaSettings() - return settings.WorkspaceGID == "", nil - case coredata.ConnectorProviderPagerDuty: - // 2-auto: subdomain is set during the OAuth callback. If for any - // reason it is missing, we still return false so the configure - // mutation does not surface — there is no manual picker. - return false, nil - case coredata.ConnectorProviderSnyk: - settings, _ := dbConnector.SnykSettings() - return settings.OrgID == "", nil - case coredata.ConnectorProviderNetlify: - settings, _ := dbConnector.NetlifySettings() - return settings.AccountSlug == "", nil - case coredata.ConnectorProviderClickUp: - settings, _ := dbConnector.ClickUpSettings() - return settings.TeamID == "", nil - case coredata.ConnectorProviderVercel: - // 2-auto: team_id is set during the OAuth callback (or synthesized - // from /v2/user for personal accounts). If for any reason it is - // missing, return false so the configure mutation does not surface - // — there is no manual picker. - return false, nil - default: + cfg, ok := providerOrgConfigs[dbConnector.Provider] + if !ok || !cfg.NeedsPicker { return false, nil } + return cfg.SelectedSlug(dbConnector) == "", nil } // ConnectionStatus is the resolver for the connectionStatus field. @@ -582,65 +498,15 @@ func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *ty 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 - } - case coredata.ConnectorProviderGitLab: - settings, _ := dbConnector.GitLabSettings() - if settings.GroupID != "" { - return &settings.GroupID, nil - } - case coredata.ConnectorProviderBitbucket: - settings, _ := dbConnector.BitbucketSettings() - if settings.Workspace != "" { - return &settings.Workspace, nil - } - case coredata.ConnectorProviderHeroku: - settings, _ := dbConnector.HerokuSettings() - if settings.TeamID != "" { - return &settings.TeamID, nil - } - case coredata.ConnectorProviderPagerDuty: - settings, _ := dbConnector.PagerDutySettings() - if settings.Subdomain != "" { - return &settings.Subdomain, nil - } - case coredata.ConnectorProviderAsana: - settings, _ := dbConnector.AsanaSettings() - if settings.WorkspaceGID != "" { - return &settings.WorkspaceGID, nil - } - case coredata.ConnectorProviderSnyk: - settings, _ := dbConnector.SnykSettings() - if settings.OrgID != "" { - return &settings.OrgID, nil - } - case coredata.ConnectorProviderNetlify: - settings, _ := dbConnector.NetlifySettings() - if settings.AccountSlug != "" { - return &settings.AccountSlug, nil - } - case coredata.ConnectorProviderClickUp: - settings, _ := dbConnector.ClickUpSettings() - if settings.TeamID != "" { - return &settings.TeamID, nil - } - case coredata.ConnectorProviderVercel: - settings, _ := dbConnector.VercelSettings() - if settings.TeamID != "" { - return &settings.TeamID, nil - } + cfg, ok := providerOrgConfigs[dbConnector.Provider] + if !ok { + return nil, nil } - - return nil, nil + slug := cfg.SelectedSlug(dbConnector) + if slug == "" { + return nil, nil + } + return &slug, nil } // Permission is the resolver for the permission field. diff --git a/pkg/server/api/console/v1/access_source_provider_config.go b/pkg/server/api/console/v1/access_source_provider_config.go new file mode 100644 index 000000000..31065108f --- /dev/null +++ b/pkg/server/api/console/v1/access_source_provider_config.go @@ -0,0 +1,134 @@ +// 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" + "net/http" + + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +// providerOrgConfig binds a connector provider to its picker-UI behavior. +// +// ListOrgs returns the orgs/workspaces/teams the authenticated user can +// scope the connector to (nil for Pattern 2-auto providers like +// PagerDuty and Vercel where the value is captured during OAuth). +// +// SelectedSlug returns the currently-configured org identifier for the +// connector (empty string if none). +// +// NeedsPicker reports whether the picker mutation should surface in the +// UI; false for 2-auto providers. +type providerOrgConfig struct { + ListOrgs func(ctx context.Context, httpClient *http.Client) ([]drivers.Organization, error) + SelectedSlug func(c *coredata.Connector) string + NeedsPicker bool +} + +// providerOrgConfigs is the single source of truth that the three +// AccessSource picker resolvers (ProviderOrganizations, +// SelectedOrganization, NeedsConfiguration) dispatch through. Adding a +// provider takes one entry here, not three switch arms. +var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{ + coredata.ConnectorProviderGitHub: { + ListOrgs: drivers.ListGitHubOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](c) + return s.Organization + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderSentry: { + ListOrgs: drivers.ListSentryOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.SentryConnectorSettings](c) + return s.OrganizationSlug + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderGitLab: { + ListOrgs: drivers.ListGitLabOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](c) + return s.GroupID + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderBitbucket: { + ListOrgs: drivers.ListBitbucketOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](c) + return s.Workspace + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderHeroku: { + ListOrgs: drivers.ListHerokuOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](c) + return s.TeamID + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderAsana: { + ListOrgs: drivers.ListAsanaOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](c) + return s.WorkspaceGID + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderSnyk: { + ListOrgs: drivers.ListSnykOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.SnykConnectorSettings](c) + return s.OrgID + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderNetlify: { + ListOrgs: drivers.ListNetlifyOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](c) + return s.AccountSlug + }, + NeedsPicker: true, + }, + coredata.ConnectorProviderClickUp: { + ListOrgs: drivers.ListClickUpOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](c) + return s.TeamID + }, + NeedsPicker: true, + }, + // Pattern 2-auto: identifier is captured during the OAuth callback + // (subdomain for PagerDuty, team_id or fallback /v2/user.id for + // Vercel). No picker UI; NeedsPicker = false. + coredata.ConnectorProviderPagerDuty: { + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.PagerDutyConnectorSettings](c) + return s.Subdomain + }, + }, + coredata.ConnectorProviderVercel: { + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.VercelConnectorSettings](c) + return s.TeamID + }, + }, +} diff --git a/pkg/server/api/console/v1/provider_organizations.go b/pkg/server/api/console/v1/provider_organizations.go index 572bc0da4..04e6b562a 100644 --- a/pkg/server/api/console/v1/provider_organizations.go +++ b/pkg/server/api/console/v1/provider_organizations.go @@ -16,444 +16,14 @@ package console_v1 import ( "context" - "encoding/json" "fmt" "io" "net/http" - "strconv" - - "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 -} - -// fetchGitLabOrganizations fetches the list of groups the authenticated -// GitLab user owns. Group IDs are numeric int64 values; we surface them -// as strings so they fit the ProviderOrganization.Slug shape. -func fetchGitLabOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - "https://gitlab.com/api/v4/groups?min_access_level=50&per_page=100", - nil, - ) - if err != nil { - return nil, fmt.Errorf("cannot create gitlab organizations request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch gitlab organizations: status %d", resp.StatusCode) - } - - var groups []struct { - ID int64 `json:"id"` - Name string `json:"name"` - FullPath string `json:"full_path"` - } - if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil { - return nil, fmt.Errorf("cannot decode gitlab organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(groups)) - for i, g := range groups { - displayName := g.Name - if displayName == "" { - displayName = g.FullPath - } - result[i] = &types.ProviderOrganization{ - Slug: strconv.FormatInt(g.ID, 10), - DisplayName: displayName, - } - } - - return result, nil -} - -// fetchBitbucketOrganizations fetches the list of workspaces the -// authenticated Bitbucket user belongs to. -func fetchBitbucketOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - "https://api.bitbucket.org/2.0/workspaces?role=member&pagelen=100", - nil, - ) - if err != nil { - return nil, fmt.Errorf("cannot create bitbucket organizations request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch bitbucket organizations: status %d", resp.StatusCode) - } - - var body struct { - Values []struct { - Slug string `json:"slug"` - Name string `json:"name"` - } `json:"values"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("cannot decode bitbucket organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(body.Values)) - for i, w := range body.Values { - displayName := w.Name - if displayName == "" { - displayName = w.Slug - } - result[i] = &types.ProviderOrganization{ - Slug: w.Slug, - DisplayName: displayName, - } - } - - return result, nil -} - -// fetchHerokuOrganizations fetches the list of teams the authenticated -// Heroku user belongs to. -func fetchHerokuOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.heroku.com/teams", nil) - if err != nil { - return nil, fmt.Errorf("cannot create heroku organizations request: %w", err) - } - req.Header.Set("Accept", "application/vnd.heroku+json; version=3") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch heroku organizations: status %d", resp.StatusCode) - } - - var teams []struct { - ID string `json:"id"` - Name string `json:"name"` - } - if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil { - return nil, fmt.Errorf("cannot decode heroku organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(teams)) - for i, t := range teams { - displayName := t.Name - if displayName == "" { - displayName = t.ID - } - result[i] = &types.ProviderOrganization{ - Slug: t.ID, - DisplayName: displayName, - } - } - - return result, nil -} - -// fetchAsanaOrganizations fetches the list of workspaces the -// authenticated Asana user belongs to. -func fetchAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - "https://app.asana.com/api/1.0/workspaces?limit=100", - nil, - ) - if err != nil { - return nil, fmt.Errorf("cannot create asana organizations request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch asana organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch asana organizations: status %d", resp.StatusCode) - } - - var body struct { - Data []struct { - GID string `json:"gid"` - Name string `json:"name"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("cannot decode asana organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(body.Data)) - for i, w := range body.Data { - displayName := w.Name - if displayName == "" { - displayName = w.GID - } - result[i] = &types.ProviderOrganization{ - Slug: w.GID, - DisplayName: displayName, - } - } - - return result, nil -} - -// fetchSnykOrganizations fetches the list of Snyk organizations the -// authenticated user belongs to. The Snyk REST API is JSON:API style; -// the org id is the unique identifier surfaced as the slug. -func fetchSnykOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - "https://api.snyk.io/rest/orgs?version=2024-10-15&limit=100", - nil, - ) - if err != nil { - return nil, fmt.Errorf("cannot create snyk organizations request: %w", err) - } - req.Header.Set("Accept", "application/vnd.api+json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch snyk organizations: status %d", resp.StatusCode) - } - - var body struct { - Data []struct { - ID string `json:"id"` - Attributes struct { - Slug string `json:"slug"` - Name string `json:"name"` - } `json:"attributes"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("cannot decode snyk organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(body.Data)) - for i, org := range body.Data { - displayName := org.Attributes.Name - if displayName == "" { - displayName = org.Attributes.Slug - } - if displayName == "" { - displayName = org.ID - } - result[i] = &types.ProviderOrganization{ - Slug: org.ID, - DisplayName: displayName, - } - } - - return result, nil -} - -// fetchNetlifyOrganizations fetches the list of Netlify accounts the -// authenticated user belongs to. -func fetchNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - "https://api.netlify.com/api/v1/accounts", - nil, - ) - if err != nil { - return nil, fmt.Errorf("cannot create netlify organizations request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch netlify organizations: status %d", resp.StatusCode) - } - - var accounts []struct { - Slug string `json:"slug"` - Name string `json:"name"` - Type string `json:"type"` - } - if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { - return nil, fmt.Errorf("cannot decode netlify organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(accounts)) - for i, a := range accounts { - displayName := a.Name - if displayName == "" { - displayName = a.Slug - } - result[i] = &types.ProviderOrganization{ - Slug: a.Slug, - DisplayName: displayName, - } - } - - return result, nil -} - -// fetchClickUpOrganizations fetches the list of ClickUp teams (workspaces) -// the authenticated user belongs to. -func fetchClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - "https://api.clickup.com/api/v2/team", - nil, - ) - if err != nil { - return nil, fmt.Errorf("cannot create clickup organizations request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cannot fetch clickup organizations: status %d", resp.StatusCode) - } - - var body struct { - Teams []struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"teams"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return nil, fmt.Errorf("cannot decode clickup organizations response: %w", err) - } - - result := make([]*types.ProviderOrganization, len(body.Teams)) - for i, t := range body.Teams { - displayName := t.Name - if displayName == "" { - displayName = t.ID - } - result[i] = &types.ProviderOrganization{ - Slug: t.ID, - 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. +// 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