diff --git a/cmd/audit.go b/cmd/audit.go index b9b0f56..286e510 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -3,76 +3,73 @@ package cmd import ( "context" "fmt" + "log/slog" "strings" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" - "github.com/unicrons/aws-root-manager/pkg/output" - "github.com/unicrons/aws-root-manager/pkg/service" + "github.com/unicrons/aws-root-manager/internal/cli/output" + "github.com/unicrons/aws-root-manager/internal/cli/ui" + "github.com/unicrons/aws-root-manager/internal/service" "github.com/spf13/cobra" ) -var auditCmd = &cobra.Command{ - Use: "audit", - Short: "Retrieve root user credentials", - Long: `Retrieve available root user credentials for all member accounts within an AWS Organization.`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - logger.Trace("cmd.audit", "audit called") +func Audit() *cobra.Command { + cmd := &cobra.Command{ + Use: "audit", + Short: "Retrieve root user credentials", + Long: `Retrieve available root user credentials for all member accounts within an AWS Organization.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("audit called") - ctx := context.Background() - awscfg, err := aws.LoadAWSConfig(ctx) - if err != nil { - logger.Error("cmd.audit", err, "failed to load aws config") - return err - } - - auditAccounts, err := service.GetTargetAccounts(ctx, accountsFlags) - if err != nil { - logger.Error("cmd.audit", err, "failed to get accounts to audit") - return err - } - if len(auditAccounts) == 0 { - logger.Info("cmd.audit", "no accounts selected") - return nil - } - logger.Debug("cmd.audit", "selected accounts: %s", strings.Join(auditAccounts, ", ")) + ctx := context.Background() + rm, err := service.NewRootManagerFromConfig(ctx) + if err != nil { + slog.Error("failed to initialize root manager", "error", err) + return err + } - iam := aws.NewIamClient(awscfg) - sts := aws.NewStsClient(awscfg) - audit, err := service.AuditAccounts(ctx, iam, sts, auditAccounts) - if err != nil { - logger.Error("cmd.audit", err, "failed to audit accounts") - return err - } + auditAccounts, err := ui.SelectTargetAccounts(ctx, accountsFlags) + if err != nil { + slog.Error("failed to get accounts to audit", "error", err) + return err + } + if len(auditAccounts) == 0 { + slog.Info("no accounts selected") + return nil + } + slog.Debug("selected accounts", "accounts", strings.Join(auditAccounts, ", ")) - var skipped int - headers := []string{"Account", "LoginProfile", "AccessKeys", "MFA Devices", "Signing Certificates"} - var data [][]any - for i, acc := range audit { - if acc.Error != "" { - skipped++ - continue + audit, err := rm.AuditAccounts(ctx, auditAccounts) + if err != nil { + slog.Error("failed to audit accounts", "error", err) + return err } - data = append(data, []any{ - auditAccounts[i], - acc.LoginProfile, - acc.AccessKeys, - acc.MfaDevices, - acc.SigningCertificates, - }) - } - output.HandleOutput(outputFlag, headers, data) - if skipped > 0 { - return fmt.Errorf("audit skipped for %d account(s)", skipped) - } - return nil - }, -} + var skipped int + headers := []string{"Account", "LoginProfile", "AccessKeys", "MFA Devices", "Signing Certificates"} + var data [][]any + for i, acc := range audit { + if acc.Error != "" { + skipped++ + continue + } + data = append(data, []any{ + auditAccounts[i], + acc.LoginProfile, + acc.AccessKeys, + acc.MfaDevices, + acc.SigningCertificates, + }) + } + output.HandleOutput(outputFlag, headers, data) -func init() { - rootCmd.AddCommand(auditCmd) - auditCmd.PersistentFlags().StringSliceVarP(&accountsFlags, "accounts", "a", []string{}, "List of AWS account IDs to audit (comma-separated). Use \"all\" to audit all accounts.") + if skipped > 0 { + return fmt.Errorf("audit skipped for %d account(s)", skipped) + } + return nil + }, + } + cmd.PersistentFlags().StringSliceVarP(&accountsFlags, "accounts", "a", []string{}, "List of AWS account IDs to audit (comma-separated). Use \"all\" to audit all accounts.") + return cmd } diff --git a/cmd/check.go b/cmd/check.go index 3594aaa..fe7132b 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -2,47 +2,44 @@ package cmd import ( "context" + "log/slog" "strconv" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" - "github.com/unicrons/aws-root-manager/pkg/output" - "github.com/unicrons/aws-root-manager/pkg/service" + "github.com/unicrons/aws-root-manager/internal/cli/output" + "github.com/unicrons/aws-root-manager/internal/service" "github.com/spf13/cobra" ) -var checkCmd = &cobra.Command{ - Use: "check", - Short: "Check if centralized root access is enabled.", - Long: `Retrieve the status of centralized root access settings for an AWS Organization.`, - Run: func(cmd *cobra.Command, args []string) { - logger.Trace("cmd.check", "check called") - - ctx := context.Background() - awscfg, err := aws.LoadAWSConfig(ctx) - if err != nil { - logger.Error("cmd.check", err, "failed to load aws config") - return - } - - iam := aws.NewIamClient(awscfg) - test, err := service.CheckRootAccess(ctx, iam) - if err != nil { - logger.Error("cmd.check", err, "failed to check root access configuration") - return - } - - headers := []string{"Name", "Status"} - data := [][]any{ - {"TrustedAccess", strconv.FormatBool(test.TrustedAccess)}, - {"RootCredentialsManagement", strconv.FormatBool(test.RootCredentialsManagement)}, - {"RootSessions", strconv.FormatBool(test.RootSessions)}, - } - output.HandleOutput(outputFlag, headers, data) - }, -} - -func init() { - rootCmd.AddCommand(checkCmd) +func Check() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Check if centralized root access is enabled.", + Long: `Retrieve the status of centralized root access settings for an AWS Organization.`, + RunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("check called") + + ctx := context.Background() + rm, err := service.NewRootManagerFromConfig(ctx) + if err != nil { + slog.Error("failed to initialize root manager", "error", err) + return err + } + + status, err := rm.CheckRootAccess(ctx) + if err != nil { + slog.Error("failed to check root access configuration", "error", err) + return err + } + + headers := []string{"Name", "Status"} + data := [][]any{ + {"TrustedAccess", strconv.FormatBool(status.TrustedAccess)}, + {"RootCredentialsManagement", strconv.FormatBool(status.RootCredentialsManagement)}, + {"RootSessions", strconv.FormatBool(status.RootSessions)}, + } + output.HandleOutput(outputFlag, headers, data) + return nil + }, + } } diff --git a/cmd/delete.go b/cmd/delete.go index 592fc23..fc2a368 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -3,151 +3,97 @@ package cmd import ( "context" "fmt" + "log/slog" "strings" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" - "github.com/unicrons/aws-root-manager/pkg/output" - "github.com/unicrons/aws-root-manager/pkg/service" + "github.com/unicrons/aws-root-manager/internal/cli/output" + "github.com/unicrons/aws-root-manager/internal/cli/ui" + "github.com/unicrons/aws-root-manager/internal/service" "github.com/spf13/cobra" ) -var deleteCmd = &cobra.Command{ - Use: "delete", - Short: "Delete root user credentials", - Long: `Delete root user credentials for specific AWS Organization member accounts.`, -} - -var deleteAllCmd = &cobra.Command{ - Use: "all", - Short: "Delete all existing root user credentials", - Long: `Delete all existing root user credentials for specific AWS Organization member accounts.`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - logger.Trace("cmd.deleteAll", "delete all called") - - if err := delete(accountsFlags, "all"); err != nil { - return err - } - - return nil - }, -} - -var deleteLoginCmd = &cobra.Command{ - Use: "login", - Short: "Delete root user Login Profile", - Long: `Delete existing root user Login Profile for specific AWS Organization member accounts.`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - logger.Trace("cmd.deleteLogin", "delete login called") - - if err := delete(accountsFlags, "login"); err != nil { - return err - } - - return nil - }, -} - -var deleteKeysCmd = &cobra.Command{ - Use: "keys", - Short: "Delete root user Access Keys", - Long: `Delete existing root user Access Keys for specific AWS Organization member accounts.`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - logger.Trace("cmd.deleteKeys", "delete keys called") - - if err := delete(accountsFlags, "keys"); err != nil { - return err - } - - return nil - }, -} - -var deleteMfaCmd = &cobra.Command{ - Use: "mfa", - Short: "Deactivate root user MFA Devices", - Long: `Deactivate existing root user MFA Devices for specific AWS Organization member accounts.`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - logger.Trace("cmd.deleteMfa", "delete mfa called") - - if err := delete(accountsFlags, "mfa"); err != nil { - return err - } - - return nil - }, -} - -var deleteCertificatesCmd = &cobra.Command{ - Use: "certificates", - Short: "Delete root user Signin Certificates", - Long: `Delete existing root user Signing Certificates for specific AWS Organization member accounts.`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - logger.Trace("cmd.deleteCertificates", "delete certificates called") - - if err := delete(accountsFlags, "certificate"); err != nil { - return err - } - - return nil - }, +func Delete() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete root user credentials", + Long: `Delete root user credentials for specific AWS Organization member accounts.`, + } + cmd.PersistentFlags().StringSliceVarP(&accountsFlags, "accounts", "a", []string{}, "List of AWS account IDs to audit (comma-separated). Use \"all\" to audit all accounts.") + cmd.AddCommand(deleteSubcommand("all", "Delete all existing root user credentials", "Delete all existing root user credentials for specific AWS Organization member accounts.")) + cmd.AddCommand(deleteSubcommand("login", "Delete root user Login Profile", "Delete existing root user Login Profile for specific AWS Organization member accounts.")) + cmd.AddCommand(deleteSubcommand("keys", "Delete root user Access Keys", "Delete existing root user Access Keys for specific AWS Organization member accounts.")) + cmd.AddCommand(deleteSubcommand("mfa", "Deactivate root user MFA Devices", "Deactivate existing root user MFA Devices for specific AWS Organization member accounts.")) + cmd.AddCommand(deleteSubcommand("certificates", "Delete root user Signin Certificates", "Delete existing root user Signing Certificates for specific AWS Organization member accounts.")) + return cmd } -func init() { - rootCmd.AddCommand(deleteCmd) - deleteCmd.AddCommand(deleteAllCmd) - deleteCmd.AddCommand(deleteLoginCmd) - deleteCmd.AddCommand(deleteKeysCmd) - deleteCmd.AddCommand(deleteMfaCmd) - deleteCmd.AddCommand(deleteCertificatesCmd) - deleteCmd.PersistentFlags().StringSliceVarP(&accountsFlags, "accounts", "a", []string{}, "List of AWS account IDs to audit (comma-separated). Use \"all\" to audit all accounts.") +func deleteSubcommand(use, short, long string) *cobra.Command { + credentialType := use + if use == "certificates" { + credentialType = "certificate" + } + return &cobra.Command{ + Use: use, + Short: short, + Long: long, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(accountsFlags, credentialType) + }, + } } -func delete(accountsFlags []string, credentialType string) error { +func runDelete(accountsFlags []string, credentialType string) error { ctx := context.Background() - awscfg, err := aws.LoadAWSConfig(ctx) + rm, err := service.NewRootManagerFromConfig(ctx) if err != nil { - return fmt.Errorf("failed to load aws config: %w", err) + return fmt.Errorf("failed to initialize root manager: %w", err) } - auditAccounts, err := service.GetTargetAccounts(ctx, accountsFlags) + auditAccounts, err := ui.SelectTargetAccounts(ctx, accountsFlags) if err != nil { return fmt.Errorf("failed to get accounts to audit: %w", err) } if len(auditAccounts) == 0 { - logger.Info("cmd.audit", "no accounts selected") + slog.Info("no accounts selected") return nil } - logger.Debug("cmd.audit", "selected accounts: %s", strings.Join(auditAccounts, ", ")) - - iam := aws.NewIamClient(awscfg) - sts := aws.NewStsClient(awscfg) + slog.Debug("selected accounts", "accounts", strings.Join(auditAccounts, ", ")) - audit, err := service.AuditAccounts(ctx, iam, sts, auditAccounts) + audit, err := rm.AuditAccounts(ctx, auditAccounts) if err != nil { return err } - if err = service.DeleteAccountsCredentials(ctx, iam, sts, audit, credentialType); err != nil { + results, err := rm.DeleteCredentials(ctx, audit, credentialType) + if err != nil { return err } - headers := []string{"Account", "CredentialType", "Status"} + headers := []string{"Account", "CredentialType", "Status", "Error"} var data [][]any - for _, account := range auditAccounts { + var failureCount int + for _, result := range results { + status := "deleted" + errorMsg := "" + if !result.Success { + status = "failed" + errorMsg = result.Error + failureCount++ + } data = append(data, []any{ - account, - credentialType, - fmt.Sprintf("deleted"), // TODO: this is not real + result.AccountId, + result.CredentialType, + status, + errorMsg, }) } output.HandleOutput(outputFlag, headers, data) + if failureCount > 0 { + return fmt.Errorf("deletion failed for %d account(s)", failureCount) + } + return nil } diff --git a/cmd/enable.go b/cmd/enable.go index 81e8ae2..81937aa 100644 --- a/cmd/enable.go +++ b/cmd/enable.go @@ -2,52 +2,48 @@ package cmd import ( "context" + "log/slog" "strconv" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" - "github.com/unicrons/aws-root-manager/pkg/output" - "github.com/unicrons/aws-root-manager/pkg/service" + "github.com/unicrons/aws-root-manager/internal/cli/output" + "github.com/unicrons/aws-root-manager/internal/service" "github.com/spf13/cobra" ) -var enableCmd = &cobra.Command{ - Use: "enable", - Short: "Enable centralized root access", - Long: `Enable centralized root access management in an AWS Organization.`, - Run: func(cmd *cobra.Command, args []string) { - logger.Trace("cmd.enable", "enable called") - - enableRootSessions, _ := cmd.Flags().GetBool("enableRootSessions") - - ctx := context.Background() - awscfg, err := aws.LoadAWSConfig(ctx) - if err != nil { - logger.Error("cmd.enable", err, "failed to load aws config") - return - } - - iam := aws.NewIamClient(awscfg) - org := aws.NewOrganizationsClient(awscfg) - - initStatus, status, err := service.EnableRootAccess(ctx, iam, org, enableRootSessions) - if err != nil { - logger.Error("cmd.enable", err, "failed to enable root access") - return - } - - headers := []string{"Name", "InitialStatus", "CurrentStatus"} - data := [][]any{ - {"TrustedAccess", strconv.FormatBool(initStatus.TrustedAccess), strconv.FormatBool(status.TrustedAccess)}, - {"RootCredentialsManagement", strconv.FormatBool(initStatus.RootCredentialsManagement), strconv.FormatBool(status.RootCredentialsManagement)}, - {"RootSessions", strconv.FormatBool(initStatus.RootSessions), strconv.FormatBool(status.RootSessions)}, - } - output.HandleOutput(outputFlag, headers, data) - }, -} - -func init() { - rootCmd.AddCommand(enableCmd) - enableCmd.PersistentFlags().Bool("enableRootSessions", false, "Enable Root Sessions, required only when working with resource policies.") +func Enable() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable", + Short: "Enable centralized root access", + Long: `Enable centralized root access management in an AWS Organization.`, + RunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("enable called") + + enableRootSessions, _ := cmd.Flags().GetBool("enableRootSessions") + + ctx := context.Background() + rm, err := service.NewRootManagerFromConfig(ctx) + if err != nil { + slog.Error("failed to initialize root manager", "error", err) + return err + } + + initStatus, status, err := rm.EnableRootAccess(ctx, enableRootSessions) + if err != nil { + slog.Error("failed to enable root access", "error", err) + return err + } + + headers := []string{"Name", "InitialStatus", "CurrentStatus"} + data := [][]any{ + {"TrustedAccess", strconv.FormatBool(initStatus.TrustedAccess), strconv.FormatBool(status.TrustedAccess)}, + {"RootCredentialsManagement", strconv.FormatBool(initStatus.RootCredentialsManagement), strconv.FormatBool(status.RootCredentialsManagement)}, + {"RootSessions", strconv.FormatBool(initStatus.RootSessions), strconv.FormatBool(status.RootSessions)}, + } + output.HandleOutput(outputFlag, headers, data) + return nil + }, + } + cmd.PersistentFlags().Bool("enableRootSessions", false, "Enable Root Sessions, required only when working with resource policies.") + return cmd } diff --git a/cmd/recovery.go b/cmd/recovery.go index 89abe33..f5f902c 100644 --- a/cmd/recovery.go +++ b/cmd/recovery.go @@ -2,64 +2,76 @@ package cmd import ( "context" + "fmt" + "log/slog" "strings" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" - "github.com/unicrons/aws-root-manager/pkg/output" - "github.com/unicrons/aws-root-manager/pkg/service" + "github.com/unicrons/aws-root-manager/internal/cli/output" + "github.com/unicrons/aws-root-manager/internal/cli/ui" + "github.com/unicrons/aws-root-manager/internal/service" "github.com/spf13/cobra" ) -var recoveryCmd = &cobra.Command{ - Use: "recovery", - Short: "Allow root password recovery", - Long: `Retrieve the status of centralized root access settings for an AWS Organization.`, - Run: func(cmd *cobra.Command, args []string) { - logger.Trace("cmd.recovery", "recovery called") +func Recovery() *cobra.Command { + cmd := &cobra.Command{ + Use: "recovery", + Short: "Allow root password recovery", + Long: `Retrieve the status of centralized root access settings for an AWS Organization.`, + RunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("recovery called") - ctx := context.Background() - awscfg, err := aws.LoadAWSConfig(ctx) - if err != nil { - logger.Error("cmd.recovery", err, "failed to load aws config") - return - } - - targetAccounts, err := service.GetTargetAccounts(ctx, accountsFlags) - if err != nil { - logger.Error("cmd.recovery", err, "failed to get target accounts") - } - if len(targetAccounts) == 0 { - logger.Info("cmd.recovery", "no accounts selected") - return - } - logger.Debug("cmd.recovery", "selected accounts: %s", strings.Join(targetAccounts, ", ")) + ctx := context.Background() + rm, err := service.NewRootManagerFromConfig(ctx) + if err != nil { + slog.Error("failed to initialize root manager", "error", err) + return err + } - iam := aws.NewIamClient(awscfg) - sts := aws.NewStsClient(awscfg) + targetAccounts, err := ui.SelectTargetAccounts(ctx, accountsFlags) + if err != nil { + slog.Error("failed to get target accounts", "error", err) + return err + } + if len(targetAccounts) == 0 { + slog.Info("no accounts selected") + return nil + } + slog.Debug("selected accounts", "accounts", strings.Join(targetAccounts, ", ")) - resultMap, err := service.RecoverAccountsRootPassword(ctx, iam, sts, targetAccounts) - if err != nil { - logger.Error("cmd.recovery", err, "failed to recover root password") - return - } + results, err := rm.RecoverRootPassword(ctx, targetAccounts) + if err != nil { + slog.Error("failed to recover root password", "error", err) + return err + } - headers := []string{"Account", "Login Profile"} - var data [][]any - for acc, success := range resultMap { - status := "recovered" - if !success { - status = "already exists" + headers := []string{"Account", "Login Profile", "Error"} + var data [][]any + var failureCount int + for _, result := range results { + status := "recovered" + errorMsg := "" + if !result.Success { + if result.Error != "" { + status = "failed" + errorMsg = result.Error + failureCount++ + } else { + status = "already exists" + } + } + data = append(data, []any{result.AccountId, status, errorMsg}) } - data = append(data, []any{acc, status}) - } - output.HandleOutput(outputFlag, headers, data) - }, -} + output.HandleOutput(outputFlag, headers, data) + + if failureCount > 0 { + return fmt.Errorf("recovery failed for %d account(s)", failureCount) + } -func init() { - rootCmd.AddCommand(recoveryCmd) - recoveryCmd.PersistentFlags().StringSliceVarP(&accountsFlags, "accounts", "a", []string{}, "List of tarjet AWS account IDs (comma-separated). Use \"all\" to select all accounts.") + return nil + }, + } + cmd.PersistentFlags().StringSliceVarP(&accountsFlags, "accounts", "a", []string{}, "List of tarjet AWS account IDs (comma-separated). Use \"all\" to select all accounts.") + return cmd } diff --git a/cmd/root.go b/cmd/root.go index 6e93e84..4c05189 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,8 @@ package cmd import ( "os" + "github.com/unicrons/aws-root-manager/internal/logger" + "github.com/spf13/cobra" ) @@ -36,5 +38,13 @@ func Execute() { } func init() { + logger.Configure(os.Getenv("LOG_LEVEL"), os.Getenv("LOG_FORMAT")) + rootCmd.PersistentFlags().StringVarP(&outputFlag, "output", "o", "table", "Set the output format (table, json, csv)") + rootCmd.AddCommand(Audit()) + rootCmd.AddCommand(Check()) + rootCmd.AddCommand(Enable()) + rootCmd.AddCommand(Delete()) + rootCmd.AddCommand(Recovery()) + rootCmd.AddCommand(Version()) } diff --git a/cmd/version.go b/cmd/version.go index d6e3f15..e59b8a6 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -8,14 +8,13 @@ import ( var version = "dev" -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Version:", version) - }, -} - -func init() { - rootCmd.AddCommand(versionCmd) +func Version() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Version:", version) + return nil + }, + } } diff --git a/go.mod b/go.mod index a39d02c..af206eb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 - github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 ) diff --git a/go.sum b/go.sum index cbcbb32..0fe338c 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,6 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -69,20 +67,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -95,5 +87,3 @@ golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/output/csv.go b/internal/cli/output/csv.go similarity index 100% rename from pkg/output/csv.go rename to internal/cli/output/csv.go diff --git a/pkg/output/json.go b/internal/cli/output/json.go similarity index 100% rename from pkg/output/json.go rename to internal/cli/output/json.go diff --git a/pkg/output/output.go b/internal/cli/output/output.go similarity index 72% rename from pkg/output/output.go rename to internal/cli/output/output.go index dc55923..f6a8954 100644 --- a/pkg/output/output.go +++ b/internal/cli/output/output.go @@ -2,8 +2,7 @@ package output import ( "fmt" - - "github.com/unicrons/aws-root-manager/pkg/logger" + "log/slog" ) // HandleOutput handles the output based on the specified format @@ -11,16 +10,16 @@ func HandleOutput(format string, headers []string, rawData [][]any) { switch format { case "json": if err := PrintJSON(headers, rawData); err != nil { - logger.Error("output.HandleOutput", err, "error printing json") + slog.Error("error printing json", "error", err) } case "csv": if err := printCSV(headers, dataToString(rawData)); err != nil { - logger.Error("output.HandleOutput", err, "error printing csv") + slog.Error("error printing csv", "error", err) } case "table": printTable(headers, rawData) default: - logger.Error("output.HandleOutput", nil, "unsupported output format: %v", format) + slog.Error("unsupported output format", "format", format) } } diff --git a/pkg/output/table.go b/internal/cli/output/table.go similarity index 100% rename from pkg/output/table.go rename to internal/cli/output/table.go diff --git a/pkg/service/accounts.go b/internal/cli/ui/account_selector.go similarity index 68% rename from pkg/service/accounts.go rename to internal/cli/ui/account_selector.go index f59fca8..79c9599 100644 --- a/pkg/service/accounts.go +++ b/internal/cli/ui/account_selector.go @@ -1,12 +1,11 @@ -package service +package ui import ( "context" "fmt" + "log/slog" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" - "github.com/unicrons/aws-root-manager/pkg/ui" + "github.com/unicrons/aws-root-manager/internal/infra/aws" ) const ( @@ -14,23 +13,31 @@ const ( AllAccountsSelectorText = "all non management accounts" ) -// Get target AWS accounts based on input flags or user interaction -func GetTargetAccounts(ctx context.Context, accounts []string) ([]string, error) { - logger.Trace("service.GetTargetAccounts", "processing target accounts: %s", accounts) +// SelectTargetAccounts handles interactive account selection or returns accounts based on flags. +// Returns account IDs based on flags or TUI prompt. +func SelectTargetAccounts(ctx context.Context, accountsFlag []string) ([]string, error) { + slog.Debug("processing target accounts", "accounts_flag", accountsFlag) // if accounts are provided and "all" is not specified, return them - if len(accounts) > 0 && accounts[0] != AllAccountsOption { - return accounts, nil + if len(accountsFlag) > 0 && accountsFlag[0] != AllAccountsOption { + return accountsFlag, nil } + // create organizations client to fetch accounts + awscfg, err := aws.LoadAWSConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load aws config: %w", err) + } + org := aws.NewOrganizationsClient(awscfg) + // fetch all non-management accounts - orgAccounts, err := aws.GetNonManagementOrganizationAccounts(ctx) + orgAccounts, err := aws.GetNonManagementOrganizationAccounts(ctx, org) if err != nil { return nil, fmt.Errorf("error fetching organization accounts: %w", err) } // if "all" is specified, return all account IDs - if len(accounts) > 0 && accounts[0] == AllAccountsOption { + if len(accountsFlag) > 0 && accountsFlag[0] == AllAccountsOption { return convertAccountsToIDs(orgAccounts), nil } @@ -40,7 +47,7 @@ func GetTargetAccounts(ctx context.Context, accounts []string) ([]string, error) for _, account := range orgAccounts { selectorChoices = append(selectorChoices, fmt.Sprintf("%s - %s", account.AccountID, account.Name)) } - selectedIndexes, err := ui.Prompt("Please select the AWS accounts to audit", selectorChoices) + selectedIndexes, err := Prompt("Please select the AWS accounts to audit", selectorChoices) if err != nil { return nil, err } @@ -50,7 +57,7 @@ func GetTargetAccounts(ctx context.Context, accounts []string) ([]string, error) // Resolve selected accounts if allSelected(selectedIndexes) { - logger.Debug("service.GetTargetAccounts", "all accounts selected") + slog.Debug("all accounts selected") return convertAccountsToIDs(orgAccounts), nil } diff --git a/pkg/ui/selector.go b/internal/cli/ui/selector.go similarity index 100% rename from pkg/ui/selector.go rename to internal/cli/ui/selector.go diff --git a/pkg/aws/config.go b/internal/infra/aws/config.go similarity index 100% rename from pkg/aws/config.go rename to internal/infra/aws/config.go diff --git a/internal/infra/aws/factory.go b/internal/infra/aws/factory.go new file mode 100644 index 0000000..1c40507 --- /dev/null +++ b/internal/infra/aws/factory.go @@ -0,0 +1,21 @@ +package aws + +import ( + awssdk "github.com/aws/aws-sdk-go-v2/aws" +) + +// IamClientFactory creates IAM clients with a given AWS config. +// This abstraction enables dependency injection of client creation logic, +// which is especially important for goroutines that create clients dynamically +// after AssumeRoot. +type IamClientFactory interface { + NewIamClient(cfg awssdk.Config) IamClient +} + +// DefaultIamClientFactory is the production implementation of IamClientFactory. +type DefaultIamClientFactory struct{} + +// NewIamClient creates a new IAM client using the concrete implementation. +func (f *DefaultIamClientFactory) NewIamClient(cfg awssdk.Config) IamClient { + return NewIamClient(cfg) +} diff --git a/pkg/aws/iam.go b/internal/infra/aws/iam.go similarity index 58% rename from pkg/aws/iam.go rename to internal/infra/aws/iam.go index 7bda6b3..da153d5 100644 --- a/pkg/aws/iam.go +++ b/internal/infra/aws/iam.go @@ -4,62 +4,41 @@ import ( "context" "errors" "fmt" + "log/slog" "slices" - "github.com/unicrons/aws-root-manager/pkg/logger" + "github.com/unicrons/aws-root-manager/rootmanager" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" ) -var ( - ErrTrustedAccessNotEnabled = errors.New("trustedAccessNotEnabled") - ErrRootCredentialsManagementNotEnabled = errors.New("rootCredentialsManagementNotEnabled") - ErrRootSessionsNotEnabled = errors.New("rootSessionsNotEnabled") - ErrEntityAlreadyExists = errors.New("entityAlreadyExists") -) - -type RootAccessStatus struct { - TrustedAccess bool - RootCredentialsManagement bool - RootSessions bool -} - -type RootCredentials struct { - AccountId string - LoginProfile bool - AccessKeys []string - MfaDevices []string - SigningCertificates []string - Error string -} - -type IamClient struct { +type iamClient struct { client *iam.Client } -func NewIamClient(awscfg aws.Config) *IamClient { +func NewIamClient(awscfg aws.Config) IamClient { client := iam.NewFromConfig(awscfg) - return &IamClient{client: client} + return &iamClient{client: client} } // Verifies if AWS centralized root access is enabled -func (c *IamClient) CheckOrganizationRootAccess(ctx context.Context, rootSessionsRequired bool) error { - logger.Trace("aws.CheckOrganizationRootAccess", "checking if organization root access is enabled") +func (c *iamClient) CheckOrganizationRootAccess(ctx context.Context, rootSessionsRequired bool) error { + slog.Debug("checking if organization root access is enabled") features, err := c.client.ListOrganizationsFeatures(ctx, &iam.ListOrganizationsFeaturesInput{}) if err != nil { var serviceAccessNotEnabledErr *types.ServiceAccessNotEnabledException if errors.As(err, &serviceAccessNotEnabledErr) { - return ErrTrustedAccessNotEnabled + return rootmanager.ErrTrustedAccessNotEnabled } return fmt.Errorf("aws.CheckOrganizationRootAccess: failed to list organization features: %w", err) } rootCredentialsManagement := slices.Contains(features.EnabledFeatures, "RootCredentialsManagement") if !rootCredentialsManagement { - return ErrRootCredentialsManagementNotEnabled + return rootmanager.ErrRootCredentialsManagementNotEnabled } if !rootSessionsRequired { @@ -68,21 +47,21 @@ func (c *IamClient) CheckOrganizationRootAccess(ctx context.Context, rootSession rootSessions := slices.Contains(features.EnabledFeatures, "RootSessions") if !rootSessions { - return ErrRootSessionsNotEnabled + return rootmanager.ErrRootSessionsNotEnabled } return nil } // Check if an account has root login profile enabled -func (c *IamClient) GetLoginProfile(ctx context.Context, accountId string) (bool, error) { - logger.Debug("aws.GetLoginProfile", "getting login profile for account %s", accountId) +func (c *iamClient) GetLoginProfile(ctx context.Context, accountId string) (bool, error) { + slog.Debug("getting login profile", "account_id", accountId) _, err := c.client.GetLoginProfile(ctx, &iam.GetLoginProfileInput{}) if err != nil { var notFoundErr *types.NoSuchEntityException if errors.As(err, ¬FoundErr) { - logger.Debug("aws.GetLoginProfile", "account %s does not have a root login profile", accountId) + slog.Debug("account does not have a root login profile", "account_id", accountId) return false, nil } return true, fmt.Errorf("error getting root login profile for account %s: %w", accountId, err) @@ -92,22 +71,20 @@ func (c *IamClient) GetLoginProfile(ctx context.Context, accountId string) (bool } // Delete root login profile for a specific account -func (c *IamClient) DeleteLoginProfile(ctx context.Context, accountId string) error { - logger.Debug("aws.DeleteLoginProfile", "deleting login profile for account %s", accountId) +func (c *iamClient) DeleteLoginProfile(ctx context.Context, accountId string) error { + slog.Debug("deleting login profile", "account_id", accountId) _, err := c.client.DeleteLoginProfile(ctx, &iam.DeleteLoginProfileInput{}) if err != nil { return fmt.Errorf("error deleting root login profile for account %s: %w", accountId, err) } - logger.Info("aws.DeleteLoginProfile", "successfully deleted login profile for account %s", accountId) - return nil } // Get a list of root access keys for a specific account -func (c *IamClient) ListAccessKeys(ctx context.Context, accountId string) ([]string, error) { - logger.Debug("aws.ListAccessKeys", "listing access keys for account %s", accountId) +func (c *iamClient) ListAccessKeys(ctx context.Context, accountId string) ([]string, error) { + slog.Debug("listing access keys", "account_id", accountId) accessKeys, err := c.client.ListAccessKeys(ctx, &iam.ListAccessKeysInput{}) if err != nil { @@ -123,8 +100,8 @@ func (c *IamClient) ListAccessKeys(ctx context.Context, accountId string) ([]str } // Delete a list of root access for a specific account -func (c *IamClient) DeleteAccessKeys(ctx context.Context, accountId string, accessKeyIds []string) error { - logger.Debug("aws.DeleteAccessKeys", "deleting root access key %s for account %s", accessKeyIds, accountId) +func (c *iamClient) DeleteAccessKeys(ctx context.Context, accountId string, accessKeyIds []string) error { + slog.Debug("deleting root access keys", "account_id", accountId, "access_key_ids", accessKeyIds) for _, accessKeyId := range accessKeyIds { _, err := c.client.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{ @@ -135,13 +112,11 @@ func (c *IamClient) DeleteAccessKeys(ctx context.Context, accountId string, acce } } - logger.Info("aws.DeleteAccessKeys", "successfully deleted access keys for account %s", accountId) - return nil } // Get a list of root MFA devices for a specific account -func (c *IamClient) ListMFADevices(ctx context.Context, accountId string) ([]string, error) { +func (c *iamClient) ListMFADevices(ctx context.Context, accountId string) ([]string, error) { mfaDevices, err := c.client.ListMFADevices(ctx, &iam.ListMFADevicesInput{}) if err != nil { return nil, fmt.Errorf("error listing root mfa devices for account %s: %w", accountId, err) @@ -156,8 +131,8 @@ func (c *IamClient) ListMFADevices(ctx context.Context, accountId string) ([]str } // Deactivate a list of root MFA devices for a specific account -func (c *IamClient) DeactivateMFADevices(ctx context.Context, accountId string, mfaSerialNumbers []string) error { - logger.Debug("aws.DeactivateMFADevices", "deleting root mfa device %s for account %s", mfaSerialNumbers, accountId) +func (c *iamClient) DeactivateMFADevices(ctx context.Context, accountId string, mfaSerialNumbers []string) error { + slog.Debug("deactivating root mfa devices", "account_id", accountId, "mfa_serial_numbers", mfaSerialNumbers) for _, mfaSerialNumber := range mfaSerialNumbers { _, err := c.client.DeactivateMFADevice(ctx, &iam.DeactivateMFADeviceInput{ @@ -168,13 +143,11 @@ func (c *IamClient) DeactivateMFADevices(ctx context.Context, accountId string, } } - logger.Info("aws.DeactivateMFADevices", "successfully deactivated mfa devices for account %s", accountId) - return nil } // Get a list of root signing certificates devices for a specific account -func (c *IamClient) ListSigningCertificates(ctx context.Context, accountId string) ([]string, error) { +func (c *iamClient) ListSigningCertificates(ctx context.Context, accountId string) ([]string, error) { certificates, err := c.client.ListSigningCertificates(ctx, &iam.ListSigningCertificatesInput{}) if err != nil { return nil, fmt.Errorf("error listing signing certificates for account %s: %w", accountId, err) @@ -189,12 +162,10 @@ func (c *IamClient) ListSigningCertificates(ctx context.Context, accountId strin } // Delete a list of root signing certificates for a specific account -func (c *IamClient) DeleteSigningCertificates(ctx context.Context, accountId string, certificates []string) error { - logger.Debug("aws.DeleteSigningCertificates", "deleting singin certificates %s for account %s", certificates, accountId) +func (c *iamClient) DeleteSigningCertificates(ctx context.Context, accountId string, certificates []string) error { + slog.Debug("deleting signing certificates", "account_id", accountId, "certificates", certificates) for _, certificate := range certificates { - logger.Debug("aws.DeleteSigningCertificates", "deleting root Signing Certificate %s", certificate) - if _, err := c.client.DeleteSigningCertificate(ctx, &iam.DeleteSigningCertificateInput{ CertificateId: aws.String(certificate), }); err != nil { @@ -202,54 +173,46 @@ func (c *IamClient) DeleteSigningCertificates(ctx context.Context, accountId str } } - logger.Info("aws.DeleteSigningCertificates", "successfully deleted signing certificates for account %s", accountId) - return nil } // Enable centralized root credentials management -func (c *IamClient) EnableOrganizationsRootCredentialsManagement(ctx context.Context) error { - logger.Debug("aws.EnableOrganizationsRootCredentialsManagement", "enabling organization root credentials management") +func (c *iamClient) EnableOrganizationsRootCredentialsManagement(ctx context.Context) error { + slog.Debug("enabling organization root credentials management") _, err := c.client.EnableOrganizationsRootCredentialsManagement(ctx, &iam.EnableOrganizationsRootCredentialsManagementInput{}) if err != nil { return fmt.Errorf("error enabling organization root credentials management: %w", err) } - logger.Info("aws.EnableOrganizationsRootCredentialsManagement", "successfully enabled organization root credentials management") - return nil } // Enable centralized root sessions -func (c *IamClient) EnableOrganizationsRootSessions(ctx context.Context) error { - logger.Debug("aws.EnableOrganizationsRootSessions", "enabling organization root sessions") +func (c *iamClient) EnableOrganizationsRootSessions(ctx context.Context) error { + slog.Debug("enabling organization root sessions") _, err := c.client.EnableOrganizationsRootSessions(ctx, &iam.EnableOrganizationsRootSessionsInput{}) if err != nil { return fmt.Errorf("error enabling organization root sessions: %w", err) } - logger.Info("aws.EnableOrganizationsRootSessions", "successfully enabled organization root sessions management") - return nil } // Allow root password recovery -func (c *IamClient) CreateLoginProfile(ctx context.Context) error { - logger.Debug("aws.createLoginProfile", "creating loggin profile") +func (c *iamClient) CreateLoginProfile(ctx context.Context) error { + slog.Debug("creating login profile") _, err := c.client.CreateLoginProfile(ctx, &iam.CreateLoginProfileInput{}) if err != nil { var entityAlreadyExistsErr *types.EntityAlreadyExistsException if errors.As(err, &entityAlreadyExistsErr) { - logger.Debug("aws.createLoginProfile", "login profile already exists") - return ErrEntityAlreadyExists + slog.Debug("login profile already exists") + return rootmanager.ErrEntityAlreadyExists } return fmt.Errorf("error creating login profile: %w", err) } - logger.Info("aws.createLoginProfile", "successfully created login profile") - return nil } diff --git a/internal/infra/aws/interfaces.go b/internal/infra/aws/interfaces.go new file mode 100644 index 0000000..53930e5 --- /dev/null +++ b/internal/infra/aws/interfaces.go @@ -0,0 +1,67 @@ +package aws + +import ( + "context" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" +) + +// IamClient defines the interface for IAM operations. +// This interface enables mocking and dependency injection for testing. +type IamClient interface { + // CheckOrganizationRootAccess verifies if AWS centralized root access is enabled + CheckOrganizationRootAccess(ctx context.Context, rootSessionsRequired bool) error + + // GetLoginProfile checks if an account has root login profile enabled + GetLoginProfile(ctx context.Context, accountId string) (bool, error) + + // DeleteLoginProfile deletes root login profile for a specific account + DeleteLoginProfile(ctx context.Context, accountId string) error + + // ListAccessKeys gets a list of root access keys for a specific account + ListAccessKeys(ctx context.Context, accountId string) ([]string, error) + + // DeleteAccessKeys deletes a list of root access keys for a specific account + DeleteAccessKeys(ctx context.Context, accountId string, accessKeyIds []string) error + + // ListMFADevices gets a list of root MFA devices for a specific account + ListMFADevices(ctx context.Context, accountId string) ([]string, error) + + // DeactivateMFADevices deactivates a list of root MFA devices for a specific account + DeactivateMFADevices(ctx context.Context, accountId string, mfaSerialNumbers []string) error + + // ListSigningCertificates gets a list of root signing certificates for a specific account + ListSigningCertificates(ctx context.Context, accountId string) ([]string, error) + + // DeleteSigningCertificates deletes a list of root signing certificates for a specific account + DeleteSigningCertificates(ctx context.Context, accountId string, certificates []string) error + + // EnableOrganizationsRootCredentialsManagement enables centralized root credentials management + EnableOrganizationsRootCredentialsManagement(ctx context.Context) error + + // EnableOrganizationsRootSessions enables centralized root sessions + EnableOrganizationsRootSessions(ctx context.Context) error + + // CreateLoginProfile allows root password recovery + CreateLoginProfile(ctx context.Context) error +} + +// StsClient defines the interface for STS operations. +// This interface enables mocking and dependency injection for testing. +type StsClient interface { + // GetAssumeRootConfig gets AWS config with assumed root credentials for a specific account and task + GetAssumeRootConfig(ctx context.Context, accountId, taskPolicyName string) (awssdk.Config, error) +} + +// OrganizationsClient defines the interface for AWS Organizations operations. +// This interface enables mocking and dependency injection for testing. +type OrganizationsClient interface { + // DescribeOrganization returns the management account ID of the organization + DescribeOrganization(ctx context.Context) (string, error) + + // ListAccounts returns all accounts in the organization + ListAccounts(ctx context.Context) ([]OrganizationAccount, error) + + // EnableAWSServiceAccess enables AWS service access for the organization + EnableAWSServiceAccess(ctx context.Context, service string) error +} diff --git a/internal/infra/aws/organizations.go b/internal/infra/aws/organizations.go new file mode 100644 index 0000000..98cc45c --- /dev/null +++ b/internal/infra/aws/organizations.go @@ -0,0 +1,99 @@ +package aws + +import ( + "context" + "fmt" + "log/slog" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/organizations" + "github.com/aws/aws-sdk-go-v2/service/organizations/types" +) + +type organizationsClient struct { + client *organizations.Client +} + +func NewOrganizationsClient(awscfg aws.Config) OrganizationsClient { + client := organizations.NewFromConfig(awscfg) + return &organizationsClient{client: client} +} + +type OrganizationAccount struct { + Name string + AccountID string +} + +// GetNonManagementOrganizationAccounts fetches active organization accounts, excluding the management account. +func GetNonManagementOrganizationAccounts(ctx context.Context, org OrganizationsClient) ([]OrganizationAccount, error) { + slog.Debug("getting organization accounts") + + mgmAccountId, err := org.DescribeOrganization(ctx) + if err != nil { + return nil, err + } + + allAccounts, err := org.ListAccounts(ctx) + if err != nil { + return nil, err + } + + var nonManagementAccounts []OrganizationAccount + for _, acc := range allAccounts { + if acc.AccountID != mgmAccountId { + nonManagementAccounts = append(nonManagementAccounts, acc) + } + } + + return nonManagementAccounts, nil +} + +func (c *organizationsClient) DescribeOrganization(ctx context.Context) (string, error) { + slog.Debug("describing organization") + + organization, err := c.client.DescribeOrganization(ctx, &organizations.DescribeOrganizationInput{}) + if err != nil { + return "", fmt.Errorf("failed to describe organization: %w", err) + } + + return *organization.Organization.MasterAccountId, nil +} + +func (c *organizationsClient) ListAccounts(ctx context.Context) ([]OrganizationAccount, error) { + slog.Debug("listing organization accounts") + + params := &organizations.ListAccountsInput{} + paginator := organizations.NewListAccountsPaginator(c.client, params) + + var accounts []OrganizationAccount + + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list organization accounts: %v", err) + } + for _, acc := range page.Accounts { + if acc.Status == types.AccountStatusActive { + accounts = append(accounts, OrganizationAccount{ + Name: aws.ToString(acc.Name), + AccountID: aws.ToString(acc.Id), + }) + } + } + } + + return accounts, nil +} + +func (c *organizationsClient) EnableAWSServiceAccess(ctx context.Context, service string) error { + slog.Debug("enabling service access", "service", service) + + _, err := c.client.EnableAWSServiceAccess(ctx, &organizations.EnableAWSServiceAccessInput{ + ServicePrincipal: aws.String(service), + }) + if err != nil { + return fmt.Errorf("aws.enableAWSServiceAccess: failed to enable service access: %w", err) + } + + return nil +} diff --git a/pkg/aws/sts.go b/internal/infra/aws/sts.go similarity index 68% rename from pkg/aws/sts.go rename to internal/infra/aws/sts.go index 46c9294..00bf9ce 100644 --- a/pkg/aws/sts.go +++ b/internal/infra/aws/sts.go @@ -3,8 +3,7 @@ package aws import ( "context" "fmt" - - "github.com/unicrons/aws-root-manager/pkg/logger" + "log/slog" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -13,17 +12,17 @@ import ( const rootPolicyPrefix = "arn:aws:iam::aws:policy/root-task/" -type StsClient struct { +type stsClient struct { client *sts.Client } -func NewStsClient(awscfg aws.Config) *StsClient { +func NewStsClient(awscfg aws.Config) StsClient { client := sts.NewFromConfig(awscfg) - return &StsClient{client: client} + return &stsClient{client: client} } -func (c *StsClient) GetAssumeRootConfig(ctx context.Context, accountId, taskPolicyName string) (aws.Config, error) { - logger.Trace("aws.GetAssumeRootConfig", "getting root aws.config account %s and task %s", accountId, taskPolicyName) +func (c *stsClient) GetAssumeRootConfig(ctx context.Context, accountId, taskPolicyName string) (aws.Config, error) { + slog.Debug("getting root aws config", "account_id", accountId, "task", taskPolicyName) stsCreds, err := c.assumeRoot(ctx, accountId, taskPolicyName) if err != nil { @@ -43,13 +42,13 @@ func (c *StsClient) GetAssumeRootConfig(ctx context.Context, accountId, taskPoli return aws.Config{}, fmt.Errorf("error loading aws root config: %s", err) } - logger.Debug("aws.GetAssumeRootConfig", "successfully generated assume root credentials for account %s and task %s", accountId, taskPolicyName) + slog.Debug("successfully generated assume root credentials", "account_id", accountId, "task", taskPolicyName) return awsrootcfg, nil } -func (c *StsClient) assumeRoot(ctx context.Context, accountId, taskPolicyName string) (types.Credentials, error) { - logger.Trace("aws.assumeRoot", "assuming root for account %s and task %s", accountId, taskPolicyName) +func (c *stsClient) assumeRoot(ctx context.Context, accountId, taskPolicyName string) (types.Credentials, error) { + slog.Debug("assuming root", "account_id", accountId, "task", taskPolicyName) params := &sts.AssumeRootInput{ TargetPrincipal: aws.String(accountId), diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..7e20e6a --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,38 @@ +package logger + +import ( + "log/slog" + "os" +) + +// Configure sets up the global slog logger based on the given level and format. +func Configure(level, format string) { + slogLevel := parseLevel(level) + + opts := &slog.HandlerOptions{Level: slogLevel, AddSource: true} + + var handler slog.Handler + switch format { + case "json": + handler = slog.NewJSONHandler(os.Stderr, opts) + default: + handler = slog.NewTextHandler(os.Stderr, opts) + } + + slog.SetDefault(slog.New(handler)) +} + +func parseLevel(level string) slog.Level { + switch level { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelError + } +} diff --git a/internal/service/audit.go b/internal/service/audit.go new file mode 100644 index 0000000..5d7095e --- /dev/null +++ b/internal/service/audit.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "log/slog" + "sync" + + "github.com/unicrons/aws-root-manager/internal/infra/aws" + "github.com/unicrons/aws-root-manager/rootmanager" +) + +// auditAccounts returns root credentials for a list of AWS accounts. +func auditAccounts(ctx context.Context, iam aws.IamClient, sts aws.StsClient, factory aws.IamClientFactory, accounts []string) ([]rootmanager.RootCredentials, error) { + slog.Debug("auditing accounts", "accounts", accounts) + + rootCredentials := make([]rootmanager.RootCredentials, len(accounts)) + var wgAccounts sync.WaitGroup + + if err := iam.CheckOrganizationRootAccess(ctx, false); err != nil { + return nil, err + } + + for i, accountId := range accounts { + wgAccounts.Add(1) + go func(idx int, accountId string) { + defer wgAccounts.Done() + if accStatus, err := auditAccount(ctx, sts, factory, accountId); err != nil { + rootCredentials[idx] = rootmanager.RootCredentials{AccountId: accountId, Error: err.Error()} + } else { + rootCredentials[idx] = accStatus + } + }(i, accountId) + } + + wgAccounts.Wait() + + return rootCredentials, nil +} + +// Get root credentials for a specific account +func auditAccount(ctx context.Context, sts aws.StsClient, factory aws.IamClientFactory, accountId string) (rootmanager.RootCredentials, error) { + slog.Debug("auditing account", "account_id", accountId) + + awscfgRoot, err := sts.GetAssumeRootConfig(ctx, accountId, "IAMAuditRootUserCredentials") + if err != nil { + return rootmanager.RootCredentials{}, err + } + + iamRoot := factory.NewIamClient(awscfgRoot) + var accountRootCredentials rootmanager.RootCredentials + + loginProfile, err := iamRoot.GetLoginProfile(ctx, accountId) + if err != nil { + return accountRootCredentials, err + } + slog.Debug("audit result", "account_id", accountId, "login_profile", loginProfile) + + accessKeys, err := iamRoot.ListAccessKeys(ctx, accountId) + if err != nil { + return accountRootCredentials, err + } + slog.Debug("audit result", "account_id", accountId, "access_keys", accessKeys) + + mfaDevices, err := iamRoot.ListMFADevices(ctx, accountId) + if err != nil { + return accountRootCredentials, err + } + slog.Debug("audit result", "account_id", accountId, "mfa_devices", mfaDevices) + + certificates, err := iamRoot.ListSigningCertificates(ctx, accountId) + if err != nil { + return accountRootCredentials, err + } + slog.Debug("audit result", "account_id", accountId, "signing_certificates", certificates) + + accountRootCredentials = rootmanager.RootCredentials{ + AccountId: accountId, + LoginProfile: loginProfile, + AccessKeys: accessKeys, + MfaDevices: mfaDevices, + SigningCertificates: certificates, + } + + return accountRootCredentials, nil +} diff --git a/pkg/service/configuration.go b/internal/service/configuration.go similarity index 52% rename from pkg/service/configuration.go rename to internal/service/configuration.go index 52b1862..c5fea72 100644 --- a/pkg/service/configuration.go +++ b/internal/service/configuration.go @@ -2,13 +2,15 @@ package service import ( "context" + "errors" + "log/slog" - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" + "github.com/unicrons/aws-root-manager/internal/infra/aws" + "github.com/unicrons/aws-root-manager/rootmanager" ) -func CheckRootAccess(ctx context.Context, iam *aws.IamClient) (aws.RootAccessStatus, error) { - var status = aws.RootAccessStatus{ +func checkRootAccess(ctx context.Context, iam aws.IamClient) (rootmanager.RootAccessStatus, error) { + var status = rootmanager.RootAccessStatus{ TrustedAccess: false, RootCredentialsManagement: false, RootSessions: false, @@ -16,24 +18,24 @@ func CheckRootAccess(ctx context.Context, iam *aws.IamClient) (aws.RootAccessSta err := iam.CheckOrganizationRootAccess(ctx, true) if err != nil { - if err == aws.ErrTrustedAccessNotEnabled { + if errors.Is(err, rootmanager.ErrTrustedAccessNotEnabled) { return status, nil } status.TrustedAccess = true - if err == aws.ErrRootCredentialsManagementNotEnabled { + if errors.Is(err, rootmanager.ErrRootCredentialsManagementNotEnabled) { return status, nil } status.RootCredentialsManagement = true - if err == aws.ErrRootSessionsNotEnabled { + if errors.Is(err, rootmanager.ErrRootSessionsNotEnabled) { return status, nil } - return aws.RootAccessStatus{}, err + return rootmanager.RootAccessStatus{}, err } - status = aws.RootAccessStatus{ + status = rootmanager.RootAccessStatus{ TrustedAccess: true, RootCredentialsManagement: true, RootSessions: true, @@ -42,16 +44,16 @@ func CheckRootAccess(ctx context.Context, iam *aws.IamClient) (aws.RootAccessSta return status, nil } -func EnableRootAccess(ctx context.Context, iam *aws.IamClient, org *aws.OrganizationsClient, enableSessions bool) (aws.RootAccessStatus, aws.RootAccessStatus, error) { - var initStatus, status aws.RootAccessStatus +func enableRootAccess(ctx context.Context, iam aws.IamClient, org aws.OrganizationsClient, enableSessions bool) (rootmanager.RootAccessStatus, rootmanager.RootAccessStatus, error) { + var initStatus, status rootmanager.RootAccessStatus - initStatus, err := CheckRootAccess(ctx, iam) + initStatus, err := checkRootAccess(ctx, iam) if err != nil { return initStatus, status, err } if !initStatus.TrustedAccess { - logger.Debug("service.EnableRootAccess", "trusted access is disabled") + slog.Debug("trusted access is disabled") err := org.EnableAWSServiceAccess(ctx, "iam.amazonaws.com") if err != nil { return initStatus, status, err @@ -59,7 +61,7 @@ func EnableRootAccess(ctx context.Context, iam *aws.IamClient, org *aws.Organiza } if !initStatus.RootCredentialsManagement { - logger.Debug("service.EnableRootAccess", "root credentials management is disabled") + slog.Debug("root credentials management is disabled") err = iam.EnableOrganizationsRootCredentialsManagement(ctx) if err != nil { return initStatus, status, err @@ -67,7 +69,7 @@ func EnableRootAccess(ctx context.Context, iam *aws.IamClient, org *aws.Organiza } if !initStatus.RootSessions && enableSessions { - logger.Debug("service.EnableRootAccess", "root sessions is disabled") + slog.Debug("root sessions is disabled") err = iam.EnableOrganizationsRootSessions(ctx) if err != nil { @@ -75,7 +77,7 @@ func EnableRootAccess(ctx context.Context, iam *aws.IamClient, org *aws.Organiza } } - status, err = CheckRootAccess(ctx, iam) + status, err = checkRootAccess(ctx, iam) if err != nil { return initStatus, status, err } diff --git a/internal/service/credentials.go b/internal/service/credentials.go new file mode 100644 index 0000000..f311f71 --- /dev/null +++ b/internal/service/credentials.go @@ -0,0 +1,169 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "sync" + + "github.com/unicrons/aws-root-manager/internal/infra/aws" + "github.com/unicrons/aws-root-manager/rootmanager" +) + +// deleteAccountsCredentials deletes root credentials for a list of AWS accounts. +// Returns a slice of DeletionResult containing the outcome for each account. +func deleteAccountsCredentials(ctx context.Context, iam aws.IamClient, sts aws.StsClient, factory aws.IamClientFactory, creds []rootmanager.RootCredentials, credentialType string) ([]rootmanager.DeletionResult, error) { + if err := iam.CheckOrganizationRootAccess(ctx, false); err != nil { + return nil, err + } + + results := make([]rootmanager.DeletionResult, len(creds)) + var wgAccounts sync.WaitGroup + + for i, accountCredentials := range creds { + wgAccounts.Add(1) + go func(idx int, accountCreds rootmanager.RootCredentials) { + defer wgAccounts.Done() + if err := deleteAccountCredentials(ctx, sts, factory, accountCreds, credentialType); err != nil { + results[idx] = rootmanager.DeletionResult{ + AccountId: accountCreds.AccountId, + CredentialType: credentialType, + Success: false, + Error: err.Error(), + } + } else { + results[idx] = rootmanager.DeletionResult{ + AccountId: accountCreds.AccountId, + CredentialType: credentialType, + Success: true, + Error: "", + } + } + }(i, accountCredentials) + } + + wgAccounts.Wait() + + return results, nil +} + +// deleteAccountCredentials deletes root credentials for a specific account. +func deleteAccountCredentials(ctx context.Context, sts aws.StsClient, factory aws.IamClientFactory, creds rootmanager.RootCredentials, credentialType string) error { + slog.Debug("checking credentials to delete", "account_id", creds.AccountId, "credential_type", credentialType) + + // Check if there are credentials to delete before assuming root + if !hasCredentialsToDelete(creds, credentialType) { + return nil + } + + awscfgDeleteRoot, err := sts.GetAssumeRootConfig(ctx, creds.AccountId, "IAMDeleteRootUserCredentials") + if err != nil { + return err + } + iamDeleteRoot := factory.NewIamClient(awscfgDeleteRoot) + + if creds.LoginProfile && (credentialType == "all" || credentialType == "login") { + err = iamDeleteRoot.DeleteLoginProfile(ctx, creds.AccountId) + if err != nil { + return err + } + } + + if len(creds.AccessKeys) > 0 && (credentialType == "all" || credentialType == "keys") { + err = iamDeleteRoot.DeleteAccessKeys(ctx, creds.AccountId, creds.AccessKeys) + if err != nil { + return err + } + } + + if len(creds.MfaDevices) > 0 && (credentialType == "all" || credentialType == "mfa") { + err = iamDeleteRoot.DeactivateMFADevices(ctx, creds.AccountId, creds.MfaDevices) + if err != nil { + return err + } + } + + if len(creds.SigningCertificates) > 0 && (credentialType == "all" || credentialType == "certificate") { + err = iamDeleteRoot.DeleteSigningCertificates(ctx, creds.AccountId, creds.SigningCertificates) + if err != nil { + return err + } + } + + return nil +} + +// Check if the account has credentials to delete based on the credential type +func hasCredentialsToDelete(creds rootmanager.RootCredentials, credentialType string) bool { + switch credentialType { + case "all": + return creds.LoginProfile || len(creds.AccessKeys) > 0 || len(creds.MfaDevices) > 0 || len(creds.SigningCertificates) > 0 + case "login": + return creds.LoginProfile + case "keys": + return len(creds.AccessKeys) > 0 + case "mfa": + return len(creds.MfaDevices) > 0 + case "certificate": + return len(creds.SigningCertificates) > 0 + default: + return false + } +} + +// recoverAccountsRootPassword initiates root password recovery for a list of AWS accounts. +// Returns a slice of RecoveryResult containing the outcome for each account. +func recoverAccountsRootPassword(ctx context.Context, iam aws.IamClient, sts aws.StsClient, factory aws.IamClientFactory, accountIds []string) ([]rootmanager.RecoveryResult, error) { + if err := iam.CheckOrganizationRootAccess(ctx, false); err != nil { + return nil, err + } + + results := make([]rootmanager.RecoveryResult, len(accountIds)) + var wgAccounts sync.WaitGroup + + for i, accountId := range accountIds { + wgAccounts.Add(1) + go func(idx int, accId string) { + defer wgAccounts.Done() + success, err := recoverAccountRootPassowrd(ctx, sts, factory, accId) + if err != nil { + results[idx] = rootmanager.RecoveryResult{ + AccountId: accId, + Success: false, + Error: err.Error(), + } + } else { + results[idx] = rootmanager.RecoveryResult{ + AccountId: accId, + Success: success, + Error: "", + } + } + }(i, accountId) + } + + wgAccounts.Wait() + + return results, nil +} + +// Enable the recovery process for root passwords for a specific account +func recoverAccountRootPassowrd(ctx context.Context, sts aws.StsClient, factory aws.IamClientFactory, accountId string) (bool, error) { + slog.Debug("trying to recover root password", "account_id", accountId) + + awscfgRecoverRoot, err := sts.GetAssumeRootConfig(ctx, accountId, "IAMCreateRootUserPassword") + if err != nil { + return false, err + } + iamRecoverRoot := factory.NewIamClient(awscfgRecoverRoot) + + err = iamRecoverRoot.CreateLoginProfile(ctx) + if err != nil { + if errors.Is(err, rootmanager.ErrEntityAlreadyExists) { + return false, nil + } + return false, err + } + + return true, nil +} diff --git a/internal/service/rootmanager.go b/internal/service/rootmanager.go new file mode 100644 index 0000000..054a859 --- /dev/null +++ b/internal/service/rootmanager.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "errors" + + "github.com/unicrons/aws-root-manager/internal/infra/aws" + "github.com/unicrons/aws-root-manager/rootmanager" +) + +// manager implements rootmanager.RootManager using AWS clients. +type manager struct { + iam aws.IamClient + sts aws.StsClient + org aws.OrganizationsClient + factory aws.IamClientFactory +} + +// NewRootManager returns a RootManager that uses the given AWS clients and factory. +// sts and org may be nil for callers that only use CheckRootAccess. +func NewRootManager(iam aws.IamClient, sts aws.StsClient, org aws.OrganizationsClient, factory aws.IamClientFactory) rootmanager.RootManager { + return &manager{iam: iam, sts: sts, org: org, factory: factory} +} + +// NewRootManagerFromConfig loads the default AWS config and returns a ready-to-use RootManager. +func NewRootManagerFromConfig(ctx context.Context) (rootmanager.RootManager, error) { + cfg, err := aws.LoadAWSConfig(ctx) + if err != nil { + return nil, err + } + return NewRootManager( + aws.NewIamClient(cfg), + aws.NewStsClient(cfg), + aws.NewOrganizationsClient(cfg), + &aws.DefaultIamClientFactory{}, + ), nil +} + +func (m *manager) AuditAccounts(ctx context.Context, accountIds []string) ([]rootmanager.RootCredentials, error) { + if m.sts == nil { + return nil, errors.New("STS client required for audit") + } + return auditAccounts(ctx, m.iam, m.sts, m.factory, accountIds) +} + +func (m *manager) CheckRootAccess(ctx context.Context) (rootmanager.RootAccessStatus, error) { + return checkRootAccess(ctx, m.iam) +} + +func (m *manager) EnableRootAccess(ctx context.Context, enableSessions bool) (rootmanager.RootAccessStatus, rootmanager.RootAccessStatus, error) { + if m.org == nil { + return rootmanager.RootAccessStatus{}, rootmanager.RootAccessStatus{}, errors.New("Organizations client required for enable") + } + return enableRootAccess(ctx, m.iam, m.org, enableSessions) +} + +func (m *manager) DeleteCredentials(ctx context.Context, creds []rootmanager.RootCredentials, credentialType string) ([]rootmanager.DeletionResult, error) { + if m.sts == nil { + return nil, errors.New("STS client required for delete") + } + return deleteAccountsCredentials(ctx, m.iam, m.sts, m.factory, creds, credentialType) +} + +func (m *manager) RecoverRootPassword(ctx context.Context, accountIds []string) ([]rootmanager.RecoveryResult, error) { + if m.sts == nil { + return nil, errors.New("STS client required for recovery") + } + return recoverAccountsRootPassword(ctx, m.iam, m.sts, m.factory, accountIds) +} diff --git a/pkg/aws/organizations.go b/pkg/aws/organizations.go deleted file mode 100644 index 37bfcd3..0000000 --- a/pkg/aws/organizations.go +++ /dev/null @@ -1,104 +0,0 @@ -package aws - -import ( - "context" - "fmt" - - "github.com/unicrons/aws-root-manager/pkg/logger" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/organizations" - "github.com/aws/aws-sdk-go-v2/service/organizations/types" -) - -type OrganizationsClient struct { - client *organizations.Client -} - -func NewOrganizationsClient(awscfg aws.Config) *OrganizationsClient { - client := organizations.NewFromConfig(awscfg) - return &OrganizationsClient{client: client} -} - -type OrganizationAccount struct { - Name string - AccountID string -} - -// Fetches AWS Organization accounts, excluding the management account -func GetNonManagementOrganizationAccounts(ctx context.Context) ([]OrganizationAccount, error) { - logger.Trace("aws.GetNonManagementOrganizationAccounts", "getting organization accounts") - - awscfg, err := LoadAWSConfig(ctx) - if err != nil { - return nil, fmt.Errorf("failed to load aws config: %w", err) - } - - organizations := NewOrganizationsClient(awscfg) - - mgmAccount, err := organizations.describeOrganization(ctx) - if err != nil { - return nil, err - } - - orgAccounts, err := organizations.listOrganizationAccounts() - if err != nil { - return nil, err - } - - var nonManagementOrgAccounts []OrganizationAccount - for _, acc := range orgAccounts { - if string(acc.State) == "ACTIVE" && *acc.Id != mgmAccount { - account := OrganizationAccount{ - Name: *acc.Name, - AccountID: *acc.Id, - } - nonManagementOrgAccounts = append(nonManagementOrgAccounts, account) - } - } - - return nonManagementOrgAccounts, nil -} - -func (c *OrganizationsClient) listOrganizationAccounts() ([]types.Account, error) { - logger.Trace("aws.listOrganizationAccounts", "listing organization accounts") - - params := &organizations.ListAccountsInput{} - paginator := organizations.NewListAccountsPaginator(c.client, params) - - var allAccounts []types.Account - - for paginator.HasMorePages() { - page, err := paginator.NextPage(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to list organization accounts: %v", err) - } - allAccounts = append(allAccounts, page.Accounts...) - } - - return allAccounts, nil -} - -func (c *OrganizationsClient) describeOrganization(ctx context.Context) (string, error) { - logger.Trace("aws.describeOrganization", "describing organization") - - organization, err := c.client.DescribeOrganization(ctx, &organizations.DescribeOrganizationInput{}) - if err != nil { - return "", fmt.Errorf("failed to describe organization: %w", err) - } - - return *organization.Organization.MasterAccountId, nil -} - -func (c *OrganizationsClient) EnableAWSServiceAccess(ctx context.Context, service string) error { - logger.Trace("aws.EnableAWSServiceAccess", "enabling %s service access", service) - - _, err := c.client.EnableAWSServiceAccess(ctx, &organizations.EnableAWSServiceAccessInput{ - ServicePrincipal: aws.String(service), - }) - if err != nil { - return fmt.Errorf("aws.enableAWSServiceAccess: failed to enable service access: %w", err) - } - - return nil -} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go deleted file mode 100644 index f57d097..0000000 --- a/pkg/logger/logger.go +++ /dev/null @@ -1,55 +0,0 @@ -package logger - -import ( - "os" - - log "github.com/sirupsen/logrus" -) - -func init() { - lvl, ok := os.LookupEnv("LOG_LEVEL") - if !ok { - lvl = "error" // default - } - - ll, err := log.ParseLevel(lvl) - if err != nil { - ll = log.DebugLevel - } - log.SetLevel(ll) - - format, ok := os.LookupEnv("LOG_FORMAT") - if ok { - SetLoggerFormat(format) - } -} - -func SetLoggerFormat(logFormat string) { - switch logFormat { - case "json": - log.SetFormatter(&log.JSONFormatter{}) - default: - log.SetFormatter(&log.TextFormatter{}) - } -} - -// Wrap logrus with function name -func Trace(funcName, format string, args ...any) { - log.WithField("function", funcName).Tracef(format, args...) -} - -func Debug(funcName, format string, args ...any) { - log.WithField("function", funcName).Debugf(format, args...) -} - -func Info(funcName, format string, args ...any) { - log.WithField("function", funcName).Infof(format, args...) -} - -func Warn(funcName, format string, args ...any) { - log.WithField("function", funcName).Warnf(format, args...) -} - -func Error(funcName string, err error, format string, args ...any) { - log.WithField("function", funcName).WithError(err).Errorf(format, args...) -} diff --git a/pkg/service/audit.go b/pkg/service/audit.go deleted file mode 100644 index 53741d5..0000000 --- a/pkg/service/audit.go +++ /dev/null @@ -1,85 +0,0 @@ -package service - -import ( - "context" - "sync" - - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" -) - -// Get root credentials for a list of AWS accounts. -func AuditAccounts(ctx context.Context, iam *aws.IamClient, sts *aws.StsClient, accounts []string) ([]aws.RootCredentials, error) { - logger.Trace("service.AuditAccounts", "auditing accounts %s", accounts) - - rootCredentials := make([]aws.RootCredentials, len(accounts)) - var wgAccounts sync.WaitGroup - - if err := iam.CheckOrganizationRootAccess(ctx, false); err != nil { - return nil, err - } - - for i, accountId := range accounts { - wgAccounts.Add(1) - go func(idx int, accountId string) { - defer wgAccounts.Done() - if accStatus, err := auditAccount(ctx, sts, accountId); err != nil { - logger.Error("service.AuditAccounts", err, "account %s: audit skipped", accountId) - rootCredentials[idx] = aws.RootCredentials{AccountId: accountId, Error: err.Error()} - } else { - rootCredentials[idx] = accStatus - } - }(i, accountId) - } - - wgAccounts.Wait() - - return rootCredentials, nil -} - -// Get root credentials for a specific account -func auditAccount(ctx context.Context, sts *aws.StsClient, accountId string) (aws.RootCredentials, error) { - logger.Trace("service.auditAccount", "auditing account %s", accountId) - - awscfgRoot, err := sts.GetAssumeRootConfig(ctx, accountId, "IAMAuditRootUserCredentials") - if err != nil { - return aws.RootCredentials{}, err - } - - iamRoot := aws.NewIamClient(awscfgRoot) - var accountRootCredentials aws.RootCredentials - - loginProfile, err := iamRoot.GetLoginProfile(ctx, accountId) - if err != nil { - return accountRootCredentials, err - } - logger.Debug("service.AuditAccounts", "account %s - login_profile: %t", accountId, loginProfile) - - accessKeys, err := iamRoot.ListAccessKeys(ctx, accountId) - if err != nil { - return accountRootCredentials, err - } - logger.Debug("service.AuditAccounts", "account %s - access_keys: %s", accountId, accessKeys) - - mfaDevices, err := iamRoot.ListMFADevices(ctx, accountId) - if err != nil { - return accountRootCredentials, err - } - logger.Debug("service.AuditAccounts", "account %s - mfa_devices: %s", accountId, mfaDevices) - - certificates, err := iamRoot.ListSigningCertificates(ctx, accountId) - if err != nil { - return accountRootCredentials, err - } - logger.Debug("service.AuditAccounts", "account %s - signing_certificates: %s", accountId, certificates) - - accountRootCredentials = aws.RootCredentials{ - AccountId: accountId, - LoginProfile: loginProfile, - AccessKeys: accessKeys, - MfaDevices: mfaDevices, - SigningCertificates: certificates, - } - - return accountRootCredentials, nil -} diff --git a/pkg/service/credentials.go b/pkg/service/credentials.go deleted file mode 100644 index 4708798..0000000 --- a/pkg/service/credentials.go +++ /dev/null @@ -1,166 +0,0 @@ -package service - -import ( - "context" - "sync" - - "github.com/unicrons/aws-root-manager/pkg/aws" - "github.com/unicrons/aws-root-manager/pkg/logger" -) - -// Delete root credentials for a list of AWS accounts -func DeleteAccountsCredentials(ctx context.Context, iam *aws.IamClient, sts *aws.StsClient, creds []aws.RootCredentials, credentialType string) error { - var ( - wgAccounts sync.WaitGroup - errChan = make(chan error, len(creds)) - ) - - if err := iam.CheckOrganizationRootAccess(ctx, false); err != nil { - return err - } - - for _, accountCredentials := range creds { - wgAccounts.Add(1) - go func(accountId aws.RootCredentials) { - defer wgAccounts.Done() - if err := deleteAccountCrendentials(ctx, sts, accountCredentials, credentialType); err != nil { - errChan <- err - } - }(accountCredentials) - } - - wgAccounts.Wait() - close(errChan) - - if len(errChan) > 0 { - return <-errChan - } - - return nil -} - -// Delete root credentials for a specific account -func deleteAccountCrendentials(ctx context.Context, sts *aws.StsClient, creds aws.RootCredentials, credentialType string) error { - logger.Trace("service.deleteAccountCrendentials", "checking if account %s has %s credentials to delete", credentialType, credentialType) - - // Check if there are credentials to delete before assuming root - if !hasCredentialsToDelete(creds, credentialType) { - logger.Info("service.deleteAccountCrendentials", "no %s credentials found for account %s", credentialType, creds.AccountId) - return nil - } - - awscfgDeleteRoot, err := sts.GetAssumeRootConfig(ctx, creds.AccountId, "IAMDeleteRootUserCredentials") - if err != nil { - return err - } - iamDeleteRoot := aws.NewIamClient(awscfgDeleteRoot) - - if creds.LoginProfile && (credentialType == "all" || credentialType == "login") { - err = iamDeleteRoot.DeleteLoginProfile(ctx, creds.AccountId) - if err != nil { - return err - } - } - - if len(creds.AccessKeys) > 0 && (credentialType == "all" || credentialType == "keys") { - err = iamDeleteRoot.DeleteAccessKeys(ctx, creds.AccountId, creds.AccessKeys) - if err != nil { - return err - } - } - - if len(creds.MfaDevices) > 0 && (credentialType == "all" || credentialType == "mfa") { - err = iamDeleteRoot.DeactivateMFADevices(ctx, creds.AccountId, creds.MfaDevices) - if err != nil { - return err - } - } - - if len(creds.SigningCertificates) > 0 && (credentialType == "all" || credentialType == "certificate") { - err = iamDeleteRoot.DeleteSigningCertificates(ctx, creds.AccountId, creds.SigningCertificates) - if err != nil { - return err - } - } - - return nil -} - -// Check if the account has credentials to delete based on the credential type -func hasCredentialsToDelete(creds aws.RootCredentials, credentialType string) bool { - switch credentialType { - case "all": - return creds.LoginProfile || len(creds.AccessKeys) > 0 || len(creds.MfaDevices) > 0 || len(creds.SigningCertificates) > 0 - case "login": - return creds.LoginProfile - case "keys": - return len(creds.AccessKeys) > 0 - case "mfa": - return len(creds.MfaDevices) > 0 - case "certificate": - return len(creds.SigningCertificates) > 0 - default: - return false - } -} - -// Enable the recovery process for root passwords for a list of AWS accounts -func RecoverAccountsRootPassword(ctx context.Context, iam *aws.IamClient, sts *aws.StsClient, accountIds []string) (map[string]bool, error) { - var ( - wgAccounts sync.WaitGroup - results = sync.Map{} - errChan = make(chan error, len(accountIds)) - ) - - if err := iam.CheckOrganizationRootAccess(ctx, false); err != nil { - return nil, err - } - - for _, acc := range accountIds { - wgAccounts.Add(1) - go func(accountId string) { - defer wgAccounts.Done() - success, err := recoverAccountRootPassowrd(ctx, sts, acc) - results.Store(accountId, success) - if err != nil { - errChan <- err - } - }(acc) - } - - wgAccounts.Wait() - close(errChan) - - resultMap := make(map[string]bool) - results.Range(func(key, value any) bool { - resultMap[key.(string)] = value.(bool) - return true - }) - - if len(errChan) > 0 { - return resultMap, <-errChan - } - - return resultMap, nil -} - -// Enable the recovery process for root passwords for a specific account -func recoverAccountRootPassowrd(ctx context.Context, sts *aws.StsClient, accountId string) (bool, error) { - logger.Trace("service.recoverAccountRootPassowrd", "trying to recover root password for account %s ", accountId) - - awscfgRecoverRoot, err := sts.GetAssumeRootConfig(ctx, accountId, "IAMCreateRootUserPassword") - if err != nil { - return false, err - } - iamRecoverRoot := aws.NewIamClient(awscfgRecoverRoot) - - err = iamRecoverRoot.CreateLoginProfile(ctx) - if err != nil { - if err == aws.ErrEntityAlreadyExists { - return false, nil - } - return false, err - } - - return true, nil -} diff --git a/rootmanager/api.go b/rootmanager/api.go new file mode 100644 index 0000000..a97584a --- /dev/null +++ b/rootmanager/api.go @@ -0,0 +1,30 @@ +package rootmanager + +import "context" + +// RootManager provides operations for managing AWS root credentials for an AWS organization. +type RootManager interface { + // AuditAccounts audits root credentials across the specified AWS accounts. + // It checks for login profiles, access keys, MFA devices, and signing certificates. + AuditAccounts(ctx context.Context, accountIds []string) ([]RootCredentials, error) + + // CheckRootAccess checks the status of centralized root access features in the organization. + // It verifies whether trusted access, root credentials management, and root sessions are enabled. + CheckRootAccess(ctx context.Context) (RootAccessStatus, error) + + // EnableRootAccess enables centralized root access features in the organization. + // The enableSessions parameter controls whether to enable root sessions (AssumeRoot). + // Returns the initial status, final status after enabling, and any error encountered. + EnableRootAccess(ctx context.Context, enableSessions bool) (RootAccessStatus, RootAccessStatus, error) + + // DeleteCredentials deletes root credentials for the specified accounts. + // The creds parameter should contain audit results identifying what credentials exist. + // The credentialType parameter specifies what to delete: "all", "login", "keys", "mfa", or "certificate". + // Returns a slice of DeletionResult showing the outcome for each account. + DeleteCredentials(ctx context.Context, creds []RootCredentials, credentialType string) ([]DeletionResult, error) + + // RecoverRootPassword initiates root password recovery for the specified accounts. + // This triggers AWS to send password reset emails to the account's root email address. + // Returns a slice of RecoveryResult showing the outcome for each account. + RecoverRootPassword(ctx context.Context, accountIds []string) ([]RecoveryResult, error) +} diff --git a/rootmanager/errors.go b/rootmanager/errors.go new file mode 100644 index 0000000..e275859 --- /dev/null +++ b/rootmanager/errors.go @@ -0,0 +1,17 @@ +package rootmanager + +import "errors" + +var ( + // ErrTrustedAccessNotEnabled indicates AWS IAM does not have trusted access to the organization. + ErrTrustedAccessNotEnabled = errors.New("AWS IAM trusted access is not enabled for the organization") + + // ErrRootCredentialsManagementNotEnabled indicates centralized root credentials management is not enabled. + ErrRootCredentialsManagementNotEnabled = errors.New("centralized root credentials management is not enabled") + + // ErrRootSessionsNotEnabled indicates root sessions (AssumeRoot) are not enabled. + ErrRootSessionsNotEnabled = errors.New("root sessions are not enabled") + + // ErrEntityAlreadyExists indicates the requested entity already exists. + ErrEntityAlreadyExists = errors.New("entity already exists") +) diff --git a/rootmanager/types.go b/rootmanager/types.go new file mode 100644 index 0000000..59f5fb1 --- /dev/null +++ b/rootmanager/types.go @@ -0,0 +1,33 @@ +package rootmanager + +// RootAccessStatus represents the status of centralized root access features in an AWS Organization. +type RootAccessStatus struct { + TrustedAccess bool // Whether AWS IAM has trusted access to the organization + RootCredentialsManagement bool // Whether centralized root credentials management is enabled + RootSessions bool // Whether root sessions (assume root) are enabled +} + +// RootCredentials represents the root user credentials for an AWS account. +type RootCredentials struct { + AccountId string // AWS account ID + LoginProfile bool // Whether a root password exists + AccessKeys []string // List of root access key IDs + MfaDevices []string // List of root MFA device serial numbers + SigningCertificates []string // List of root signing certificate IDs + Error string // Error message if audit failed for this account +} + +// RecoveryResult represents the result of a root password recovery operation for an account. +type RecoveryResult struct { + AccountId string // AWS account ID + Success bool // Whether recovery email was successfully sent + Error string // Error message if recovery failed (empty if Success=true) +} + +// DeletionResult represents the result of a credential deletion operation for an account. +type DeletionResult struct { + AccountId string // AWS account ID + CredentialType string // Type of credential deleted (login, keys, mfa, certificate, all) + Success bool // Whether deletion was successful + Error string // Error message if deletion failed (empty if Success=true) +}