mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
fix: seperate system account (#70)
This commit is contained in:
+18
-24
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user