From 1ff08ab80ac2ff62be143c17ab141d747b968c10 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:30:34 +1000 Subject: [PATCH] fix: seperate system account (#70) --- INSTALL.md | 42 +++--- cmd/hive/cmd/admin.go | 115 ++++++++++----- .../cmd/templates/predastore-multinode.toml | 7 + cmd/hive/cmd/templates/predastore.toml | 9 +- hive/admin/admin.go | 26 +++- hive/admin/admin_test.go | 16 +- hive/formation/formation.go | 4 + hive/gateway/auth_test.go | 4 +- hive/gateway/iam/handler_test.go | 6 +- hive/gateway/iam_request_test.go | 6 +- hive/handlers/iam/account_test.go | 10 +- hive/handlers/iam/crypto_test.go | 78 ++++++++++ hive/handlers/iam/service.go | 2 +- hive/handlers/iam/service_impl.go | 137 +++++++++++++++--- hive/handlers/iam/service_impl_test.go | 108 +++++++++++++- hive/handlers/iam/types.go | 13 +- hive/services/awsgw/awsgw.go | 8 +- scripts/start-dev.sh | 33 +++++ tests/e2e/run-e2e.sh | 68 +++++---- 19 files changed, 544 insertions(+), 148 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index b54f0fa9b..3334d25b1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -131,20 +131,10 @@ Start the core services for development. ./scripts/start-dev.sh ``` -## Create Account - -With the cluster running, create an account. This provisions an IAM account with an admin user, generates access keys, and configures your local AWS CLI profile automatically. +Set the AWS profile to use the default admin account created during init: ```bash -./bin/hive admin account create --name myaccount -``` - -The command outputs your account ID and credentials. It also creates an AWS CLI profile named `hive-myaccount` in `~/.aws/credentials` and `~/.aws/config`, pointing to your local Hive endpoint. - -Set the AWS profile to use your new account: - -```bash -export AWS_PROFILE=hive-myaccount +export AWS_PROFILE=hive ``` All subsequent `aws` CLI commands will use this account. @@ -652,6 +642,20 @@ aws ec2 describe-instances --instance-ids $INSTANCE_ID On success no data will be returned, since the instance is no longer available. +## Additional Accounts (Optional) + +The default `hive` account is created automatically during init. To create additional isolated accounts with their own resources (instances, volumes, VPCs, key pairs): + +```bash +./bin/hive admin account create --name myteam +``` + +This provisions an IAM account with an admin user, generates access keys, and configures a local AWS CLI profile named `hive-myteam`. Resources created under one account are isolated from other accounts. + +```bash +export AWS_PROFILE=hive-myteam +``` + ## UI Management Panel The Hive Platform also offers a web interface for managing hive services. Simply have the hive server running and go to [https://localhost:3000](https://localhost:3000) in your browser to continue. @@ -971,20 +975,10 @@ sudo cp ~/node1/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt sudo update-ca-certificates ``` -## Create Account - -With the cluster running, create an account from any node: +Set the AWS profile to use the default admin account created during init: ```bash -./bin/hive admin account create --name myaccount -``` - -This creates an IAM account with an admin user, generates access keys, and configures your local AWS CLI profile automatically. - -Set the AWS profile to use your new account: - -```bash -export AWS_PROFILE=hive-myaccount +export AWS_PROFILE=hive ``` ## Using the Cluster diff --git a/cmd/hive/cmd/admin.go b/cmd/hive/cmd/admin.go index 1c24dba17..aa968e0b2 100644 --- a/cmd/hive/cmd/admin.go +++ b/cmd/hive/cmd/admin.go @@ -592,15 +592,10 @@ func runAdminInit(cmd *cobra.Command, args []string) { } fmt.Printf("āœ… Created config directory: %s\n", configDir) - // Generate AWS credentials + // Generate system credentials (for service-to-service auth in config files) accessKey := admin.GenerateAWSAccessKey() secretKey := admin.GenerateAWSSecretKey() - accountID := admin.GenerateAccountID() - - fmt.Println("\nšŸ”‘ Generated root credentials (save these — they won't be shown again):") - fmt.Printf(" Access Key: %s\n", accessKey) - fmt.Printf(" Secret Key: %s\n", secretKey) - fmt.Printf(" Account ID: %s\n", accountID) + accountID := admin.SystemAccountID() // Generate IAM master key (AES-256, used to encrypt secrets in NATS KV) masterKey, err := handlers_iam.GenerateMasterKey() @@ -608,7 +603,8 @@ func runAdminInit(cmd *cobra.Command, args []string) { fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err) os.Exit(1) } - if err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID); err != nil { + bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID) + if err != nil { fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err) os.Exit(1) } @@ -616,6 +612,12 @@ func runAdminInit(cmd *cobra.Command, args []string) { fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key")) fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json")) + fmt.Printf("\nšŸ”‘ Generated admin credentials (save these — they won't be shown again):\n") + fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey) + fmt.Printf(" Secret Key: %s\n", bootstrapResult.AdminSecretKey) + fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID()) + fmt.Printf(" AWS Profile: hive\n") + // Generate SSL certificates (with bind IP in SANs for multi-node support) certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP) @@ -689,7 +691,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { } // Generate multi-node predastore.toml - predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region) + predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP) if err != nil { fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err) os.Exit(1) @@ -710,6 +712,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { Region: region, NatsToken: natsToken, DataDir: hiveRoot, + ConfigDir: configDir, Node: node, Az: az, @@ -741,9 +744,9 @@ func runAdminInit(cmd *cobra.Command, args []string) { os.Exit(1) } - // Update ~/.aws/credentials and ~/.aws/config + // Update ~/.aws/credentials and ~/.aws/config with admin credentials (not system) fmt.Println("\nšŸ”§ Configuring AWS credentials...") - if err := admin.SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP); err != nil { + if err := admin.SetupAWSCredentials(bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, certPath, bindIP); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err) } else { fmt.Println("āœ… AWS credentials configured") @@ -786,7 +789,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, fmt.Fprintf(os.Stderr, "āŒ Error generating IAM master key: %v\n", err) os.Exit(1) } - if err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID); err != nil { + bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID) + if err != nil { fmt.Fprintf(os.Stderr, "āŒ Error writing bootstrap files: %v\n", err) os.Exit(1) } @@ -806,12 +810,14 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, } creds := &formation.SharedCredentials{ - AccessKey: accessKey, - SecretKey: secretKey, - AccountID: accountID, - NatsToken: natsToken, - ClusterName: clusterName, - Region: region, + AccessKey: accessKey, + SecretKey: secretKey, + AccountID: accountID, + NatsToken: natsToken, + ClusterName: clusterName, + Region: region, + AdminAccessKey: bootstrapResult.AdminAccessKey, + AdminSecretKey: bootstrapResult.AdminSecretKey, } fs := formation.NewFormationServer(expectedNodes, creds, string(caCertData), string(caKeyData)) @@ -878,7 +884,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, // Generate multi-node predastore config var predastoreNodeID int if len(predastoreNodes) >= 3 { - predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region) + predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP) if err != nil { fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err) os.Exit(1) @@ -903,6 +909,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, Region: region, NatsToken: natsToken, DataDir: hiveRoot, + ConfigDir: configDir, Node: node, Az: az, @@ -935,9 +942,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, os.Exit(1) } - // Update ~/.aws/credentials and ~/.aws/config + // Update ~/.aws/credentials and ~/.aws/config with admin credentials (not system) fmt.Println("\nšŸ”§ Configuring AWS credentials...") - if err := admin.SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP); err != nil { + if err := admin.SetupAWSCredentials(bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, certPath, bindIP); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err) } else { fmt.Println("āœ… AWS credentials configured") @@ -1155,7 +1162,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) { fmt.Fprintf(os.Stderr, "āŒ Error decoding master key: %v\n", err) os.Exit(1) } - if err := writeBootstrapFiles(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID); err != nil { + if err := writeBootstrapFilesWithAdmin(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil { fmt.Fprintf(os.Stderr, "āŒ Error writing bootstrap files: %v\n", err) os.Exit(1) } @@ -1195,7 +1202,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) { hasPredastoreConfig := len(predastoreNodes) >= 3 if hasPredastoreConfig { - predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region) + predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP) if err != nil { fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err) os.Exit(1) @@ -1224,6 +1231,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) { Region: creds.Region, NatsToken: creds.NatsToken, DataDir: dataDir, + ConfigDir: configDir, Node: node, Az: az, @@ -1257,9 +1265,9 @@ func runAdminJoin(cmd *cobra.Command, args []string) { os.Exit(1) } - // Update ~/.aws/credentials and ~/.aws/config + // Update ~/.aws/credentials and ~/.aws/config with admin credentials (not system) fmt.Println("\nšŸ”§ Configuring AWS credentials...") - if err := admin.SetupAWSCredentials(creds.AccessKey, creds.SecretKey, creds.Region, caCertPath, bindIP); err != nil { + if err := admin.SetupAWSCredentials(creds.AdminAccessKey, creds.AdminSecretKey, creds.Region, caCertPath, bindIP); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err) } else { fmt.Println("āœ… AWS credentials configured") @@ -1411,8 +1419,12 @@ func runAccountCreate(cmd *cobra.Command, args []string) { }); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not update AWS credentials: %v\n", err) } + region := cfg.Nodes[cfg.Node].Region + if region == "" { + region = "ap-southeast-2" + } if err := admin.UpdateAWSINIFile(configPath, "profile "+profileName, map[string]string{ - "region": "us-east-1", + "region": region, "endpoint_url": endpointURL, "ca_bundle": certPath, "output": "json", @@ -1462,18 +1474,55 @@ func runAccountList(cmd *cobra.Command, args []string) { } } -func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) error { +// writeBootstrapResult holds the admin credentials so callers can +// write them to ~/.aws/credentials instead of the system credentials. +type writeBootstrapResult struct { + AdminAccessKey string + AdminSecretKey string +} + +// writeBootstrapFiles generates new admin credentials and writes the bootstrap +// files (master.key + bootstrap.json). Used by init flows (single and multi-node). +func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) { + adminAccessKey := admin.GenerateAWSAccessKey() + adminSecretKey := admin.GenerateAWSSecretKey() + if err := writeBootstrapFilesWithAdmin(configDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil { + return nil, err + } + return &writeBootstrapResult{ + AdminAccessKey: adminAccessKey, + AdminSecretKey: adminSecretKey, + }, nil +} + +// writeBootstrapFilesWithAdmin writes the bootstrap files using the provided +// admin credentials. Used by join flows where admin creds come from the leader. +func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error { if err := handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), masterKey); err != nil { return fmt.Errorf("saving master key: %w", err) } encryptedSecret, err := handlers_iam.EncryptSecret(secretKey, masterKey) if err != nil { - return fmt.Errorf("encrypting root secret: %w", err) + return fmt.Errorf("encrypting system secret: %w", err) } - return handlers_iam.SaveBootstrapData( - filepath.Join(configDir, "bootstrap.json"), - &handlers_iam.BootstrapData{ - AccessKeyID: accessKey, EncryptedSecret: encryptedSecret, AccountID: accountID, + + adminEncryptedSecret, err := handlers_iam.EncryptSecret(adminSecretKey, masterKey) + if err != nil { + return fmt.Errorf("encrypting admin secret: %w", err) + } + + bd := &handlers_iam.BootstrapData{ + AccessKeyID: accessKey, + EncryptedSecret: encryptedSecret, + AccountID: accountID, + Admin: &handlers_iam.AdminBootstrapData{ + AccountID: admin.DefaultAccountID(), + AccountName: admin.DefaultAccountName(), + UserName: "admin", + AccessKeyID: adminAccessKey, + EncryptedSecret: adminEncryptedSecret, }, - ) + } + + return handlers_iam.SaveBootstrapData(filepath.Join(configDir, "bootstrap.json"), bd) } diff --git a/cmd/hive/cmd/templates/predastore-multinode.toml b/cmd/hive/cmd/templates/predastore-multinode.toml index 495f46032..a8abc2c55 100644 --- a/cmd/hive/cmd/templates/predastore-multinode.toml +++ b/cmd/hive/cmd/templates/predastore-multinode.toml @@ -46,3 +46,10 @@ secret_access_key = "{{.SecretKey}}" policy = [ { bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] }, ] + +# IAM authentication via NATS KV (enables multi-account S3 access) +[iam] +nats_url = "nats://{{.BindIP}}:4222" +nats_token = "{{.NatsToken}}" +master_key_path = "{{.ConfigDir}}/master.key" +access_keys_bucket = "hive-iam-access-keys" diff --git a/cmd/hive/cmd/templates/predastore.toml b/cmd/hive/cmd/templates/predastore.toml index 476e1fe88..08274565a 100644 --- a/cmd/hive/cmd/templates/predastore.toml +++ b/cmd/hive/cmd/templates/predastore.toml @@ -79,4 +79,11 @@ access_key_id = "{{.AccessKey}}" secret_access_key = "{{.SecretKey}}" policy = [ { bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] }, -] \ No newline at end of file +] + +# IAM authentication via NATS KV (enables multi-account S3 access) +[iam] +nats_url = "nats://localhost:4222" +nats_token = "{{.NatsToken}}" +master_key_path = "{{.ConfigDir}}/master.key" +access_keys_bucket = "hive-iam-access-keys" \ No newline at end of file diff --git a/hive/admin/admin.go b/hive/admin/admin.go index 0d9977fa7..03e3f31d0 100644 --- a/hive/admin/admin.go +++ b/hive/admin/admin.go @@ -37,6 +37,7 @@ type ConfigSettings struct { Region string NatsToken string DataDir string + ConfigDir string // Add more fields as needed Node string @@ -264,13 +265,23 @@ func GenerateAWSSecretKey() string { return base64.StdEncoding.EncodeToString(bytes) } -// GenerateAccountID returns the global platform account ID (000000000000). -// This is used during bootstrap (hive admin init) for the root/platform account. -// Customer accounts are created via IAMService.CreateAccount() with sequential IDs. -func GenerateAccountID() string { +// SystemAccountID returns the system/root account ID (000000000000). +// Used for service-to-service auth credentials baked into config files. +func SystemAccountID() string { return "000000000000" } +// DefaultAccountID returns the default admin account ID (000000000001). +// This is the first human-facing account created during bootstrap. +func DefaultAccountID() string { + return "000000000001" +} + +// DefaultAccountName returns the default admin account name ("hive"). +func DefaultAccountName() string { + return "hive" +} + // generateNATSToken generates a secure random token for NATS func GenerateNATSToken() string { bytes := make([]byte, 32) @@ -600,7 +611,7 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string) // GenerateMultiNodePredastoreConfig produces a complete predastore.toml for a // multi-node Predastore cluster. Each node gets its own DB entry (port 6660) // and shard entry (port 9991) on a distinct IP. Node ID 1 is the bootstrap leader. -func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region string) (string, error) { +func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region, natsToken, configDir, bindIP string) (string, error) { if len(nodes) < 3 { return "", fmt.Errorf("multi-node predastore requires at least 3 nodes, got %d", len(nodes)) } @@ -610,7 +621,10 @@ func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNod AccessKey string SecretKey string Region string - }{nodes, accessKey, secretKey, region} + NatsToken string + ConfigDir string + BindIP string + }{nodes, accessKey, secretKey, region, natsToken, configDir, bindIP} tmpl, err := template.New("predastore-multinode").Parse(templateStr) if err != nil { diff --git a/hive/admin/admin_test.go b/hive/admin/admin_test.go index 5ecf02c84..13ba6e5c5 100644 --- a/hive/admin/admin_test.go +++ b/hive/admin/admin_test.go @@ -42,14 +42,16 @@ func TestGenerateAWSSecretKey_Uniqueness(t *testing.T) { assert.NotEqual(t, GenerateAWSSecretKey(), GenerateAWSSecretKey()) } -func TestGenerateAccountID_ReturnsGlobalID(t *testing.T) { - id := GenerateAccountID() +func TestSystemAccountID(t *testing.T) { + id := SystemAccountID() assert.Equal(t, "000000000000", id) assert.Len(t, id, 12) } -func TestGenerateAccountID_Deterministic(t *testing.T) { - assert.Equal(t, GenerateAccountID(), GenerateAccountID()) +func TestDefaultAccountID(t *testing.T) { + id := DefaultAccountID() + assert.Equal(t, "000000000001", id) + assert.Len(t, id, 12) } func TestGenerateNATSToken_Format(t *testing.T) { @@ -523,7 +525,7 @@ host = "{{.Host}}" {ID: 3, Host: "10.0.0.3"}, } - result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1") + result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1") require.NoError(t, err) assert.Contains(t, result, `host = "10.0.0.1"`) assert.Contains(t, result, `host = "10.0.0.3"`) @@ -535,7 +537,7 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) { _, err := GenerateMultiNodePredastoreConfig(tmpl, []PredastoreNodeConfig{ {ID: 1, Host: "10.0.0.1"}, {ID: 2, Host: "10.0.0.2"}, - }, "AK", "SK", "us-east-1") + }, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1") assert.Error(t, err) assert.Contains(t, err.Error(), "at least 3 nodes") } @@ -543,7 +545,7 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) { func TestGenerateMultiNodePredastoreConfig_InvalidTemplate(t *testing.T) { _, err := GenerateMultiNodePredastoreConfig("{{.Unclosed", []PredastoreNodeConfig{ {ID: 1, Host: "a"}, {ID: 2, Host: "b"}, {ID: 3, Host: "c"}, - }, "AK", "SK", "us-east-1") + }, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1") assert.Error(t, err) assert.Contains(t, err.Error(), "failed to parse") } diff --git a/hive/formation/formation.go b/hive/formation/formation.go index 2ab83f905..868fb7292 100644 --- a/hive/formation/formation.go +++ b/hive/formation/formation.go @@ -56,6 +56,10 @@ type SharedCredentials struct { NatsToken string `json:"nats_token"` ClusterName string `json:"cluster_name"` Region string `json:"region"` + + // Admin credentials (generated by init node, shared to join nodes) + AdminAccessKey string `json:"admin_access_key,omitempty"` + AdminSecretKey string `json:"admin_secret_key,omitempty"` } // FormationServer is a lightweight HTTP server that coordinates cluster formation. diff --git a/hive/gateway/auth_test.go b/hive/gateway/auth_test.go index 7725c8c2a..949debc2f 100644 --- a/hive/gateway/auth_test.go +++ b/hive/gateway/auth_test.go @@ -93,8 +93,8 @@ func (m *mockIAMService) GetUserPolicies(_, _ string) ([]handlers_iam.PolicyDocu func (m *mockIAMService) DecryptSecret(ciphertext string) (string, error) { return handlers_iam.DecryptSecret(ciphertext, m.masterKey) } -func (m *mockIAMService) SeedRootUser(_ *handlers_iam.BootstrapData) error { return nil } -func (m *mockIAMService) IsEmpty() (bool, error) { return true, nil } +func (m *mockIAMService) SeedBootstrap(_ *handlers_iam.BootstrapData) error { return nil } +func (m *mockIAMService) IsEmpty() (bool, error) { return true, nil } func (m *mockIAMService) CreateAccount(_ string) (*handlers_iam.Account, error) { return nil, nil } diff --git a/hive/gateway/iam/handler_test.go b/hive/gateway/iam/handler_test.go index 3a0af2388..bc0d44a69 100644 --- a/hive/gateway/iam/handler_test.go +++ b/hive/gateway/iam/handler_test.go @@ -87,9 +87,9 @@ func (s *stubIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey, err return nil, nil } -func (s *stubIAMService) DecryptSecret(_ string) (string, error) { return "", nil } -func (s *stubIAMService) SeedRootUser(_ *handlers_iam.BootstrapData) error { return nil } -func (s *stubIAMService) IsEmpty() (bool, error) { return true, nil } +func (s *stubIAMService) DecryptSecret(_ string) (string, error) { return "", nil } +func (s *stubIAMService) SeedBootstrap(_ *handlers_iam.BootstrapData) error { return nil } +func (s *stubIAMService) IsEmpty() (bool, error) { return true, nil } func (s *stubIAMService) CreateAccount(_ string) (*handlers_iam.Account, error) { return nil, nil diff --git a/hive/gateway/iam_request_test.go b/hive/gateway/iam_request_test.go index 6ca91cee5..d1480c38c 100644 --- a/hive/gateway/iam_request_test.go +++ b/hive/gateway/iam_request_test.go @@ -124,9 +124,9 @@ func (m *flexMockIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey, return nil, errors.New("not implemented") } -func (m *flexMockIAMService) DecryptSecret(_ string) (string, error) { return "", nil } -func (m *flexMockIAMService) SeedRootUser(_ *handlers_iam.BootstrapData) error { return nil } -func (m *flexMockIAMService) IsEmpty() (bool, error) { return true, nil } +func (m *flexMockIAMService) DecryptSecret(_ string) (string, error) { return "", nil } +func (m *flexMockIAMService) SeedBootstrap(_ *handlers_iam.BootstrapData) error { return nil } +func (m *flexMockIAMService) IsEmpty() (bool, error) { return true, nil } func (m *flexMockIAMService) CreateAccount(_ string) (*handlers_iam.Account, error) { return nil, nil diff --git a/hive/handlers/iam/account_test.go b/hive/handlers/iam/account_test.go index ec8790dba..a576a8eee 100644 --- a/hive/handlers/iam/account_test.go +++ b/hive/handlers/iam/account_test.go @@ -218,7 +218,7 @@ func TestAccessKeyReturnsAccountID(t *testing.T) { } // ============================================================================ -// SeedRootUser Account-Scoped Tests +// SeedBootstrap Account-Scoped Tests // ============================================================================ // ============================================================================ @@ -340,16 +340,16 @@ func TestCrossAccount_LookupAccessKeyReturnsCorrectAccount(t *testing.T) { } // ============================================================================ -// SeedRootUser Account-Scoped Tests +// SeedBootstrap Account-Scoped Tests // ============================================================================ -func TestSeedRootUser_AccountScoped(t *testing.T) { +func TestSeedBootstrap_AccountScoped(t *testing.T) { svc := setupTestIAMService(t) encryptedSecret, err := EncryptSecret("root-secret", svc.masterKey) require.NoError(t, err) - err = svc.SeedRootUser(&BootstrapData{ + err = svc.SeedBootstrap(&BootstrapData{ AccessKeyID: "AKIAROOTEXAMPLE12345", EncryptedSecret: encryptedSecret, AccountID: utils.GlobalAccountID, @@ -374,6 +374,6 @@ func TestSeedRootUser_AccountScoped(t *testing.T) { account, err := svc.GetAccount(utils.GlobalAccountID) require.NoError(t, err) assert.Equal(t, utils.GlobalAccountID, account.AccountID) - assert.Equal(t, "Global", account.AccountName) + assert.Equal(t, "system", account.AccountName) assert.Equal(t, "ACTIVE", account.Status) } diff --git a/hive/handlers/iam/crypto_test.go b/hive/handlers/iam/crypto_test.go index c58e3a414..2b48bd168 100644 --- a/hive/handlers/iam/crypto_test.go +++ b/hive/handlers/iam/crypto_test.go @@ -270,6 +270,84 @@ func TestSaveBootstrapData_RoundTrip(t *testing.T) { } } +func TestBootstrapData_ExtendedRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bootstrap.json") + + data := &BootstrapData{ + AccessKeyID: "AKIASYSTEM1234567890", + EncryptedSecret: "c3lzdGVtLXNlY3JldA==", + AccountID: "000000000000", + Admin: &AdminBootstrapData{ + AccountID: "000000000001", + AccountName: "hive", + UserName: "admin", + AccessKeyID: "AKIAADMIN12345678901", + EncryptedSecret: "YWRtaW4tc2VjcmV0", + }, + } + + if err := SaveBootstrapData(path, data); err != nil { + t.Fatalf("SaveBootstrapData() error: %v", err) + } + + loaded, err := LoadBootstrapData(path) + if err != nil { + t.Fatalf("LoadBootstrapData() error: %v", err) + } + + // System fields + if loaded.AccessKeyID != data.AccessKeyID { + t.Fatalf("AccessKeyID: expected %q, got %q", data.AccessKeyID, loaded.AccessKeyID) + } + if loaded.AccountID != data.AccountID { + t.Fatalf("AccountID: expected %q, got %q", data.AccountID, loaded.AccountID) + } + + // Admin fields + if loaded.Admin == nil { + t.Fatal("Admin field should not be nil") + } + if loaded.Admin.AccountID != "000000000001" { + t.Fatalf("Admin.AccountID: expected %q, got %q", "000000000001", loaded.Admin.AccountID) + } + if loaded.Admin.AccountName != "hive" { + t.Fatalf("Admin.AccountName: expected %q, got %q", "hive", loaded.Admin.AccountName) + } + if loaded.Admin.UserName != "admin" { + t.Fatalf("Admin.UserName: expected %q, got %q", "admin", loaded.Admin.UserName) + } + if loaded.Admin.AccessKeyID != "AKIAADMIN12345678901" { + t.Fatalf("Admin.AccessKeyID: expected %q, got %q", "AKIAADMIN12345678901", loaded.Admin.AccessKeyID) + } + if loaded.Admin.EncryptedSecret != "YWRtaW4tc2VjcmV0" { + t.Fatalf("Admin.EncryptedSecret mismatch") + } +} + +func TestBootstrapData_NoAdmin_BackwardCompat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bootstrap.json") + + // Write bootstrap data without admin field (old format) + oldJSON := `{"access_key_id":"AKIAOLD123","encrypted_secret":"b2xk","account_id":"000000000000"}` + if err := os.WriteFile(path, []byte(oldJSON), 0600); err != nil { + t.Fatal(err) + } + + loaded, err := LoadBootstrapData(path) + if err != nil { + t.Fatalf("LoadBootstrapData() error: %v", err) + } + + if loaded.AccessKeyID != "AKIAOLD123" { + t.Fatalf("AccessKeyID: expected %q, got %q", "AKIAOLD123", loaded.AccessKeyID) + } + if loaded.Admin != nil { + t.Fatal("Admin field should be nil for old-format bootstrap data") + } +} + func TestSaveBootstrapData_InvalidPath(t *testing.T) { data := &BootstrapData{ AccessKeyID: "AKIAIOSFODNN7EXAMPLE", diff --git a/hive/handlers/iam/service.go b/hive/handlers/iam/service.go index fa4211f00..fe0a9d974 100644 --- a/hive/handlers/iam/service.go +++ b/hive/handlers/iam/service.go @@ -36,7 +36,7 @@ type IAMService interface { // Auth (internal — used by SigV4 middleware and bootstrap, not exposed via gateway) LookupAccessKey(accessKeyID string) (*AccessKey, error) DecryptSecret(ciphertext string) (string, error) - SeedRootUser(data *BootstrapData) error + SeedBootstrap(data *BootstrapData) error IsEmpty() (bool, error) // Account operations diff --git a/hive/handlers/iam/service_impl.go b/hive/handlers/iam/service_impl.go index c00d41b99..d6c57603f 100644 --- a/hive/handlers/iam/service_impl.go +++ b/hive/handlers/iam/service_impl.go @@ -504,24 +504,27 @@ func (s *IAMServiceImpl) DecryptSecret(ciphertext string) (string, error) { return s.decrypter.Decrypt(ciphertext) } -// SeedRootUser consumes bootstrap data to create the root IAM user and access -// key in NATS KV. Uses conditional create (put-if-not-exists) for multi-node -// race safety — the first node to call this wins; others skip silently. -// Also creates the global account record (000000000000) if it doesn't exist. -func (s *IAMServiceImpl) SeedRootUser(data *BootstrapData) error { - // Ensure the global account record exists - globalAccount := Account{ +// SeedBootstrap consumes bootstrap data to create the system root IAM user and +// (optionally) the default admin account in NATS KV. Uses conditional create +// (put-if-not-exists) for multi-node race safety — the first node wins; others +// skip silently. Also creates the system account record (000000000000). +// If data.Admin is non-nil, it also seeds the admin account, user, access key, +// AdministratorAccess policy, and sets the account counter so subsequent +// CreateAccount calls start at 000000000002. +func (s *IAMServiceImpl) SeedBootstrap(data *BootstrapData) error { + // --- Seed system account (000000000000) --- + systemAccount := Account{ AccountID: utils.GlobalAccountID, - AccountName: "Global", + AccountName: "system", Status: AccountStatusActive, CreatedAt: time.Now().UTC().Format(time.RFC3339), } - accountData, err := json.Marshal(globalAccount) + accountData, err := json.Marshal(systemAccount) if err != nil { - return fmt.Errorf("marshal global account: %w", err) + return fmt.Errorf("marshal system account: %w", err) } if _, err := s.accountsBucket.Create(utils.GlobalAccountID, accountData); err != nil && !errors.Is(err, nats.ErrKeyExists) { - return fmt.Errorf("seed global account: %w", err) + return fmt.Errorf("seed system account: %w", err) } kvKey := utils.GlobalAccountID + ".root" @@ -546,9 +549,7 @@ func (s *IAMServiceImpl) SeedRootUser(data *BootstrapData) error { _, err = s.usersBucket.Create(kvKey, userData) if errors.Is(err, nats.ErrKeyExists) { slog.Info("Root user already seeded by another node, skipping") - return nil - } - if err != nil { + } else if err != nil { return fmt.Errorf("seed root user: %w", err) } @@ -567,16 +568,112 @@ func (s *IAMServiceImpl) SeedRootUser(data *BootstrapData) error { return fmt.Errorf("marshal root access key: %w", err) } - _, err = s.accessKeysBucket.Create(data.AccessKeyID, akData) - if errors.Is(err, nats.ErrKeyExists) { + if _, err = s.accessKeysBucket.Create(data.AccessKeyID, akData); errors.Is(err, nats.ErrKeyExists) { slog.Info("Root access key already seeded by another node, skipping") - return nil - } - if err != nil { + } else if err != nil { return fmt.Errorf("seed root access key: %w", err) + } else { + slog.Info("System root user seeded", "accountID", utils.GlobalAccountID, "accessKeyID", data.AccessKeyID) } - slog.Info("Root IAM user seeded", "accountID", utils.GlobalAccountID, "accessKeyID", data.AccessKeyID) + // --- Seed admin account (000000000001) if present --- + if data.Admin != nil { + if err := s.seedAdminAccount(data.Admin); err != nil { + return fmt.Errorf("seed admin account: %w", err) + } + } + + return nil +} + +// seedAdminAccount creates the default admin account, user, access key, +// AdministratorAccess policy, and sets the account counter to 2. +func (s *IAMServiceImpl) seedAdminAccount(admin *AdminBootstrapData) error { + now := time.Now().UTC().Format(time.RFC3339) + + // Create admin account record + account := Account{ + AccountID: admin.AccountID, + AccountName: admin.AccountName, + Status: AccountStatusActive, + CreatedAt: now, + } + accountData, err := json.Marshal(account) + if err != nil { + return fmt.Errorf("marshal admin account: %w", err) + } + if _, err := s.accountsBucket.Create(admin.AccountID, accountData); err != nil && !errors.Is(err, nats.ErrKeyExists) { + return fmt.Errorf("store admin account: %w", err) + } + + // Set account counter to 2 so next CreateAccount gets 000000000002 + if _, err := s.accountCounterBucket.Create("next_id", []byte("2")); err != nil && !errors.Is(err, nats.ErrKeyExists) { + return fmt.Errorf("seed account counter: %w", err) + } + + // Create AdministratorAccess policy + policyARN := fmt.Sprintf("arn:aws:iam::%s:policy/AdministratorAccess", admin.AccountID) + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}` + policy := Policy{ + PolicyName: "AdministratorAccess", + PolicyID: generateIAMID("ANPA"), + ARN: policyARN, + Path: "/", + Description: "Full administrator access", + PolicyDocument: policyDoc, + CreatedAt: now, + DefaultVersion: "v1", + Tags: []Tag{}, + } + policyData, err := json.Marshal(policy) + if err != nil { + return fmt.Errorf("marshal admin policy: %w", err) + } + policyKVKey := admin.AccountID + ".AdministratorAccess" + if _, err := s.policiesBucket.Create(policyKVKey, policyData); err != nil && !errors.Is(err, nats.ErrKeyExists) { + return fmt.Errorf("store admin policy: %w", err) + } + + // Create admin user (with policy already attached) + kvKey := admin.AccountID + "." + admin.UserName + adminUser := User{ + UserName: admin.UserName, + UserID: generateIAMID("AIDA"), + AccountID: admin.AccountID, + ARN: fmt.Sprintf("arn:aws:iam::%s:user/%s", admin.AccountID, admin.UserName), + Path: "/", + CreatedAt: now, + AccessKeys: []string{admin.AccessKeyID}, + Tags: []Tag{}, + AttachedPolicies: []string{policyARN}, + } + + userData, err := json.Marshal(adminUser) + if err != nil { + return fmt.Errorf("marshal admin user: %w", err) + } + if _, err := s.usersBucket.Create(kvKey, userData); err != nil && !errors.Is(err, nats.ErrKeyExists) { + return fmt.Errorf("store admin user: %w", err) + } + + // Create admin access key + ak := AccessKey{ + AccessKeyID: admin.AccessKeyID, + SecretAccessKey: admin.EncryptedSecret, + UserName: admin.UserName, + AccountID: admin.AccountID, + Status: AccessKeyStatusActive, + CreatedAt: now, + } + akData, err := json.Marshal(ak) + if err != nil { + return fmt.Errorf("marshal admin access key: %w", err) + } + if _, err := s.accessKeysBucket.Create(admin.AccessKeyID, akData); err != nil && !errors.Is(err, nats.ErrKeyExists) { + return fmt.Errorf("store admin access key: %w", err) + } + + slog.Info("Admin account seeded", "accountID", admin.AccountID, "userName", admin.UserName, "accessKeyID", admin.AccessKeyID) return nil } diff --git a/hive/handlers/iam/service_impl_test.go b/hive/handlers/iam/service_impl_test.go index 9392183ef..1f9d2157c 100644 --- a/hive/handlers/iam/service_impl_test.go +++ b/hive/handlers/iam/service_impl_test.go @@ -661,16 +661,16 @@ func TestLookupAccessKey_InactiveKey(t *testing.T) { } // ============================================================================ -// SeedRootUser Tests +// SeedBootstrap Tests // ============================================================================ -func TestSeedRootUser(t *testing.T) { +func TestSeedBootstrap(t *testing.T) { svc := setupTestIAMService(t) encryptedSecret, err := EncryptSecret("test-secret-key", svc.masterKey) require.NoError(t, err) - err = svc.SeedRootUser(&BootstrapData{ + err = svc.SeedBootstrap(&BootstrapData{ AccessKeyID: "AKIAEXAMPLE123456789", EncryptedSecret: encryptedSecret, AccountID: utils.GlobalAccountID, @@ -702,11 +702,11 @@ func TestSeedRootUser(t *testing.T) { account, err := svc.GetAccount(utils.GlobalAccountID) require.NoError(t, err) assert.Equal(t, utils.GlobalAccountID, account.AccountID) - assert.Equal(t, "Global", account.AccountName) + assert.Equal(t, "system", account.AccountName) assert.Equal(t, "ACTIVE", account.Status) } -func TestSeedRootUser_Idempotent(t *testing.T) { +func TestSeedBootstrap_Idempotent(t *testing.T) { svc := setupTestIAMService(t) encryptedSecret, _ := EncryptSecret("test-secret", svc.masterKey) @@ -717,11 +717,11 @@ func TestSeedRootUser_Idempotent(t *testing.T) { } // First call seeds - err := svc.SeedRootUser(data) + err := svc.SeedBootstrap(data) require.NoError(t, err) // Second call should succeed (no-op, idempotent) - err = svc.SeedRootUser(data) + err = svc.SeedBootstrap(data) require.NoError(t, err) // Root user should still exist with original data @@ -732,6 +732,100 @@ func TestSeedRootUser_Idempotent(t *testing.T) { assert.Equal(t, "root", *out.User.UserName) } +func TestSeedBootstrap_WithAdmin(t *testing.T) { + svc := setupTestIAMService(t) + + systemSecret, err := EncryptSecret("system-secret", svc.masterKey) + require.NoError(t, err) + adminSecret, err := EncryptSecret("admin-secret", svc.masterKey) + require.NoError(t, err) + + err = svc.SeedBootstrap(&BootstrapData{ + AccessKeyID: "AKIASYSTEM1234567890", + EncryptedSecret: systemSecret, + AccountID: utils.GlobalAccountID, + Admin: &AdminBootstrapData{ + AccountID: "000000000001", + AccountName: "hive", + UserName: "admin", + AccessKeyID: "AKIAADMIN12345678901", + EncryptedSecret: adminSecret, + }, + }) + require.NoError(t, err) + + // Verify system root user exists + out, err := svc.GetUser(utils.GlobalAccountID, &iam.GetUserInput{ + UserName: aws.String("root"), + }) + require.NoError(t, err) + assert.Equal(t, "root", *out.User.UserName) + + // Verify admin account created + account, err := svc.GetAccount("000000000001") + require.NoError(t, err) + assert.Equal(t, "000000000001", account.AccountID) + assert.Equal(t, "hive", account.AccountName) + assert.Equal(t, "ACTIVE", account.Status) + + // Verify admin user exists + adminOut, err := svc.GetUser("000000000001", &iam.GetUserInput{ + UserName: aws.String("admin"), + }) + require.NoError(t, err) + assert.Equal(t, "admin", *adminOut.User.UserName) + assert.Contains(t, *adminOut.User.Arn, "000000000001") + + // Verify admin access key + ak, err := svc.LookupAccessKey("AKIAADMIN12345678901") + require.NoError(t, err) + assert.Equal(t, "admin", ak.UserName) + assert.Equal(t, "000000000001", ak.AccountID) + + decrypted, err := DecryptSecret(ak.SecretAccessKey, svc.masterKey) + require.NoError(t, err) + assert.Equal(t, "admin-secret", decrypted) + + // Verify AdministratorAccess policy was created and attached + policies, err := svc.GetUserPolicies("000000000001", "admin") + require.NoError(t, err) + require.Len(t, policies, 1) + assert.Equal(t, "Allow", string(policies[0].Statement[0].Effect)) + assert.Equal(t, StringOrArr{"*"}, policies[0].Statement[0].Action) + assert.Equal(t, StringOrArr{"*"}, policies[0].Statement[0].Resource) + + // Verify account counter set to 2 (next CreateAccount gets 000000000002) + nextAccount, err := svc.CreateAccount("test-org") + require.NoError(t, err) + assert.Equal(t, "000000000002", nextAccount.AccountID) +} + +func TestSeedBootstrap_AdminNil_BackwardCompat(t *testing.T) { + svc := setupTestIAMService(t) + + encryptedSecret, err := EncryptSecret("test-secret", svc.masterKey) + require.NoError(t, err) + + // No Admin field — backward compatibility with old bootstrap.json + err = svc.SeedBootstrap(&BootstrapData{ + AccessKeyID: "AKIAEXAMPLE123456789", + EncryptedSecret: encryptedSecret, + AccountID: utils.GlobalAccountID, + }) + require.NoError(t, err) + + // Root user should exist + out, err := svc.GetUser(utils.GlobalAccountID, &iam.GetUserInput{ + UserName: aws.String("root"), + }) + require.NoError(t, err) + assert.Equal(t, "root", *out.User.UserName) + + // Admin account should NOT exist + _, err = svc.GetAccount("000000000001") + assert.Error(t, err) +} + // ============================================================================ // IsEmpty Tests // ============================================================================ diff --git a/hive/handlers/iam/types.go b/hive/handlers/iam/types.go index 75c3f22a1..2d80d92ea 100644 --- a/hive/handlers/iam/types.go +++ b/hive/handlers/iam/types.go @@ -103,9 +103,20 @@ func (s StringOrArr) MarshalJSON() ([]byte, error) { // BootstrapData is the on-disk JSON file consumed on first gateway start // to seed the root IAM user into NATS KV. type BootstrapData struct { + AccessKeyID string `json:"access_key_id"` + EncryptedSecret string `json:"encrypted_secret"` + AccountID string `json:"account_id"` + Admin *AdminBootstrapData `json:"admin,omitempty"` +} + +// AdminBootstrapData holds the default admin account credentials seeded +// alongside the system account during first boot. +type AdminBootstrapData struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + UserName string `json:"user_name"` AccessKeyID string `json:"access_key_id"` EncryptedSecret string `json:"encrypted_secret"` - AccountID string `json:"account_id"` } // Account represents a Hive account. Accounts namespace IAM users, diff --git a/hive/services/awsgw/awsgw.go b/hive/services/awsgw/awsgw.go index ad607c1a7..3077785ac 100644 --- a/hive/services/awsgw/awsgw.go +++ b/hive/services/awsgw/awsgw.go @@ -90,12 +90,12 @@ func launchService(config *config.ClusterConfig) error { return fmt.Errorf("initialize IAM service: %w", err) } - // First boot: consume bootstrap.json → seed root user into NATS KV → delete file + // First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file bootstrapPath := filepath.Join(nodeConfig.BaseDir, "config", "bootstrap.json") if data, err := handlers_iam.LoadBootstrapData(bootstrapPath); err == nil { - slog.Info("Bootstrap file found, seeding root IAM user") - if err := iamService.SeedRootUser(data); err != nil { - return fmt.Errorf("seed root user from bootstrap.json: %w", err) + slog.Info("Bootstrap file found, seeding IAM users") + if err := iamService.SeedBootstrap(data); err != nil { + return fmt.Errorf("seed bootstrap from bootstrap.json: %w", err) } if err := os.Remove(bootstrapPath); err != nil { slog.Warn("Failed to delete bootstrap file", "path", bootstrapPath, "err", err) diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh index ba3e64003..22d66c267 100755 --- a/scripts/start-dev.sh +++ b/scripts/start-dev.sh @@ -241,9 +241,42 @@ if has_service "nats"; then export HIVE_NATS_JETSTREAM=false fi + # Extract NATS listen address from config (multi-node binds to specific IP, not 127.0.0.1) + NATS_CHECK_HOST="127.0.0.1" + if [ -f "$CONFIG_DIR/nats/nats.conf" ]; then + NATS_LISTEN_IP=$(grep -oP '^listen:\s*\K[^:]+' "$CONFIG_DIR/nats/nats.conf" 2>/dev/null || true) + if [ -n "$NATS_LISTEN_IP" ] && [ "$NATS_LISTEN_IP" != "0.0.0.0" ]; then + NATS_CHECK_HOST="$NATS_LISTEN_IP" + fi + fi + NATS_CMD="./bin/hive service nats start" start_service "nats" "$NATS_CMD" set_oom_score "nats" "-500" + check_service "NATS" "$NATS_CHECK_HOST" "4222" + + # Ensure NATS JetStream is ready before Predastore starts. + # Predastore lazily opens IAM KV buckets — it starts with config-only auth + # and activates IAM auth once the hive daemon creates the KV buckets. + echo "šŸ” Waiting for NATS JetStream..." + NATS_JS_READY=false + for i in $(seq 1 15); do + if nats -s nats://$NATS_CHECK_HOST:4222 stream ls --names 2>/dev/null | grep -q "KV_" 2>/dev/null; then + echo " āœ… NATS JetStream is ready (KV streams found)" + NATS_JS_READY=true + break + fi + # JetStream may be up with no KV buckets yet (fresh install before admin init) + if nats -s nats://$NATS_CHECK_HOST:4222 account info 2>/dev/null | grep -q "JetStream" 2>/dev/null; then + echo " āœ… NATS JetStream is ready (no KV buckets yet — run 'hive admin init' to bootstrap)" + NATS_JS_READY=true + break + fi + sleep 1 + done + if [ "$NATS_JS_READY" = "false" ]; then + echo " āš ļø NATS JetStream not confirmed ready — Predastore may fall back to config-only auth" + fi else echo "1ļøāƒ£ Skipping NATS (not a local service)" fi diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index 436e2aa75..7ad783a60 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -1478,11 +1478,8 @@ echo "IAM Phase 3 passed" echo "" echo "IAM Phase 4: Policy CRUD" -GLOBAL_ACCOUNT="000000000000" - -# CreatePolicy — EC2ReadOnly -echo " Creating EC2ReadOnly policy..." -aws iam create-policy \ +# Get the admin account ID from the current profile +POLICY_OUTPUT=$(aws iam create-policy \ --policy-name EC2ReadOnly \ --policy-document '{ "Version": "2012-10-17", @@ -1491,8 +1488,13 @@ aws iam create-policy \ "Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs"], "Resource": "*" }] - }' > /dev/null -echo " Created EC2ReadOnly" + }') +ADMIN_ACCOUNT=$(echo "$POLICY_OUTPUT" | jq -r '.Policy.Arn' | cut -d: -f5) +if [ -z "$ADMIN_ACCOUNT" ]; then + echo " ERROR: Failed to extract account ID from CreatePolicy response" + exit 1 +fi +echo " Created EC2ReadOnly (account=$ADMIN_ACCOUNT)" # CreatePolicy — FullAdmin echo " Creating FullAdmin policy..." @@ -1564,19 +1566,19 @@ expect_error "MalformedPolicyDocument" aws iam create-policy --policy-name BadPo # GetPolicy echo " Getting EC2ReadOnly policy..." aws iam get-policy \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" | \ + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" | \ jq -e '.Policy.PolicyName == "EC2ReadOnly"' > /dev/null echo " GetPolicy passed" # GetPolicy — not found echo " Getting non-existent policy (expect error)..." expect_error "NoSuchEntity" aws iam get-policy \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/Ghost" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/Ghost" # GetPolicyVersion echo " Getting EC2ReadOnly policy version v1..." aws iam get-policy-version \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" \ + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" \ --version-id v1 | jq -e '.PolicyVersion.VersionId == "v1"' > /dev/null echo " GetPolicyVersion passed" @@ -1606,13 +1608,13 @@ setup_test_profile hive-charlie "$CHARLIE_KEY_ID" "$CHARLIE_SECRET" # Attach policies echo " Attaching EC2ReadOnly + IAMReadOnly to alice..." aws iam attach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" aws iam attach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/IAMReadOnly" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly" echo " Attaching DenyTerminate to bob..." aws iam attach-user-policy --user-name bob \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/DenyTerminate" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" # ListAttachedUserPolicies echo " Verifying alice's attached policies..." @@ -1626,7 +1628,7 @@ echo " Alice has $ALICE_POLICIES policies" # Idempotent attach echo " Testing idempotent attach..." aws iam attach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" ALICE_POLICIES=$(aws iam list-attached-user-policies --user-name alice | jq '.AttachedPolicies | length') if [ "$ALICE_POLICIES" -ne 2 ]; then echo " ERROR: Expected 2 policies after idempotent attach, got $ALICE_POLICIES" @@ -1637,11 +1639,11 @@ echo " Idempotent attach passed" # Attach edge cases echo " Attaching non-existent policy (expect error)..." expect_error "NoSuchEntity" aws iam attach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/Ghost" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/Ghost" echo " Attaching to non-existent user (expect error)..." expect_error "NoSuchEntity" aws iam attach-user-policy --user-name ghost \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" # --- Enforcement Tests --- @@ -1696,11 +1698,11 @@ echo " Root bypass passed" # Prefix wildcard — swap alice to EC2DescribeAll echo " Testing prefix wildcard (swap alice to EC2DescribeAll)..." aws iam detach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" aws iam detach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/IAMReadOnly" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly" aws iam attach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2DescribeAll" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll" aws ec2 describe-instances --profile hive-alice > /dev/null echo " ec2:DescribeInstances — allowed (Describe*)" @@ -1717,7 +1719,7 @@ echo " Prefix wildcard passed" # FullAdmin — give charlie full access echo " Testing FullAdmin (charlie)..." aws iam attach-user-policy --user-name charlie \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/admin/FullAdmin" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin" aws ec2 describe-instances --profile hive-charlie > /dev/null echo " ec2:DescribeInstances — allowed (was denied)" aws iam list-users --profile hive-charlie > /dev/null @@ -1733,7 +1735,7 @@ echo "IAM Phase 6: Policy Lifecycle — Detach & Delete" # Detach alice's policy → she loses access echo " Detaching EC2DescribeAll from alice..." aws iam detach-user-policy --user-name alice \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2DescribeAll" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll" expect_error "AccessDenied" \ aws ec2 describe-instances --profile hive-alice echo " Alice lost access after detach" @@ -1741,18 +1743,18 @@ echo " Alice lost access after detach" # DeletePolicy — conflict (DenyTerminate still attached to bob) echo " Deleting DenyTerminate while attached (expect DeleteConflict)..." expect_error "DeleteConflict" aws iam delete-policy \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/DenyTerminate" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" # Detach first, then delete echo " Detaching DenyTerminate from bob, then deleting..." aws iam detach-user-policy --user-name bob \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/DenyTerminate" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" aws iam delete-policy \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/DenyTerminate" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" # Verify it's gone expect_error "NoSuchEntity" aws iam get-policy \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/DenyTerminate" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" echo " DenyTerminate deleted" echo "IAM Phase 6 passed" @@ -1774,7 +1776,7 @@ aws iam delete-user --user-name bob # Delete charlie (detach FullAdmin first) echo " Cleaning up charlie..." aws iam detach-user-policy --user-name charlie \ - --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/admin/FullAdmin" + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin" aws iam delete-access-key --user-name charlie --access-key-id "$CHARLIE_KEY_ID" aws iam delete-user --user-name charlie @@ -1786,12 +1788,16 @@ if [ "$FINAL_USER_COUNT" -ne 1 ]; then fi echo " Users cleaned up (root only)" -# Delete remaining policies +# Delete remaining policies (including bootstrap AdministratorAccess) echo " Cleaning up policies..." -aws iam delete-policy --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2ReadOnly" -aws iam delete-policy --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/admin/FullAdmin" -aws iam delete-policy --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/IAMReadOnly" -aws iam delete-policy --policy-arn "arn:aws:iam::${GLOBAL_ACCOUNT}:policy/EC2DescribeAll" +aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" +aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin" +aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly" +aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll" +# AdministratorAccess was created by 'admin init' and attached to the admin user +aws iam detach-user-policy --user-name admin \ + --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/AdministratorAccess" +aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/AdministratorAccess" FINAL_POLICY_COUNT=$(aws iam list-policies | jq '.Policies | length') if [ "$FINAL_POLICY_COUNT" -ne 0 ]; then