Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ func newInstallCmd() *cobra.Command {
return runDryRun(ctx, client, printer, org, repos, roles, inferenceProvider, inferenceProviderName, allRepos)
}

if err := checkInstallScopes(ctx, client, printer); err != nil {
return err
}
printer.Blank()

// Collect agent credentials via app setup.
var agentCreds []layers.AgentCredentials
if !skipAppSetup {
Expand Down Expand Up @@ -817,6 +822,57 @@ func buildLayerStack(
)
}

// installRequiredScopes is the set of OAuth scopes the install command
// needs. Keep in sync with the union of RequiredScopes(OpInstall) across
// all layers; TestCheckInstallScopes_SyncWithLayers asserts parity.
var installRequiredScopes = []string{"repo", "workflow", "admin:org"}

// checkInstallScopes verifies that the token has the scopes needed for
// install before starting interactive app setup. This avoids wasting
// time on browser-based app creation only to fail on missing scopes.
func checkInstallScopes(ctx context.Context, client forge.Client, printer *ui.Printer) error {
printer.StepStart("Checking token permissions")

granted, err := client.GetTokenScopes(ctx)
if err != nil {
printer.StepFail("Could not verify token permissions")
return fmt.Errorf("checking token scopes: %w", err)
}

if granted == nil {
printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)")
return nil
}

required := installRequiredScopes
grantedSet := make(map[string]bool, len(granted))
for _, s := range granted {
grantedSet[s] = true
}

var missing []string
for _, scope := range required {
if !grantedSet[scope] {
missing = append(missing, scope)
}
}

if len(missing) > 0 {
printer.StepFail("Token is missing required scopes")
printer.Blank()
result := &layers.PreflightResult{
Required: required,
Granted: granted,
Missing: missing,
}
printer.ErrorBox("Missing token scopes", result.Error())
return fmt.Errorf("token is missing required scopes: %s", strings.Join(missing, ", "))
}

printer.StepDone("Token permissions verified")
return nil
}

// runPreflight checks that the token has all required scopes for the
// given operation. Returns nil if all scopes are present or if scope
// introspection is unavailable (fine-grained tokens). Returns an error
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"sort"
"strings"
Expand All @@ -12,6 +13,7 @@ import (

"github.com/fullsend-ai/fullsend/internal/config"
"github.com/fullsend-ai/fullsend/internal/forge"
"github.com/fullsend-ai/fullsend/internal/layers"
"github.com/fullsend-ai/fullsend/internal/ui"
)

Expand Down Expand Up @@ -724,3 +726,64 @@ type errorReader struct{}
func (e *errorReader) Read(p []byte) (n int, err error) {
return 0, fmt.Errorf("simulated read error")
}

func TestCheckInstallScopes_AllPresent(t *testing.T) {
client := &forge.FakeClient{
TokenScopes: []string{"repo", "workflow", "admin:org", "read:org"},
}
printer := ui.New(&discardWriter{})

err := checkInstallScopes(context.Background(), client, printer)
require.NoError(t, err)
}

func TestCheckInstallScopes_Missing(t *testing.T) {
client := &forge.FakeClient{
TokenScopes: []string{"repo"},
}
printer := ui.New(&discardWriter{})

err := checkInstallScopes(context.Background(), client, printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "workflow")
assert.Contains(t, err.Error(), "admin:org")
}

func TestCheckInstallScopes_FineGrainedToken(t *testing.T) {
client := &forge.FakeClient{
TokenScopes: nil,
}
printer := ui.New(&discardWriter{})

err := checkInstallScopes(context.Background(), client, printer)
require.NoError(t, err)
}

func TestCheckInstallScopes_GetTokenScopesError(t *testing.T) {
client := &forge.FakeClient{
Errors: map[string]error{"GetTokenScopes": errors.New("network error")},
}
printer := ui.New(&discardWriter{})

err := checkInstallScopes(context.Background(), client, printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "checking token scopes")
assert.Contains(t, err.Error(), "network error")
}

func TestCheckInstallScopes_SyncWithLayers(t *testing.T) {
emptyCfg := &config.OrgConfig{}
stack := layers.NewStack(
layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false),
layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", ""),
layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})),
layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})),
layers.NewDispatchTokenLayer("test-org", nil, "", nil, ui.New(&discardWriter{}), nil),
layers.NewEnrollmentLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})),
layers.NewVendorBinaryLayer("test-org", nil, ui.New(&discardWriter{}), false, nil),
)
layerScopes := stack.CollectRequiredScopes(layers.OpInstall)

assert.ElementsMatch(t, installRequiredScopes, layerScopes,
"installRequiredScopes must match the union of RequiredScopes(OpInstall) from all layers; update the variable if a layer's scopes change")
}
Loading