fix: seperate system account (#70)

This commit is contained in:
Julian Sommer
2026-03-10 15:30:34 +10:00
committed by GitHub
parent d17eef2ba9
commit 1ff08ab80a
19 changed files with 544 additions and 148 deletions
+18 -24
View File
@@ -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
+82 -33
View File
@@ -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)
}
@@ -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"
+8 -1
View File
@@ -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"] },
]
]
# 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"
+20 -6
View File
@@ -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 {
+9 -7
View File
@@ -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")
}
+4
View File
@@ -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.
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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)
}
+78
View File
@@ -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",
+1 -1
View File
@@ -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
+117 -20
View File
@@ -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
}
+101 -7
View File
@@ -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
// ============================================================================
+12 -1
View File
@@ -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,
+4 -4
View File
@@ -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)
+33
View File
@@ -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
+37 -31
View File
@@ -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