From 8a3ef72dd20f1d49035a1641e646648cb23f0109 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:06:24 -0500 Subject: [PATCH 01/13] Refactored credentials.go for deploy access status and added deploy access display to cre whoami command --- cmd/whoami/whoami.go | 16 +++++++++ internal/credentials/credentials.go | 55 ++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 6719baf8..b26ebb2f 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -82,6 +82,12 @@ func (h *Handler) Execute(ctx context.Context) error { return fmt.Errorf("graphql request failed: %w", err) } + // Get deployment access status + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + h.log.Debug().Err(err).Msg("failed to get deployment access status") + } + fmt.Println("") fmt.Println("Account details retrieved:") fmt.Println("") @@ -90,6 +96,16 @@ func (h *Handler) Execute(ctx context.Context) error { } fmt.Printf("\tOrganization ID: %s\n", respEnvelope.GetOrganization.OrganizationID) fmt.Printf("\tOrganization Name: %s\n", respEnvelope.GetOrganization.DisplayName) + + // Display deployment access status + if deployAccess != nil { + if deployAccess.HasAccess { + fmt.Printf("\tDeploy Access: Enabled\n") + } else { + fmt.Printf("\tDeploy Access: Not enabled (run 'cre account access' to request)\n") + } + } + fmt.Println("") return nil diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 6b53867b..9b7339b2 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -35,8 +35,17 @@ const ( AuthTypeBearer = "bearer" ConfigDir = ".cre" ConfigFile = "cre.yaml" + + // DeploymentAccessStatusFullAccess indicates the organization has full deployment access + DeploymentAccessStatusFullAccess = "FULL_ACCESS" ) +// DeploymentAccess holds information about an organization's deployment access status +type DeploymentAccess struct { + HasAccess bool // Whether the organization has deployment access + Status string // The raw status value (e.g., "FULL_ACCESS", "PENDING", etc.) +} + // UngatedOrgRequiredMsg is the error message shown when an organization does not have ungated access. var UngatedOrgRequiredMsg = "\n✖ Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Request access here: https://cre.chain.link/request-access\n" @@ -96,36 +105,38 @@ func SaveCredentials(tokenSet *CreLoginTokenSet) error { return nil } -// CheckIsUngatedOrganization verifies that the organization associated with the credentials -// has FULL_ACCESS status (is not gated). This check is required for certain operations like -// workflow key linking. -func (c *Credentials) CheckIsUngatedOrganization() error { - // API keys can only be generated on ungated organizations, so they always pass +// GetDeploymentAccessStatus returns the deployment access status for the organization. +// This can be used to check and display whether the user has deployment access. +func (c *Credentials) GetDeploymentAccessStatus() (*DeploymentAccess, error) { + // API keys can only be generated on ungated organizations, so they always have access if c.AuthType == AuthTypeApiKey { - return nil + return &DeploymentAccess{ + HasAccess: true, + Status: DeploymentAccessStatusFullAccess, + }, nil } // For JWT bearer tokens, we need to parse the token and check the organization_status claim if c.Tokens == nil || c.Tokens.AccessToken == "" { - return fmt.Errorf("no access token available") + return nil, fmt.Errorf("no access token available") } // Parse the JWT to extract claims parts := strings.Split(c.Tokens.AccessToken, ".") if len(parts) < 2 { - return fmt.Errorf("invalid JWT token format") + return nil, fmt.Errorf("invalid JWT token format") } // Decode the payload (second part of the JWT) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return fmt.Errorf("failed to decode JWT payload: %w", err) + return nil, fmt.Errorf("failed to decode JWT payload: %w", err) } // Parse claims into a map var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { - return fmt.Errorf("failed to unmarshal JWT claims: %w", err) + return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err) } // Log all claims for debugging @@ -146,17 +157,27 @@ func (c *Credentials) CheckIsUngatedOrganization() error { c.log.Debug().Str("claim_key", orgStatusKey).Str("organization_status", orgStatus).Msg("checking organization status claim") - if orgStatus == "" { - // If the claim is missing or empty, the organization is considered gated - return errors.New(UngatedOrgRequiredMsg) + hasAccess := orgStatus == DeploymentAccessStatusFullAccess + c.log.Debug().Str("organization_status", orgStatus).Bool("has_access", hasAccess).Msg("deployment access status retrieved") + + return &DeploymentAccess{ + HasAccess: hasAccess, + Status: orgStatus, + }, nil +} + +// CheckIsUngatedOrganization verifies that the organization associated with the credentials +// has FULL_ACCESS status (is not gated). This check is required for certain operations like +// workflow key linking. +func (c *Credentials) CheckIsUngatedOrganization() error { + access, err := c.GetDeploymentAccessStatus() + if err != nil { + return err } - // Check if the organization has full access - if orgStatus != "FULL_ACCESS" { - c.log.Debug().Str("organization_status", orgStatus).Msg("organization does not have FULL_ACCESS - organization is gated") + if !access.HasAccess { return errors.New(UngatedOrgRequiredMsg) } - c.log.Debug().Str("organization_status", orgStatus).Msg("organization has FULL_ACCESS - organization is ungated") return nil } From f54545302e8f435c3c164c3f2a280b841f076d74 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:09:36 -0500 Subject: [PATCH 02/13] Updated gated message with the command to request access --- internal/credentials/credentials.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 9b7339b2..1dd6dc59 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -47,7 +47,7 @@ type DeploymentAccess struct { } // UngatedOrgRequiredMsg is the error message shown when an organization does not have ungated access. -var UngatedOrgRequiredMsg = "\n✖ Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Request access here: https://cre.chain.link/request-access\n" +var UngatedOrgRequiredMsg = "\n✖ Workflow deployment is currently in early access. We're onboarding organizations gradually.\n\nWant to deploy?\n→ Run 'cre account access' to request access\n" func New(logger *zerolog.Logger) (*Credentials, error) { cfg := &Credentials{ From b962d62e1cce067f5c914e41d64b98829b18b653 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:21:02 -0500 Subject: [PATCH 03/13] added new account access command --- cmd/account/access/access.go | 72 ++++++++++++++++++++++++++++++++++++ cmd/account/account.go | 2 + 2 files changed, 74 insertions(+) create mode 100644 cmd/account/access/access.go diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go new file mode 100644 index 00000000..733a1cbd --- /dev/null +++ b/cmd/account/access/access.go @@ -0,0 +1,72 @@ +package access + +import ( + "context" + "fmt" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func New(runtimeCtx *runtime.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "access", + Short: "Check or request deployment access", + Long: "Check your deployment access status or request access to deploy workflows.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + h := NewHandler(runtimeCtx) + return h.Execute(cmd.Context()) + }, + } + + return cmd +} + +type Handler struct { + log *zerolog.Logger + credentials *credentials.Credentials +} + +func NewHandler(ctx *runtime.Context) *Handler { + return &Handler{ + log: ctx.Logger, + credentials: ctx.Credentials, + } +} + +func (h *Handler) Execute(ctx context.Context) error { + // Get deployment access status + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return fmt.Errorf("failed to check deployment access: %w", err) + } + + if deployAccess.HasAccess { + fmt.Println("") + fmt.Println("You have deployment access enabled for your organization.") + fmt.Println("") + fmt.Println("You're all set to deploy workflows. Get started with:") + fmt.Println("") + fmt.Println(" cre workflow deploy") + fmt.Println("") + fmt.Println("For more information, run 'cre workflow deploy --help'") + fmt.Println("") + return nil + } + + // User doesn't have access - submit request to Zendesk + // TODO: Implement Zendesk request submission + fmt.Println("") + fmt.Println("Deployment access is not enabled for your organization.") + fmt.Println("") + fmt.Println("Submitting access request...") + fmt.Println("") + + // TODO: Call Zendesk API here + + return nil +} diff --git a/cmd/account/account.go b/cmd/account/account.go index d69ec3a9..27deaee5 100644 --- a/cmd/account/account.go +++ b/cmd/account/account.go @@ -3,6 +3,7 @@ package account import ( "github.com/spf13/cobra" + "github.com/smartcontractkit/cre-cli/cmd/account/access" "github.com/smartcontractkit/cre-cli/cmd/account/link_key" "github.com/smartcontractkit/cre-cli/cmd/account/list_key" "github.com/smartcontractkit/cre-cli/cmd/account/unlink_key" @@ -16,6 +17,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { Long: "Manage your linked public key addresses for workflow operations.", } + accountCmd.AddCommand(access.New(runtimeContext)) accountCmd.AddCommand(link_key.New(runtimeContext)) accountCmd.AddCommand(unlink_key.New(runtimeContext)) accountCmd.AddCommand(list_key.New(runtimeContext)) From 45f6f7b37f2fa74e9223bdc09be8fa1bccbea03d Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:23:22 -0500 Subject: [PATCH 04/13] added account access command to settings exclusion --- cmd/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/root.go b/cmd/root.go index 51af5fb8..86429339 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,6 +261,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre login": {}, "cre logout": {}, "cre whoami": {}, + "cre account access": {}, "cre account list-key": {}, "cre init": {}, "cre generate-bindings": {}, From c62fde9e0a1ee0cdc8d480fb27f5f228a2ff7a0f Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:28:22 -0500 Subject: [PATCH 05/13] access command logic to submit form to zendesk --- cmd/account/access/access.go | 154 +++++++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 733a1cbd..6fc1061c 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,16 +1,36 @@ package access import ( + "bytes" "context" + "encoding/base64" + "encoding/json" "fmt" + "net/http" + "os" + "github.com/machinebox/graphql" "github.com/rs/zerolog" "github.com/spf13/cobra" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" ) +const ( + // Environment variables for Zendesk credentials + EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" + EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" + + // Zendesk configuration + zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" + zendeskBrandID = "41986419936660" + zendeskRequestTypeField = "41987045113748" + zendeskRequestTypeValue = "cre_customer_deploy_access_request" +) + func New(runtimeCtx *runtime.Context) *cobra.Command { cmd := &cobra.Command{ Use: "access", @@ -27,17 +47,25 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { } type Handler struct { - log *zerolog.Logger - credentials *credentials.Credentials + log *zerolog.Logger + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet } func NewHandler(ctx *runtime.Context) *Handler { return &Handler{ - log: ctx.Logger, - credentials: ctx.Credentials, + log: ctx.Logger, + credentials: ctx.Credentials, + environmentSet: ctx.EnvironmentSet, } } +type userInfo struct { + Email string + Name string + OrganizationID string +} + func (h *Handler) Execute(ctx context.Context) error { // Get deployment access status deployAccess, err := h.credentials.GetDeploymentAccessStatus() @@ -59,14 +87,126 @@ func (h *Handler) Execute(ctx context.Context) error { } // User doesn't have access - submit request to Zendesk - // TODO: Implement Zendesk request submission fmt.Println("") - fmt.Println("Deployment access is not enabled for your organization.") + fmt.Println("Deployment access is not yet enabled for your organization.") fmt.Println("") + + // Fetch user info for the request + user, err := h.fetchUserInfo(ctx) + if err != nil { + return fmt.Errorf("failed to fetch user info: %w", err) + } + fmt.Println("Submitting access request...") + + if err := h.submitAccessRequest(user); err != nil { + return fmt.Errorf("failed to submit access request: %w", err) + } + + fmt.Println("") + fmt.Println("Access request submitted successfully!") + fmt.Println("") + fmt.Println("Our team will review your request and get back to you shortly.") + fmt.Println("You'll receive a confirmation email at: " + user.Email) fmt.Println("") - // TODO: Call Zendesk API here + return nil +} + +func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { + query := ` + query GetAccountDetails { + getAccountDetails { + emailAddress + firstName + lastName + } + getOrganization { + organizationId + } + }` + + client := graphqlclient.New(h.credentials, h.environmentSet, h.log) + req := graphql.NewRequest(query) + + var resp struct { + GetAccountDetails struct { + EmailAddress string `json:"emailAddress"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } `json:"getAccountDetails"` + GetOrganization struct { + OrganizationID string `json:"organizationId"` + } `json:"getOrganization"` + } + + if err := client.Execute(ctx, req, &resp); err != nil { + return nil, fmt.Errorf("graphql request failed: %w", err) + } + + name := resp.GetAccountDetails.FirstName + if resp.GetAccountDetails.LastName != "" { + name += " " + resp.GetAccountDetails.LastName + } + + return &userInfo{ + Email: resp.GetAccountDetails.EmailAddress, + Name: name, + OrganizationID: resp.GetOrganization.OrganizationID, + }, nil +} + +func (h *Handler) submitAccessRequest(user *userInfo) error { + username := os.Getenv(EnvVarZendeskUsername) + password := os.Getenv(EnvVarZendeskPassword) + + if username == "" || password == "" { + return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) + } + + ticket := map[string]interface{}{ + "ticket": map[string]interface{}{ + "subject": "CRE Deployment Access Request", + "comment": map[string]interface{}{ + "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), + }, + "brand_id": zendeskBrandID, + "custom_fields": []map[string]interface{}{ + { + "id": zendeskRequestTypeField, + "value": zendeskRequestTypeValue, + }, + }, + "requester": map[string]interface{}{ + "name": user.Name, + "email": user.Email, + }, + }, + } + + body, err := json.Marshal(ticket) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + credentials := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+credentials) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) + } return nil } From c05e1877533e6861cb3e98ab39608e90285136dc Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 19:44:03 -0500 Subject: [PATCH 06/13] added prompt to request access when running cre account access cmd --- cmd/account/access/access.go | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 6fc1061c..3345752f 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "os" @@ -16,6 +17,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" ) @@ -38,7 +40,7 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { Long: "Check your deployment access status or request access to deploy workflows.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - h := NewHandler(runtimeCtx) + h := NewHandler(runtimeCtx, cmd.InOrStdin()) return h.Execute(cmd.Context()) }, } @@ -50,13 +52,15 @@ type Handler struct { log *zerolog.Logger credentials *credentials.Credentials environmentSet *environments.EnvironmentSet + stdin io.Reader } -func NewHandler(ctx *runtime.Context) *Handler { +func NewHandler(ctx *runtime.Context, stdin io.Reader) *Handler { return &Handler{ log: ctx.Logger, credentials: ctx.Credentials, environmentSet: ctx.EnvironmentSet, + stdin: stdin, } } @@ -86,17 +90,30 @@ func (h *Handler) Execute(ctx context.Context) error { return nil } - // User doesn't have access - submit request to Zendesk + // User doesn't have access - prompt to submit request fmt.Println("") fmt.Println("Deployment access is not yet enabled for your organization.") fmt.Println("") + // Ask user if they want to request access + shouldRequest, err := prompt.YesNoPrompt(h.stdin, "Request deployment access?") + if err != nil { + return fmt.Errorf("failed to get user confirmation: %w", err) + } + + if !shouldRequest { + fmt.Println("") + fmt.Println("Access request canceled.") + return nil + } + // Fetch user info for the request user, err := h.fetchUserInfo(ctx) if err != nil { return fmt.Errorf("failed to fetch user info: %w", err) } + fmt.Println("") fmt.Println("Submitting access request...") if err := h.submitAccessRequest(user); err != nil { @@ -118,8 +135,6 @@ func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { query GetAccountDetails { getAccountDetails { emailAddress - firstName - lastName } getOrganization { organizationId @@ -132,8 +147,6 @@ func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { var resp struct { GetAccountDetails struct { EmailAddress string `json:"emailAddress"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` } `json:"getAccountDetails"` GetOrganization struct { OrganizationID string `json:"organizationId"` @@ -144,10 +157,8 @@ func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { return nil, fmt.Errorf("graphql request failed: %w", err) } - name := resp.GetAccountDetails.FirstName - if resp.GetAccountDetails.LastName != "" { - name += " " + resp.GetAccountDetails.LastName - } + // Use email as name since firstName/lastName are not available in the schema + name := resp.GetAccountDetails.EmailAddress return &userInfo{ Email: resp.GetAccountDetails.EmailAddress, From 876535d7eca4b58f8a69cf55a49fac2463466b4e Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:13:51 -0500 Subject: [PATCH 07/13] Refactor access request logic into shared package and add deploy access check to workflow deploy command --- cmd/account/access/access.go | 166 +--------------------- cmd/root.go | 2 +- cmd/workflow/deploy/deploy.go | 17 ++- internal/accessrequest/accessrequest.go | 179 ++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 164 deletions(-) create mode 100644 internal/accessrequest/accessrequest.go diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index 3345752f..e74e3fe5 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,38 +1,18 @@ package access import ( - "bytes" "context" - "encoding/base64" - "encoding/json" "fmt" - "io" - "net/http" - "os" - "github.com/machinebox/graphql" "github.com/rs/zerolog" "github.com/spf13/cobra" - "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" - "github.com/smartcontractkit/cre-cli/internal/prompt" "github.com/smartcontractkit/cre-cli/internal/runtime" ) -const ( - // Environment variables for Zendesk credentials - EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" - EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" - - // Zendesk configuration - zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" - zendeskBrandID = "41986419936660" - zendeskRequestTypeField = "41987045113748" - zendeskRequestTypeValue = "cre_customer_deploy_access_request" -) - func New(runtimeCtx *runtime.Context) *cobra.Command { cmd := &cobra.Command{ Use: "access", @@ -52,26 +32,19 @@ type Handler struct { log *zerolog.Logger credentials *credentials.Credentials environmentSet *environments.EnvironmentSet - stdin io.Reader + requester *accessrequest.Requester } -func NewHandler(ctx *runtime.Context, stdin io.Reader) *Handler { +func NewHandler(ctx *runtime.Context, stdin interface{ Read([]byte) (int, error) }) *Handler { return &Handler{ log: ctx.Logger, credentials: ctx.Credentials, environmentSet: ctx.EnvironmentSet, - stdin: stdin, + requester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), } } -type userInfo struct { - Email string - Name string - OrganizationID string -} - func (h *Handler) Execute(ctx context.Context) error { - // Get deployment access status deployAccess, err := h.credentials.GetDeploymentAccessStatus() if err != nil { return fmt.Errorf("failed to check deployment access: %w", err) @@ -90,134 +63,5 @@ func (h *Handler) Execute(ctx context.Context) error { return nil } - // User doesn't have access - prompt to submit request - fmt.Println("") - fmt.Println("Deployment access is not yet enabled for your organization.") - fmt.Println("") - - // Ask user if they want to request access - shouldRequest, err := prompt.YesNoPrompt(h.stdin, "Request deployment access?") - if err != nil { - return fmt.Errorf("failed to get user confirmation: %w", err) - } - - if !shouldRequest { - fmt.Println("") - fmt.Println("Access request canceled.") - return nil - } - - // Fetch user info for the request - user, err := h.fetchUserInfo(ctx) - if err != nil { - return fmt.Errorf("failed to fetch user info: %w", err) - } - - fmt.Println("") - fmt.Println("Submitting access request...") - - if err := h.submitAccessRequest(user); err != nil { - return fmt.Errorf("failed to submit access request: %w", err) - } - - fmt.Println("") - fmt.Println("Access request submitted successfully!") - fmt.Println("") - fmt.Println("Our team will review your request and get back to you shortly.") - fmt.Println("You'll receive a confirmation email at: " + user.Email) - fmt.Println("") - - return nil -} - -func (h *Handler) fetchUserInfo(ctx context.Context) (*userInfo, error) { - query := ` - query GetAccountDetails { - getAccountDetails { - emailAddress - } - getOrganization { - organizationId - } - }` - - client := graphqlclient.New(h.credentials, h.environmentSet, h.log) - req := graphql.NewRequest(query) - - var resp struct { - GetAccountDetails struct { - EmailAddress string `json:"emailAddress"` - } `json:"getAccountDetails"` - GetOrganization struct { - OrganizationID string `json:"organizationId"` - } `json:"getOrganization"` - } - - if err := client.Execute(ctx, req, &resp); err != nil { - return nil, fmt.Errorf("graphql request failed: %w", err) - } - - // Use email as name since firstName/lastName are not available in the schema - name := resp.GetAccountDetails.EmailAddress - - return &userInfo{ - Email: resp.GetAccountDetails.EmailAddress, - Name: name, - OrganizationID: resp.GetOrganization.OrganizationID, - }, nil -} - -func (h *Handler) submitAccessRequest(user *userInfo) error { - username := os.Getenv(EnvVarZendeskUsername) - password := os.Getenv(EnvVarZendeskPassword) - - if username == "" || password == "" { - return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) - } - - ticket := map[string]interface{}{ - "ticket": map[string]interface{}{ - "subject": "CRE Deployment Access Request", - "comment": map[string]interface{}{ - "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), - }, - "brand_id": zendeskBrandID, - "custom_fields": []map[string]interface{}{ - { - "id": zendeskRequestTypeField, - "value": zendeskRequestTypeValue, - }, - }, - "requester": map[string]interface{}{ - "name": user.Name, - "email": user.Email, - }, - }, - } - - body, err := json.Marshal(ticket) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - credentials := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Basic "+credentials) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) - } - - return nil + return h.requester.PromptAndSubmitRequest(ctx) } diff --git a/cmd/root.go b/cmd/root.go index 86429339..87da047d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -126,7 +126,7 @@ func newRootCommand() *cobra.Command { // Check if organization is ungated for commands that require it cmdPath := cmd.CommandPath() - if cmdPath == "cre account link-key" || cmdPath == "cre workflow deploy" { + if cmdPath == "cre account link-key" { if err := runtimeContext.Credentials.CheckIsUngatedOrganization(); err != nil { return err } diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 2839ae68..514f9bde 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -1,6 +1,7 @@ package deploy import ( + "context" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "github.com/spf13/viper" "github.com/smartcontractkit/cre-cli/cmd/client" + "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" @@ -62,6 +64,7 @@ type handler struct { workflowArtifact *workflowArtifact wrc *client.WorkflowRegistryV2Client runtimeContext *runtime.Context + accessRequester *accessrequest.Requester validated bool @@ -94,7 +97,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { if err := h.ValidateInputs(); err != nil { return err } - return h.Execute() + return h.Execute(cmd.Context()) }, } @@ -118,6 +121,7 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, @@ -177,7 +181,16 @@ func (h *handler) ValidateInputs() error { return nil } -func (h *handler) Execute() error { +func (h *handler) Execute(ctx context.Context) error { + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return fmt.Errorf("failed to check deployment access: %w", err) + } + + if !deployAccess.HasAccess { + return h.accessRequester.PromptAndSubmitRequest(ctx) + } + h.displayWorkflowDetails() if err := h.Compile(); err != nil { diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go new file mode 100644 index 00000000..b8ff2576 --- /dev/null +++ b/internal/accessrequest/accessrequest.go @@ -0,0 +1,179 @@ +package accessrequest + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/machinebox/graphql" + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/prompt" +) + +const ( + EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" + EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" + + zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" + zendeskBrandID = "41986419936660" + zendeskRequestTypeField = "41987045113748" + zendeskRequestTypeValue = "cre_customer_deploy_access_request" +) + +type UserInfo struct { + Email string + Name string + OrganizationID string +} + +type Requester struct { + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet + log *zerolog.Logger + stdin io.Reader +} + +func NewRequester(creds *credentials.Credentials, envSet *environments.EnvironmentSet, log *zerolog.Logger, stdin io.Reader) *Requester { + return &Requester{ + credentials: creds, + environmentSet: envSet, + log: log, + stdin: stdin, + } +} + +func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { + fmt.Println("") + fmt.Println("Deployment access is not yet enabled for your organization.") + fmt.Println("") + + shouldRequest, err := prompt.YesNoPrompt(r.stdin, "Request deployment access?") + if err != nil { + return fmt.Errorf("failed to get user confirmation: %w", err) + } + + if !shouldRequest { + fmt.Println("") + fmt.Println("Access request canceled.") + return nil + } + + user, err := r.FetchUserInfo(ctx) + if err != nil { + return fmt.Errorf("failed to fetch user info: %w", err) + } + + fmt.Println("") + fmt.Println("Submitting access request...") + + if err := r.SubmitAccessRequest(user); err != nil { + return fmt.Errorf("failed to submit access request: %w", err) + } + + fmt.Println("") + fmt.Println("Access request submitted successfully!") + fmt.Println("") + fmt.Println("Our team will review your request and get back to you shortly.") + fmt.Println("You'll receive a confirmation email at: " + user.Email) + fmt.Println("") + + return nil +} + +func (r *Requester) FetchUserInfo(ctx context.Context) (*UserInfo, error) { + query := ` + query GetAccountDetails { + getAccountDetails { + emailAddress + } + getOrganization { + organizationId + } + }` + + client := graphqlclient.New(r.credentials, r.environmentSet, r.log) + req := graphql.NewRequest(query) + + var resp struct { + GetAccountDetails struct { + EmailAddress string `json:"emailAddress"` + } `json:"getAccountDetails"` + GetOrganization struct { + OrganizationID string `json:"organizationId"` + } `json:"getOrganization"` + } + + if err := client.Execute(ctx, req, &resp); err != nil { + return nil, fmt.Errorf("graphql request failed: %w", err) + } + + return &UserInfo{ + Email: resp.GetAccountDetails.EmailAddress, + Name: resp.GetAccountDetails.EmailAddress, + OrganizationID: resp.GetOrganization.OrganizationID, + }, nil +} + +func (r *Requester) SubmitAccessRequest(user *UserInfo) error { + username := os.Getenv(EnvVarZendeskUsername) + password := os.Getenv(EnvVarZendeskPassword) + + if username == "" || password == "" { + return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) + } + + ticket := map[string]interface{}{ + "ticket": map[string]interface{}{ + "subject": "CRE Deployment Access Request", + "comment": map[string]interface{}{ + "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), + }, + "brand_id": zendeskBrandID, + "custom_fields": []map[string]interface{}{ + { + "id": zendeskRequestTypeField, + "value": zendeskRequestTypeValue, + }, + }, + "requester": map[string]interface{}{ + "name": user.Name, + "email": user.Email, + }, + }, + } + + body, err := json.Marshal(ticket) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+creds) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) + } + + return nil +} From 648c7250005f86e598d85ef4945e2b99c458906b Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:24:11 -0500 Subject: [PATCH 08/13] Fix background goroutine error appearing during deploy access prompt --- cmd/workflow/deploy/deploy.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 514f9bde..ef734c69 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -126,6 +126,11 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { wg: sync.WaitGroup{}, wrcErr: nil, } + + return &h +} + +func (h *handler) initWorkflowRegistryClient() { h.wg.Add(1) go func() { defer h.wg.Done() @@ -136,8 +141,6 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { } h.wrc = wrc }() - - return &h } func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) { @@ -191,6 +194,8 @@ func (h *handler) Execute(ctx context.Context) error { return h.accessRequester.PromptAndSubmitRequest(ctx) } + h.initWorkflowRegistryClient() + h.displayWorkflowDetails() if err := h.Compile(); err != nil { From 793b5094e54628d990262980a1d8c666cc4fa451 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:29:44 -0500 Subject: [PATCH 09/13] Update account command description to mention deploy access --- cmd/account/account.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/account/account.go b/cmd/account/account.go index 27deaee5..bc96644c 100644 --- a/cmd/account/account.go +++ b/cmd/account/account.go @@ -13,8 +13,8 @@ import ( func New(runtimeContext *runtime.Context) *cobra.Command { accountCmd := &cobra.Command{ Use: "account", - Short: "Manages account", - Long: "Manage your linked public key addresses for workflow operations.", + Short: "Manage account and request deploy access", + Long: "Manage your linked public key addresses for workflow operations and request deployment access.", } accountCmd.AddCommand(access.New(runtimeContext)) From 1195a66697ced79451246dd3e19b69af503ecaa0 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:42:49 -0500 Subject: [PATCH 10/13] Show deploy access hint after successful workflow simulation --- cmd/workflow/simulate/simulate.go | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index bd62e471..10b08853 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -41,6 +41,7 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/validation" @@ -101,6 +102,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { type handler struct { log *zerolog.Logger runtimeContext *runtime.Context + credentials *credentials.Credentials validated bool } @@ -108,6 +110,7 @@ func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, runtimeContext: ctx, + credentials: ctx.Credentials, validated: false, } } @@ -332,7 +335,36 @@ func (h *handler) Execute(inputs Inputs) error { // if logger instance is set to DEBUG, that means verbosity flag is set by the user verbosity := h.log.GetLevel() == zerolog.DebugLevel - return run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + err = run(ctx, wasmFileBinary, config, secrets, inputs, verbosity) + if err != nil { + return err + } + + h.showDeployAccessHint() + + return nil +} + +func (h *handler) showDeployAccessHint() { + if h.credentials == nil { + return + } + + deployAccess, err := h.credentials.GetDeploymentAccessStatus() + if err != nil { + return + } + + if !deployAccess.HasAccess { + fmt.Println("") + fmt.Println("─────────────────────────────────────────────────────────────") + fmt.Println("") + fmt.Println(" Simulation complete! Ready to deploy your workflow?") + fmt.Println("") + fmt.Println(" Run 'cre account access' to request deployment access.") + fmt.Println("") + fmt.Println("─────────────────────────────────────────────────────────────") + } } // run instantiates the engine, starts it and blocks until the context is canceled. From eea3004f5de6b907aa4d93110051045e6c1675e2 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Thu, 29 Jan 2026 21:55:32 -0500 Subject: [PATCH 11/13] Add deploy access hint to global help template for gated users --- cmd/root.go | 17 +++++++++++++++++ cmd/template/help_template.tpl | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index 87da047d..7c21d264 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/context" + "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/logger" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" @@ -174,6 +175,22 @@ func newRootCommand() *cobra.Command { return false }) + cobra.AddTemplateFunc("needsDeployAccess", func() bool { + creds := runtimeContext.Credentials + if creds == nil { + var err error + creds, err = credentials.New(rootLogger) + if err != nil { + return false + } + } + deployAccess, err := creds.GetDeploymentAccessStatus() + if err != nil { + return false + } + return !deployAccess.HasAccess + }) + rootCmd.SetHelpTemplate(helpTemplate) // Definition of global flags: diff --git a/cmd/template/help_template.tpl b/cmd/template/help_template.tpl index 2e55d2d3..cef15808 100644 --- a/cmd/template/help_template.tpl +++ b/cmd/template/help_template.tpl @@ -91,6 +91,12 @@ Use "{{.CommandPath}} [command] --help" for more information about a command. to login into your cre account, then: $ cre init to create your first cre project. +{{- if needsDeployAccess}} + +🔑 Ready to deploy? Run: + $ cre account access + to request deployment access. +{{- end}} 📘 Need more help? Visit https://docs.chain.link/cre From 168f1dafd72f4778add7e4ecfcd55cff14729a14 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Fri, 30 Jan 2026 10:47:40 -0500 Subject: [PATCH 12/13] Added prompt to describe use cases when request access request --- internal/accessrequest/accessrequest.go | 34 +++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index b8ff2576..594e10f8 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -1,6 +1,7 @@ package accessrequest import ( + "bufio" "bytes" "context" "encoding/base64" @@ -9,6 +10,7 @@ import ( "io" "net/http" "os" + "strings" "github.com/machinebox/graphql" "github.com/rs/zerolog" @@ -67,6 +69,21 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { return nil } + fmt.Println("") + fmt.Println("Briefly describe your use case (what are you building with CRE?):") + fmt.Print("> ") + + reader := bufio.NewReader(r.stdin) + useCase, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read use case: %w", err) + } + useCase = strings.TrimSpace(useCase) + + if useCase == "" { + return fmt.Errorf("use case description is required") + } + user, err := r.FetchUserInfo(ctx) if err != nil { return fmt.Errorf("failed to fetch user info: %w", err) @@ -75,7 +92,7 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { fmt.Println("") fmt.Println("Submitting access request...") - if err := r.SubmitAccessRequest(user); err != nil { + if err := r.SubmitAccessRequest(user, useCase); err != nil { return fmt.Errorf("failed to submit access request: %w", err) } @@ -123,7 +140,7 @@ func (r *Requester) FetchUserInfo(ctx context.Context) (*UserInfo, error) { }, nil } -func (r *Requester) SubmitAccessRequest(user *UserInfo) error { +func (r *Requester) SubmitAccessRequest(user *UserInfo, useCase string) error { username := os.Getenv(EnvVarZendeskUsername) password := os.Getenv(EnvVarZendeskPassword) @@ -131,11 +148,18 @@ func (r *Requester) SubmitAccessRequest(user *UserInfo) error { return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) } + body := fmt.Sprintf(`Deployment access request submitted via CRE CLI. + +Organization ID: %s + +Use Case: +%s`, user.OrganizationID, useCase) + ticket := map[string]interface{}{ "ticket": map[string]interface{}{ "subject": "CRE Deployment Access Request", "comment": map[string]interface{}{ - "body": fmt.Sprintf("Deployment access request submitted via CRE CLI.\n\nOrganization ID: %s", user.OrganizationID), + "body": body, }, "brand_id": zendeskBrandID, "custom_fields": []map[string]interface{}{ @@ -151,12 +175,12 @@ func (r *Requester) SubmitAccessRequest(user *UserInfo) error { }, } - body, err := json.Marshal(ticket) + jsonBody, err := json.Marshal(ticket) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(jsonBody)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } From 059360056549aa5452d7f823ed1f9d22294d146f Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Fri, 30 Jan 2026 11:05:13 -0500 Subject: [PATCH 13/13] update code so that request is sent to a proxy that will take care of submitting the request to zendesk --- cmd/account/access/access.go | 22 ++-- cmd/workflow/deploy/deploy.go | 4 +- internal/accessrequest/accessrequest.go | 142 +++++++----------------- 3 files changed, 50 insertions(+), 118 deletions(-) diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go index e74e3fe5..b37e9801 100644 --- a/cmd/account/access/access.go +++ b/cmd/account/access/access.go @@ -1,7 +1,6 @@ package access import ( - "context" "fmt" "github.com/rs/zerolog" @@ -9,7 +8,6 @@ import ( "github.com/smartcontractkit/cre-cli/internal/accessrequest" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" ) @@ -21,7 +19,7 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { h := NewHandler(runtimeCtx, cmd.InOrStdin()) - return h.Execute(cmd.Context()) + return h.Execute() }, } @@ -29,22 +27,20 @@ func New(runtimeCtx *runtime.Context) *cobra.Command { } type Handler struct { - log *zerolog.Logger - credentials *credentials.Credentials - environmentSet *environments.EnvironmentSet - requester *accessrequest.Requester + log *zerolog.Logger + credentials *credentials.Credentials + requester *accessrequest.Requester } func NewHandler(ctx *runtime.Context, stdin interface{ Read([]byte) (int, error) }) *Handler { return &Handler{ - log: ctx.Logger, - credentials: ctx.Credentials, - environmentSet: ctx.EnvironmentSet, - requester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), + log: ctx.Logger, + credentials: ctx.Credentials, + requester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), } } -func (h *Handler) Execute(ctx context.Context) error { +func (h *Handler) Execute() error { deployAccess, err := h.credentials.GetDeploymentAccessStatus() if err != nil { return fmt.Errorf("failed to check deployment access: %w", err) @@ -63,5 +59,5 @@ func (h *Handler) Execute(ctx context.Context) error { return nil } - return h.requester.PromptAndSubmitRequest(ctx) + return h.requester.PromptAndSubmitRequest() } diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index ef734c69..8f0015e8 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -121,7 +121,7 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, - accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger, stdin), + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.Logger, stdin), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, @@ -191,7 +191,7 @@ func (h *handler) Execute(ctx context.Context) error { } if !deployAccess.HasAccess { - return h.accessRequester.PromptAndSubmitRequest(ctx) + return h.accessRequester.PromptAndSubmitRequest() } h.initWorkflowRegistryClient() diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go index 594e10f8..5c87dc2c 100644 --- a/internal/accessrequest/accessrequest.go +++ b/internal/accessrequest/accessrequest.go @@ -3,8 +3,6 @@ package accessrequest import ( "bufio" "bytes" - "context" - "encoding/base64" "encoding/json" "fmt" "io" @@ -12,48 +10,35 @@ import ( "os" "strings" - "github.com/machinebox/graphql" "github.com/rs/zerolog" - "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/prompt" ) const ( - EnvVarZendeskUsername = "CRE_ZENDESK_USERNAME" - EnvVarZendeskPassword = "CRE_ZENDESK_PASSWORD" - - zendeskAPIURL = "https://chainlinklabs.zendesk.com/api/v2/tickets.json" - zendeskBrandID = "41986419936660" - zendeskRequestTypeField = "41987045113748" - zendeskRequestTypeValue = "cre_customer_deploy_access_request" + EnvVarAccessRequestURL = "CRE_ACCESS_REQUEST_URL" ) -type UserInfo struct { - Email string - Name string - OrganizationID string +type AccessRequest struct { + UseCase string `json:"useCase"` } type Requester struct { - credentials *credentials.Credentials - environmentSet *environments.EnvironmentSet - log *zerolog.Logger - stdin io.Reader + credentials *credentials.Credentials + log *zerolog.Logger + stdin io.Reader } -func NewRequester(creds *credentials.Credentials, envSet *environments.EnvironmentSet, log *zerolog.Logger, stdin io.Reader) *Requester { +func NewRequester(creds *credentials.Credentials, log *zerolog.Logger, stdin io.Reader) *Requester { return &Requester{ - credentials: creds, - environmentSet: envSet, - log: log, - stdin: stdin, + credentials: creds, + log: log, + stdin: stdin, } } -func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { +func (r *Requester) PromptAndSubmitRequest() error { fmt.Println("") fmt.Println("Deployment access is not yet enabled for your organization.") fmt.Println("") @@ -84,15 +69,10 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { return fmt.Errorf("use case description is required") } - user, err := r.FetchUserInfo(ctx) - if err != nil { - return fmt.Errorf("failed to fetch user info: %w", err) - } - fmt.Println("") fmt.Println("Submitting access request...") - if err := r.SubmitAccessRequest(user, useCase); err != nil { + if err := r.SubmitAccessRequest(useCase); err != nil { return fmt.Errorf("failed to submit access request: %w", err) } @@ -100,94 +80,50 @@ func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { fmt.Println("Access request submitted successfully!") fmt.Println("") fmt.Println("Our team will review your request and get back to you shortly.") - fmt.Println("You'll receive a confirmation email at: " + user.Email) fmt.Println("") return nil } -func (r *Requester) FetchUserInfo(ctx context.Context) (*UserInfo, error) { - query := ` - query GetAccountDetails { - getAccountDetails { - emailAddress - } - getOrganization { - organizationId - } - }` - - client := graphqlclient.New(r.credentials, r.environmentSet, r.log) - req := graphql.NewRequest(query) - - var resp struct { - GetAccountDetails struct { - EmailAddress string `json:"emailAddress"` - } `json:"getAccountDetails"` - GetOrganization struct { - OrganizationID string `json:"organizationId"` - } `json:"getOrganization"` - } - - if err := client.Execute(ctx, req, &resp); err != nil { - return nil, fmt.Errorf("graphql request failed: %w", err) - } - - return &UserInfo{ - Email: resp.GetAccountDetails.EmailAddress, - Name: resp.GetAccountDetails.EmailAddress, - OrganizationID: resp.GetOrganization.OrganizationID, - }, nil -} - -func (r *Requester) SubmitAccessRequest(user *UserInfo, useCase string) error { - username := os.Getenv(EnvVarZendeskUsername) - password := os.Getenv(EnvVarZendeskPassword) - - if username == "" || password == "" { - return fmt.Errorf("zendesk credentials not configured (set %s and %s environment variables)", EnvVarZendeskUsername, EnvVarZendeskPassword) +func (r *Requester) SubmitAccessRequest(useCase string) error { + apiURL := os.Getenv(EnvVarAccessRequestURL) + if apiURL == "" { + return fmt.Errorf("access request API URL not configured (set %s environment variable)", EnvVarAccessRequestURL) } - body := fmt.Sprintf(`Deployment access request submitted via CRE CLI. - -Organization ID: %s - -Use Case: -%s`, user.OrganizationID, useCase) - - ticket := map[string]interface{}{ - "ticket": map[string]interface{}{ - "subject": "CRE Deployment Access Request", - "comment": map[string]interface{}{ - "body": body, - }, - "brand_id": zendeskBrandID, - "custom_fields": []map[string]interface{}{ - { - "id": zendeskRequestTypeField, - "value": zendeskRequestTypeValue, - }, - }, - "requester": map[string]interface{}{ - "name": user.Name, - "email": user.Email, - }, - }, + reqBody := AccessRequest{ + UseCase: useCase, } - jsonBody, err := json.Marshal(ticket) + jsonBody, err := json.MarshalIndent(reqBody, "", " ") if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequest(http.MethodPost, zendeskAPIURL, bytes.NewReader(jsonBody)) + if r.credentials.Tokens == nil || r.credentials.Tokens.AccessToken == "" { + return fmt.Errorf("no access token available - please run 'cre login' first") + } + token := r.credentials.Tokens.AccessToken + + fmt.Println("") + fmt.Println("Request Details:") + fmt.Println("----------------") + fmt.Printf("URL: %s\n", apiURL) + fmt.Printf("Method: POST\n") + fmt.Println("Headers:") + fmt.Println(" Content-Type: application/json") + fmt.Printf(" Authorization: Bearer %s\n", token) + fmt.Println("Body:") + fmt.Println(string(jsonBody)) + fmt.Println("----------------") + + req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(jsonBody)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } - creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Basic "+creds) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { @@ -196,7 +132,7 @@ Use Case: defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("zendesk API returned status %d", resp.StatusCode) + return fmt.Errorf("access request API returned status %d", resp.StatusCode) } return nil