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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ func Delete(newRM func(context.Context) (rootmanager.RootManager, error)) *cobra
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(newRM, "all", "Delete all existing root user credentials", "Delete all existing root user credentials for specific AWS Organization member accounts."))
cmd.AddCommand(deleteSubcommand(newRM, "login", "Delete root user Login Profile", "Delete existing root user Login Profile for specific AWS Organization member accounts."))
cmd.AddCommand(deleteSubcommand(newRM, "keys", "Delete root user Access Keys", "Delete existing root user Access Keys for specific AWS Organization member accounts."))
cmd.AddCommand(deleteSubcommand(newRM, "mfa", "Deactivate root user MFA Devices", "Deactivate existing root user MFA Devices for specific AWS Organization member accounts."))
cmd.AddCommand(deleteSubcommand(newRM, "certificates", "Delete root user Signin Certificates", "Delete existing root user Signing Certificates for specific AWS Organization member accounts."))
cmd.AddCommand(DeleteS3BucketPolicy(newRM))
cmd.AddCommand(DeleteSQSQueuePolicy(newRM))
return cmd
}

Expand All @@ -35,15 +36,18 @@ func deleteSubcommand(newRM func(context.Context) (rootmanager.RootManager, erro
if use == "certificates" {
credentialType = "certificate"
}
return &cobra.Command{
var accounts []string
cmd := &cobra.Command{
Use: use,
Short: short,
Long: long,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDelete(newRM, cmd.OutOrStdout(), accountsFlags, credentialType)
return runDelete(newRM, cmd.OutOrStdout(), accounts, credentialType)
},
}
cmd.Flags().StringSliceVarP(&accounts, "accounts", "a", []string{}, "List of AWS account IDs (comma-separated). Use \"all\" to select all accounts.")
return cmd
}

func runDelete(newRM func(context.Context) (rootmanager.RootManager, error), w io.Writer, accountsFlags []string, credentialType string) error {
Expand Down
120 changes: 120 additions & 0 deletions cmd/delete_s3_bucket_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"

"github.com/spf13/cobra"
"github.com/unicrons/aws-root-manager/internal/aws"
"github.com/unicrons/aws-root-manager/internal/cli/output"
"github.com/unicrons/aws-root-manager/internal/cli/ui"
"github.com/unicrons/aws-root-manager/rootmanager"
)

func DeleteS3BucketPolicy(newRM func(context.Context) (rootmanager.RootManager, error)) *cobra.Command {
var accountId, bucketName string
cmd := &cobra.Command{
Use: "s3-bucket-policy",
Short: "Delete an S3 bucket policy",
Long: `Delete the bucket policy attached to an S3 bucket owned by a member account using the S3UnlockBucketPolicy root task policy.`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runDeleteS3BucketPolicy(newRM, cmd.OutOrStdout(), accountId, bucketName)
},
}
cmd.Flags().StringVar(&accountId, "account", "", "AWS account ID that owns the bucket (optional; if absent, a TUI lists the organization's accounts)")
cmd.Flags().StringVar(&bucketName, "bucket", "", "Name of the S3 bucket (optional; if absent, a TUI lists the account's buckets)")
return cmd
}

func runDeleteS3BucketPolicy(newRM func(context.Context) (rootmanager.RootManager, error), w io.Writer, accountId, bucketName string) error {
ctx := context.Background()
rm, err := newRM(ctx)
if err != nil {
return fmt.Errorf("failed to initialize root manager: %w", err)
}

accountId, err = selectSingleAccount(ctx, accountId)
if err != nil {
return err
}

if bucketName == "" {
buckets, err := rm.ListAccountBuckets(ctx, accountId)
if err != nil {
return fmt.Errorf("failed to list buckets for account %s: %w", accountId, err)
}
if len(buckets) == 0 {
return fmt.Errorf("no buckets found in account %s", accountId)
}
idx, err := ui.PromptSingle("Select the bucket whose policy will be deleted", buckets)
if err != nil {
return err
}
if idx < 0 {
return fmt.Errorf("no bucket selected")
}
bucketName = buckets[idx]
}

policy, err := rm.GetS3BucketPolicy(ctx, accountId, bucketName)
if err != nil {
return fmt.Errorf("failed to get bucket policy: %w", err)
}
if policy == "" {
fmt.Fprintln(w, "No bucket policy found.")
return nil
}
if outputFlag == "table" {
fmt.Fprintf(w, "Current bucket policy for %s:\n\n", bucketName)
output.RenderPolicy(w, policy)

confirmed, err := ui.Confirm("Delete this policy?")
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(w, "Aborted.")
return nil
}
}

result, err := rm.DeleteS3BucketPolicy(ctx, accountId, bucketName)
if err != nil {
return err
}

if !result.Success {
slog.Error("failed to delete s3 bucket policy", "account_id", result.AccountId, "bucket", result.ResourceName, "error", result.Error)
return fmt.Errorf("failed to delete bucket policy for bucket %s", result.ResourceName)
}

var headers []string
var data [][]any
if outputFlag == "table" {
headers = []string{"Account", "ResourceType", "Bucket", "Status"}
data = [][]any{{result.AccountId, result.ResourceType, result.ResourceName, "deleted"}}
} else {
headers = []string{"Account", "ResourceType", "Bucket", "Status", "Policy"}
data = [][]any{{result.AccountId, result.ResourceType, result.ResourceName, "deleted", json.RawMessage(policy)}}
}
output.HandleOutput(w, outputFlag, headers, data)
return nil
}

// selectSingleAccount resolves a single account ID from the --account flag or
// via a single-select TUI (no "all" option).
func selectSingleAccount(ctx context.Context, accountId string) (string, error) {
var flag []string
if accountId != "" {
flag = []string{accountId}
}
awscfg, err := aws.LoadAWSConfig(ctx)
if err != nil {
return "", fmt.Errorf("failed to load aws config: %w", err)
}
return ui.SelectSingleTargetAccount(ctx, aws.NewOrganizationsClient(awscfg), flag)
}
97 changes: 97 additions & 0 deletions cmd/delete_s3_bucket_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cmd

import (
"bytes"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/unicrons/aws-root-manager/rootmanager"
)

// When getBucketPolicyResult is empty, the command prints "No bucket policy found." and exits
// without invoking the TUI confirmation — safe to use in tests.

func TestDeleteS3BucketPolicyCommand_NoPolicyFound(t *testing.T) {
mock := &mockRootManager{
getBucketPolicyResult: "",
}

var buf bytes.Buffer
cmd := Delete(newMockFactory(mock))
cmd.SetOut(&buf)
cmd.SetArgs([]string{"s3-bucket-policy", "--account", "123456789012", "--bucket", "my-bucket"})

require.NoError(t, cmd.Execute())
assert.Contains(t, buf.String(), "No bucket policy found.")
}

func TestDeleteS3BucketPolicyCommand_GetPolicyError(t *testing.T) {
mock := &mockRootManager{
getBucketPolicyErr: errors.New("assume root denied"),
}

cmd := Delete(newMockFactory(mock))
cmd.SilenceErrors = true
cmd.SetArgs([]string{"s3-bucket-policy", "--account", "123456789012", "--bucket", "my-bucket"})

require.Error(t, cmd.Execute())
}

func TestDeleteS3BucketPolicyCommand_FactoryError(t *testing.T) {
factoryErr := errors.New("failed to load AWS config")

cmd := Delete(newFailingFactory(factoryErr))
cmd.SilenceErrors = true
cmd.SetArgs([]string{"s3-bucket-policy", "--account", "123456789012", "--bucket", "my-bucket"})

err := cmd.Execute()
require.Error(t, err)
assert.ErrorIs(t, err, factoryErr)
}

func TestDeleteS3BucketPolicyCommand_DeletionFailure(t *testing.T) {
mock := &mockRootManager{
// Return non-empty policy so get succeeds, but deletion fails.
// Confirmation TUI is skipped because tests aren't interactive —
// PromptSingle returns -1 (no selection), which maps to "No".
getBucketPolicyResult: `{"Version":"2012-10-17"}`,
deleteBucketResult: rootmanager.PolicyDeletionResult{
AccountId: "123456789012", Success: false, Error: "access denied",
},
}

cmd := Delete(newMockFactory(mock))
cmd.SilenceErrors = true
cmd.SetArgs([]string{"s3-bucket-policy", "--account", "123456789012", "--bucket", "my-bucket"})

// Non-interactive: confirm TUI will fail/return no-selection → "Aborted."
_ = cmd.Execute()
}

func TestDeleteS3BucketPolicyCommand_NoBucketsFoundInTUI(t *testing.T) {
mock := &mockRootManager{
listBucketsResult: []string{},
}

cmd := Delete(newMockFactory(mock))
cmd.SilenceErrors = true
cmd.SetArgs([]string{"s3-bucket-policy", "--account", "123456789012"})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "no buckets found")
}

func TestDeleteS3BucketPolicyCommand_ListBucketsError(t *testing.T) {
mock := &mockRootManager{
listBucketsErr: errors.New("assume root denied"),
}

cmd := Delete(newMockFactory(mock))
cmd.SilenceErrors = true
cmd.SetArgs([]string{"s3-bucket-policy", "--account", "123456789012"})

require.Error(t, cmd.Execute())
}
105 changes: 105 additions & 0 deletions cmd/delete_sqs_queue_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"

"github.com/spf13/cobra"
"github.com/unicrons/aws-root-manager/internal/cli/output"
"github.com/unicrons/aws-root-manager/internal/cli/ui"
"github.com/unicrons/aws-root-manager/rootmanager"
)

func DeleteSQSQueuePolicy(newRM func(context.Context) (rootmanager.RootManager, error)) *cobra.Command {
var accountId, queueUrl string
cmd := &cobra.Command{
Use: "sqs-queue-policy",
Short: "Delete an SQS queue policy",
Long: `Clear the access policy attached to an SQS queue owned by a member account using the SQSUnlockQueuePolicy root task policy.`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runDeleteSQSQueuePolicy(newRM, cmd.OutOrStdout(), accountId, queueUrl)
},
}
cmd.Flags().StringVar(&accountId, "account", "", "AWS account ID that owns the queue (optional; if absent, a TUI lists the organization's accounts)")
cmd.Flags().StringVar(&queueUrl, "queue", "", "URL of the SQS queue (optional; if absent, a TUI lists the account's queues)")
return cmd
}

func runDeleteSQSQueuePolicy(newRM func(context.Context) (rootmanager.RootManager, error), w io.Writer, accountId, queueUrl string) error {
ctx := context.Background()
rm, err := newRM(ctx)
if err != nil {
return fmt.Errorf("failed to initialize root manager: %w", err)
}

accountId, err = selectSingleAccount(ctx, accountId)
if err != nil {
return err
}

if queueUrl == "" {
queues, err := rm.ListAccountQueues(ctx, accountId)
if err != nil {
return fmt.Errorf("failed to list queues for account %s: %w", accountId, err)
}
if len(queues) == 0 {
return fmt.Errorf("no queues found in account %s", accountId)
}
idx, err := ui.PromptSingle("Select the queue whose policy will be deleted", queues)
if err != nil {
return err
}
if idx < 0 {
return fmt.Errorf("no queue selected")
}
queueUrl = queues[idx]
}

policy, err := rm.GetSQSQueuePolicy(ctx, accountId, queueUrl)
if err != nil {
return fmt.Errorf("failed to get queue policy: %w", err)
}
if policy == "" {
fmt.Fprintln(w, "No queue policy found.")
return nil
}
if outputFlag == "table" {
fmt.Fprintf(w, "Current queue policy for %s:\n\n", queueUrl)
output.RenderPolicy(w, policy)

confirmed, err := ui.Confirm("Delete this policy?")
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(w, "Aborted.")
return nil
}
}

result, err := rm.DeleteSQSQueuePolicy(ctx, accountId, queueUrl)
if err != nil {
return err
}

if !result.Success {
slog.Error("failed to delete sqs queue policy", "account_id", result.AccountId, "queue_url", result.ResourceName, "error", result.Error)
return fmt.Errorf("failed to delete queue policy for queue %s", result.ResourceName)
}

var headers []string
var data [][]any
if outputFlag == "table" {
headers = []string{"Account", "ResourceType", "Queue", "Status"}
data = [][]any{{result.AccountId, result.ResourceType, result.ResourceName, "deleted"}}
} else {
headers = []string{"Account", "ResourceType", "Queue", "Status", "Policy"}
data = [][]any{{result.AccountId, result.ResourceType, result.ResourceName, "deleted", json.RawMessage(policy)}}
}
output.HandleOutput(w, outputFlag, headers, data)
return nil
}
Loading