diff --git a/cmd/account/access/access.go b/cmd/account/access/access.go new file mode 100644 index 00000000..32baab97 --- /dev/null +++ b/cmd/account/access/access.go @@ -0,0 +1,65 @@ +package access + +import ( + "context" + "fmt" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/accessrequest" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +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 + requester *accessrequest.Requester +} + +func NewHandler(ctx *runtime.Context) *Handler { + return &Handler{ + log: ctx.Logger, + credentials: ctx.Credentials, + requester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger), + } +} + +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 { + ui.Line() + ui.Success("You have deployment access enabled for your organization.") + ui.Line() + ui.Print("You're all set to deploy workflows. Get started with:") + ui.Line() + ui.Command(" cre workflow deploy") + ui.Line() + ui.Dim("For more information, run 'cre workflow deploy --help'") + ui.Line() + return nil + } + + return h.requester.PromptAndSubmitRequest(ctx) +} diff --git a/cmd/account/access/access_test.go b/cmd/account/access/access_test.go new file mode 100644 index 00000000..ebca9079 --- /dev/null +++ b/cmd/account/access/access_test.go @@ -0,0 +1,85 @@ +package access_test + +import ( + "context" + "io" + "os" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/cmd/account/access" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +func TestHandlerExecute_HasAccess(t *testing.T) { + // API key auth type always returns HasAccess: true + creds := &credentials.Credentials{ + AuthType: "api-key", + APIKey: "test-key", + } + logger := zerolog.New(io.Discard) + envSet := &environments.EnvironmentSet{} + + rtCtx := &runtime.Context{ + Credentials: creds, + Logger: &logger, + EnvironmentSet: envSet, + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + h := access.NewHandler(rtCtx) + err := h.Execute(context.Background()) + + w.Close() + os.Stdout = oldStdout + var output strings.Builder + _, _ = io.Copy(&output, r) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := output.String() + expectedSnippets := []string{ + "deployment access enabled", + "cre workflow deploy", + } + for _, snippet := range expectedSnippets { + if !strings.Contains(out, snippet) { + t.Errorf("output missing %q; full output:\n%s", snippet, out) + } + } +} + +func TestHandlerExecute_NoTokens(t *testing.T) { + // Bearer auth with no tokens should return an error from GetDeploymentAccessStatus + creds := &credentials.Credentials{ + AuthType: "bearer", + } + logger := zerolog.New(io.Discard) + envSet := &environments.EnvironmentSet{} + + rtCtx := &runtime.Context{ + Credentials: creds, + Logger: &logger, + EnvironmentSet: envSet, + } + + h := access.NewHandler(rtCtx) + err := h.Execute(context.Background()) + + if err == nil { + t.Fatal("expected error for missing tokens, got nil") + } + if !strings.Contains(err.Error(), "failed to check deployment access") { + t.Errorf("expected 'failed to check deployment access' error, got: %v", err) + } +} diff --git a/cmd/account/account.go b/cmd/account/account.go index d69ec3a9..bc96644c 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" @@ -12,10 +13,11 @@ 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)) accountCmd.AddCommand(link_key.New(runtimeContext)) accountCmd.AddCommand(unlink_key.New(runtimeContext)) accountCmd.AddCommand(list_key.New(runtimeContext)) diff --git a/cmd/root.go b/cmd/root.go index 25dd21fd..e0136602 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,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" @@ -187,7 +188,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 { if showSpinner { spinner.Stop() @@ -274,6 +275,21 @@ func newRootCommand() *cobra.Command { cobra.AddTemplateFunc("styleURL", func(s string) string { return ui.URLStyle.Render(s) // Chainlink Blue, underlined }) + 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) @@ -362,6 +378,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": {}, diff --git a/cmd/template/help_template.tpl b/cmd/template/help_template.tpl index f91585f6..f2d04869 100644 --- a/cmd/template/help_template.tpl +++ b/cmd/template/help_template.tpl @@ -91,6 +91,12 @@ to login into your cre account, then: {{styleCode "$ cre init"}} to create your first cre project. +{{- if needsDeployAccess}} + +šŸ”‘ Ready to deploy? Run: + $ cre account access + to request deployment access. +{{- end}} {{styleSection "Need more help?"}} Visit {{styleURL "https://docs.chain.link/cre"}} diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 7fa0c879..3ba3be5c 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -88,6 +88,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") + } + ui.Line() ui.Title("Account Details") @@ -101,6 +107,15 @@ func (h *Handler) Execute(ctx context.Context) error { details) } + // Add deployment access status + if deployAccess != nil { + if deployAccess.HasAccess { + details = fmt.Sprintf("%s\nDeploy Access: Enabled", details) + } else { + details = fmt.Sprintf("%s\nDeploy Access: Not enabled", details) + } + } + ui.Box(details) ui.Line() diff --git a/cmd/workflow/deploy/compile_test.go b/cmd/workflow/deploy/compile_test.go index d0ebadd8..74bde2f5 100644 --- a/cmd/workflow/deploy/compile_test.go +++ b/cmd/workflow/deploy/compile_test.go @@ -1,6 +1,7 @@ package deploy import ( + "context" "encoding/base64" "errors" "io" @@ -212,7 +213,7 @@ func TestCompileCmd(t *testing.T) { err := handler.ValidateInputs() require.NoError(t, err) - err = handler.Execute() + err = handler.Execute(context.Background()) w.Close() os.Stdout = oldStdout diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 59c303d8..781bbbdb 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,10 +121,16 @@ func newHandler(ctx *runtime.Context, stdin io.Reader) *handler { workflowArtifact: &workflowArtifact{}, wrc: nil, runtimeContext: ctx, + accessRequester: accessrequest.NewRequester(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger), validated: false, wg: sync.WaitGroup{}, wrcErr: nil, } + + return &h +} + +func (h *handler) initWorkflowRegistryClient() { h.wg.Add(1) go func() { defer h.wg.Done() @@ -132,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) { @@ -177,7 +184,18 @@ 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.initWorkflowRegistryClient() + h.displayWorkflowDetails() if err := h.Compile(); err != nil { diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 373a82bf..307ac1c1 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/ui" @@ -102,6 +103,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { type handler struct { log *zerolog.Logger runtimeContext *runtime.Context + credentials *credentials.Credentials validated bool } @@ -109,6 +111,7 @@ func newHandler(ctx *runtime.Context) *handler { return &handler{ log: ctx.Logger, runtimeContext: ctx, + credentials: ctx.Credentials, validated: false, } } @@ -340,7 +343,32 @@ 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 { + ui.Line() + message := ui.RenderSuccess("Simulation complete!") + " Ready to deploy your workflow?\n\n" + + "Run " + ui.RenderCommand("cre account access") + " to request deployment access." + ui.Box(message) + } } // run instantiates the engine, starts it and blocks until the context is canceled. diff --git a/internal/accessrequest/accessrequest.go b/internal/accessrequest/accessrequest.go new file mode 100644 index 00000000..267bd512 --- /dev/null +++ b/internal/accessrequest/accessrequest.go @@ -0,0 +1,130 @@ +package accessrequest + +import ( + "context" + "fmt" + + "github.com/charmbracelet/huh" + "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/ui" +) + +const requestDeploymentAccessMutation = ` +mutation RequestDeploymentAccess($input: RequestDeploymentAccessInput!) { + requestDeploymentAccess(input: $input) { + success + message + } +}` + +type Requester struct { + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet + log *zerolog.Logger +} + +func NewRequester(creds *credentials.Credentials, environmentSet *environments.EnvironmentSet, log *zerolog.Logger) *Requester { + return &Requester{ + credentials: creds, + environmentSet: environmentSet, + log: log, + } +} + +func (r *Requester) PromptAndSubmitRequest(ctx context.Context) error { + ui.Line() + ui.Warning("Deployment access is not yet enabled for your organization.") + ui.Line() + + shouldRequest := true + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Request deployment access?"). + Value(&shouldRequest), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := confirmForm.Run(); err != nil { + return fmt.Errorf("failed to get user confirmation: %w", err) + } + + if !shouldRequest { + ui.Line() + ui.Dim("Access request canceled.") + return nil + } + + var useCase string + inputForm := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Briefly describe your use case"). + Description("What are you building with CRE?"). + Value(&useCase). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("use case description is required") + } + return nil + }), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := inputForm.Run(); err != nil { + return fmt.Errorf("failed to read use case: %w", err) + } + + ui.Line() + spinner := ui.NewSpinner() + spinner.Start("Submitting access request...") + + if err := r.SubmitAccessRequest(ctx, useCase); err != nil { + spinner.Stop() + return fmt.Errorf("failed to submit access request: %w", err) + } + + spinner.Stop() + ui.Line() + ui.Success("Access request submitted successfully!") + ui.Line() + ui.Print("Our team will review your request and get back to you via email shortly.") + ui.Line() + + return nil +} + +func (r *Requester) SubmitAccessRequest(ctx context.Context, useCase string) error { + client := graphqlclient.New(r.credentials, r.environmentSet, r.log) + + req := graphql.NewRequest(requestDeploymentAccessMutation) + req.Var("input", map[string]any{ + "description": useCase, + }) + + var resp struct { + RequestDeploymentAccess struct { + Success bool `json:"success"` + Message *string `json:"message"` + } `json:"requestDeploymentAccess"` + } + + if err := client.Execute(ctx, req, &resp); err != nil { + return fmt.Errorf("graphql request failed: %w", err) + } + + if !resp.RequestDeploymentAccess.Success { + msg := "access request was not successful" + if resp.RequestDeploymentAccess.Message != nil { + msg = *resp.RequestDeploymentAccess.Message + } + return fmt.Errorf("request failed: %s", msg) + } + + return nil +} diff --git a/internal/accessrequest/accessrequest_test.go b/internal/accessrequest/accessrequest_test.go new file mode 100644 index 00000000..8bfdecb1 --- /dev/null +++ b/internal/accessrequest/accessrequest_test.go @@ -0,0 +1,144 @@ +package accessrequest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/accessrequest" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" +) + +func TestSubmitAccessRequest(t *testing.T) { + tests := []struct { + name string + useCase string + graphqlHandler http.HandlerFunc + wantErr bool + wantErrMsg string + }{ + { + name: "successful request", + useCase: "Building a cross-chain DeFi protocol", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodyStr := string(body) + + if !strings.Contains(bodyStr, "requestDeploymentAccess") { + t.Errorf("expected mutation requestDeploymentAccess in body, got: %s", bodyStr) + } + if !strings.Contains(bodyStr, "Building a cross-chain DeFi protocol") { + t.Errorf("expected use case description in body, got: %s", bodyStr) + } + + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "requestDeploymentAccess": map[string]interface{}{ + "success": true, + "message": nil, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: false, + }, + { + name: "request denied with message", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "requestDeploymentAccess": map[string]interface{}{ + "success": false, + "message": "organization is not eligible", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: true, + wantErrMsg: "organization is not eligible", + }, + { + name: "request denied without message", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "requestDeploymentAccess": map[string]interface{}{ + "success": false, + "message": nil, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: true, + wantErrMsg: "access request was not successful", + }, + { + name: "graphql server error", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + }, + wantErr: true, + wantErrMsg: "graphql request failed", + }, + { + name: "graphql returns errors", + useCase: "some use case", + graphqlHandler: func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "errors": []map[string]interface{}{ + {"message": "not authenticated"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, + wantErr: true, + wantErrMsg: "graphql request failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts := httptest.NewServer(tc.graphqlHandler) + defer ts.Close() + + envSet := &environments.EnvironmentSet{ + GraphQLURL: ts.URL, + } + creds := &credentials.Credentials{} + logger := zerolog.New(io.Discard) + + requester := accessrequest.NewRequester(creds, envSet, &logger) + err := requester.SubmitAccessRequest(context.Background(), tc.useCase) + + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErrMsg) { + t.Errorf("expected error containing %q, got: %v", tc.wantErrMsg, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 6b53867b..1dd6dc59 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -35,10 +35,19 @@ 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" +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{ @@ -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 }