Skip to content

Commit 91089c0

Browse files
committed
feat(aws): add credential validation with --validate-creds flag
Adds an optional CredentialValidator interface to the provider package and an AWS implementation that verifies the caller has all permissions required for a Nebari deployment. Default validation now uses sts:GetCallerIdentity instead of Credentials.Retrieve, which only checks the local credential chain without confirming AWS will accept them. The new --validate-creds flag on the validate command runs the full permission check via iam:SimulatePrincipalPolicy, batched in groups of 100 (the API limit) using slices.Chunk. Providers that don't implement CredentialValidator print a note explaining the flag isn't supported. The permission list lives in pkg/provider/aws/permissions.go to make it easy to find and audit. Permissions are composed conditionally based on cluster config: - VPC permissions skipped when ExistingVPCID is set - IAM permissions skipped when ExistingClusterRoleArn is set - EFS permissions only included when EFS.Enabled is true Closes #6
1 parent 0de398b commit 91089c0

8 files changed

Lines changed: 593 additions & 10 deletions

File tree

cmd/nic/validate.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,30 @@ import (
1010
"go.opentelemetry.io/otel/attribute"
1111

1212
"github.com/nebari-dev/nebari-infrastructure-core/pkg/config"
13+
"github.com/nebari-dev/nebari-infrastructure-core/pkg/provider"
1314
"github.com/nebari-dev/nebari-infrastructure-core/pkg/registry"
1415
)
1516

1617
var (
1718
validateConfigFile string
19+
validateCreds bool
1820

1921
validateCmd = &cobra.Command{
2022
Use: "validate",
2123
Short: "Validate configuration file",
2224
Long: `Validate the nebari-config.yaml file without deploying any infrastructure.
2325
This command checks that the configuration file is properly formatted and contains
24-
all required fields.`,
26+
all required fields.
27+
28+
Use --validate-creds to perform thorough credential validation including permission
29+
checks (currently supported for AWS only).`,
2530
RunE: runValidate,
2631
}
2732
)
2833

2934
func init() {
3035
validateCmd.Flags().StringVarP(&validateConfigFile, "file", "f", "", "Path to nebari-config.yaml file (auto-discovered if omitted)")
36+
validateCmd.Flags().BoolVar(&validateCreds, "validate-creds", false, "Perform thorough credential validation (AWS only)")
3137
}
3238

3339
func runValidate(cmd *cobra.Command, args []string) error {
@@ -45,7 +51,10 @@ func runValidate(cmd *cobra.Command, args []string) error {
4551
ctx, span := tracer.Start(ctx, "cmd.validate")
4652
defer span.End()
4753

48-
span.SetAttributes(attribute.String("config.file", validateConfigFile))
54+
span.SetAttributes(
55+
attribute.String("config.file", validateConfigFile),
56+
attribute.Bool("validate_creds", validateCreds),
57+
)
4958

5059
slog.Info("Validating configuration", "config_file", validateConfigFile)
5160

@@ -73,6 +82,31 @@ func runValidate(cmd *cobra.Command, args []string) error {
7382
fmt.Printf(" Provider: %s\n", cfg.Cluster.ProviderName())
7483
fmt.Printf(" Project: %s\n", cfg.ProjectName)
7584

85+
// Perform thorough credential validation if requested.
86+
if validateCreds {
87+
providerName := cfg.Cluster.ProviderName()
88+
p, err := reg.ClusterProviders.Get(ctx, providerName)
89+
if err != nil {
90+
span.RecordError(err)
91+
slog.Error("Provider not available", "error", err, "provider", providerName)
92+
return err
93+
}
94+
95+
cv, ok := p.(provider.CredentialValidator)
96+
if !ok {
97+
fmt.Printf("Note: The %s provider does not support --validate-creds\n", providerName)
98+
return nil
99+
}
100+
101+
slog.Info("Performing credential validation", "provider", providerName)
102+
if err := cv.ValidateCredentials(ctx, cfg.ProjectName, cfg.Cluster); err != nil {
103+
span.RecordError(err)
104+
slog.Error("Credential validation failed", "error", err, "provider", providerName)
105+
return err
106+
}
107+
fmt.Printf("✓ Credentials are valid with required permissions\n")
108+
}
109+
76110
return nil
77111
}
78112

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ require (
77
github.com/aws/aws-sdk-go-v2/config v1.31.20
88
github.com/aws/aws-sdk-go-v2/service/ec2 v1.286.0
99
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.19
10+
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.11
11+
github.com/aws/aws-sdk-go-v2/service/iam v1.53.8
1012
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1
1113
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2
1214
github.com/aws/smithy-go v1.25.0
@@ -24,6 +26,7 @@ require (
2426
go.opentelemetry.io/otel/sdk v1.38.0
2527
go.opentelemetry.io/otel/trace v1.38.0
2628
golang.org/x/crypto v0.48.0
29+
gopkg.in/yaml.v3 v3.0.1
2730
helm.sh/helm/v3 v3.19.4
2831
k8s.io/api v0.35.0
2932
k8s.io/apimachinery v0.35.0
@@ -53,7 +56,6 @@ require (
5356
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
5457
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
5558
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
56-
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.11 // indirect
5759
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
5860
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
5961
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
@@ -169,7 +171,6 @@ require (
169171
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
170172
gopkg.in/inf.v0 v0.9.1 // indirect
171173
gopkg.in/warnings.v0 v0.1.2 // indirect
172-
gopkg.in/yaml.v3 v3.0.1 // indirect
173174
k8s.io/apiextensions-apiserver v0.34.2 // indirect
174175
k8s.io/apiserver v0.34.2 // indirect
175176
k8s.io/cli-runtime v0.34.2 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.19 h1:ybEda2mkkX
6262
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.19/go.mod h1:RiMytGvN4azx4yLM0Kn3bX/XO9dLxj+eG72Smy+vNzI=
6363
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.11 h1:0iNKMyO0SXuRfl5FF6TQASAHTXnTYZlQS3/oJSfpEbQ=
6464
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.11/go.mod h1:WdVArS8riWgCC1JoIVmKdqfBfj2+cSWAjBU5dEapMhA=
65+
github.com/aws/aws-sdk-go-v2/service/iam v1.53.8 h1:p0oB4eZfBfBAOasnKvHJOlNcuHVE/ieuWs7uIZgQlyQ=
66+
github.com/aws/aws-sdk-go-v2/service/iam v1.53.8/go.mod h1:epCaPnGVdiX5ra1lHPfRkVuiQGxrdY8bRI2FBJU+6ok=
6567
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
6668
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
6769
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=

pkg/provider/aws/credentials.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package aws
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"slices"
7+
"strings"
8+
9+
"github.com/aws/aws-sdk-go-v2/aws"
10+
awsconfig "github.com/aws/aws-sdk-go-v2/config"
11+
"github.com/aws/aws-sdk-go-v2/service/iam"
12+
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
13+
"github.com/aws/aws-sdk-go-v2/service/sts"
14+
"go.opentelemetry.io/otel"
15+
"go.opentelemetry.io/otel/attribute"
16+
17+
"github.com/nebari-dev/nebari-infrastructure-core/pkg/config"
18+
"github.com/nebari-dev/nebari-infrastructure-core/pkg/provider"
19+
"github.com/nebari-dev/nebari-infrastructure-core/pkg/status"
20+
)
21+
22+
// iamSimulateBatchSize is the maximum number of action names per
23+
// SimulatePrincipalPolicy call. The AWS API caps this at 100.
24+
const iamSimulateBatchSize = 100
25+
26+
// IAMClient defines the IAM operations needed for credential validation.
27+
type IAMClient interface {
28+
SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error)
29+
}
30+
31+
// newIAMClient creates a new IAM client for the specified region.
32+
func newIAMClient(ctx context.Context, region string) (IAMClient, error) {
33+
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to load AWS config: %w", err)
36+
}
37+
return iam.NewFromConfig(cfg), nil
38+
}
39+
40+
// CredentialValidationResult contains the results of credential validation.
41+
type CredentialValidationResult struct {
42+
AccountID string
43+
Arn string
44+
MissingPermissions []string
45+
}
46+
47+
// validateCredentialsWithClients performs thorough credential validation using
48+
// provided clients (for testability).
49+
func validateCredentialsWithClients(ctx context.Context, stsClient STSClient, iamClient IAMClient, cfg *Config) (*CredentialValidationResult, error) {
50+
tracer := otel.Tracer("nebari-infrastructure-core")
51+
ctx, span := tracer.Start(ctx, "aws.validateCredentialsWithClients")
52+
defer span.End()
53+
54+
identity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
55+
if err != nil {
56+
span.RecordError(err)
57+
return nil, fmt.Errorf("failed to get caller identity: %w", err)
58+
}
59+
60+
result := &CredentialValidationResult{
61+
AccountID: aws.ToString(identity.Account),
62+
Arn: aws.ToString(identity.Arn),
63+
}
64+
65+
span.SetAttributes(
66+
attribute.String("aws.account_id", result.AccountID),
67+
attribute.String("aws.arn", result.Arn),
68+
)
69+
70+
requiredPerms := getRequiredPermissions(cfg)
71+
72+
var missingPerms []string
73+
for batch := range slices.Chunk(requiredPerms, iamSimulateBatchSize) {
74+
simResult, err := iamClient.SimulatePrincipalPolicy(ctx, &iam.SimulatePrincipalPolicyInput{
75+
PolicySourceArn: identity.Arn,
76+
ActionNames: batch,
77+
ResourceArns: []string{"*"},
78+
})
79+
if err != nil {
80+
span.RecordError(err)
81+
return nil, fmt.Errorf("failed to simulate IAM policy: %w", err)
82+
}
83+
84+
for _, evalResult := range simResult.EvaluationResults {
85+
if evalResult.EvalDecision != iamtypes.PolicyEvaluationDecisionTypeAllowed {
86+
missingPerms = append(missingPerms, aws.ToString(evalResult.EvalActionName))
87+
}
88+
}
89+
}
90+
91+
result.MissingPermissions = missingPerms
92+
93+
span.SetAttributes(
94+
attribute.Int("permissions.checked", len(requiredPerms)),
95+
attribute.Int("permissions.missing", len(missingPerms)),
96+
)
97+
98+
return result, nil
99+
}
100+
101+
// ValidateCredentials implements provider.CredentialValidator for thorough
102+
// credential validation including IAM permission checks.
103+
func (p *Provider) ValidateCredentials(ctx context.Context, projectName string, clusterConfig *config.ClusterConfig) error {
104+
tracer := otel.Tracer("nebari-infrastructure-core")
105+
ctx, span := tracer.Start(ctx, "aws.ValidateCredentials")
106+
defer span.End()
107+
108+
span.SetAttributes(attribute.String("project_name", projectName))
109+
110+
awsCfg, err := extractAWSConfig(ctx, clusterConfig)
111+
if err != nil {
112+
span.RecordError(err)
113+
return err
114+
}
115+
116+
stsClient, err := newSTSClient(ctx, awsCfg.Region)
117+
if err != nil {
118+
span.RecordError(err)
119+
return fmt.Errorf("failed to create STS client: %w", err)
120+
}
121+
122+
iamClient, err := newIAMClient(ctx, awsCfg.Region)
123+
if err != nil {
124+
span.RecordError(err)
125+
return fmt.Errorf("failed to create IAM client: %w", err)
126+
}
127+
128+
result, err := validateCredentialsWithClients(ctx, stsClient, iamClient, awsCfg)
129+
if err != nil {
130+
span.RecordError(err)
131+
return err
132+
}
133+
134+
status.Send(ctx, status.NewUpdate(status.LevelSuccess, "AWS credentials validated").
135+
WithResource("credentials").
136+
WithAction("validated").
137+
WithMetadata("identity", result.Arn).
138+
WithMetadata("account", result.AccountID))
139+
140+
if len(result.MissingPermissions) > 0 {
141+
status.Send(ctx, status.NewUpdate(status.LevelError, fmt.Sprintf("Missing %d required permissions: %s", len(result.MissingPermissions), strings.Join(result.MissingPermissions, ", "))).
142+
WithResource("credentials").
143+
WithAction("validated").
144+
WithMetadata("missing_permissions", result.MissingPermissions))
145+
return fmt.Errorf("credential validation failed: missing %d permissions: %s", len(result.MissingPermissions), strings.Join(result.MissingPermissions, ", "))
146+
}
147+
148+
return nil
149+
}
150+
151+
// Compile-time check that Provider implements CredentialValidator.
152+
var _ provider.CredentialValidator = (*Provider)(nil)

0 commit comments

Comments
 (0)