Skip to content

Commit f39a138

Browse files
raphaeltmlionello
andauthored
Tenant support (#1400)
* feat: add tenant selection and management with CLI support * feat: add compact target listing mode when verbose flag is disabled * feat: add DEFANG_TENANT environment variable support for tenant selection * feat: skip userinfo endpoint resolution for GitHub Actions tokens * return nil on fabric issuer --------- Co-authored-by: Lio李歐 <[email protected]>
1 parent f51ef72 commit f39a138

File tree

5 files changed

+339
-2
lines changed

5 files changed

+339
-2
lines changed

pkgs/npm/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ The Defang CLI recognizes the following environment variables:
3232
- `DEFANG_HIDE_HINTS` - If set to `true`, hides hints in the CLI output; defaults to `false`
3333
- `DEFANG_HIDE_UPDATE` - If set to `true`, hides the update notification; defaults to `false`
3434
- `DEFANG_PROVIDER` - The name of the cloud provider to use, `auto` (default), `aws`, `digitalocean`, or `defang`
35+
- `DEFANG_TENANT` - The name of the tenant to use.
3536
- `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal
3637
- `TZ` - The timezone to use for log timestamps: an IANA TZ name like `UTC` or `Europe/Amsterdam`; defaults to `Local`
3738
- `XDG_STATE_HOME` - The directory to use for storing state; defaults to `~/.local/state`

src/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The Defang CLI recognizes the following environment variables:
4343
- `DEFANG_PULUMI_BACKEND` - The Pulumi backend URL or `"pulumi-cloud"`; defaults to a self-hosted backend
4444
- `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only)
4545
- `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only)
46+
- `DEFANG_TENANT` - The name of the tenant to use.
4647
- `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal
4748
- `PULUMI_ACCESS_TOKEN` - The Pulumi access token to use for authentication to Pulumi Cloud; see `DEFANG_PULUMI_BACKEND`
4849
- `PULUMI_CONFIG_PASSPHRASE` - Passphrase used to generate a unique key for your stack, and configuration and encrypted state values

src/cmd/cli/command/commands.go

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import (
99
"os/exec"
1010
"path/filepath"
1111
"regexp"
12+
"sort"
1213
"strings"
1314
"time"
1415

1516
"github.com/AlecAivazis/survey/v2"
1617
_ "github.com/DefangLabs/defang/src/cmd/cli/autoload"
1718
"github.com/DefangLabs/defang/src/pkg"
19+
"github.com/DefangLabs/defang/src/pkg/auth"
1820
"github.com/DefangLabs/defang/src/pkg/cli"
1921
cliClient "github.com/DefangLabs/defang/src/pkg/cli/client"
2022
"github.com/DefangLabs/defang/src/pkg/cli/client/byoc"
@@ -57,6 +59,7 @@ var (
5759
modelId = os.Getenv("DEFANG_MODEL_ID") // for Pro users only
5860
nonInteractive = !hasTty
5961
org string
62+
tenantFlag string
6063
providerID = cliClient.ProviderID(pkg.Getenv("DEFANG_PROVIDER", "auto"))
6164
verbose = false
6265
)
@@ -163,6 +166,7 @@ func SetupCommands(ctx context.Context, version string) {
163166
RootCmd.PersistentFlags().StringVarP(&cluster, "cluster", "s", pcluster.DefangFabric, "Defang cluster to connect to")
164167
RootCmd.PersistentFlags().MarkHidden("cluster")
165168
RootCmd.PersistentFlags().StringVar(&org, "org", os.Getenv("DEFANG_ORG"), "override GitHub organization name (tenant)")
169+
RootCmd.PersistentFlags().StringVar(&tenantFlag, "tenant", "", "select tenant by name")
166170
RootCmd.PersistentFlags().VarP(&providerID, "provider", "P", fmt.Sprintf(`bring-your-own-cloud provider; one of %v`, cliClient.AllProviders()))
167171
// RootCmd.Flag("provider").NoOptDefVal = "auto" NO this will break the "--provider aws"
168172
RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging") // backwards compat: only used by tail
@@ -215,6 +219,9 @@ func SetupCommands(ctx context.Context, version string) {
215219
// Whoami Command
216220
RootCmd.AddCommand(whoamiCmd)
217221

222+
// Tenants Command
223+
RootCmd.AddCommand(tenantsCmd)
224+
218225
// Logout Command
219226
RootCmd.AddCommand(logoutCmd)
220227

@@ -365,6 +372,18 @@ var RootCmd = &cobra.Command{
365372
}
366373
}
367374

375+
// Configure tenant selection based on --tenant flag
376+
if f := cmd.Root().Flag("tenant"); f != nil && f.Changed {
377+
// Highest precedence: explicit --tenant flag
378+
auth.SetSelectedTenantName(tenantFlag)
379+
} else if envTenant := os.Getenv("DEFANG_TENANT"); strings.TrimSpace(envTenant) != "" {
380+
// Next precedence: DEFANG_TENANT environment variable
381+
auth.SetSelectedTenantName(envTenant)
382+
} else {
383+
// Default behavior: auto-select tenant by JWT subject if no explicit name is provided
384+
auth.SetAutoSelectBySub(true)
385+
}
386+
368387
client, err = cli.Connect(ctx, getCluster())
369388

370389
if v, err := client.GetVersions(ctx); err == nil {
@@ -376,6 +395,8 @@ var RootCmd = &cobra.Command{
376395
}
377396
}
378397

398+
// (deliberately skip tenant resolution here to avoid blocking non-auth commands)
399+
379400
// Check if we are correctly logged in, but only if the command needs authorization
380401
if _, ok := cmd.Annotations[authNeeded]; !ok {
381402
return nil
@@ -387,7 +408,80 @@ var RootCmd = &cobra.Command{
387408
err = login.InteractiveRequireLoginAndToS(ctx, client, getCluster())
388409
}
389410

390-
return err
411+
if err != nil {
412+
return err
413+
}
414+
415+
// Ensure tenant is resolved post-login as we now have a token
416+
if tok := pcluster.GetExistingToken(getCluster()); tok != "" {
417+
if err2 := auth.ResolveAndSetTenantFromToken(ctx, tok); err2 != nil {
418+
return err2
419+
}
420+
// log the tenant name and id
421+
term.Debug("Selected tenant:", auth.GetSelectedTenantName(), "(", auth.GetSelectedTenantID(), ")")
422+
}
423+
424+
return nil
425+
},
426+
}
427+
428+
var tenantsCmd = &cobra.Command{
429+
Use: "tenants",
430+
Args: cobra.NoArgs,
431+
Annotations: authNeededAnnotation,
432+
Short: "List tenants available to the logged-in user",
433+
RunE: func(cmd *cobra.Command, args []string) error {
434+
ctx := cmd.Context()
435+
tok := pcluster.GetExistingToken(getCluster())
436+
if strings.TrimSpace(tok) == "" {
437+
return errors.New("not logged in; run 'defang login'")
438+
}
439+
440+
tenants, err := auth.ListTenantsFromToken(ctx, tok)
441+
if err != nil {
442+
return err
443+
}
444+
445+
// Sort by name for stable output
446+
sort.Slice(tenants, func(i, j int) bool { return strings.ToLower(tenants[i].Name) < strings.ToLower(tenants[j].Name) })
447+
448+
if len(tenants) == 0 {
449+
term.Info("No tenants found")
450+
return nil
451+
}
452+
453+
currentID := auth.GetSelectedTenantID()
454+
currentName := auth.GetSelectedTenantName()
455+
456+
// Compute longest name for aligned output
457+
maxNameLen := 0
458+
for _, t := range tenants {
459+
if l := len(t.Name); l > maxNameLen {
460+
maxNameLen = l
461+
}
462+
}
463+
464+
for _, t := range tenants {
465+
selected := t.ID == currentID || (currentID == "" && t.Name == currentName && strings.TrimSpace(currentName) != "")
466+
marker := "-"
467+
if selected {
468+
marker = "*" // highlight selected
469+
}
470+
471+
var line string
472+
if verbose {
473+
line = fmt.Sprintf("%s %-*s (%s)\n", marker, maxNameLen, t.Name, t.ID)
474+
} else {
475+
line = fmt.Sprintf("%s %s\n", marker, t.Name)
476+
}
477+
478+
if selected {
479+
term.Printc(term.BrightCyan, line)
480+
} else {
481+
term.Printc(term.InfoColor, line)
482+
}
483+
}
484+
return nil
391485
},
392486
}
393487

src/pkg/auth/interceptor.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import (
77
"github.com/bufbuild/connect-go"
88
)
99

10-
const XDefangOrgID = "X-Defang-Orgid"
10+
const (
11+
XDefangOrgID = "X-Defang-Orgid"
12+
XDefangTenantID = "X-Defang-Tenant-Id"
13+
)
1114

1215
type authInterceptor struct {
1316
authorization string
@@ -23,6 +26,9 @@ func (a *authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
2326
req.Header().Set("Authorization", a.authorization)
2427
req.Header().Set("Content-Type", "application/grpc") // same as the gRPC client
2528
req.Header().Set(XDefangOrgID, a.orgID)
29+
if tid := GetSelectedTenantID(); tid != "" {
30+
req.Header().Set(XDefangTenantID, tid)
31+
}
2632
return next(ctx, req)
2733
}
2834
}
@@ -33,6 +39,9 @@ func (a *authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc)
3339
conn.RequestHeader().Set("Authorization", a.authorization)
3440
conn.RequestHeader().Set("Content-Type", "application/grpc") // same as the gRPC client
3541
conn.RequestHeader().Set(XDefangOrgID, a.orgID)
42+
if tid := GetSelectedTenantID(); tid != "" {
43+
conn.RequestHeader().Set(XDefangTenantID, tid)
44+
}
3645
return conn
3746
}
3847
}

0 commit comments

Comments
 (0)