mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 12:47:48 +00:00
feat: IAM v1 (#59)
This commit is contained in:
+258
-5
@@ -19,6 +19,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,9 +32,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/hive/hive/admin"
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/formation"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/mulgadc/viperblock/viperblock"
|
||||
"github.com/mulgadc/viperblock/viperblock/backends/s3"
|
||||
@@ -128,6 +132,28 @@ var imagesListCmd = &cobra.Command{
|
||||
Run: runimagesListCmd,
|
||||
}
|
||||
|
||||
var accountCmd = &cobra.Command{
|
||||
Use: "account",
|
||||
Short: "Manage Hive accounts",
|
||||
Long: `Create and manage Hive accounts. Each account namespaces IAM users, policies, and resources.`,
|
||||
}
|
||||
|
||||
var accountCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new account with an admin user",
|
||||
Long: `Create a new Hive account. This creates an account with a sequential 12-digit ID,
|
||||
an admin user, and an AdministratorAccess policy attached to the admin user.
|
||||
Requires the cluster to be running (connects to NATS).`,
|
||||
Run: runAccountCreate,
|
||||
}
|
||||
|
||||
var accountListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all accounts",
|
||||
Long: `List all Hive accounts with their ID, name, status, and creation time.`,
|
||||
Run: runAccountList,
|
||||
}
|
||||
|
||||
/*
|
||||
CLI ideas
|
||||
|
||||
@@ -162,6 +188,12 @@ func init() {
|
||||
imagesCmd.AddCommand(imagesImportCmd)
|
||||
imagesCmd.AddCommand(imagesListCmd)
|
||||
|
||||
adminCmd.AddCommand(accountCmd)
|
||||
accountCmd.AddCommand(accountCreateCmd)
|
||||
accountCmd.AddCommand(accountListCmd)
|
||||
accountCreateCmd.Flags().String("name", "", "Account name (required)")
|
||||
accountCreateCmd.MarkFlagRequired("name")
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
configDir := fmt.Sprintf("%s/hive/config", homeDir)
|
||||
hiveDir := fmt.Sprintf("%s/hive/", homeDir)
|
||||
@@ -421,8 +453,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
VolumeSize: utils.SafeInt64ToUint64(imageStat.Size()),
|
||||
Bucket: appConfig.Nodes[appConfig.Node].Predastore.Bucket,
|
||||
Region: appConfig.Nodes[appConfig.Node].Predastore.Region,
|
||||
AccessKey: appConfig.Nodes[appConfig.Node].AccessKey,
|
||||
SecretKey: appConfig.Nodes[appConfig.Node].SecretKey,
|
||||
AccessKey: appConfig.Nodes[appConfig.Node].Predastore.AccessKey,
|
||||
SecretKey: appConfig.Nodes[appConfig.Node].Predastore.SecretKey,
|
||||
Host: appConfig.Nodes[appConfig.Node].Predastore.Host,
|
||||
}
|
||||
|
||||
@@ -565,11 +597,25 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
secretKey := admin.GenerateAWSSecretKey()
|
||||
accountID := admin.GenerateAccountID()
|
||||
|
||||
fmt.Println("\n🔑 Generated AWS credentials:")
|
||||
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)
|
||||
|
||||
// Generate IAM master key (AES-256, used to encrypt secrets in NATS KV)
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
if err != nil {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n🔐 Generated IAM master key")
|
||||
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
|
||||
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
|
||||
|
||||
// Generate SSL certificates (with bind IP in SANs for multi-node support)
|
||||
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP)
|
||||
|
||||
@@ -689,8 +735,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
})
|
||||
}
|
||||
|
||||
err := admin.GenerateConfigFiles(configs, configSettings)
|
||||
if err != nil {
|
||||
if err := admin.GenerateConfigFiles(configs, configSettings); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating configuration files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -734,6 +779,19 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate IAM master key for the cluster
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
if err != nil {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n🔐 Generated IAM master key")
|
||||
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
|
||||
|
||||
// Read CA cert/key for distribution to joining nodes
|
||||
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
|
||||
if err != nil {
|
||||
@@ -757,6 +815,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
|
||||
fs := formation.NewFormationServer(expectedNodes, creds, string(caCertData), string(caKeyData))
|
||||
|
||||
// Include master key in formation server for distribution to joining nodes
|
||||
fs.SetMasterKey(base64.StdEncoding.EncodeToString(masterKey))
|
||||
|
||||
// Register self (init node) as the first node
|
||||
selfNode := formation.NodeInfo{
|
||||
Name: node,
|
||||
@@ -1083,6 +1144,23 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
fmt.Printf("✅ CA certificate received from leader: %s\n", caCertPath)
|
||||
|
||||
// Extract and write master key from formation server
|
||||
if statusResp.MasterKey == "" {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: formation server did not return master key\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
masterKeyBytes, err := base64.StdEncoding.DecodeString(statusResp.MasterKey)
|
||||
if err != nil {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✅ IAM master key received from leader")
|
||||
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(configDir, "bootstrap.json"))
|
||||
|
||||
// Generate server cert signed by CA with this node's bind IP
|
||||
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
|
||||
@@ -1223,3 +1301,178 @@ func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode string)
|
||||
})
|
||||
return remote
|
||||
}
|
||||
|
||||
// initIAMServiceFromConfig loads config, connects to NATS, loads the master
|
||||
// key, and returns an initialised IAMServiceImpl. Callers must defer nc.Close().
|
||||
func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterConfig, func(), error) {
|
||||
cfg, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
|
||||
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
|
||||
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, nil, fmt.Errorf("load master key: %w", err)
|
||||
}
|
||||
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(nc, masterKey)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
|
||||
}
|
||||
|
||||
return svc, cfg, func() { nc.Close() }, nil
|
||||
}
|
||||
|
||||
// adminAccessPolicyDocument is the AdministratorAccess policy document that
|
||||
// grants full access to all actions and resources.
|
||||
const adminAccessPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`
|
||||
|
||||
func runAccountCreate(cmd *cobra.Command, args []string) {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
|
||||
svc, cfg, cleanup, err := initIAMServiceFromConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// 1. Create the account
|
||||
account, err := svc.CreateAccount(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
accountID := account.AccountID
|
||||
|
||||
// 2. Create admin user
|
||||
_, err = svc.CreateUser(accountID, &iam.CreateUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating admin user: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 3. Create access key for admin user
|
||||
akOut, err := svc.CreateAccessKey(accountID, &iam.CreateAccessKeyInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating access key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 4. Create AdministratorAccess policy scoped to this account
|
||||
policyARN := fmt.Sprintf("arn:aws:iam::%s:policy/AdministratorAccess", accountID)
|
||||
_, err = svc.CreatePolicy(accountID, &iam.CreatePolicyInput{
|
||||
PolicyName: aws.String("AdministratorAccess"),
|
||||
PolicyDocument: aws.String(adminAccessPolicyDocument),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating admin policy: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 5. Attach policy to admin user
|
||||
_, err = svc.AttachUserPolicy(accountID, &iam.AttachUserPolicyInput{
|
||||
UserName: aws.String("admin"),
|
||||
PolicyArn: aws.String(policyARN),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error attaching policy: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Configure AWS CLI profile automatically
|
||||
profileName := "hive-" + strings.ToLower(strings.ReplaceAll(name, " ", "-"))
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
|
||||
endpointHost := "localhost"
|
||||
certPath := filepath.Join(cfg.NodeBaseDir(), "config", "ca.pem")
|
||||
nodeConfig := cfg.Nodes[cfg.Node]
|
||||
if h, _, err := net.SplitHostPort(nodeConfig.AWSGW.Host); err == nil {
|
||||
if h != "" && h != "0.0.0.0" {
|
||||
endpointHost = h
|
||||
}
|
||||
}
|
||||
endpointURL := fmt.Sprintf("https://%s:9999", endpointHost)
|
||||
|
||||
credPath := filepath.Join(homeDir, ".aws", "credentials")
|
||||
configPath := filepath.Join(homeDir, ".aws", "config")
|
||||
|
||||
if err := admin.UpdateAWSINIFile(credPath, profileName, map[string]string{
|
||||
"aws_access_key_id": *akOut.AccessKey.AccessKeyId,
|
||||
"aws_secret_access_key": *akOut.AccessKey.SecretAccessKey,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not update AWS credentials: %v\n", err)
|
||||
}
|
||||
if err := admin.UpdateAWSINIFile(configPath, "profile "+profileName, map[string]string{
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": endpointURL,
|
||||
"ca_bundle": certPath,
|
||||
"output": "json",
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not update AWS config: %v\n", err)
|
||||
}
|
||||
|
||||
// Print credentials
|
||||
fmt.Println("\nAccount created successfully!")
|
||||
fmt.Printf(" Account ID: %s\n", accountID)
|
||||
fmt.Printf(" Account Name: %s\n", name)
|
||||
fmt.Printf(" Admin User: admin\n")
|
||||
fmt.Printf(" Access Key ID: %s\n", *akOut.AccessKey.AccessKeyId)
|
||||
fmt.Printf(" Secret Access Key: %s\n", *akOut.AccessKey.SecretAccessKey)
|
||||
fmt.Printf(" AWS Profile: %s\n", profileName)
|
||||
fmt.Println("\nUse with:")
|
||||
fmt.Printf(" AWS_PROFILE=%s aws ec2 describe-instances\n", profileName)
|
||||
}
|
||||
|
||||
func runAccountList(cmd *cobra.Command, args []string) {
|
||||
svc, _, cleanup, err := initIAMServiceFromConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
accounts, err := svc.ListAccounts()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error listing accounts: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(accounts) == 0 {
|
||||
fmt.Println("No accounts found.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%-14s %-20s %-10s %s\n", "ACCOUNT ID", "NAME", "STATUS", "CREATED")
|
||||
fmt.Printf("%-14s %-20s %-10s %s\n", "----------", "----", "------", "-------")
|
||||
for _, a := range accounts {
|
||||
created := a.CreatedAt
|
||||
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
|
||||
created = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Printf("%-14s %-20s %-10s %s\n", a.AccountID, a.AccountName, a.Status, created)
|
||||
}
|
||||
}
|
||||
|
||||
func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID 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 handlers_iam.SaveBootstrapData(
|
||||
filepath.Join(configDir, "bootstrap.json"),
|
||||
&handlers_iam.BootstrapData{
|
||||
AccessKeyID: accessKey, EncryptedSecret: encryptedSecret, AccountID: accountID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -73,6 +74,13 @@ func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
|
||||
return nil, nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Backfill BaseDir from config file path when not set in the config.
|
||||
// Config lives at <baseDir>/config/hive.toml, so baseDir is two levels up.
|
||||
if nodeConfig, ok := cfg.Nodes[cfg.Node]; ok && nodeConfig.BaseDir == "" {
|
||||
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
|
||||
cfg.Nodes[cfg.Node] = nodeConfig
|
||||
}
|
||||
|
||||
nodeConfig := cfg.Nodes[cfg.Node]
|
||||
nc, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
|
||||
if err != nil {
|
||||
|
||||
@@ -530,7 +530,7 @@ var awsgwStartCmd = &cobra.Command{
|
||||
|
||||
baseDir := viper.GetString("base-dir")
|
||||
|
||||
if awsgwTlsKey != "" {
|
||||
if baseDir != "" {
|
||||
fmt.Println("Overwriting awsgw base-dir to:", baseDir)
|
||||
nodeConfig.BaseDir = baseDir
|
||||
}
|
||||
|
||||
@@ -4,10 +4,3 @@ region = "{{.Region}}"
|
||||
|
||||
# Enable verbose debugging
|
||||
debug = false
|
||||
|
||||
[[auth]]
|
||||
access_key_id = "{{.AccessKey}}"
|
||||
secret_access_key = "{{.SecretKey}}"
|
||||
policy = [
|
||||
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
|
||||
]
|
||||
|
||||
@@ -13,8 +13,6 @@ services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
|
||||
{{- end}}
|
||||
|
||||
account_id = "{{.AccountID}}"
|
||||
accesskey = "{{.AccessKey}}"
|
||||
secretkey = "{{.SecretKey}}"
|
||||
|
||||
[nodes.{{.Node}}.daemon]
|
||||
host = "{{.BindIP}}:{{.Port}}"
|
||||
|
||||
+185
-64
@@ -1,6 +1,82 @@
|
||||
# AWS Command Implementation Matrix
|
||||
# Command Implementation Matrix
|
||||
|
||||
## EC2 - Instance Management
|
||||
## Hive Admin CLI
|
||||
|
||||
Platform management commands not exposed via the AWS gateway API. These are CLI-only commands.
|
||||
|
||||
### Service Management
|
||||
|
||||
Service lifecycle commands for starting, stopping, and checking status of all Hive cluster services. Each service subcommand supports `start`, `stop`, and `status` operations.
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Status |
|
||||
|---------|-------|---------------|-------------|--------|
|
||||
| `hive service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | TLS cert/key, base-path, config-path required | Creates predastore service instance with S3-compatible storage backend → starts service | **DONE** |
|
||||
| `hive service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
|
||||
| `hive service predastore status` | — | None | Reports predastore service status | **DONE** |
|
||||
| `hive service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (default: /opt/hive/lib/nbdkit-viperblock-plugin.so) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
|
||||
| `hive service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
|
||||
| `hive service viperblock status` | — | None | Reports viperblock service status | **DONE** |
|
||||
| `hive service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | None | Starts embedded NATS server with optional JetStream | **DONE** |
|
||||
| `hive service nats stop` | — | NATS must be running | Stops the NATS service | **DONE** |
|
||||
| `hive service nats status` | — | None | Reports NATS service status | **DONE** |
|
||||
| `hive service hive start` | `--wal-dir` | `--config` required | Loads cluster config → starts hive daemon (VM orchestration, NATS subscriptions, health endpoint) | **DONE** |
|
||||
| `hive service hive stop` | — | Hive daemon must be running | Stops the hive daemon service | **DONE** |
|
||||
| `hive service hive status` | — | None | Reports hive daemon service status | **DONE** |
|
||||
| `hive service awsgw start` | `--host` (default: 0.0.0.0:9999), `--tls-cert`, `--tls-key`, `--debug` | `--config` required | Loads cluster config → starts AWS-compatible gateway with SigV4 auth, IAM policy enforcement, TLS | **DONE** |
|
||||
| `hive service awsgw stop` | — | AWS gateway must be running | Stops the AWS gateway service | **DONE** |
|
||||
| `hive service awsgw status` | — | None | Reports AWS gateway service status | **DONE** |
|
||||
| `hive service hive-ui start` | `--port` (default: 3000), `--host` (default: 0.0.0.0), `--tls-cert`, `--tls-key` | None | Starts embedded web UI server serving the React frontend. Aliases: `ui`, `hiveui` | **DONE** |
|
||||
| `hive service hive-ui stop` | — | hive-ui must be running | Stops the hive-ui service | **DONE** |
|
||||
| `hive service hive-ui status` | — | None | Reports hive-ui service status | **DONE** |
|
||||
| `hive service vpcd start` | — | `--config` required, OVN/OVS installed | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) | **DONE** |
|
||||
| `hive service vpcd stop` | — | vpcd must be running | Stops the vpcd service | **DONE** |
|
||||
| `hive service vpcd status` | — | None | Reports vpcd service status | **DONE** |
|
||||
|
||||
### Cluster Inspection
|
||||
|
||||
Operational commands for inspecting cluster state. These fan out NATS requests to all nodes and aggregate responses.
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Output Columns | Status |
|
||||
|---------|-------|---------------|-------------|----------------|--------|
|
||||
| `hive get nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Loads config → publishes to `hive.node.status` fan-out topic → collects responses within timeout → merges config-known nodes with NATS responders → nodes that don't respond shown as `NotReady` | NAME, STATUS, IP, REGION, AZ, UPTIME, VMs, SERVICES | **DONE** |
|
||||
| `hive get vms` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `hive.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `hive get instances` | INSTANCE, STATUS, TYPE, VCPU, MEM, NODE, IP, AGE | **DONE** |
|
||||
|
||||
### Resource Monitoring
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Output | Status |
|
||||
|---------|-------|---------------|-------------|--------|--------|
|
||||
| `hive top nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `hive.node.status` fan-out topic → collects CPU/memory usage per node → aggregates instance type capacity across all nodes → prints two tables: per-node resource usage and cluster-wide instance type availability | Table 1: NAME, CPU (used/total), MEM (used/total), VMs. Table 2: INSTANCE TYPE, AVAILABLE, VCPU, MEMORY | **DONE** |
|
||||
|
||||
### Cluster Initialization
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → creates NATS config with auth token → writes hive.toml, awsgw.toml, predastore.toml → configures AWS CLI `hive` profile → creates directory structure under `~/hive/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `hive` auto-configured | **DONE** |
|
||||
| `hive admin join` | `--host` (required), `--node` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error) | **DONE** |
|
||||
|
||||
### Cluster Operations
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive admin cluster shutdown` | — | Cluster must be running | Gracefully shuts down the entire cluster | 1. Shutdown running cluster<br>2. All nodes stop cleanly | **DONE** |
|
||||
|
||||
### Account Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive admin account create` | `--name` | Cluster must be running (NATS), master key must exist | Connects to NATS → CAS loop on `hive-account-counter:next_id` for sequential 12-digit ID → creates Account record in `hive-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials | 1. Create first account (ID 000000000001)<br>2. Sequential IDs (000000000002, etc.)<br>3. Admin user has full access<br>4. Credentials printed once | **DONE** |
|
||||
| `hive admin account list` | — | Cluster must be running (NATS), master key must exist | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created | 1. List all accounts<br>2. Empty list (only global account) | **DONE** |
|
||||
|
||||
### Image Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image<br>2. Image appears in `describe-images` | **DONE** |
|
||||
| `hive admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
|
||||
|
||||
## AWS Commands
|
||||
|
||||
### EC2 - Instance Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -16,7 +92,7 @@
|
||||
| `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring<br>2. Verify monitoring state in describe-instances | **NOT STARTED** |
|
||||
| `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring<br>2. Verify monitoring state reverts in describe-instances | **NOT STARTED** |
|
||||
|
||||
## EC2 - Key Pair Management
|
||||
### EC2 - Key Pair Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -25,7 +101,7 @@
|
||||
| `delete-key-pair` | `--key-name`, `--key-pair-id` | `--dry-run` | Key must exist | NATS `ec2.DeleteKeyPair` → daemon deletes public key from Predastore S3 → returns success | 1. Delete existing key pair<br>2. Delete non-existent key (idempotent, no error)<br>3. Verify key no longer in describe-key-pairs | **DONE** |
|
||||
| `import-key-pair` | `--key-name`, `--public-key-material` | `--tag-specifications`, `--dry-run` | None | NATS `ec2.ImportKeyPair` → daemon stores provided public key in Predastore S3 → returns key name and fingerprint | 1. Import valid SSH public key<br>2. Import invalid key material (error)<br>3. Import duplicate name (error)<br>4. Verify imported key usable with run-instances | **DONE** |
|
||||
|
||||
## EC2 - AMI Image Management
|
||||
### EC2 - AMI Image Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -39,7 +115,7 @@
|
||||
| `modify-image-attribute` | — | `--image-id`, `--launch-permission`, `--description`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--dry-run` | AMI must exist | Update specific attribute in AMI metadata → persist to S3 | 1. Add launch permission<br>2. Modify description<br>3. Invalid AMI (error) | **NOT STARTED** |
|
||||
| `reset-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Reset attribute to default value in AMI metadata | 1. Reset launch permission to owner-only<br>2. Invalid AMI (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Volume (EBS) Management
|
||||
### EC2 - Volume (EBS) Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -52,7 +128,7 @@
|
||||
| `describe-volume-status` | `--volume-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes | **DONE** |
|
||||
| `describe-volumes-modifications` | — | `--volume-ids`, `--filters`, `--max-results` | None | Query pending/completed volume modifications → return modification state, progress, original/target size | 1. Check in-progress modification<br>2. Check completed modification<br>3. No modifications returns empty | **NOT STARTED** |
|
||||
|
||||
## EC2 - Snapshot Management
|
||||
### EC2 - Snapshot Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -62,7 +138,7 @@
|
||||
| `describe-snapshots` | `--snapshot-ids` | `--owner-ids`, `--filters`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → filters by snapshot ID if specified → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
|
||||
| `copy-snapshot` | `--source-snapshot-id`, `--source-region`, `--description` | `--encrypted`, `--dry-run` | Source snapshot must exist | Gateway validates snap- prefix + source region → NATS `ec2.CopySnapshot` → daemon reads source SnapshotConfig → generates new snap-ID → copies metadata (preserves tags, description override) → stores as completed → returns new snapshot ID | 1. Copy within same region<br>2. Copy non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing source ID (InvalidParameterValue)<br>4. Missing source region (MissingParameter)<br>5. Copy preserves tags<br>6. Copy with description override | **DONE** |
|
||||
|
||||
## EC2 - Tags
|
||||
### EC2 - Tags
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -70,20 +146,20 @@
|
||||
| `delete-tags` | `--resources`, `--tags` | `--dry-run` | None | Gateway validates input (resources non-empty) → NATS `ec2.DeleteTags` → daemon removes specified tag keys from resource JSON, or all tags if no keys specified → return success | 1. Delete specific tag by key<br>2. Delete all tags (no tags specified)<br>3. Empty resources list (error: MissingParameter) | **DONE** |
|
||||
| `describe-tags` | `--filters` (resource-id, resource-type, key, value) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeTags` → daemon lists all `tags/*.json` from Predastore S3 → applies filters (resource-id, resource-type, key, value) → returns tag list with resource type auto-detected from ID prefix | 1. List all tags<br>2. Filter by resource-id<br>3. Filter by resource-type (instance, volume, image, snapshot, vpc, subnet, security-group, route-table, internet-gateway, egress-only-internet-gateway)<br>4. Filter by key<br>5. Filter by value<br>6. Empty result when no tags exist | **DONE** |
|
||||
|
||||
## EC2 - Regions & Availability Zones
|
||||
### EC2 - Regions & Availability Zones
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `describe-regions` | *(returns configured region, endpoint, opt-in status from config — input params ignored)* | `--region-names`, `--filters`, `--all-regions`, `--dry-run` | None | Return configured region from hive init config with Endpoint and OptInStatus → local-only response, no NATS | 1. List all regions<br>2. Filter by region name<br>3. Verify endpoint URL returned<br>4. Verify OptInStatus returned | **DONE** |
|
||||
| `describe-availability-zones` | *(returns configured AZ, region, zone ID, state, opt-in status from config — input params ignored)* | `--zone-names`, `--filters`, `--all-availability-zones` | None | Return configured AZ from hive init config with zone ID, state, group name, network border group → local-only response, no NATS | 1. List all AZs<br>2. Filter by zone name<br>3. Verify zone state is available<br>4. Verify region name matches config | **DONE** |
|
||||
|
||||
## EC2 - Account Attributes
|
||||
### EC2 - Account Attributes
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `describe-account-attributes` | `--attribute-names` | `--dry-run` | None | Gateway parses input → returns static account attributes: supported-platforms=VPC, default-vpc=none, max-instances=100, vpc-max-security-groups-per-interface=5, max-elastic-ips=5, vpc-max-elastic-ips=20 → local-only response, no NATS | 1. List all account attributes<br>2. Filter by attribute name<br>3. Verify all 6 attributes returned with correct values | **DONE** |
|
||||
|
||||
## EC2 - Account Settings (Deferred)
|
||||
### EC2 - Account Settings (Deferred)
|
||||
|
||||
Persistence layer works (NATS JetStream KV), but stored values are not yet enforced by downstream services. Will be completed post-merge.
|
||||
|
||||
@@ -104,15 +180,17 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `modify-instance-metadata-defaults` | — | `--http-tokens`, `--http-put-response-hop-limit`, `--http-endpoint`, `--instance-metadata-tags` | None | Store instance metadata defaults in JetStream KV | 1. Set httpTokens=required<br>2. Set hop limit | **NOT STARTED** (enforcement pending) |
|
||||
| `get-instance-metadata-defaults` | — | `--dry-run` | None | Read instance metadata defaults from JetStream KV | 1. Get current defaults | **NOT STARTED** (enforcement pending) |
|
||||
|
||||
## EC2 - Egress-Only Internet Gateway
|
||||
### EC2 - Egress-Only Internet Gateway
|
||||
|
||||
Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGWs are stored in `hive-eigw` KV bucket but have no effect on network topology (no vpcd events published, unlike IGW which publishes `vpc.igw-attach`/`vpc.igw-detach`).
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-egress-only-internet-gateway` | — | `--vpc-id`, `--client-token`, `--tag-specifications`, `--dry-run` | VPC must exist | NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates record in NATS JetStream KV with state=attached → returns EgressOnlyInternetGateway with attachment | 1. Create with valid VpcId<br>2. Missing VpcId (error: MissingParameter)<br>3. Create with inline tags<br>4. Verify ID format eigw-{16hex} | **NOT STARTED** |
|
||||
| `delete-egress-only-internet-gateway` | — | `--egress-only-internet-gateway-id`, `--dry-run` | EIGW must exist | NATS `ec2.DeleteEgressOnlyInternetGateway` → daemon verifies EIGW exists in KV → deletes from KV → returns ReturnCode=true | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **NOT STARTED** |
|
||||
| `describe-egress-only-internet-gateways` | — | `--egress-only-internet-gateway-ids`, `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from KV bucket → filters by ID if specified → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **NOT STARTED** |
|
||||
| `create-egress-only-internet-gateway` | `--vpc-id`, `--tag-specifications` | `--client-token`, `--dry-run` | VPC must exist | Gateway validates VpcId → NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates EgressOnlyIGWRecord in `hive-eigw` KV bucket with state=attached (always attached at creation, unlike IGW) → returns EgressOnlyInternetGateway with attachment | 1. Create with valid VpcId<br>2. Missing VpcId (error: MissingParameter)<br>3. Create with inline tags<br>4. Verify ID format eigw-{16hex} | **STARTED** (KV only, no OVN) |
|
||||
| `delete-egress-only-internet-gateway` | `--egress-only-internet-gateway-id` | `--dry-run` | EIGW must exist | NATS `ec2.DeleteEgressOnlyInternetGateway` → daemon verifies EIGW exists in KV → deletes from `hive-eigw` KV → returns ReturnCode=true. No detach required (unlike IGW). | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **STARTED** (KV only, no OVN) |
|
||||
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `hive-eigw` KV bucket → filters by ID if specified → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **STARTED** (KV only, no OVN) |
|
||||
|
||||
## EC2 - Placement Groups
|
||||
### EC2 - Placement Groups
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -120,7 +198,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `delete-placement-group` | — | `--group-name`, `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists and is empty → delete from KV → return success | 1. Delete empty group<br>2. Delete group with instances (error)<br>3. Delete non-existent group (error) | **NOT STARTED** |
|
||||
| `describe-placement-groups` | — | `--group-names`, `--group-ids`, `--filters` | None | NATS `ec2.DescribePlacementGroups` → daemon lists groups from KV → return list with strategy and state | 1. List all groups<br>2. Filter by name<br>3. Filter by strategy | **NOT STARTED** |
|
||||
|
||||
## EC2 - Dedicated Hosts
|
||||
### EC2 - Dedicated Hosts
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -128,7 +206,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `describe-hosts` | — | `--host-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeHosts` → daemon lists hosts from KV → return host list with capacity and instance info | 1. List all hosts<br>2. Filter by host ID<br>3. Filter by instance type | **NOT STARTED** |
|
||||
| `release-hosts` | — | `--host-ids` | Host must exist, no running instances | NATS `ec2.ReleaseHosts` → daemon verifies no instances on host → delete from KV → return success/failure per host | 1. Release empty host<br>2. Release host with instances (error)<br>3. Release non-existent host (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - IPv4 Pools
|
||||
### EC2 - IPv4 Pools
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -136,7 +214,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `delete-public-ipv4-pool` | — | `--pool-id`, `--dry-run` | Pool must exist and be empty | NATS `ec2.DeletePublicIpv4Pool` → daemon verifies pool empty → delete from KV → return success | 1. Delete empty pool<br>2. Delete pool with addresses (error) | **NOT STARTED** |
|
||||
| `describe-public-ipv4-pools` | — | `--pool-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribePublicIpv4Pools` → daemon lists pools from KV → return pool list with address counts | 1. List all pools<br>2. Filter by pool ID | **NOT STARTED** |
|
||||
|
||||
## EC2 - DHCP Options
|
||||
### EC2 - DHCP Options
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -145,7 +223,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `describe-dhcp-options` | — | `--dhcp-options-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeDhcpOptions` → daemon lists DHCP options from KV → return list with configurations | 1. List all DHCP options<br>2. Filter by ID | **NOT STARTED** |
|
||||
| `associate-dhcp-options` | — | `--dhcp-options-id`, `--vpc-id`, `--dry-run` | DHCP options and VPC must exist | NATS `ec2.AssociateDhcpOptions` → daemon links DHCP options to VPC → update VPC metadata → return success | 1. Associate with VPC<br>2. Missing DHCP options ID (error)<br>3. Missing VPC ID (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Capacity Reservations
|
||||
### EC2 - Capacity Reservations
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -154,7 +232,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `describe-capacity-reservations` | — | `--capacity-reservation-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeCapacityReservations` → daemon lists reservations from KV → return list with state and counts | 1. List all reservations<br>2. Filter by ID<br>3. Filter by instance type | **NOT STARTED** |
|
||||
| `modify-capacity-reservation` | — | `--capacity-reservation-id`, `--instance-count`, `--end-date`, `--end-date-type` | Reservation must exist | NATS `ec2.ModifyCapacityReservation` → daemon updates reservation in KV → return success | 1. Modify instance count<br>2. Modify end date<br>3. Missing reservation ID (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Elastic IP
|
||||
### EC2 - Elastic IP
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -164,7 +242,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `disassociate-address` | — | `--association-id`, `--dry-run` | Association must exist | NATS `ec2.DisassociateAddress` → daemon unlinks EIP from instance/ENI → remove OVS DNAT/SNAT rules → return success | 1. Disassociate existing<br>2. Disassociate non-existent (error) | **NOT STARTED** |
|
||||
| `describe-addresses` | — | `--allocation-ids`, `--filters`, `--public-ips` | None | NATS `ec2.DescribeAddresses` → daemon lists EIPs from KV → return list with association info | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP | **NOT STARTED** |
|
||||
|
||||
## EC2 - NAT Gateway
|
||||
### EC2 - NAT Gateway
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -174,21 +252,21 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
|
||||
| `assign-private-nat-gateway-address` | — | `--nat-gateway-id`, `--private-ip-addresses` | NAT gateway must exist (private type) | NATS `ec2.AssignPrivateNatGatewayAddress` → daemon assigns private IPs to NAT gateway → return assigned addresses | 1. Assign address<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
|
||||
| `associate-nat-gateway-address` | — | `--nat-gateway-id`, `--allocation-ids` | NAT gateway and EIPs must exist | NATS `ec2.AssociateNatGatewayAddress` → daemon associates EIPs with NAT gateway → return association info | 1. Associate EIP<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - VPC Networking (Core)
|
||||
### EC2 - VPC Networking (Core)
|
||||
|
||||
Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Single AZ for Hive v1.
|
||||
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, and routers. Single AZ for Hive v1. IPAM uses CAS-based allocation from NATS KV (`hive-vpc-ipam` bucket).
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-vpc` | — | `--cidr-block`, `--tag-specifications`, `--instance-tenancy` | None | Create OVS bridge for VPC → assign vpc-ID → store VPC metadata in NATS KV → configure CIDR range → return VPC with state=available | 1. Create VPC with /16 CIDR<br>2. Overlapping CIDR (error)<br>3. Invalid CIDR format (error)<br>4. Verify in describe-vpcs | **NOT STARTED** |
|
||||
| `delete-vpc` | — | `--vpc-id` | VPC must be empty (no subnets, gateways, instances) | Verify no dependent resources → delete OVS bridge → remove metadata → return success | 1. Delete empty VPC<br>2. Delete VPC with subnets (error: DependencyViolation)<br>3. Delete non-existent VPC (error) | **NOT STARTED** |
|
||||
| `describe-vpcs` | — | `--vpc-ids`, `--filters`, `--max-results` | None | Query NATS KV for VPC metadata → return VPC list with CIDR, state, tags | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Filter by CIDR block | **NOT STARTED** |
|
||||
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | None | Gateway validates CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateVpc` → daemon allocates unique VNI from `hive-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `hive-vpc-vpcs` KV → publishes `vpc.create` event to vpcd (creates OVN logical switch) → returns VPC with state=available | 1. Create VPC with /16 CIDR<br>2. Invalid CIDR format (error: InvalidVpcRange)<br>3. CIDR prefix outside /16–/28 (error)<br>4. Verify in describe-vpcs<br>5. Tags applied at creation | **DONE** |
|
||||
| `delete-vpc` | `--vpc-id` | `--dry-run` | VPC must be empty (no subnets) | Gateway validates VpcId → NATS `ec2.DeleteVpc` → daemon checks for dependent subnets in `hive-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `hive-vpc-vpcs` KV → publishes `vpc.delete` event to vpcd (removes OVN resources) | 1. Delete empty VPC<br>2. Delete VPC with subnets (DependencyViolation)<br>3. Delete non-existent VPC (error) | **DONE** |
|
||||
| `describe-vpcs` | `--vpc-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `hive-vpc-vpcs` KV → filters by VPC IDs if specified → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error | **DONE** |
|
||||
| `modify-vpc-attribute` | — | `--vpc-id`, `--enable-dns-support`, `--enable-dns-hostnames` | VPC must exist | NATS `ec2.ModifyVpcAttribute` → daemon updates VPC attribute in KV → return success | 1. Enable DNS support<br>2. Enable DNS hostnames<br>3. Missing VPC ID (error) | **NOT STARTED** |
|
||||
| `associate-vpc-cidr-block` | — | `--vpc-id`, `--cidr-block` | VPC must exist | NATS `ec2.AssociateVpcCidrBlock` → daemon adds secondary CIDR to VPC metadata → return association | 1. Add secondary CIDR<br>2. Overlapping CIDR (error)<br>3. Max CIDR blocks exceeded (error) | **NOT STARTED** |
|
||||
| `disassociate-vpc-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateVpcCidrBlock` → daemon removes secondary CIDR from VPC → return success | 1. Remove secondary CIDR<br>2. Remove primary CIDR (error) | **NOT STARTED** |
|
||||
| `create-subnet` | — | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | VPC must exist | Validate CIDR within VPC range → create OVS port group with VLAN tag → assign subnet-ID → store metadata | 1. Create subnet within VPC CIDR<br>2. Subnet CIDR outside VPC range (error)<br>3. Overlapping subnet CIDRs (error) | **NOT STARTED** |
|
||||
| `delete-subnet` | — | `--subnet-id` | Subnet must be empty (no instances) | Verify no instances in subnet → remove OVS port group → delete metadata | 1. Delete empty subnet<br>2. Delete subnet with instances (error) | **NOT STARTED** |
|
||||
| `describe-subnets` | — | `--subnet-ids`, `--filters`, `--max-results` | None | Query NATS KV for subnet metadata → return subnet list | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by AZ | **NOT STARTED** |
|
||||
| `create-subnet` | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates VpcId + CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateSubnet` → daemon verifies parent VPC exists → validates subnet CIDR is within VPC CIDR range → checks for overlapping subnets in same VPC → calculates available IPs (total hosts - 5 AWS-reserved: .0/.1/.2/.3/.255) → generates subnet-ID → stores SubnetRecord in `hive-vpc-subnets` KV → publishes `vpc.create-subnet` event to vpcd → defaults AZ from config if omitted | 1. Create subnet within VPC CIDR<br>2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)<br>3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)<br>4. CIDR prefix outside /16–/28 (error)<br>5. Missing VPC or CIDR (error: MissingParameter) | **DONE** |
|
||||
| `delete-subnet` | `--subnet-id` | `--dry-run` | Subnet must exist | NATS `ec2.DeleteSubnet` → daemon verifies subnet exists → deletes from `hive-vpc-subnets` KV → publishes `vpc.delete-subnet` event to vpcd | 1. Delete existing subnet<br>2. Delete non-existent subnet (error) | **DONE** |
|
||||
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `hive-vpc-subnets` KV → applies vpc-id filter if present → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error | **DONE** |
|
||||
| `modify-subnet-attribute` | — | `--subnet-id`, `--map-public-ip-on-launch`, `--assign-ipv6-address-on-creation` | Subnet must exist | NATS `ec2.ModifySubnetAttribute` → daemon updates subnet attribute in KV → return success | 1. Enable auto-assign public IP<br>2. Missing subnet ID (error) | **NOT STARTED** |
|
||||
| `associate-subnet-cidr-block` | — | `--subnet-id`, `--ipv6-cidr-block` | Subnet must exist | NATS `ec2.AssociateSubnetCidrBlock` → daemon adds IPv6 CIDR to subnet → return association | 1. Add IPv6 CIDR<br>2. Missing subnet ID (error) | **NOT STARTED** |
|
||||
| `disassociate-subnet-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateSubnetCidrBlock` → daemon removes IPv6 CIDR from subnet → return success | 1. Remove IPv6 CIDR<br>2. Invalid association (error) | **NOT STARTED** |
|
||||
@@ -199,13 +277,13 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `authorize-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist | Add outbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR | **NOT STARTED** |
|
||||
| `revoke-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove inbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
|
||||
| `revoke-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove outbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
|
||||
| `create-internet-gateway` | — | `--tag-specifications` | None | Create IGW metadata → assign igw-ID → configure OVS NAT bridge for external access | 1. Create IGW<br>2. Verify in describe-internet-gateways | **NOT STARTED** |
|
||||
| `attach-internet-gateway` | — | `--internet-gateway-id`, `--vpc-id` | IGW and VPC must exist | Link IGW to VPC → configure OVS flows for internet routing → update metadata | 1. Attach IGW to VPC<br>2. Attach already-attached IGW (error)<br>3. Attach to non-existent VPC (error) | **NOT STARTED** |
|
||||
| `detach-internet-gateway` | — | `--internet-gateway-id`, `--vpc-id` | IGW must be attached to VPC | Unlink IGW from VPC → remove OVS internet routing flows | 1. Detach attached IGW<br>2. Detach unattached IGW (error) | **NOT STARTED** |
|
||||
| `delete-internet-gateway` | — | `--internet-gateway-id` | IGW must be detached | Verify IGW detached → remove OVS NAT bridge → delete metadata | 1. Delete detached IGW<br>2. Delete attached IGW (error) | **NOT STARTED** |
|
||||
| `describe-internet-gateways` | — | `--internet-gateway-ids`, `--filters`, `--max-results` | None | Query NATS KV for IGW metadata → return list with attachment info | 1. List all IGWs<br>2. Filter by attachment VPC | **NOT STARTED** |
|
||||
| `create-internet-gateway` | `--tag-specifications` | `--dry-run` | None | NATS `ec2.CreateInternetGateway` → daemon generates igw-ID → creates IGWRecord in `hive-igw` KV bucket with state=available (not attached) → returns InternetGateway with empty Attachments | 1. Create IGW<br>2. Verify in describe-internet-gateways<br>3. Tags applied at creation | **DONE** |
|
||||
| `attach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW and VPC must exist | NATS `ec2.AttachInternetGateway` → daemon validates IGW exists and is not already attached (ResourceAlreadyAssociated) → sets VpcId on record → state=attached → publishes `vpc.igw-attach` event to vpcd (creates OVN external switch, gateway port, SNAT rules) | 1. Attach IGW to VPC<br>2. Attach already-attached IGW (ResourceAlreadyAssociated)<br>3. Non-existent IGW (error)<br>4. Missing params (MissingParameter) | **DONE** |
|
||||
| `detach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW must be attached to specified VPC | NATS `ec2.DetachInternetGateway` → daemon validates IGW exists and is attached to specified VPC (GatewayNotAttached if wrong VPC) → clears VpcId → state=available → publishes `vpc.igw-detach` event to vpcd (removes OVN resources) | 1. Detach attached IGW<br>2. Detach from wrong VPC (GatewayNotAttached)<br>3. Detach unattached IGW (error) | **DONE** |
|
||||
| `delete-internet-gateway` | `--internet-gateway-id` | `--dry-run` | IGW must be detached | NATS `ec2.DeleteInternetGateway` → daemon validates IGW exists and VpcId is empty (DependencyViolation if attached) → deletes from `hive-igw` KV | 1. Delete detached IGW<br>2. Delete attached IGW (DependencyViolation)<br>3. Delete non-existent (error) | **DONE** |
|
||||
| `describe-internet-gateways` | `--internet-gateway-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `hive-igw` KV → filters by IGW IDs if specified → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info | **DONE** |
|
||||
|
||||
## EC2 - Route Tables
|
||||
### EC2 - Route Tables
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -218,7 +296,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `associate-route-table` | — | `--route-table-id`, `--subnet-id` or `--gateway-id` | Route table and target must exist | NATS `ec2.AssociateRouteTable` → daemon links route table to subnet/gateway → return rtbassoc-ID | 1. Associate with subnet<br>2. Missing route table ID (error) | **NOT STARTED** |
|
||||
| `disassociate-route-table` | — | `--association-id` | Association must exist, not main table association | NATS `ec2.DisassociateRouteTable` → daemon unlinks route table → return success | 1. Disassociate existing<br>2. Disassociate main table (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - VPC Peering
|
||||
### EC2 - VPC Peering
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -229,7 +307,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `describe-vpc-peering-connections` | — | `--vpc-peering-connection-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeVpcPeeringConnections` → daemon lists peerings from KV → return list with state | 1. List all peerings<br>2. Filter by ID<br>3. Filter by VPC | **NOT STARTED** |
|
||||
| `modify-vpc-peering-connection-options` | — | `--vpc-peering-connection-id`, `--requester-peering-connection-options`, `--accepter-peering-connection-options` | Peering must be active | NATS `ec2.ModifyVpcPeeringConnectionOptions` → daemon updates peering options in KV → return modified options | 1. Enable DNS resolution<br>2. Missing peering ID (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - VPC Endpoints
|
||||
### EC2 - VPC Endpoints
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -239,7 +317,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `describe-vpc-endpoint-services` | — | `--service-names`, `--filters`, `--max-results` | None | NATS `ec2.DescribeVpcEndpointServices` → daemon returns available services (S3, DynamoDB equivalents) → return service list | 1. List available services<br>2. Filter by service name | **NOT STARTED** |
|
||||
| `modify-vpc-endpoint` | — | `--vpc-endpoint-id`, `--add-route-table-ids`, `--remove-route-table-ids`, `--add-subnet-ids`, `--remove-subnet-ids` | Endpoint must exist | NATS `ec2.ModifyVpcEndpoint` → daemon updates endpoint route tables/subnets in KV → return success | 1. Add route table<br>2. Remove subnet<br>3. Missing endpoint ID (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - VPN Gateway & Customer Gateway
|
||||
### EC2 - VPN Gateway & Customer Gateway
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -252,7 +330,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `detach-vpn-gateway` | — | `--vpn-gateway-id`, `--vpc-id` | Must be attached | NATS `ec2.DetachVpnGateway` → daemon unlinks VPN gateway from VPC → return success | 1. Detach from VPC<br>2. Not attached (error) | **NOT STARTED** |
|
||||
| `describe-vpn-gateways` | — | `--vpn-gateway-ids`, `--filters` | None | NATS `ec2.DescribeVpnGateways` → daemon lists from KV → return list with attachment info | 1. List all<br>2. Filter by VPC attachment | **NOT STARTED** |
|
||||
|
||||
## EC2 - VPN Connections
|
||||
### EC2 - VPN Connections
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -262,7 +340,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `modify-vpn-connection` | — | `--vpn-connection-id`, `--vpn-gateway-id`, `--customer-gateway-id` | Connection must exist | NATS `ec2.ModifyVpnConnection` → daemon updates connection target in KV → return modified connection | 1. Change VPN gateway<br>2. Missing connection ID (error) | **NOT STARTED** |
|
||||
| `modify-vpn-connection-options` | — | `--vpn-connection-id`, `--local-ipv4-network-cidr`, `--remote-ipv4-network-cidr` | Connection must exist | NATS `ec2.ModifyVpnConnectionOptions` → daemon updates tunnel options in KV → return modified connection | 1. Modify CIDR ranges<br>2. Missing connection ID (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Network ACLs
|
||||
### EC2 - Network ACLs
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -274,7 +352,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `replace-network-acl-association` | — | `--association-id`, `--network-acl-id` | Both must exist | NATS `ec2.ReplaceNetworkAclAssociation` → daemon swaps NACL for subnet → return new association ID | 1. Replace association<br>2. Missing association ID (error) | **NOT STARTED** |
|
||||
| `replace-network-acl-entry` | — | `--network-acl-id`, `--rule-number`, `--protocol`, `--rule-action`, `--cidr-block`, `--ingress`/`--egress`, `--port-range` | Entry must exist | NATS `ec2.ReplaceNetworkAclEntry` → daemon replaces rule in KV → update OVS flow → return success | 1. Replace existing entry<br>2. Replace non-existent entry (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Prefix Lists
|
||||
### EC2 - Prefix Lists
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -285,20 +363,22 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `get-managed-prefix-list-entries` | — | `--prefix-list-id`, `--target-version`, `--max-results` | Prefix list must exist | NATS `ec2.GetManagedPrefixListEntries` → daemon reads entries from KV → return entry list | 1. List entries<br>2. List entries at specific version | **NOT STARTED** |
|
||||
| `get-managed-prefix-list-associations` | — | `--prefix-list-id`, `--max-results` | Prefix list must exist | NATS `ec2.GetManagedPrefixListAssociations` → daemon finds references in SGs/route tables → return association list | 1. List associations<br>2. No associations returns empty | **NOT STARTED** |
|
||||
|
||||
## EC2 - Network Interfaces
|
||||
### EC2 - Network Interfaces
|
||||
|
||||
ENIs are managed via `hive-vpc-enis` KV bucket with CAS-based IPAM allocation from `hive-vpc-ipam`. ENIs are auto-created by `run-instances --subnet-id` and auto-deleted on termination. MAC addresses are deterministically generated from the ENI ID. Attach/detach are internal operations used by instance lifecycle — no standalone attach/detach API yet.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-network-interface` | — | `--subnet-id`, `--description`, `--private-ip-address`, `--groups`, `--tag-specifications` | Subnet must exist | NATS `ec2.CreateNetworkInterface` → daemon creates ENI in KV with eni-ID → assign private IP from subnet pool → return ENI | 1. Create in subnet<br>2. Create with specific private IP<br>3. Missing subnet ID (error) | **NOT STARTED** |
|
||||
| `delete-network-interface` | — | `--network-interface-id`, `--dry-run` | Must not be attached | NATS `ec2.DeleteNetworkInterface` → daemon verifies detached → release IP → delete from KV → return success | 1. Delete detached ENI<br>2. Delete attached ENI (error) | **NOT STARTED** |
|
||||
| `describe-network-interfaces` | — | `--network-interface-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists ENIs from KV → return list with IP/attachment info | 1. List all ENIs<br>2. Filter by subnet<br>3. Filter by attachment | **NOT STARTED** |
|
||||
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | NATS `ec2.AttachNetworkInterface` → daemon attaches OVS port to instance QEMU process → configure MAC/IP → return eni-attach-ID | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** |
|
||||
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | NATS `ec2.DetachNetworkInterface` → daemon detaches OVS port from instance → return success | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** |
|
||||
| `create-network-interface` | `--subnet-id`, `--private-ip-address`, `--description`, `--tag-specifications` | `--groups`, `--dry-run` | Subnet must exist | Gateway validates SubnetId (required) → NATS `ec2.CreateNetworkInterface` → daemon verifies subnet exists in `hive-vpc-subnets` KV → if no PrivateIpAddress, allocates from IPAM pool (CAS-based, up to 5 retries) → generates eni-ID → generates deterministic MAC from ENI ID → stores ENIRecord in `hive-vpc-enis` KV → publishes `vpc.create-port` event to vpcd (creates OVN logical port) → returns NetworkInterface with status=available | 1. Create in subnet (auto-allocate IP)<br>2. Create with specific private IP<br>3. Missing subnet ID (MissingParameter)<br>4. Non-existent subnet (error)<br>5. Tags applied at creation | **DONE** |
|
||||
| `delete-network-interface` | `--network-interface-id` | `--dry-run` | Must not be attached (status != in-use) | NATS `ec2.DeleteNetworkInterface` → daemon verifies ENI exists and status is not "in-use" (InvalidNetworkInterfaceInUse) → releases IP back to IPAM pool → deletes from `hive-vpc-enis` KV → publishes `vpc.delete-port` event to vpcd | 1. Delete detached ENI<br>2. Delete attached ENI (InvalidNetworkInterfaceInUse)<br>3. Delete non-existent (error) | **DONE** |
|
||||
| `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists all keys from `hive-vpc-enis` KV → applies filters (subnet-id, vpc-id, attachment.instance-id) → filters by ENI IDs if specified → returns error for non-existent requested IDs | 1. List all ENIs<br>2. Filter by subnet-id<br>3. Filter by vpc-id<br>4. Filter by attachment.instance-id<br>5. Non-existent ENI returns error | **DONE** |
|
||||
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | Internal operation used by `run-instances --subnet-id`. Marks ENI as "in-use", sets instance attachment, generates attachment ID. No standalone API endpoint yet. | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** (internal only) |
|
||||
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | Internal operation used by terminate-instances. Marks ENI as "available", clears attachment info. No standalone API endpoint yet. | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** (internal only) |
|
||||
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--source-dest-check`, `--groups` | ENI must exist | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon updates ENI attributes in KV → return success | 1. Modify description<br>2. Toggle source/dest check<br>3. Update security groups | **NOT STARTED** |
|
||||
| `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` or `--secondary-private-ip-address-count` | ENI must exist | NATS `ec2.AssignPrivateIpAddresses` → daemon allocates IPs from subnet pool → assign to ENI → return assigned IPs | 1. Assign specific IPs<br>2. Assign by count<br>3. Cannot specify both (error) | **NOT STARTED** |
|
||||
| `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | IPs must be assigned to ENI | NATS `ec2.UnassignPrivateIpAddresses` → daemon releases IPs → return success | 1. Unassign secondary IP<br>2. Unassign primary IP (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Launch Templates
|
||||
### EC2 - Launch Templates
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -308,7 +388,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `describe-launch-templates` | — | `--launch-template-ids`, `--launch-template-names`, `--filters` | None | NATS `ec2.DescribeLaunchTemplates` → daemon lists templates from KV → return list | 1. List all templates<br>2. Filter by name<br>3. Filter by ID | **NOT STARTED** |
|
||||
| `describe-launch-template-versions` | — | `--launch-template-id` or `--launch-template-name`, `--versions`, `--min-version`, `--max-version` | Template must exist | NATS `ec2.DescribeLaunchTemplateVersions` → daemon lists versions from KV → return version list with data | 1. List all versions<br>2. List specific version<br>3. Template not found (error) | **NOT STARTED** |
|
||||
|
||||
## EC2 - Misc Operations
|
||||
### EC2 - Misc Operations
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -316,7 +396,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `enable-address-transfer` | — | `--allocation-id`, `--transfer-account-id` | EIP must exist | NATS `ec2.EnableAddressTransfer` → daemon stores transfer in JetStream KV with 7-day expiration → return AllocationId and TransferAccountId | 1. Enable transfer<br>2. Verify transfer stored with expiration | **NOT STARTED** |
|
||||
| `disable-address-transfer` | — | `--allocation-id` | Transfer must exist | NATS `ec2.DisableAddressTransfer` → daemon updates transfer status to disabled in KV → return status | 1. Disable active transfer<br>2. Disable non-existent transfer (error) | **NOT STARTED** |
|
||||
|
||||
## EBS Direct API
|
||||
### EBS Direct API
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
@@ -327,7 +407,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
|
||||
| `list-snapshot-blocks` | — | `--snapshot-id`, `--max-results`, `--next-token` | Snapshot must exist | List all non-empty blocks in snapshot with tokens and sizes | 1. List blocks of populated snapshot<br>2. List blocks of empty snapshot | **NOT STARTED** |
|
||||
| `list-changed-blocks` | — | `--second-snapshot-id`, `--first-snapshot-id`, `--max-results` | Snapshots must exist | Compare two snapshots → return list of differing block indexes | 1. Compare parent and child snapshots<br>2. Compare unrelated snapshots | **NOT STARTED** |
|
||||
|
||||
## S3 (via Predastore)
|
||||
### S3 (via Predastore)
|
||||
|
||||
Currently S3 operations are handled directly by Predastore. Consider moving control/data plane to Hive format for AWS gateway integration.
|
||||
|
||||
@@ -340,19 +420,60 @@ Currently S3 operations are handled directly by Predastore. Consider moving cont
|
||||
| `s3 rm` (DeleteObject) | — | `s3://bucket/key`, `--recursive` | Object must exist | NATS `s3.deleteobject` → Predastore deletes object shards | 1. Delete single object<br>2. Recursive delete<br>3. Delete non-existent (idempotent) | **NOT STARTED** |
|
||||
| `s3 sync` | — | `source dest`, `--delete`, `--exclude`, `--include` | Bucket must exist | Compare source and dest → upload changed/new files → optionally delete removed files | 1. Sync local dir to S3<br>2. Sync with --delete<br>3. Sync with exclude filter | **NOT STARTED** |
|
||||
|
||||
## IAM
|
||||
### IAM - User Management
|
||||
|
||||
All IAM operations are account-scoped. Users, policies, and access keys are namespaced per account. The gateway extracts the account ID from the authenticated access key via `c.Locals("sigv4.accountId")` and passes it to all IAM service methods. Root user (account `000000000000`) bypasses policy evaluation entirely.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-user` | — | `--user-name`, `--path`, `--tags` | None | Store user in JetStream KV → generate AIDA-prefixed UserId → return User with ARN | 1. Create user<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing username (error) | **NOT STARTED** |
|
||||
| `get-user` | — | `--user-name` | User must exist | Read user from JetStream KV → return User details | 1. Get existing user<br>2. Get non-existent user (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-users` | — | `--path-prefix`, `--max-items`, `--marker` | None | List users from JetStream KV → return user list | 1. List all users<br>2. List with path prefix<br>3. Empty list | **NOT STARTED** |
|
||||
| `delete-user` | — | `--user-name` | User must exist, no access keys/policies | Remove user from JetStream KV → return success | 1. Delete user with no keys<br>2. Delete user with keys (error)<br>3. Delete non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `create-access-key` | — | `--user-name` | User must exist | Generate access key ID (AKIA prefix) and secret → store in JetStream KV → return credentials | 1. Create key for user<br>2. Max keys exceeded (error) | **NOT STARTED** (gateway stub exists) |
|
||||
| `delete-access-key` | — | `--access-key-id`, `--user-name` | Key must exist | Remove access key from JetStream KV → return success | 1. Delete existing key<br>2. Delete non-existent key (error) | **NOT STARTED** |
|
||||
| `list-access-keys` | — | `--user-name`, `--max-items` | None | Query JetStream KV for user's access keys → return list | 1. List keys for user<br>2. No keys returns empty | **NOT STARTED** |
|
||||
| `create-policy` | — | `--policy-name`, `--policy-document`, `--path`, `--description`, `--tags` | None | Validate JSON policy document → store in JetStream KV → generate policy ARN → return Policy | 1. Create with valid document<br>2. Invalid JSON (error)<br>3. Duplicate name (EntityAlreadyExists) | **NOT STARTED** |
|
||||
| `delete-policy` | — | `--policy-arn` | Policy must exist, not attached to any user | Remove policy from JetStream KV → return success | 1. Delete unattached policy<br>2. Delete attached policy (error) | **NOT STARTED** |
|
||||
| `attach-user-policy` | — | `--user-name`, `--policy-arn` | User and policy must exist | Link policy to user in JetStream KV → return success | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent user (NoSuchEntity) | **NOT STARTED** |
|
||||
| `detach-user-policy` | — | `--user-name`, `--policy-arn` | User and policy must exist, policy attached | Unlink policy from user in JetStream KV → return success | 1. Detach attached policy<br>2. Not attached (error) | **NOT STARTED** |
|
||||
| `list-attached-user-policies` | — | `--user-name`, `--path-prefix`, `--max-items` | User must exist | List policies attached to user from JetStream KV → return policy list | 1. List attached policies<br>2. No policies returns empty | **NOT STARTED** |
|
||||
| `create-user` | `--user-name`, `--path` | `--tags`, `--permissions-boundary` | None | Gateway validates UserName non-empty → IAMService.CreateUser(accountID, input) → generates AIDA-prefixed UserId → stores in KV at key `{accountID}.{userName}` → ARN includes real account ID → conditional create for race safety | 1. Create user<br>2. Duplicate name in same account (EntityAlreadyExists)<br>3. Same name in different account (succeeds)<br>4. Missing username (InvalidInput)<br>5. Default path is `/` | **DONE** |
|
||||
| `get-user` | `--user-name` | — | User must exist in caller's account | Gateway validates UserName → IAMService.GetUser(accountID, input) → reads from KV at `{accountID}.{userName}` → returns User with ARN, Path, CreateDate | 1. Get existing user<br>2. Get non-existent user (NoSuchEntity)<br>3. Cannot see user in different account (NoSuchEntity) | **DONE** |
|
||||
| `list-users` | `--path-prefix` | `--max-items`, `--marker` | None | IAMService.ListUsers(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by path prefix if specified → returns user list scoped to caller's account | 1. List all users in account<br>2. List with path prefix filter<br>3. Empty list<br>4. Does not show other accounts' users | **DONE** |
|
||||
| `delete-user` | `--user-name` | — | User must exist, no access keys attached | Gateway validates UserName → IAMService.DeleteUser(accountID, input) → checks user has no access keys → deletes from KV | 1. Delete user with no keys<br>2. Delete user with keys attached (DeleteConflict)<br>3. Delete non-existent user (NoSuchEntity) | **DONE** |
|
||||
|
||||
### IAM - Access Key Management
|
||||
|
||||
Access keys are globally unique (keyed by AccessKeyID) but associated with an account. Max 2 keys per user. Secrets encrypted with AES-256-GCM using master key.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-access-key` | `--user-name` | — | User must exist | IAMService.CreateAccessKey(accountID, input) → generates AKIA-prefixed key ID + random secret → encrypts secret with master key → stores in KV (globally unique key ID) → updates user's AccessKeys list → returns plaintext secret (one-time only) | 1. Create key for user (AKIA prefix)<br>2. Max 2 keys per user (LimitExceeded on 3rd)<br>3. Non-existent user (NoSuchEntity)<br>4. Secret only returned on creation | **DONE** |
|
||||
| `list-access-keys` | `--user-name` | `--max-items`, `--marker` | None | IAMService.ListAccessKeys(accountID, input) → reads user's AccessKeys list → fetches each key's metadata → returns key IDs and status (no secrets) | 1. List keys for user<br>2. No keys returns empty list<br>3. Shows Status (Active/Inactive) but not secrets | **DONE** |
|
||||
| `delete-access-key` | `--access-key-id`, `--user-name` | — | Key must exist | IAMService.DeleteAccessKey(accountID, input) → removes key from global KV → removes key ID from user's AccessKeys list | 1. Delete existing key<br>2. Delete non-existent key (NoSuchEntity) | **DONE** |
|
||||
| `update-access-key` | `--access-key-id`, `--user-name`, `--status` (Active/Inactive) | — | Key must exist | IAMService.UpdateAccessKey(input) → reads key from global KV (key ID is globally unique, no accountID needed) → updates Status field → inactive keys rejected at SigV4 auth | 1. Deactivate key (Inactive)<br>2. Reactivate key (Active)<br>3. Non-existent key (NoSuchEntity)<br>4. Inactive key → auth returns InvalidClientTokenId | **DONE** |
|
||||
|
||||
### IAM - Policy Management
|
||||
|
||||
Policies are account-scoped. KV key is `{accountID}.{policyName}`. Policy documents require `Version: "2012-10-17"` and valid Statement array. Supports wildcard action matching (`ec2:*`, `ec2:Describe*`, `*`). Evaluation order: explicit Deny > explicit Allow > implicit Deny.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-policy` | `--policy-name`, `--policy-document`, `--path`, `--description` | `--tags` | None | Gateway validates PolicyName and PolicyDocument non-empty → IAMService.CreatePolicy(accountID, input) → validates JSON (Version, Statement with Effect/Action/Resource) → generates ANPA-prefixed PolicyId → stores at `{accountID}.{policyName}` → ARN includes real account ID | 1. Create with valid document<br>2. Malformed JSON (MalformedPolicyDocument)<br>3. Missing Effect/Action/Resource (MalformedPolicyDocument)<br>4. Duplicate name in same account (EntityAlreadyExists)<br>5. Same name in different account (succeeds)<br>6. With path and description | **DONE** |
|
||||
| `get-policy` | `--policy-arn` | — | Policy must exist | Gateway validates PolicyArn → extracts account ID and policy name from ARN → IAMService.GetPolicy(accountID, input) → returns policy metadata (no document) with AttachmentCount | 1. Get existing policy<br>2. Non-existent policy (NoSuchEntity)<br>3. Returns metadata only, not document | **DONE** |
|
||||
| `get-policy-version` | `--policy-arn`, `--version-id` | — | Policy must exist | Gateway validates PolicyArn and VersionId → IAMService.GetPolicyVersion(accountID, input) → returns PolicyVersion with URL-encoded Document, VersionId, IsDefaultVersion | 1. Get version v1 (returns document)<br>2. Non-existent version (NoSuchEntity)<br>3. Non-existent policy (NoSuchEntity) | **DONE** |
|
||||
| `list-policies` | — | `--scope`, `--only-attached`, `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListPolicies(accountID, input) → prefix-scans KV for `{accountID}.*` → returns all policies in caller's account | 1. List all policies in account<br>2. Empty list<br>3. Does not show other accounts' policies | **DONE** |
|
||||
| `delete-policy` | `--policy-arn` | — | Policy must exist, not attached to any user | Gateway validates PolicyArn → IAMService.DeletePolicy(accountID, input) → checks no users have this policy attached (scans account's users) → deletes from KV | 1. Delete unattached policy<br>2. Delete attached policy (DeleteConflict)<br>3. Delete non-existent (NoSuchEntity) | **DONE** |
|
||||
|
||||
### IAM - Policy Attachment
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `attach-user-policy` | `--user-name`, `--policy-arn` | — | User and policy must exist in caller's account | Gateway validates both params → IAMService.AttachUserPolicy(accountID, input) → verifies policy exists → adds policy ARN to user's AttachedPolicies list → idempotent (re-attach succeeds without duplicate) | 1. Attach policy to user<br>2. Already attached (idempotent, no error)<br>3. Non-existent user (NoSuchEntity)<br>4. Non-existent policy (NoSuchEntity) | **DONE** |
|
||||
| `detach-user-policy` | `--user-name`, `--policy-arn` | — | Policy must be attached to user | Gateway validates both params → IAMService.DetachUserPolicy(accountID, input) → removes policy ARN from user's AttachedPolicies list | 1. Detach attached policy<br>2. Policy not attached (NoSuchEntity)<br>3. Non-existent user (NoSuchEntity) | **DONE** |
|
||||
| `list-attached-user-policies` | `--user-name` | `--path-prefix`, `--max-items`, `--marker` | User must exist | IAMService.ListAttachedUserPolicies(accountID, input) → reads user's AttachedPolicies list → returns policy ARNs and names | 1. List attached policies<br>2. No policies returns empty list | **DONE** |
|
||||
|
||||
|
||||
### IAM - Policy Evaluation
|
||||
|
||||
The gateway evaluates IAM policies on every request (except root user bypass). Policy evaluation is internal — not an AWS API.
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| Root user bypass | Root user (`000000000000:root`) skips all policy checks | **DONE** |
|
||||
| Default deny | Users with no attached policies are denied all actions | **DONE** |
|
||||
| Explicit allow | Matching Allow statement grants access | **DONE** |
|
||||
| Explicit deny | Deny statements override Allow (deny wins) | **DONE** |
|
||||
| Wildcard matching | `*` matches all, `ec2:*` matches all EC2, `ec2:Describe*` matches prefix | **DONE** |
|
||||
| Account-scoped evaluation | Policies resolved from caller's account context | **DONE** |
|
||||
| EC2 action mapping | 54 EC2 actions mapped to IAM policy strings | **DONE** |
|
||||
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
|
||||
|
||||
+4
-5
@@ -264,12 +264,11 @@ func GenerateAWSSecretKey() string {
|
||||
return base64.StdEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// generateAccountID generates a 12-digit AWS account ID
|
||||
// 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 {
|
||||
// Generate random 12-digit number
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(900000000000))
|
||||
accountID := num.Int64() + 100000000000 // Ensure it's 12 digits
|
||||
return fmt.Sprintf("%012d", accountID)
|
||||
return "000000000000"
|
||||
}
|
||||
|
||||
// generateNATSToken generates a secure random token for NATS
|
||||
|
||||
@@ -42,18 +42,14 @@ func TestGenerateAWSSecretKey_Uniqueness(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateAWSSecretKey(), GenerateAWSSecretKey())
|
||||
}
|
||||
|
||||
func TestGenerateAccountID_Format(t *testing.T) {
|
||||
func TestGenerateAccountID_ReturnsGlobalID(t *testing.T) {
|
||||
id := GenerateAccountID()
|
||||
assert.Equal(t, "000000000000", id)
|
||||
assert.Len(t, id, 12)
|
||||
for _, c := range id {
|
||||
assert.True(t, c >= '0' && c <= '9', "expected digit, got %c", c)
|
||||
}
|
||||
// First digit should not be 0
|
||||
assert.NotEqual(t, byte('0'), id[0], "12-digit account ID should not start with 0")
|
||||
}
|
||||
|
||||
func TestGenerateAccountID_Uniqueness(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateAccountID(), GenerateAccountID())
|
||||
func TestGenerateAccountID_Deterministic(t *testing.T) {
|
||||
assert.Equal(t, GenerateAccountID(), GenerateAccountID())
|
||||
}
|
||||
|
||||
func TestGenerateNATSToken_Format(t *testing.T) {
|
||||
|
||||
@@ -423,6 +423,15 @@ var (
|
||||
ErrorVpnGatewayAttachmentLimitExceeded = "VpnGatewayAttachmentLimitExceeded"
|
||||
ErrorVpnGatewayLimitExceeded = "VpnGatewayLimitExceeded"
|
||||
ErrorZonesMismatched = "ZonesMismatched"
|
||||
|
||||
// IAM-specific error codes
|
||||
ErrorIAMNoSuchEntity = "NoSuchEntity"
|
||||
ErrorIAMEntityAlreadyExists = "EntityAlreadyExists"
|
||||
ErrorIAMDeleteConflict = "DeleteConflict"
|
||||
ErrorIAMLimitExceeded = "LimitExceeded"
|
||||
ErrorIAMInvalidInput = ErrorInvalidInput // alias for IAM usage
|
||||
ErrorIAMMalformedPolicyDocument = "MalformedPolicyDocument"
|
||||
ErrorAccessDenied = "AccessDenied"
|
||||
)
|
||||
|
||||
// ValidErrorCode returns the error code if it exists in ErrorLookup,
|
||||
@@ -853,4 +862,12 @@ var ErrorLookup = map[string]ErrorMessage{
|
||||
ErrorVpnGatewayAttachmentLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of VPCs that can be attached to the specified virtual private gateway."},
|
||||
ErrorVpnGatewayLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of virtual private gateways that you can create. For more information about limits, see Amazon VPC quotas."},
|
||||
ErrorZonesMismatched: {HTTPCode: 400, Message: "The Availability Zone for the instance does not match that of the Dedicated Host"},
|
||||
|
||||
// IAM error codes
|
||||
ErrorIAMNoSuchEntity: {HTTPCode: 404, Message: "The request was rejected because it referenced a resource entity that does not exist."},
|
||||
ErrorIAMEntityAlreadyExists: {HTTPCode: 409, Message: "The request was rejected because it attempted to create a resource that already exists."},
|
||||
ErrorIAMDeleteConflict: {HTTPCode: 409, Message: "The request was rejected because it attempted to delete a resource that has attached subordinate entities."},
|
||||
ErrorIAMLimitExceeded: {HTTPCode: 409, Message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
|
||||
ErrorIAMMalformedPolicyDocument: {HTTPCode: 400, Message: "The policy document is malformed."},
|
||||
ErrorAccessDenied: {HTTPCode: 403, Message: "User is not authorized to perform this action."},
|
||||
}
|
||||
|
||||
@@ -425,6 +425,14 @@ func TestErrorLookup(t *testing.T) {
|
||||
{code: "VpnGatewayAttachmentLimitExceeded", http: 400, message: "You've reached the limit on the number of VPCs that can be attached to the specified virtual private gateway."},
|
||||
{code: "VpnGatewayLimitExceeded", http: 400, message: "You've reached the limit on the number of virtual private gateways that you can create. For more information about limits, see Amazon VPC quotas."},
|
||||
{code: "ZonesMismatched", http: 400, message: "The Availability Zone for the instance does not match that of the Dedicated Host"},
|
||||
|
||||
// IAM error codes
|
||||
{code: "NoSuchEntity", http: 404, message: "The request was rejected because it referenced a resource entity that does not exist."},
|
||||
{code: "EntityAlreadyExists", http: 409, message: "The request was rejected because it attempted to create a resource that already exists."},
|
||||
{code: "DeleteConflict", http: 409, message: "The request was rejected because it attempted to delete a resource that has attached subordinate entities."},
|
||||
{code: "LimitExceeded", http: 409, message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
|
||||
{code: "MalformedPolicyDocument", http: 400, message: "The policy document is malformed."},
|
||||
{code: "AccessDenied", http: 403, message: "User is not authorized to perform this action."},
|
||||
}
|
||||
|
||||
if len(ErrorLookup) != len(expected) {
|
||||
|
||||
@@ -36,12 +36,8 @@ type Config struct {
|
||||
AWSGW AWSGWConfig `mapstructure:"awsgw"`
|
||||
VPCD VPCDConfig `mapstructure:"vpcd"`
|
||||
|
||||
// Authentication
|
||||
// TODO: Move to more appropriate setting above
|
||||
AccessKey string `mapstructure:"accesskey"`
|
||||
SecretKey string `mapstructure:"secretkey"`
|
||||
BaseDir string `mapstructure:"base_dir"`
|
||||
WalDir string `mapstructure:"wal_dir"`
|
||||
BaseDir string `mapstructure:"base_dir"`
|
||||
WalDir string `mapstructure:"wal_dir"`
|
||||
}
|
||||
|
||||
// NodeBaseDir returns the BaseDir for the current node, or "" if the config
|
||||
|
||||
@@ -32,9 +32,6 @@ node = "node1"
|
||||
node = "node1"
|
||||
region = "us-east-1"
|
||||
az = "us-east-1a"
|
||||
accesskey = "AKIATEST"
|
||||
secretkey = "SECRET"
|
||||
|
||||
[nodes.node1.daemon]
|
||||
host = "127.0.0.1:8080"
|
||||
|
||||
@@ -63,7 +60,6 @@ region = "us-east-1"
|
||||
require.True(t, ok, "node1 should exist in Nodes map")
|
||||
assert.Equal(t, "us-east-1", node.Region)
|
||||
assert.Equal(t, "us-east-1a", node.AZ)
|
||||
assert.Equal(t, "AKIATEST", node.AccessKey)
|
||||
assert.Equal(t, "127.0.0.1:8080", node.Daemon.Host)
|
||||
assert.Equal(t, "127.0.0.1:4222", node.NATS.Host)
|
||||
assert.Equal(t, "nats_testtoken", node.NATS.ACL.Token)
|
||||
|
||||
@@ -450,7 +450,7 @@ func (d *Daemon) Start() error {
|
||||
}
|
||||
|
||||
// Create services before loading/launching instances, since LaunchInstance depends on them
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(d.config.Predastore.Host, d.config.Predastore.Region, d.config.AccessKey, d.config.SecretKey)
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(d.config.Predastore.Host, d.config.Predastore.Region, d.config.Predastore.AccessKey, d.config.Predastore.SecretKey)
|
||||
d.instanceService = handlers_ec2_instance.NewInstanceServiceImpl(d.config, d.resourceMgr.instanceTypes, d.natsConn, &d.Instances, store)
|
||||
d.keyService = handlers_ec2_key.NewKeyServiceImpl(d.config)
|
||||
d.imageService = handlers_ec2_image.NewImageServiceImpl(d.config, d.natsConn)
|
||||
@@ -1097,8 +1097,6 @@ func (d *Daemon) ClusterManager() error {
|
||||
NATS: d.config.NATS,
|
||||
Predastore: d.config.Predastore,
|
||||
AWSGW: d.config.AWSGW,
|
||||
AccessKey: d.config.AccessKey,
|
||||
SecretKey: d.config.SecretKey,
|
||||
BaseDir: req.DataDir,
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,6 @@ func createTestDaemon(t *testing.T, natsURL string) *Daemon {
|
||||
SecretKey: "test-secret-key",
|
||||
BaseDir: tmpDir,
|
||||
},
|
||||
AccessKey: "test-access-key",
|
||||
SecretKey: "test-secret-key",
|
||||
}
|
||||
|
||||
clusterCfg.Nodes["node-1"] = *cfg
|
||||
@@ -359,8 +357,6 @@ func TestDaemon_Initialization(t *testing.T) {
|
||||
NATS: config.NATSConfig{
|
||||
Host: "nats://localhost:4222",
|
||||
},
|
||||
AccessKey: "test-key",
|
||||
SecretKey: "test-secret",
|
||||
}
|
||||
|
||||
clusterCfg.Nodes["node-1"] = *cfg
|
||||
|
||||
@@ -45,6 +45,7 @@ type StatusResponse struct {
|
||||
Credentials *SharedCredentials `json:"credentials,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
CAKey string `json:"ca_key,omitempty"`
|
||||
MasterKey string `json:"master_key,omitempty"`
|
||||
}
|
||||
|
||||
// SharedCredentials contains the cluster-wide credentials distributed during formation.
|
||||
@@ -68,6 +69,7 @@ type FormationServer struct {
|
||||
credentials *SharedCredentials
|
||||
caCert string
|
||||
caKey string
|
||||
masterKey string
|
||||
done chan struct{}
|
||||
server *http.Server
|
||||
}
|
||||
@@ -152,6 +154,13 @@ func (fs *FormationServer) Nodes() map[string]NodeInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
// SetMasterKey sets the base64-encoded IAM master key for distribution to joining nodes.
|
||||
func (fs *FormationServer) SetMasterKey(key string) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.masterKey = key
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the given address (e.g. "10.0.0.1:4432").
|
||||
func (fs *FormationServer) Start(bindAddr string) error {
|
||||
mux := http.NewServeMux()
|
||||
@@ -238,6 +247,7 @@ func (fs *FormationServer) handleStatus(w http.ResponseWriter, r *http.Request)
|
||||
resp.Credentials = fs.credentials
|
||||
resp.CACert = fs.caCert
|
||||
resp.CAKey = fs.caKey
|
||||
resp.MasterKey = fs.masterKey
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
@@ -207,6 +207,34 @@ func TestStatusEndpointComplete(t *testing.T) {
|
||||
assert.Equal(t, "ca-key-data", sr.CAKey)
|
||||
}
|
||||
|
||||
func TestStatusEndpointMasterKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := NewFormationServer(2, testCreds(), "ca-cert", "ca-key")
|
||||
fs.SetMasterKey("dGVzdC1tYXN0ZXIta2V5LWJhc2U2NC1lbmNvZGVk")
|
||||
ts := testServer(t, fs)
|
||||
defer ts.Close()
|
||||
|
||||
// Before completion: master key should not be exposed
|
||||
resp, err := http.Get(ts.URL + "/formation/status")
|
||||
require.NoError(t, err)
|
||||
var sr StatusResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sr))
|
||||
resp.Body.Close()
|
||||
assert.Empty(t, sr.MasterKey)
|
||||
|
||||
// Complete formation
|
||||
require.NoError(t, fs.RegisterNode(testNode("node1", "10.0.0.1")))
|
||||
require.NoError(t, fs.RegisterNode(testNode("node2", "10.0.0.2")))
|
||||
|
||||
// After completion: master key should be present
|
||||
resp, err = http.Get(ts.URL + "/formation/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sr))
|
||||
assert.True(t, sr.Complete)
|
||||
assert.Equal(t, "dGVzdC1tYXN0ZXIta2V5LWJhc2U2NC1lbmNvZGVk", sr.MasterKey)
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := NewFormationServer(1, testCreds(), "", "")
|
||||
|
||||
+33
-10
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
)
|
||||
|
||||
@@ -76,13 +77,32 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() fiber.Handler {
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
|
||||
}
|
||||
|
||||
// Verify access key matches configured credentials
|
||||
fmt.Println("accessKey", accessKey, "gw.AccessKey", gw.AccessKey)
|
||||
if accessKey != gw.AccessKey {
|
||||
slog.Debug("Invalid access key", "provided", accessKey, "expected", gw.AccessKey)
|
||||
// Lookup access key in IAM KV store
|
||||
if gw.IAMService == nil {
|
||||
slog.Error("SigV4 auth: IAM service not initialized")
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
ak, err := gw.IAMService.LookupAccessKey(accessKey)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) {
|
||||
slog.Debug("Access key not found", "accessKeyID", accessKey)
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorInvalidClientTokenId)
|
||||
}
|
||||
slog.Error("IAM lookup failed", "accessKeyID", accessKey, "err", err)
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorInternalError)
|
||||
}
|
||||
if ak.Status != handlers_iam.AccessKeyStatusActive {
|
||||
slog.Debug("Access key inactive", "accessKeyID", accessKey)
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorInvalidClientTokenId)
|
||||
}
|
||||
|
||||
secret, err := gw.IAMService.DecryptSecret(ak.SecretAccessKey)
|
||||
if err != nil {
|
||||
slog.Error("Failed to decrypt IAM secret", "accessKeyID", accessKey, "err", err)
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
// Get timestamp from X-Amz-Date header
|
||||
timestamp := c.Get("X-Amz-Date")
|
||||
if timestamp == "" {
|
||||
@@ -100,8 +120,8 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() fiber.Handler {
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorSignatureDoesNotMatch)
|
||||
}
|
||||
|
||||
// Compute expected signature
|
||||
expectedSignature := gw.computeSignature(c, date, timestamp, region, service, signedHeaders)
|
||||
// Compute expected signature using decrypted secret
|
||||
expectedSignature := computeSignatureWithSecret(c, secret, date, timestamp, region, service, signedHeaders)
|
||||
|
||||
// Compare signatures using constant-time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(providedSignature)) != 1 {
|
||||
@@ -113,17 +133,20 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() fiber.Handler {
|
||||
}
|
||||
|
||||
// Store parsed auth data in context for downstream handlers
|
||||
c.Locals("sigv4.identity", ak.UserName)
|
||||
c.Locals("sigv4.accountId", ak.AccountID)
|
||||
c.Locals("sigv4.service", service)
|
||||
c.Locals("sigv4.region", region)
|
||||
c.Locals("sigv4.accessKey", accessKey)
|
||||
|
||||
slog.Debug("SigV4 authentication successful", "accessKey", accessKey)
|
||||
slog.Debug("SigV4 authentication successful", "accessKey", accessKey, "identity", ak.UserName)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// computeSignature builds the canonical request and computes the AWS Signature V4 signature.
|
||||
func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region, service, signedHeaders string) string {
|
||||
// computeSignatureWithSecret builds the canonical request and computes the AWS Signature V4 signature
|
||||
// using the provided secret key.
|
||||
func computeSignatureWithSecret(c *fiber.Ctx, secretKey, date, timestamp, region, service, signedHeaders string) string {
|
||||
// Build canonical URI (URI-encoded path)
|
||||
path := c.Path()
|
||||
if path == "" {
|
||||
@@ -184,7 +207,7 @@ func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region,
|
||||
)
|
||||
|
||||
// Derive signing key and compute signature
|
||||
signingKey := auth.GetSigningKey(gw.SecretKey, date, region, service)
|
||||
signingKey := auth.GetSigningKey(secretKey, date, region, service)
|
||||
signature := auth.HmacSHA256Hex(signingKey, stringToSign)
|
||||
|
||||
return signature
|
||||
|
||||
+1295
-2
File diff suppressed because it is too large
Load Diff
@@ -236,6 +236,10 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if err := gw.checkPolicy(ctx, "ec2", action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil && !ec2LocalActions[action] {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
+95
-9
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/mulgadc/hive/hive/awsec2query"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/gateway/policy"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -27,8 +30,7 @@ type GatewayConfig struct {
|
||||
ExpectedNodes int // Number of expected hive nodes for multi-node operations
|
||||
Region string // Region this gateway is running in
|
||||
AZ string // Availability zone this gateway is running in
|
||||
AccessKey string // AWS Access Key ID for authentication
|
||||
SecretKey string // AWS Secret Access Key for authentication
|
||||
IAMService handlers_iam.IAMService
|
||||
}
|
||||
|
||||
var supportedServices = map[string]bool{
|
||||
@@ -169,14 +171,59 @@ func (gw *GatewayConfig) GetService(ctx *fiber.Ctx) (string, error) {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error {
|
||||
// TODO: Support service type specific errors (e.g EC2, S3, IAM, differ)
|
||||
// checkPolicy evaluates IAM policies for the current request. Returns nil
|
||||
// if access is allowed, or an ErrorAccessDenied error if denied.
|
||||
// Root users bypass evaluation entirely. If the IAM service is unavailable,
|
||||
// access is allowed (pre-IAM compatibility).
|
||||
func (gw *GatewayConfig) checkPolicy(ctx *fiber.Ctx, service, action string) error {
|
||||
if gw.IAMService == nil {
|
||||
slog.Warn("checkPolicy: IAM service not available, skipping policy check",
|
||||
"service", service, "action", action)
|
||||
return nil
|
||||
}
|
||||
|
||||
identityVal := ctx.Locals("sigv4.identity")
|
||||
if identityVal == nil {
|
||||
// No auth context — pre-IAM compatibility
|
||||
return nil
|
||||
}
|
||||
identity, ok := identityVal.(string)
|
||||
if !ok {
|
||||
slog.Error("checkPolicy: identity has unexpected type", "type", fmt.Sprintf("%T", identityVal))
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
// Extract account ID from auth context
|
||||
accountID, _ := ctx.Locals("sigv4.accountId").(string)
|
||||
if accountID == "" {
|
||||
slog.Error("checkPolicy: no account ID in auth context", "user", identity)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
if identity == "" || (identity == "root" && accountID == handlers_iam.GlobalAccountID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve the IAM action string (e.g. "ec2:RunInstances")
|
||||
iamAction := policy.IAMAction(service, action)
|
||||
|
||||
policies, err := gw.IAMService.GetUserPolicies(accountID, identity)
|
||||
if err != nil {
|
||||
slog.Error("checkPolicy: failed to get user policies", "user", identity, "err", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
if policy.EvaluateAccess(identity, iamAction, "*", policies) == policy.Deny {
|
||||
slog.Info("checkPolicy: access denied", "user", identity, "action", iamAction)
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error {
|
||||
svc, _ := gw.GetService(ctx)
|
||||
slog.Debug("ErrorHandler", "service", svc, "error", err.Error())
|
||||
|
||||
// Status code defaults to 500
|
||||
|
||||
// Get the request ID
|
||||
var requestId = uuid.NewString()
|
||||
requestId = ctx.Get("x-amz-request-id", requestId)
|
||||
@@ -191,7 +238,13 @@ func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error {
|
||||
|
||||
errorMsg = awserrors.ErrorLookup[err.Error()]
|
||||
|
||||
xmlError := GenerateEC2ErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
// IAM uses a different error XML format than EC2
|
||||
var xmlError []byte
|
||||
if svc == "iam" {
|
||||
xmlError = GenerateIAMErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
} else {
|
||||
xmlError = GenerateEC2ErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
}
|
||||
|
||||
slog.Debug("Generated error response", "error", err.Error(), "xml", string(xmlError), "requestId", requestId)
|
||||
|
||||
@@ -199,7 +252,6 @@ func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error {
|
||||
errorMsg.HTTPCode = 500
|
||||
}
|
||||
|
||||
// Set standard S3 error response headers
|
||||
ctx.Set("Content-Type", "application/xml")
|
||||
return ctx.Status(errorMsg.HTTPCode).Send(xmlError)
|
||||
}
|
||||
@@ -239,7 +291,7 @@ func GenerateEC2ErrorResponse(code, message, requestID string) (output []byte) {
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Failed to build XML", "error", err)
|
||||
return nil
|
||||
return []byte(xml.Header + "<Response><Errors><Error><Code>InternalError</Code><Message>Internal error</Message></Error></Errors><RequestID>" + requestID + "</RequestID></Response>")
|
||||
}
|
||||
|
||||
// Add XML header
|
||||
@@ -248,6 +300,40 @@ func GenerateEC2ErrorResponse(code, message, requestID string) (output []byte) {
|
||||
return output
|
||||
}
|
||||
|
||||
// IAMErrorResponse represents the IAM-style error XML format:
|
||||
// <ErrorResponse><Error><Type>Sender</Type><Code>...</Code><Message>...</Message></Error><RequestId>...</RequestId></ErrorResponse>
|
||||
type IAMErrorResponse struct {
|
||||
XMLName xml.Name `xml:"ErrorResponse"`
|
||||
Error IAMErrorDetail `xml:"Error"`
|
||||
RequestID string `xml:"RequestId"`
|
||||
}
|
||||
|
||||
type IAMErrorDetail struct {
|
||||
Type string `xml:"Type"`
|
||||
Code string `xml:"Code"`
|
||||
Message string `xml:"Message"`
|
||||
}
|
||||
|
||||
func GenerateIAMErrorResponse(code, message, requestID string) (output []byte) {
|
||||
errorXml := IAMErrorResponse{
|
||||
Error: IAMErrorDetail{
|
||||
Type: "Sender",
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
RequestID: requestID,
|
||||
}
|
||||
|
||||
output, err := xml.MarshalIndent(errorXml, "", " ")
|
||||
if err != nil {
|
||||
slog.Error("Failed to build IAM error XML", "error", err)
|
||||
return []byte(xml.Header + "<ErrorResponse><Error><Type>Sender</Type><Code>InternalError</Code><Message>Internal error</Message></Error><RequestId>" + requestID + "</RequestId></ErrorResponse>")
|
||||
}
|
||||
|
||||
output = append([]byte(xml.Header), output...)
|
||||
return output
|
||||
}
|
||||
|
||||
func ParseArgsToStruct(input *any, args map[string]string) (err error) {
|
||||
|
||||
// Generated from input shape: RunInstancesRequest
|
||||
|
||||
@@ -3,11 +3,15 @@ package gateway
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -85,6 +89,97 @@ func TestGenerateEC2ErrorResponse_ValidXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIAMErrorResponse_Structure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
message string
|
||||
requestID string
|
||||
}{
|
||||
{
|
||||
name: "entity not found",
|
||||
code: "NoSuchEntity",
|
||||
message: "The request was rejected because it referenced a resource entity that does not exist.",
|
||||
requestID: "req-iam-001",
|
||||
},
|
||||
{
|
||||
name: "entity already exists",
|
||||
code: "EntityAlreadyExists",
|
||||
message: "The request was rejected because it attempted to create a resource that already exists.",
|
||||
requestID: "req-iam-002",
|
||||
},
|
||||
{
|
||||
name: "empty fields",
|
||||
code: "",
|
||||
message: "",
|
||||
requestID: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output := GenerateIAMErrorResponse(tc.code, tc.message, tc.requestID)
|
||||
require.NotNil(t, output)
|
||||
|
||||
xmlStr := string(output)
|
||||
|
||||
// Verify XML header
|
||||
assert.True(t, strings.HasPrefix(xmlStr, xml.Header))
|
||||
|
||||
// IAM uses <ErrorResponse> root, not <Response>
|
||||
assert.Contains(t, xmlStr, "<ErrorResponse>")
|
||||
assert.Contains(t, xmlStr, "</ErrorResponse>")
|
||||
assert.NotContains(t, xmlStr, "<Response>")
|
||||
|
||||
// Verify IAM-specific structure
|
||||
assert.Contains(t, xmlStr, "<Type>Sender</Type>")
|
||||
assert.Contains(t, xmlStr, "<Code>"+tc.code+"</Code>")
|
||||
assert.Contains(t, xmlStr, "<RequestId>"+tc.requestID+"</RequestId>")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIAMErrorResponse_ValidXML(t *testing.T) {
|
||||
output := GenerateIAMErrorResponse("NoSuchEntity", "Entity not found", "req-iam-999")
|
||||
require.NotNil(t, output)
|
||||
|
||||
xmlBody := strings.TrimPrefix(string(output), xml.Header)
|
||||
decoder := xml.NewDecoder(strings.NewReader(xmlBody))
|
||||
for {
|
||||
_, err := decoder.Token()
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_IAMService(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
|
||||
return gw.ErrorHandler(ctx, err)
|
||||
},
|
||||
})
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
c.Locals("sigv4.service", "iam")
|
||||
return errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 404, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
xmlStr := string(body)
|
||||
// IAM format uses <ErrorResponse> not <Response>
|
||||
assert.Contains(t, xmlStr, "<ErrorResponse>")
|
||||
assert.Contains(t, xmlStr, "<Type>Sender</Type>")
|
||||
assert.Contains(t, xmlStr, "<Code>NoSuchEntity</Code>")
|
||||
assert.NotContains(t, xmlStr, "<Errors>")
|
||||
}
|
||||
|
||||
func startTestNATS(t *testing.T) *nats.Conn {
|
||||
t.Helper()
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
|
||||
+121
-2
@@ -1,8 +1,127 @@
|
||||
package gateway
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/mulgadc/hive/hive/awsec2query"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
gateway_iam "github.com/mulgadc/hive/hive/gateway/iam"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
)
|
||||
|
||||
// IAMHandler processes parsed query args and returns XML response bytes.
|
||||
type IAMHandler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error)
|
||||
|
||||
// iamHandler creates a type-safe IAMHandler that allocates the typed input struct,
|
||||
// parses query params into it, calls the handler, and marshals the output to XML.
|
||||
func iamHandler[In any](handler func(string, *In, handlers_iam.IAMService) (any, error)) IAMHandler {
|
||||
return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) {
|
||||
input := new(In)
|
||||
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorIAMInvalidInput)
|
||||
}
|
||||
output, err := handler(accountID, input, gw.IAMService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := utils.GenerateIAMXMLPayload(action, output)
|
||||
xmlOutput, err := utils.MarshalToXML(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
return xmlOutput, nil
|
||||
}
|
||||
}
|
||||
|
||||
var iamActions = map[string]IAMHandler{
|
||||
"CreateUser": iamHandler(func(accountID string, input *iam.CreateUserInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.CreateUser(accountID, input, svc)
|
||||
}),
|
||||
"GetUser": iamHandler(func(accountID string, input *iam.GetUserInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.GetUser(accountID, input, svc)
|
||||
}),
|
||||
"ListUsers": iamHandler(func(accountID string, input *iam.ListUsersInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.ListUsers(accountID, input, svc)
|
||||
}),
|
||||
"DeleteUser": iamHandler(func(accountID string, input *iam.DeleteUserInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.DeleteUser(accountID, input, svc)
|
||||
}),
|
||||
"CreateAccessKey": iamHandler(func(accountID string, input *iam.CreateAccessKeyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.CreateAccessKey(accountID, input, svc)
|
||||
}),
|
||||
"ListAccessKeys": iamHandler(func(accountID string, input *iam.ListAccessKeysInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.ListAccessKeys(accountID, input, svc)
|
||||
}),
|
||||
"DeleteAccessKey": iamHandler(func(accountID string, input *iam.DeleteAccessKeyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.DeleteAccessKey(accountID, input, svc)
|
||||
}),
|
||||
"UpdateAccessKey": iamHandler(func(accountID string, input *iam.UpdateAccessKeyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.UpdateAccessKey(accountID, input, svc)
|
||||
}),
|
||||
|
||||
// Policy CRUD
|
||||
"CreatePolicy": iamHandler(func(accountID string, input *iam.CreatePolicyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.CreatePolicy(accountID, input, svc)
|
||||
}),
|
||||
"GetPolicy": iamHandler(func(accountID string, input *iam.GetPolicyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.GetPolicy(accountID, input, svc)
|
||||
}),
|
||||
"GetPolicyVersion": iamHandler(func(accountID string, input *iam.GetPolicyVersionInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.GetPolicyVersion(accountID, input, svc)
|
||||
}),
|
||||
"ListPolicies": iamHandler(func(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.ListPolicies(accountID, input, svc)
|
||||
}),
|
||||
"DeletePolicy": iamHandler(func(accountID string, input *iam.DeletePolicyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.DeletePolicy(accountID, input, svc)
|
||||
}),
|
||||
|
||||
// Policy attachment
|
||||
"AttachUserPolicy": iamHandler(func(accountID string, input *iam.AttachUserPolicyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.AttachUserPolicy(accountID, input, svc)
|
||||
}),
|
||||
"DetachUserPolicy": iamHandler(func(accountID string, input *iam.DetachUserPolicyInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.DetachUserPolicy(accountID, input, svc)
|
||||
}),
|
||||
"ListAttachedUserPolicies": iamHandler(func(accountID string, input *iam.ListAttachedUserPoliciesInput, svc handlers_iam.IAMService) (any, error) {
|
||||
return gateway_iam.ListAttachedUserPolicies(accountID, input, svc)
|
||||
}),
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) IAM_Request(ctx *fiber.Ctx) error {
|
||||
queryArgs := ParseAWSQueryArgs(string(ctx.Body()))
|
||||
|
||||
return nil
|
||||
action := queryArgs["Action"]
|
||||
handler, ok := iamActions[action]
|
||||
if !ok {
|
||||
slog.Debug("IAM: unknown action", "action", action)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if gw.IAMService == nil {
|
||||
slog.Error("IAM: service not initialized")
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
if err := gw.checkPolicy(ctx, "iam", action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract account ID from auth context
|
||||
accountID, _ := ctx.Locals("sigv4.accountId").(string)
|
||||
if accountID == "" {
|
||||
slog.Error("IAM_Request: no account ID in auth context")
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
xmlOutput, err := handler(action, queryArgs, gw, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
package gateway_iam
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testAccountID = "000000000000"
|
||||
|
||||
// stubIAMService returns empty non-nil outputs for all methods.
|
||||
type stubIAMService struct{}
|
||||
|
||||
func (s *stubIAMService) CreateUser(_ string, _ *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
|
||||
return &iam.CreateUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) GetUser(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
|
||||
return &iam.GetUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) ListUsers(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
|
||||
return &iam.ListUsersOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) DeleteUser(_ string, _ *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
|
||||
return &iam.DeleteUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) CreateAccessKey(_ string, _ *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) {
|
||||
return &iam.CreateAccessKeyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) ListAccessKeys(_ string, _ *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
|
||||
return &iam.ListAccessKeysOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) DeleteAccessKey(_ string, _ *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
|
||||
return &iam.DeleteAccessKeyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) UpdateAccessKey(_ string, _ *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) {
|
||||
return &iam.UpdateAccessKeyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) CreatePolicy(_ string, _ *iam.CreatePolicyInput) (*iam.CreatePolicyOutput, error) {
|
||||
return &iam.CreatePolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) GetPolicy(_ string, _ *iam.GetPolicyInput) (*iam.GetPolicyOutput, error) {
|
||||
return &iam.GetPolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) GetPolicyVersion(_ string, _ *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) {
|
||||
return &iam.GetPolicyVersionOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) ListPolicies(_ string, _ *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) {
|
||||
return &iam.ListPoliciesOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) DeletePolicy(_ string, _ *iam.DeletePolicyInput) (*iam.DeletePolicyOutput, error) {
|
||||
return &iam.DeletePolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) AttachUserPolicy(_ string, _ *iam.AttachUserPolicyInput) (*iam.AttachUserPolicyOutput, error) {
|
||||
return &iam.AttachUserPolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) DetachUserPolicy(_ string, _ *iam.DetachUserPolicyInput) (*iam.DetachUserPolicyOutput, error) {
|
||||
return &iam.DetachUserPolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) ListAttachedUserPolicies(_ string, _ *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) {
|
||||
return &iam.ListAttachedUserPoliciesOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) GetUserPolicies(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey, error) {
|
||||
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) CreateAccount(_ string) (*handlers_iam.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIAMService) GetAccount(_ string) (*handlers_iam.Account, error) { return nil, nil }
|
||||
func (s *stubIAMService) ListAccounts() ([]*handlers_iam.Account, error) { return nil, nil }
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.CreateUserInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.CreateUserInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.CreateUserInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.CreateUserInput{UserName: aws.String("alice")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := CreateUser(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.GetUserInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.GetUserInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.GetUserInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.GetUserInput{UserName: aws.String("alice")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := GetUser(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
_, err := ListUsers(testAccountID, &iam.ListUsersInput{}, svc)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.DeleteUserInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.DeleteUserInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.DeleteUserInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.DeleteUserInput{UserName: aws.String("alice")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := DeleteUser(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAccessKey(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.CreateAccessKeyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.CreateAccessKeyInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.CreateAccessKeyInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.CreateAccessKeyInput{UserName: aws.String("alice")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := CreateAccessKey(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccessKeys(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.ListAccessKeysInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.ListAccessKeysInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.ListAccessKeysInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.ListAccessKeysInput{UserName: aws.String("alice")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ListAccessKeys(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccessKey(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.DeleteAccessKeyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.DeleteAccessKeyInput{AccessKeyId: aws.String("AKIA123")}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.DeleteAccessKeyInput{UserName: aws.String(""), AccessKeyId: aws.String("AKIA123")}, awserrors.ErrorMissingParameter},
|
||||
{"nil AccessKeyId", &iam.DeleteAccessKeyInput{UserName: aws.String("alice")}, awserrors.ErrorMissingParameter},
|
||||
{"empty AccessKeyId", &iam.DeleteAccessKeyInput{UserName: aws.String("alice"), AccessKeyId: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.DeleteAccessKeyInput{UserName: aws.String("alice"), AccessKeyId: aws.String("AKIA123")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := DeleteAccessKey(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccessKey(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.UpdateAccessKeyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil AccessKeyId", &iam.UpdateAccessKeyInput{Status: aws.String("Active")}, awserrors.ErrorMissingParameter},
|
||||
{"empty AccessKeyId", &iam.UpdateAccessKeyInput{AccessKeyId: aws.String(""), Status: aws.String("Active")}, awserrors.ErrorMissingParameter},
|
||||
{"nil Status", &iam.UpdateAccessKeyInput{AccessKeyId: aws.String("AKIA123")}, awserrors.ErrorMissingParameter},
|
||||
{"empty Status", &iam.UpdateAccessKeyInput{AccessKeyId: aws.String("AKIA123"), Status: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.UpdateAccessKeyInput{AccessKeyId: aws.String("AKIA123"), Status: aws.String("Active")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := UpdateAccessKey(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Policy CRUD ---
|
||||
|
||||
func TestCreatePolicy(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.CreatePolicyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil PolicyName", &iam.CreatePolicyInput{PolicyDocument: aws.String(doc)}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyName", &iam.CreatePolicyInput{PolicyName: aws.String(""), PolicyDocument: aws.String(doc)}, awserrors.ErrorMissingParameter},
|
||||
{"nil PolicyDocument", &iam.CreatePolicyInput{PolicyName: aws.String("mypolicy")}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyDocument", &iam.CreatePolicyInput{PolicyName: aws.String("mypolicy"), PolicyDocument: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.CreatePolicyInput{PolicyName: aws.String("mypolicy"), PolicyDocument: aws.String(doc)}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := CreatePolicy(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPolicy(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.GetPolicyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil PolicyArn", &iam.GetPolicyInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyArn", &iam.GetPolicyInput{PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.GetPolicyInput{PolicyArn: aws.String("arn:aws:iam::000000000000:policy/mypolicy")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := GetPolicy(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPolicyVersion(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
arn := "arn:aws:iam::000000000000:policy/mypolicy"
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.GetPolicyVersionInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil PolicyArn", &iam.GetPolicyVersionInput{VersionId: aws.String("v1")}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyArn", &iam.GetPolicyVersionInput{PolicyArn: aws.String(""), VersionId: aws.String("v1")}, awserrors.ErrorMissingParameter},
|
||||
{"nil VersionId", &iam.GetPolicyVersionInput{PolicyArn: aws.String(arn)}, awserrors.ErrorMissingParameter},
|
||||
{"empty VersionId", &iam.GetPolicyVersionInput{PolicyArn: aws.String(arn), VersionId: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.GetPolicyVersionInput{PolicyArn: aws.String(arn), VersionId: aws.String("v1")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := GetPolicyVersion(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPolicies(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
_, err := ListPolicies(testAccountID, &iam.ListPoliciesInput{}, svc)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDeletePolicy(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.DeletePolicyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil PolicyArn", &iam.DeletePolicyInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyArn", &iam.DeletePolicyInput{PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.DeletePolicyInput{PolicyArn: aws.String("arn:aws:iam::000000000000:policy/mypolicy")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := DeletePolicy(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Policy Attachment ---
|
||||
|
||||
func TestAttachUserPolicy(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
arn := "arn:aws:iam::000000000000:policy/mypolicy"
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.AttachUserPolicyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.AttachUserPolicyInput{PolicyArn: aws.String(arn)}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.AttachUserPolicyInput{UserName: aws.String(""), PolicyArn: aws.String(arn)}, awserrors.ErrorMissingParameter},
|
||||
{"nil PolicyArn", &iam.AttachUserPolicyInput{UserName: aws.String("alice")}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyArn", &iam.AttachUserPolicyInput{UserName: aws.String("alice"), PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.AttachUserPolicyInput{UserName: aws.String("alice"), PolicyArn: aws.String(arn)}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := AttachUserPolicy(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachUserPolicy(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
arn := "arn:aws:iam::000000000000:policy/mypolicy"
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.DetachUserPolicyInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.DetachUserPolicyInput{PolicyArn: aws.String(arn)}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.DetachUserPolicyInput{UserName: aws.String(""), PolicyArn: aws.String(arn)}, awserrors.ErrorMissingParameter},
|
||||
{"nil PolicyArn", &iam.DetachUserPolicyInput{UserName: aws.String("alice")}, awserrors.ErrorMissingParameter},
|
||||
{"empty PolicyArn", &iam.DetachUserPolicyInput{UserName: aws.String("alice"), PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.DetachUserPolicyInput{UserName: aws.String("alice"), PolicyArn: aws.String(arn)}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := DetachUserPolicy(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAttachedUserPolicies(t *testing.T) {
|
||||
svc := &stubIAMService{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input *iam.ListAttachedUserPoliciesInput
|
||||
wantErr string
|
||||
}{
|
||||
{"nil UserName", &iam.ListAttachedUserPoliciesInput{}, awserrors.ErrorMissingParameter},
|
||||
{"empty UserName", &iam.ListAttachedUserPoliciesInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
|
||||
{"valid", &iam.ListAttachedUserPoliciesInput{UserName: aws.String("alice")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ListAttachedUserPolicies(testAccountID, tc.input, svc)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.wantErr, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package gateway_iam
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
)
|
||||
|
||||
func CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput, svc handlers_iam.IAMService) (*iam.CreateAccessKeyOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.CreateAccessKey(accountID, input)
|
||||
}
|
||||
|
||||
func ListAccessKeys(accountID string, input *iam.ListAccessKeysInput, svc handlers_iam.IAMService) (*iam.ListAccessKeysOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.ListAccessKeys(accountID, input)
|
||||
}
|
||||
|
||||
func DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput, svc handlers_iam.IAMService) (*iam.DeleteAccessKeyOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.AccessKeyId == nil || *input.AccessKeyId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.DeleteAccessKey(accountID, input)
|
||||
}
|
||||
|
||||
func UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput, svc handlers_iam.IAMService) (*iam.UpdateAccessKeyOutput, error) {
|
||||
if input.AccessKeyId == nil || *input.AccessKeyId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.Status == nil || *input.Status == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.UpdateAccessKey(accountID, input)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package gateway_iam
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
)
|
||||
|
||||
func CreatePolicy(accountID string, input *iam.CreatePolicyInput, svc handlers_iam.IAMService) (*iam.CreatePolicyOutput, error) {
|
||||
if input.PolicyName == nil || *input.PolicyName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.PolicyDocument == nil || *input.PolicyDocument == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.CreatePolicy(accountID, input)
|
||||
}
|
||||
|
||||
func GetPolicy(accountID string, input *iam.GetPolicyInput, svc handlers_iam.IAMService) (*iam.GetPolicyOutput, error) {
|
||||
if input.PolicyArn == nil || *input.PolicyArn == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.GetPolicy(accountID, input)
|
||||
}
|
||||
|
||||
func GetPolicyVersion(accountID string, input *iam.GetPolicyVersionInput, svc handlers_iam.IAMService) (*iam.GetPolicyVersionOutput, error) {
|
||||
if input.PolicyArn == nil || *input.PolicyArn == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.VersionId == nil || *input.VersionId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.GetPolicyVersion(accountID, input)
|
||||
}
|
||||
|
||||
func ListPolicies(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (*iam.ListPoliciesOutput, error) {
|
||||
return svc.ListPolicies(accountID, input)
|
||||
}
|
||||
|
||||
func DeletePolicy(accountID string, input *iam.DeletePolicyInput, svc handlers_iam.IAMService) (*iam.DeletePolicyOutput, error) {
|
||||
if input.PolicyArn == nil || *input.PolicyArn == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.DeletePolicy(accountID, input)
|
||||
}
|
||||
|
||||
func AttachUserPolicy(accountID string, input *iam.AttachUserPolicyInput, svc handlers_iam.IAMService) (*iam.AttachUserPolicyOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.PolicyArn == nil || *input.PolicyArn == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.AttachUserPolicy(accountID, input)
|
||||
}
|
||||
|
||||
func DetachUserPolicy(accountID string, input *iam.DetachUserPolicyInput, svc handlers_iam.IAMService) (*iam.DetachUserPolicyOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.PolicyArn == nil || *input.PolicyArn == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.DetachUserPolicy(accountID, input)
|
||||
}
|
||||
|
||||
func ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput, svc handlers_iam.IAMService) (*iam.ListAttachedUserPoliciesOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.ListAttachedUserPolicies(accountID, input)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gateway_iam
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
)
|
||||
|
||||
func CreateUser(accountID string, input *iam.CreateUserInput, svc handlers_iam.IAMService) (*iam.CreateUserOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.CreateUser(accountID, input)
|
||||
}
|
||||
|
||||
func GetUser(accountID string, input *iam.GetUserInput, svc handlers_iam.IAMService) (*iam.GetUserOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.GetUser(accountID, input)
|
||||
}
|
||||
|
||||
func ListUsers(accountID string, input *iam.ListUsersInput, svc handlers_iam.IAMService) (*iam.ListUsersOutput, error) {
|
||||
return svc.ListUsers(accountID, input)
|
||||
}
|
||||
|
||||
func DeleteUser(accountID string, input *iam.DeleteUserInput, svc handlers_iam.IAMService) (*iam.DeleteUserOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.DeleteUser(accountID, input)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// flexMockIAMService is a configurable mock with per-method overrides.
|
||||
type flexMockIAMService struct {
|
||||
createUserFn func(string, *iam.CreateUserInput) (*iam.CreateUserOutput, error)
|
||||
getUserFn func(string, *iam.GetUserInput) (*iam.GetUserOutput, error)
|
||||
listUsersFn func(string, *iam.ListUsersInput) (*iam.ListUsersOutput, error)
|
||||
deleteUserFn func(string, *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
|
||||
createAccessKeyFn func(string, *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
|
||||
listAccessKeysFn func(string, *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error)
|
||||
deleteAccessKeyFn func(string, *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error)
|
||||
updateAccessKeyFn func(string, *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error)
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) CreateUser(accountID string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
|
||||
if m.createUserFn != nil {
|
||||
return m.createUserFn(accountID, input)
|
||||
}
|
||||
return &iam.CreateUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
|
||||
if m.getUserFn != nil {
|
||||
return m.getUserFn(accountID, input)
|
||||
}
|
||||
return &iam.GetUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
|
||||
if m.listUsersFn != nil {
|
||||
return m.listUsersFn(accountID, input)
|
||||
}
|
||||
return &iam.ListUsersOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
|
||||
if m.deleteUserFn != nil {
|
||||
return m.deleteUserFn(accountID, input)
|
||||
}
|
||||
return &iam.DeleteUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) {
|
||||
if m.createAccessKeyFn != nil {
|
||||
return m.createAccessKeyFn(accountID, input)
|
||||
}
|
||||
return &iam.CreateAccessKeyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) ListAccessKeys(accountID string, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
|
||||
if m.listAccessKeysFn != nil {
|
||||
return m.listAccessKeysFn(accountID, input)
|
||||
}
|
||||
return &iam.ListAccessKeysOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
|
||||
if m.deleteAccessKeyFn != nil {
|
||||
return m.deleteAccessKeyFn(accountID, input)
|
||||
}
|
||||
return &iam.DeleteAccessKeyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) {
|
||||
if m.updateAccessKeyFn != nil {
|
||||
return m.updateAccessKeyFn(accountID, input)
|
||||
}
|
||||
return &iam.UpdateAccessKeyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) CreatePolicy(_ string, _ *iam.CreatePolicyInput) (*iam.CreatePolicyOutput, error) {
|
||||
return &iam.CreatePolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) GetPolicy(_ string, _ *iam.GetPolicyInput) (*iam.GetPolicyOutput, error) {
|
||||
return &iam.GetPolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) GetPolicyVersion(_ string, _ *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) {
|
||||
return &iam.GetPolicyVersionOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) ListPolicies(_ string, _ *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) {
|
||||
return &iam.ListPoliciesOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) DeletePolicy(_ string, _ *iam.DeletePolicyInput) (*iam.DeletePolicyOutput, error) {
|
||||
return &iam.DeletePolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) AttachUserPolicy(_ string, _ *iam.AttachUserPolicyInput) (*iam.AttachUserPolicyOutput, error) {
|
||||
return &iam.AttachUserPolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) DetachUserPolicy(_ string, _ *iam.DetachUserPolicyInput) (*iam.DetachUserPolicyOutput, error) {
|
||||
return &iam.DetachUserPolicyOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) ListAttachedUserPolicies(_ string, _ *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) {
|
||||
return &iam.ListAttachedUserPoliciesOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) GetUserPolicies(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *flexMockIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey, error) {
|
||||
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) CreateAccount(_ string) (*handlers_iam.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *flexMockIAMService) GetAccount(_ string) (*handlers_iam.Account, error) { return nil, nil }
|
||||
func (m *flexMockIAMService) ListAccounts() ([]*handlers_iam.Account, error) { return nil, nil }
|
||||
|
||||
// setupIAMRequestApp creates a Fiber app wired for IAM_Request testing.
|
||||
func setupIAMRequestApp(svc handlers_iam.IAMService) *fiber.App {
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: svc,
|
||||
}
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
|
||||
return gw.ErrorHandler(ctx, err)
|
||||
},
|
||||
})
|
||||
app.Post("/", func(c *fiber.Ctx) error {
|
||||
c.Locals("sigv4.service", "iam")
|
||||
c.Locals("sigv4.accountId", "000000000000")
|
||||
return gw.IAM_Request(c)
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func TestIAMRequest_CreateUser_Success(t *testing.T) {
|
||||
svc := &flexMockIAMService{
|
||||
createUserFn: func(_ string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
|
||||
return &iam.CreateUserOutput{
|
||||
User: &iam.User{
|
||||
UserName: input.UserName,
|
||||
UserId: aws.String("AIDAEXAMPLE123"),
|
||||
Arn: aws.String("arn:aws:iam::000000000000:user/alice"),
|
||||
Path: aws.String("/"),
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
app := setupIAMRequestApp(svc)
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser&UserName=alice"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
xmlStr := string(body)
|
||||
assert.Contains(t, xmlStr, "CreateUserResult")
|
||||
assert.Contains(t, xmlStr, "alice")
|
||||
}
|
||||
|
||||
func TestIAMRequest_ListUsers_Success(t *testing.T) {
|
||||
svc := &flexMockIAMService{
|
||||
listUsersFn: func(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
|
||||
return &iam.ListUsersOutput{
|
||||
Users: []*iam.User{
|
||||
{UserName: aws.String("alice"), UserId: aws.String("AID1")},
|
||||
{UserName: aws.String("bob"), UserId: aws.String("AID2")},
|
||||
},
|
||||
IsTruncated: aws.Bool(false),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
app := setupIAMRequestApp(svc)
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("Action=ListUsers"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
xmlStr := string(body)
|
||||
assert.Contains(t, xmlStr, "ListUsersResult")
|
||||
assert.Contains(t, xmlStr, "alice")
|
||||
assert.Contains(t, xmlStr, "bob")
|
||||
}
|
||||
|
||||
func TestIAMRequest_UnknownAction(t *testing.T) {
|
||||
app := setupIAMRequestApp(&flexMockIAMService{})
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("Action=DoesNotExist"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(body), "InvalidAction")
|
||||
}
|
||||
|
||||
func TestIAMRequest_EmptyAction(t *testing.T) {
|
||||
app := setupIAMRequestApp(&flexMockIAMService{})
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("UserName=alice"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(body), "InvalidAction")
|
||||
}
|
||||
|
||||
func TestIAMRequest_NilService(t *testing.T) {
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: nil,
|
||||
}
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
|
||||
return gw.ErrorHandler(ctx, err)
|
||||
},
|
||||
})
|
||||
app.Post("/", func(c *fiber.Ctx) error {
|
||||
c.Locals("sigv4.service", "iam")
|
||||
c.Locals("sigv4.accountId", "000000000000")
|
||||
return gw.IAM_Request(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser&UserName=alice"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 500, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(body), "InternalError")
|
||||
}
|
||||
|
||||
func TestIAMRequest_ServiceError(t *testing.T) {
|
||||
svc := &flexMockIAMService{
|
||||
createUserFn: func(_ string, _ *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
},
|
||||
}
|
||||
app := setupIAMRequestApp(svc)
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser&UserName=alice"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 409, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
xmlStr := string(body)
|
||||
assert.Contains(t, xmlStr, "EntityAlreadyExists")
|
||||
assert.Contains(t, xmlStr, "<ErrorResponse>")
|
||||
}
|
||||
|
||||
func TestIAMRequest_ValidationError(t *testing.T) {
|
||||
// CreateUser with missing UserName should return MissingParameter
|
||||
app := setupIAMRequestApp(&flexMockIAMService{})
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(body), "MissingParameter")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package policy
|
||||
|
||||
// IAMAction converts a gateway service name and action name into the
|
||||
// IAM policy action format "service:ActionName".
|
||||
// For example: ("ec2", "RunInstances") -> "ec2:RunInstances"
|
||||
func IAMAction(service, action string) string {
|
||||
return service + ":" + action
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package policy implements IAM policy evaluation for access control decisions.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
)
|
||||
|
||||
// Decision represents the outcome of a policy evaluation.
|
||||
type Decision int
|
||||
|
||||
const (
|
||||
// Deny is the default — no matching Allow, or an explicit Deny.
|
||||
Deny Decision = iota
|
||||
// Allow means an explicit Allow was found with no overriding Deny.
|
||||
Allow
|
||||
)
|
||||
|
||||
// EvaluateAccess checks whether the given identity is permitted to perform
|
||||
// the specified action on the specified resource, based on the supplied
|
||||
// policy documents. It follows AWS's evaluation order:
|
||||
//
|
||||
// 1. Explicit Deny in any statement → Deny (wins immediately).
|
||||
// 2. Explicit Allow in any statement → Allow.
|
||||
// 3. No matching statement → Deny (implicit default).
|
||||
//
|
||||
// Root bypass is handled by the gateway before this function is called.
|
||||
func EvaluateAccess(identity, action, resource string, policies []handlers_iam.PolicyDocument) Decision {
|
||||
hasAllow := false
|
||||
for i := range policies {
|
||||
for j := range policies[i].Statement {
|
||||
stmt := &policies[i].Statement[j]
|
||||
|
||||
if !matchesAny(stmt.Action, action) {
|
||||
continue
|
||||
}
|
||||
if !matchesAny(stmt.Resource, resource) {
|
||||
continue
|
||||
}
|
||||
switch stmt.Effect {
|
||||
case handlers_iam.PolicyEffectDeny:
|
||||
return Deny
|
||||
case handlers_iam.PolicyEffectAllow:
|
||||
hasAllow = true
|
||||
default:
|
||||
slog.Warn("EvaluateAccess: unrecognized Effect, treating as Deny",
|
||||
"effect", stmt.Effect, "action", action, "identity", identity)
|
||||
return Deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasAllow {
|
||||
return Allow
|
||||
}
|
||||
return Deny
|
||||
}
|
||||
|
||||
// matchesAny returns true if any pattern in patterns matches the given value.
|
||||
// Supports the same wildcard matching for both actions and resources:
|
||||
// - "*" — matches everything
|
||||
// - "ec2:*" — matches all actions in the ec2 service
|
||||
// - "s3:Get*" — matches s3:GetObject, s3:GetBucketPolicy, etc.
|
||||
// - "ec2:RunInstances" — exact match
|
||||
func matchesAny(patterns []string, value string) bool {
|
||||
for _, p := range patterns {
|
||||
if matchWildcard(p, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchWildcard performs simple wildcard matching where "*" can appear at the
|
||||
// end of a pattern as a suffix wildcard, or alone to match everything.
|
||||
// Examples:
|
||||
//
|
||||
// "*" matches anything
|
||||
// "ec2:*" matches "ec2:RunInstances"
|
||||
// "s3:Get*" matches "s3:GetObject"
|
||||
// "ec2:RunInstances" matches only "ec2:RunInstances"
|
||||
func matchWildcard(pattern, value string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
return strings.HasPrefix(value, prefix)
|
||||
}
|
||||
return strings.EqualFold(pattern, value)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
)
|
||||
|
||||
// helper to build a single-statement policy document.
|
||||
func doc(effect, action, resource string) handlers_iam.PolicyDocument {
|
||||
return handlers_iam.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: effect, Action: handlers_iam.StringOrArr{action}, Resource: handlers_iam.StringOrArr{resource}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Root bypass is tested at the gateway layer (checkPolicy), not in the evaluator.
|
||||
|
||||
func TestEvaluateAccess_DefaultDeny(t *testing.T) {
|
||||
// Non-root with no policies → default deny.
|
||||
got := EvaluateAccess("alice", "ec2:RunInstances", "*", nil)
|
||||
if got != Deny {
|
||||
t.Fatalf("expected default Deny with no policies, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_DefaultDenyEmptyPolicies(t *testing.T) {
|
||||
// Non-root with empty policy list → default deny.
|
||||
got := EvaluateAccess("alice", "ec2:RunInstances", "*", []handlers_iam.PolicyDocument{})
|
||||
if got != Deny {
|
||||
t.Fatalf("expected default Deny with empty policies, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_ExplicitAllow(t *testing.T) {
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:RunInstances", "*"),
|
||||
}
|
||||
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
|
||||
if got != Allow {
|
||||
t.Fatalf("expected Allow, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_ExplicitDenyWins(t *testing.T) {
|
||||
// Explicit deny overrides an explicit allow.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:*", "*"),
|
||||
doc("Deny", "ec2:TerminateInstances", "*"),
|
||||
}
|
||||
got := EvaluateAccess("alice", "ec2:TerminateInstances", "*", policies)
|
||||
if got != Deny {
|
||||
t.Fatalf("expected Deny (explicit deny wins), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_ExplicitDenyWinsSamePolicy(t *testing.T) {
|
||||
// Deny and Allow in the same policy document — Deny wins.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"ec2:*"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
{Effect: "Deny", Action: handlers_iam.StringOrArr{"ec2:TerminateInstances"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := EvaluateAccess("alice", "ec2:TerminateInstances", "*", policies)
|
||||
if got != Deny {
|
||||
t.Fatalf("expected Deny (same-policy explicit deny), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_NoMatchingAction(t *testing.T) {
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "s3:GetObject", "*"),
|
||||
}
|
||||
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
|
||||
if got != Deny {
|
||||
t.Fatalf("expected Deny (no matching action), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_WildcardAllActions(t *testing.T) {
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "*", "*"),
|
||||
}
|
||||
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
|
||||
if got != Allow {
|
||||
t.Fatalf("expected Allow with wildcard *, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_ServiceWildcard(t *testing.T) {
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:*", "*"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
want Decision
|
||||
}{
|
||||
{"ec2:RunInstances", Allow},
|
||||
{"ec2:DescribeInstances", Allow},
|
||||
{"s3:GetObject", Deny},
|
||||
{"iam:CreateUser", Deny},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := EvaluateAccess("alice", tt.action, "*", policies)
|
||||
if got != tt.want {
|
||||
t.Errorf("ec2:* policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_PrefixWildcard(t *testing.T) {
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "s3:Get*", "*"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
want Decision
|
||||
}{
|
||||
{"s3:GetObject", Allow},
|
||||
{"s3:GetBucketPolicy", Allow},
|
||||
{"s3:PutObject", Deny},
|
||||
{"s3:DeleteObject", Deny},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := EvaluateAccess("alice", tt.action, "*", policies)
|
||||
if got != tt.want {
|
||||
t.Errorf("s3:Get* policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_MultipleActions(t *testing.T) {
|
||||
// A statement with multiple actions.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: handlers_iam.StringOrArr{"ec2:DescribeInstances", "ec2:RunInstances"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
want Decision
|
||||
}{
|
||||
{"ec2:DescribeInstances", Allow},
|
||||
{"ec2:RunInstances", Allow},
|
||||
{"ec2:TerminateInstances", Deny},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := EvaluateAccess("alice", tt.action, "*", policies)
|
||||
if got != tt.want {
|
||||
t.Errorf("multi-action policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_MultiplePolicies(t *testing.T) {
|
||||
// Permissions spread across multiple policy documents.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:DescribeInstances", "*"),
|
||||
doc("Allow", "s3:GetObject", "*"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
want Decision
|
||||
}{
|
||||
{"ec2:DescribeInstances", Allow},
|
||||
{"s3:GetObject", Allow},
|
||||
{"iam:CreateUser", Deny},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := EvaluateAccess("alice", tt.action, "*", policies)
|
||||
if got != tt.want {
|
||||
t.Errorf("multi-policy, action=%s: expected %v, got %v", tt.action, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_ResourceMismatch(t *testing.T) {
|
||||
// Allow only on a specific resource, request uses "*".
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "s3:GetObject", "arn:aws:s3:::my-bucket/*"),
|
||||
}
|
||||
got := EvaluateAccess("alice", "s3:GetObject", "*", policies)
|
||||
if got != Deny {
|
||||
t.Fatalf("expected Deny (resource mismatch), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_CaseInsensitiveAction(t *testing.T) {
|
||||
// Action matching should be case-insensitive for exact matches.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "EC2:RunInstances", "*"),
|
||||
}
|
||||
got := EvaluateAccess("alice", "ec2:RunInstances", "*", policies)
|
||||
if got != Allow {
|
||||
t.Fatalf("expected Allow (case-insensitive match), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- matchWildcard tests ---
|
||||
|
||||
func TestMatchWildcard(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
// Global wildcard
|
||||
{"*", "anything", true},
|
||||
{"*", "", true},
|
||||
|
||||
// Service wildcard
|
||||
{"ec2:*", "ec2:RunInstances", true},
|
||||
{"ec2:*", "ec2:DescribeInstances", true},
|
||||
{"ec2:*", "s3:GetObject", false},
|
||||
|
||||
// Prefix wildcard
|
||||
{"s3:Get*", "s3:GetObject", true},
|
||||
{"s3:Get*", "s3:GetBucketPolicy", true},
|
||||
{"s3:Get*", "s3:PutObject", false},
|
||||
|
||||
// Exact match
|
||||
{"ec2:RunInstances", "ec2:RunInstances", true},
|
||||
{"ec2:RunInstances", "ec2:StopInstances", false},
|
||||
|
||||
// Case insensitive exact match
|
||||
{"EC2:RunInstances", "ec2:RunInstances", true},
|
||||
{"ec2:runinstances", "ec2:RunInstances", true},
|
||||
|
||||
// Edge cases
|
||||
{"", "", true},
|
||||
{"", "something", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := matchWildcard(tt.pattern, tt.value)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Action mapping tests ---
|
||||
|
||||
func TestIAMAction(t *testing.T) {
|
||||
got := IAMAction("ec2", "RunInstances")
|
||||
if got != "ec2:RunInstances" {
|
||||
t.Fatalf("IAMAction(ec2, RunInstances) = %q, want %q", got, "ec2:RunInstances")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Realistic scenario tests ---
|
||||
|
||||
func TestEvaluateAccess_ReadOnlyUser(t *testing.T) {
|
||||
// Simulate a read-only user: allow all Describe* actions, deny everything else.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: handlers_iam.StringOrArr{"ec2:Describe*"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
want Decision
|
||||
}{
|
||||
{"ec2:DescribeInstances", Allow},
|
||||
{"ec2:DescribeVolumes", Allow},
|
||||
{"ec2:DescribeVpcs", Allow},
|
||||
{"ec2:RunInstances", Deny},
|
||||
{"ec2:TerminateInstances", Deny},
|
||||
{"iam:CreateUser", Deny},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := EvaluateAccess("viewer", tt.action, "*", policies)
|
||||
if got != tt.want {
|
||||
t.Errorf("read-only user, action=%s: expected %v, got %v", tt.action, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccess_AdminWithDenyTerminate(t *testing.T) {
|
||||
// Admin that can do everything except terminate instances.
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "*", "*"),
|
||||
doc("Deny", "ec2:TerminateInstances", "*"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
want Decision
|
||||
}{
|
||||
{"ec2:RunInstances", Allow},
|
||||
{"ec2:DescribeInstances", Allow},
|
||||
{"s3:GetObject", Allow},
|
||||
{"iam:CreateUser", Allow},
|
||||
{"ec2:TerminateInstances", Deny}, // explicit deny
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := EvaluateAccess("admin", tt.action, "*", policies)
|
||||
if got != tt.want {
|
||||
t.Errorf("admin-no-terminate, action=%s: expected %v, got %v", tt.action, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,8 +257,8 @@ func (s *InstanceServiceImpl) newViperblock(volumeName string, size int, volumeC
|
||||
VolumeSize: utils.SafeIntToUint64(size),
|
||||
Bucket: s.config.Predastore.Bucket,
|
||||
Region: s.config.Predastore.Region,
|
||||
AccessKey: s.config.AccessKey,
|
||||
SecretKey: s.config.SecretKey,
|
||||
AccessKey: s.config.Predastore.AccessKey,
|
||||
SecretKey: s.config.Predastore.SecretKey,
|
||||
Host: s.config.Predastore.Host,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package handlers_iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Account CRUD Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCreateAccount(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
acc1, err := svc.CreateAccount("Team Alpha")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "000000000001", acc1.AccountID)
|
||||
assert.Equal(t, "Team Alpha", acc1.AccountName)
|
||||
assert.Equal(t, "ACTIVE", acc1.Status)
|
||||
assert.NotEmpty(t, acc1.CreatedAt)
|
||||
|
||||
// Second account gets sequential ID
|
||||
acc2, err := svc.CreateAccount("Team Beta")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "000000000002", acc2.AccountID)
|
||||
assert.Equal(t, "Team Beta", acc2.AccountName)
|
||||
}
|
||||
|
||||
func TestCreateAccount_SequentialIDs(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
var prevID string
|
||||
for range 5 {
|
||||
acc, err := svc.CreateAccount("account")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, acc.AccountID, 12)
|
||||
if prevID != "" {
|
||||
assert.Greater(t, acc.AccountID, prevID)
|
||||
}
|
||||
prevID = acc.AccountID
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccount(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
created, err := svc.CreateAccount("Lookup Test")
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := svc.GetAccount(created.AccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.AccountID, got.AccountID)
|
||||
assert.Equal(t, "Lookup Test", got.AccountName)
|
||||
assert.Equal(t, "ACTIVE", got.Status)
|
||||
}
|
||||
|
||||
func TestGetAccount_NotFound(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
_, err := svc.GetAccount("999999999999")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListAccounts(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
svc.CreateAccount("Acct1")
|
||||
svc.CreateAccount("Acct2")
|
||||
svc.CreateAccount("Acct3")
|
||||
|
||||
accounts, err := svc.ListAccounts()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accounts, 3)
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, a := range accounts {
|
||||
names[a.AccountName] = true
|
||||
}
|
||||
assert.True(t, names["Acct1"])
|
||||
assert.True(t, names["Acct2"])
|
||||
assert.True(t, names["Acct3"])
|
||||
}
|
||||
|
||||
func TestListAccounts_Empty(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
accounts, err := svc.ListAccounts()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accounts, 0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account-Scoped User Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestAccountScopedUsers(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
acc1, err := svc.CreateAccount("Org A")
|
||||
require.NoError(t, err)
|
||||
acc2, err := svc.CreateAccount("Org B")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create same username in both accounts
|
||||
_, err = svc.CreateUser(acc1.AccountID, &iam.CreateUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateUser(acc2.AccountID, &iam.CreateUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both should be independently retrievable
|
||||
out1, err := svc.GetUser(acc1.AccountID, &iam.GetUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *out1.User.Arn, acc1.AccountID)
|
||||
|
||||
out2, err := svc.GetUser(acc2.AccountID, &iam.GetUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *out2.User.Arn, acc2.AccountID)
|
||||
|
||||
// Listing users in acc1 should only return acc1's user
|
||||
list1, err := svc.ListUsers(acc1.AccountID, &iam.ListUsersInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list1.Users, 1)
|
||||
|
||||
list2, err := svc.ListUsers(acc2.AccountID, &iam.ListUsersInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list2.Users, 1)
|
||||
|
||||
// Deleting in one account shouldn't affect the other
|
||||
_, err = svc.DeleteUser(acc1.AccountID, &iam.DeleteUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.GetUser(acc2.AccountID, &iam.GetUserInput{
|
||||
UserName: aws.String("admin"),
|
||||
})
|
||||
require.NoError(t, err) // acc2's admin still exists
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account-Scoped Policy Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestAccountScopedPolicies(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
acc1, err := svc.CreateAccount("Policy Org A")
|
||||
require.NoError(t, err)
|
||||
acc2, err := svc.CreateAccount("Policy Org B")
|
||||
require.NoError(t, err)
|
||||
|
||||
doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`
|
||||
|
||||
// Create same policy name in both accounts
|
||||
p1, err := svc.CreatePolicy(acc1.AccountID, &iam.CreatePolicyInput{
|
||||
PolicyName: aws.String("AdminAccess"),
|
||||
PolicyDocument: aws.String(doc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *p1.Policy.Arn, acc1.AccountID)
|
||||
|
||||
p2, err := svc.CreatePolicy(acc2.AccountID, &iam.CreatePolicyInput{
|
||||
PolicyName: aws.String("AdminAccess"),
|
||||
PolicyDocument: aws.String(doc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *p2.Policy.Arn, acc2.AccountID)
|
||||
|
||||
// Listing in each account should be independent
|
||||
list1, err := svc.ListPolicies(acc1.AccountID, &iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list1.Policies, 1)
|
||||
|
||||
list2, err := svc.ListPolicies(acc2.AccountID, &iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list2.Policies, 1)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Access Key Account ID Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestAccessKeyReturnsAccountID(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
acc, err := svc.CreateAccount("Key Org")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateUser(acc.AccountID, &iam.CreateUserInput{
|
||||
UserName: aws.String("keyuser"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
akOut, err := svc.CreateAccessKey(acc.AccountID, &iam.CreateAccessKeyInput{
|
||||
UserName: aws.String("keyuser"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// LookupAccessKey should return the correct account ID
|
||||
ak, err := svc.LookupAccessKey(*akOut.AccessKey.AccessKeyId)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, acc.AccountID, ak.AccountID)
|
||||
assert.Equal(t, "keyuser", ak.UserName)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SeedRootUser Account-Scoped Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestSeedRootUser_AccountScoped(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
encryptedSecret, err := EncryptSecret("root-secret", svc.masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.SeedRootUser(&BootstrapData{
|
||||
AccessKeyID: "AKIAROOTEXAMPLE12345",
|
||||
EncryptedSecret: encryptedSecret,
|
||||
AccountID: GlobalAccountID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Root user stored at 000000000000:root
|
||||
out, err := svc.GetUser(GlobalAccountID, &iam.GetUserInput{
|
||||
UserName: aws.String("root"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", *out.User.UserName)
|
||||
assert.Contains(t, *out.User.Arn, GlobalAccountID)
|
||||
|
||||
// Access key has correct AccountID
|
||||
ak, err := svc.LookupAccessKey("AKIAROOTEXAMPLE12345")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, GlobalAccountID, ak.AccountID)
|
||||
assert.Equal(t, "root", ak.UserName)
|
||||
|
||||
// Global account record was created
|
||||
account, err := svc.GetAccount(GlobalAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, GlobalAccountID, account.AccountID)
|
||||
assert.Equal(t, "Global", account.AccountName)
|
||||
assert.Equal(t, "ACTIVE", account.Status)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package handlers_iam
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const masterKeySize = 32 // AES-256
|
||||
|
||||
// GenerateMasterKey returns 32 cryptographically random bytes suitable
|
||||
// for use as an AES-256-GCM key.
|
||||
func GenerateMasterKey() ([]byte, error) {
|
||||
key := make([]byte, masterKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate master key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// LoadMasterKey reads a master key from disk and validates it is exactly 32 bytes.
|
||||
func LoadMasterKey(path string) ([]byte, error) {
|
||||
key, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read master key: %w", err)
|
||||
}
|
||||
if len(key) != masterKeySize {
|
||||
return nil, fmt.Errorf("master key must be %d bytes, got %d", masterKeySize, len(key))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SaveMasterKey writes a master key to disk with 0600 permissions.
|
||||
func SaveMasterKey(path string, key []byte) error {
|
||||
if len(key) != masterKeySize {
|
||||
return fmt.Errorf("master key must be %d bytes, got %d", masterKeySize, len(key))
|
||||
}
|
||||
if err := os.WriteFile(path, key, 0600); err != nil {
|
||||
return fmt.Errorf("write master key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptSecret encrypts a plaintext secret with AES-256-GCM.
|
||||
// Returns base64(nonce + ciphertext + tag).
|
||||
func EncryptSecret(plaintext string, key []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Seal appends ciphertext+tag to nonce
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypter holds a pre-computed AES-256-GCM cipher for repeated decryption.
|
||||
// Create once at startup with NewDecrypter and reuse across requests.
|
||||
type Decrypter struct {
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
// NewDecrypter creates a Decrypter with a pre-computed AES-GCM cipher from the given key.
|
||||
func NewDecrypter(key []byte) (*Decrypter, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
return &Decrypter{gcm: gcm}, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a base64-encoded AES-256-GCM ciphertext using the pre-computed cipher.
|
||||
func (d *Decrypter) Decrypt(ciphertext string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := d.gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, sealed := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := d.gcm.Open(nil, nonce, sealed, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// DecryptSecret decrypts a base64-encoded AES-256-GCM ciphertext.
|
||||
func DecryptSecret(ciphertext string, key []byte) (string, error) {
|
||||
d, err := NewDecrypter(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return d.Decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// SaveBootstrapData writes bootstrap data as JSON to disk with 0600 permissions.
|
||||
func SaveBootstrapData(path string, data *BootstrapData) error {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal bootstrap data: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0600); err != nil {
|
||||
return fmt.Errorf("write bootstrap data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadBootstrapData reads and parses a bootstrap JSON file from disk.
|
||||
func LoadBootstrapData(path string) (*BootstrapData, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bd BootstrapData
|
||||
if err := json.Unmarshal(data, &bd); err != nil {
|
||||
return nil, fmt.Errorf("parse bootstrap data: %w", err)
|
||||
}
|
||||
return &bd, nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package handlers_iam
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateMasterKey(t *testing.T) {
|
||||
key, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateMasterKey() error: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Fatalf("expected 32 bytes, got %d", len(key))
|
||||
}
|
||||
|
||||
// Two generated keys should differ
|
||||
key2, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateMasterKey() second call error: %v", err)
|
||||
}
|
||||
if string(key) == string(key2) {
|
||||
t.Fatal("two generated keys should not be identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadMasterKey(t *testing.T) {
|
||||
key, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateMasterKey() error: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "master.key")
|
||||
|
||||
if err := SaveMasterKey(path, key); err != nil {
|
||||
t.Fatalf("SaveMasterKey() error: %v", err)
|
||||
}
|
||||
|
||||
// Check file permissions
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat master.key: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("expected permissions 0600, got %04o", perm)
|
||||
}
|
||||
|
||||
loaded, err := LoadMasterKey(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMasterKey() error: %v", err)
|
||||
}
|
||||
if string(loaded) != string(key) {
|
||||
t.Fatal("loaded key does not match saved key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMasterKeyWrongSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.key")
|
||||
|
||||
if err := os.WriteFile(path, []byte("too-short"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadMasterKey(path)
|
||||
if err == nil {
|
||||
t.Fatal("LoadMasterKey() should fail for wrong-size key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMasterKeyNotFound(t *testing.T) {
|
||||
_, err := LoadMasterKey("/nonexistent/master.key")
|
||||
if err == nil {
|
||||
t.Fatal("LoadMasterKey() should fail for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveMasterKeyWrongSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.key")
|
||||
|
||||
err := SaveMasterKey(path, []byte("too-short"))
|
||||
if err == nil {
|
||||
t.Fatal("SaveMasterKey() should fail for wrong-size key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
key, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateMasterKey() error: %v", err)
|
||||
}
|
||||
|
||||
secret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
|
||||
encrypted, err := EncryptSecret(secret, key)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptSecret() error: %v", err)
|
||||
}
|
||||
|
||||
if encrypted == secret {
|
||||
t.Fatal("encrypted output should differ from plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptSecret(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptSecret() error: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != secret {
|
||||
t.Fatalf("expected %q, got %q", secret, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
key, _ := GenerateMasterKey()
|
||||
secret := "test-secret"
|
||||
|
||||
ct1, _ := EncryptSecret(secret, key)
|
||||
ct2, _ := EncryptSecret(secret, key)
|
||||
|
||||
if ct1 == ct2 {
|
||||
t.Fatal("encrypting the same plaintext twice should produce different ciphertexts (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongKey(t *testing.T) {
|
||||
key1, _ := GenerateMasterKey()
|
||||
key2, _ := GenerateMasterKey()
|
||||
secret := "test-secret"
|
||||
|
||||
encrypted, err := EncryptSecret(secret, key1)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptSecret() error: %v", err)
|
||||
}
|
||||
|
||||
_, err = DecryptSecret(encrypted, key2)
|
||||
if err == nil {
|
||||
t.Fatal("DecryptSecret() should fail with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptTamperedCiphertext(t *testing.T) {
|
||||
key, _ := GenerateMasterKey()
|
||||
secret := "test-secret"
|
||||
|
||||
encrypted, _ := EncryptSecret(secret, key)
|
||||
|
||||
// Tamper with the ciphertext by flipping a character
|
||||
tampered := []byte(encrypted)
|
||||
if tampered[len(tampered)-2] == 'A' {
|
||||
tampered[len(tampered)-2] = 'B'
|
||||
} else {
|
||||
tampered[len(tampered)-2] = 'A'
|
||||
}
|
||||
|
||||
_, err := DecryptSecret(string(tampered), key)
|
||||
if err == nil {
|
||||
t.Fatal("DecryptSecret() should fail with tampered ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidBase64(t *testing.T) {
|
||||
key, _ := GenerateMasterKey()
|
||||
_, err := DecryptSecret("not-valid-base64!!!", key)
|
||||
if err == nil {
|
||||
t.Fatal("DecryptSecret() should fail with invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptTooShort(t *testing.T) {
|
||||
key, _ := GenerateMasterKey()
|
||||
// Valid base64 but too short for nonce + ciphertext
|
||||
_, err := DecryptSecret("AQID", key)
|
||||
if err == nil {
|
||||
t.Fatal("DecryptSecret() should fail with too-short ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBootstrapData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bootstrap.json")
|
||||
|
||||
bd := BootstrapData{
|
||||
AccessKeyID: "AKIAIOSFODNN7EXAMPLE",
|
||||
EncryptedSecret: "dGVzdC1lbmNyeXB0ZWQ=",
|
||||
AccountID: "000000000000",
|
||||
}
|
||||
data, _ := json.Marshal(bd)
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadBootstrapData(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBootstrapData() error: %v", err)
|
||||
}
|
||||
|
||||
if loaded.AccessKeyID != bd.AccessKeyID {
|
||||
t.Fatalf("AccessKeyID: expected %q, got %q", bd.AccessKeyID, loaded.AccessKeyID)
|
||||
}
|
||||
if loaded.EncryptedSecret != bd.EncryptedSecret {
|
||||
t.Fatalf("EncryptedSecret mismatch")
|
||||
}
|
||||
if loaded.AccountID != bd.AccountID {
|
||||
t.Fatalf("AccountID: expected %q, got %q", bd.AccountID, loaded.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBootstrapDataNotFound(t *testing.T) {
|
||||
_, err := LoadBootstrapData("/nonexistent/bootstrap.json")
|
||||
if err == nil {
|
||||
t.Fatal("LoadBootstrapData() should fail for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBootstrapDataInvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bootstrap.json")
|
||||
|
||||
if err := os.WriteFile(path, []byte("{invalid"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadBootstrapData(path)
|
||||
if err == nil {
|
||||
t.Fatal("LoadBootstrapData() should fail for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBootstrapData_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bootstrap.json")
|
||||
|
||||
data := &BootstrapData{
|
||||
AccessKeyID: "AKIAIOSFODNN7EXAMPLE",
|
||||
EncryptedSecret: "dGVzdC1lbmNyeXB0ZWQ=",
|
||||
AccountID: "000000000000",
|
||||
}
|
||||
|
||||
if err := SaveBootstrapData(path, data); err != nil {
|
||||
t.Fatalf("SaveBootstrapData() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify file permissions
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat bootstrap.json: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("expected permissions 0600, got %04o", perm)
|
||||
}
|
||||
|
||||
// Verify round-trip
|
||||
loaded, err := LoadBootstrapData(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBootstrapData() error: %v", err)
|
||||
}
|
||||
if loaded.AccessKeyID != data.AccessKeyID {
|
||||
t.Fatalf("AccessKeyID: expected %q, got %q", data.AccessKeyID, loaded.AccessKeyID)
|
||||
}
|
||||
if loaded.EncryptedSecret != data.EncryptedSecret {
|
||||
t.Fatalf("EncryptedSecret: expected %q, got %q", data.EncryptedSecret, loaded.EncryptedSecret)
|
||||
}
|
||||
if loaded.AccountID != data.AccountID {
|
||||
t.Fatalf("AccountID: expected %q, got %q", data.AccountID, loaded.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBootstrapData_InvalidPath(t *testing.T) {
|
||||
data := &BootstrapData{
|
||||
AccessKeyID: "AKIAIOSFODNN7EXAMPLE",
|
||||
EncryptedSecret: "dGVzdA==",
|
||||
AccountID: "000000000000",
|
||||
}
|
||||
err := SaveBootstrapData("/nonexistent/dir/bootstrap.json", data)
|
||||
if err == nil {
|
||||
t.Fatal("SaveBootstrapData() should fail for invalid path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handlers_iam
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
)
|
||||
|
||||
// IAMService defines the interface for IAM operations.
|
||||
type IAMService interface {
|
||||
// User CRUD — account-scoped
|
||||
CreateUser(accountID string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error)
|
||||
GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
|
||||
ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error)
|
||||
DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
|
||||
|
||||
// Access key lifecycle — account-scoped
|
||||
CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
|
||||
ListAccessKeys(accountID string, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error)
|
||||
DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error)
|
||||
UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error)
|
||||
|
||||
// Policy CRUD — account-scoped
|
||||
CreatePolicy(accountID string, input *iam.CreatePolicyInput) (*iam.CreatePolicyOutput, error)
|
||||
GetPolicy(accountID string, input *iam.GetPolicyInput) (*iam.GetPolicyOutput, error)
|
||||
GetPolicyVersion(accountID string, input *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error)
|
||||
ListPolicies(accountID string, input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error)
|
||||
DeletePolicy(accountID string, input *iam.DeletePolicyInput) (*iam.DeletePolicyOutput, error)
|
||||
|
||||
// Policy attachment — account-scoped
|
||||
AttachUserPolicy(accountID string, input *iam.AttachUserPolicyInput) (*iam.AttachUserPolicyOutput, error)
|
||||
DetachUserPolicy(accountID string, input *iam.DetachUserPolicyInput) (*iam.DetachUserPolicyOutput, error)
|
||||
ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error)
|
||||
|
||||
// Policy evaluation (internal — used by gateway enforcement)
|
||||
GetUserPolicies(accountID, userName string) ([]PolicyDocument, error)
|
||||
|
||||
// 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
|
||||
IsEmpty() (bool, error)
|
||||
|
||||
// Account operations
|
||||
CreateAccount(name string) (*Account, error)
|
||||
GetAccount(accountID string) (*Account, error)
|
||||
ListAccounts() ([]*Account, error)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
package handlers_iam
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
AccessKeyStatusActive = "Active"
|
||||
AccessKeyStatusInactive = "Inactive"
|
||||
|
||||
AccountStatusActive = "ACTIVE"
|
||||
AccountStatusSuspended = "SUSPENDED"
|
||||
|
||||
PolicyEffectAllow = "Allow"
|
||||
PolicyEffectDeny = "Deny"
|
||||
)
|
||||
|
||||
// User represents an IAM user stored in JetStream KV.
|
||||
type User struct {
|
||||
UserName string `json:"user_name"`
|
||||
UserID string `json:"user_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
ARN string `json:"arn"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
AccessKeys []string `json:"access_keys"`
|
||||
Tags []Tag `json:"tags"`
|
||||
AttachedPolicies []string `json:"attached_policies"` // policy ARNs
|
||||
}
|
||||
|
||||
// AccessKey represents an IAM access key stored in JetStream KV.
|
||||
// SecretAccessKey is AES-256-GCM encrypted (base64-encoded), not hashed,
|
||||
// so the SigV4 middleware can recover the plaintext for signature verification.
|
||||
type AccessKey struct {
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"` // AES-256-GCM encrypted, base64-encoded
|
||||
UserName string `json:"user_name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Status string `json:"status"` // Active or Inactive
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Tag represents a key-value tag on an IAM resource.
|
||||
type Tag struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Policy represents a managed IAM policy stored in JetStream KV.
|
||||
type Policy struct {
|
||||
PolicyName string `json:"policy_name"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
ARN string `json:"arn"`
|
||||
Path string `json:"path"`
|
||||
Description string `json:"description,omitempty"`
|
||||
PolicyDocument string `json:"policy_document"` // JSON string
|
||||
CreatedAt string `json:"created_at"`
|
||||
DefaultVersion string `json:"default_version"` // always "v1"
|
||||
Tags []Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// PolicyDocument is the parsed IAM policy JSON structure.
|
||||
type PolicyDocument struct {
|
||||
Version string `json:"Version"`
|
||||
Statement []Statement `json:"Statement"`
|
||||
}
|
||||
|
||||
// Statement is a single statement within a policy document.
|
||||
type Statement struct {
|
||||
Sid string `json:"Sid,omitempty"`
|
||||
Effect string `json:"Effect"`
|
||||
Action StringOrArr `json:"Action"`
|
||||
Resource StringOrArr `json:"Resource"`
|
||||
}
|
||||
|
||||
// StringOrArr handles JSON fields that can be either a string or an array of strings.
|
||||
type StringOrArr []string
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling for string-or-array fields.
|
||||
func (s *StringOrArr) UnmarshalJSON(data []byte) error {
|
||||
// Try string first
|
||||
var single string
|
||||
if err := json.Unmarshal(data, &single); err == nil {
|
||||
*s = []string{single}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try array
|
||||
var arr []string
|
||||
if err := json.Unmarshal(data, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = arr
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON marshals as a string if single element, otherwise as an array.
|
||||
func (s StringOrArr) MarshalJSON() ([]byte, error) {
|
||||
if len(s) == 1 {
|
||||
return json.Marshal(s[0])
|
||||
}
|
||||
return json.Marshal([]string(s))
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Account represents a Hive account. Accounts namespace IAM users,
|
||||
// policies, and resources. Created via CLI only in v1.
|
||||
type Account struct {
|
||||
AccountID string `json:"account_id"` // 12-digit zero-padded, sequential
|
||||
AccountName string `json:"account_name"` // Friendly name
|
||||
Status string `json:"status"` // "ACTIVE" or "SUSPENDED"
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/gateway"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
var serviceName = "awsgw"
|
||||
@@ -52,14 +56,14 @@ func (svc *Service) Reload() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func launchService(config *config.ClusterConfig) (err error) {
|
||||
func launchService(config *config.ClusterConfig) error {
|
||||
|
||||
nodeConfig := config.Nodes[config.Node]
|
||||
|
||||
// Connect to NATS for service communication
|
||||
natsConn, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
|
||||
// Connect to NATS for service communication. On concurrent startup the
|
||||
// local NATS server may not be listening yet, so retry with backoff.
|
||||
natsConn, err := connectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
|
||||
if err != nil {
|
||||
slog.Error("Failed to connect to NATS", "err", err)
|
||||
return err
|
||||
}
|
||||
defer natsConn.Close()
|
||||
@@ -69,6 +73,34 @@ func launchService(config *config.ClusterConfig) (err error) {
|
||||
nodeConfig.AWSGW.Config = fmt.Sprintf("%s/%s", nodeConfig.BaseDir, nodeConfig.AWSGW.Config)
|
||||
}
|
||||
|
||||
// Load IAM master key from disk (required for all authenticated requests)
|
||||
masterKeyPath := filepath.Join(nodeConfig.BaseDir, "config", "master.key")
|
||||
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load IAM master key from %s: %w", masterKeyPath, err)
|
||||
}
|
||||
|
||||
// Initialize IAM service with NATS KV backend (required for auth).
|
||||
// On multi-node clusters, JetStream KV requires cluster quorum which may
|
||||
// not be available yet if nodes start concurrently. Retry with backoff.
|
||||
iamService, err := initIAMService(natsConn, masterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize IAM service: %w", err)
|
||||
}
|
||||
|
||||
// First boot: consume bootstrap.json → seed root user 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)
|
||||
}
|
||||
if err := os.Remove(bootstrapPath); err != nil {
|
||||
slog.Warn("Failed to delete bootstrap file", "path", bootstrapPath, "err", err)
|
||||
}
|
||||
slog.Info("Bootstrap complete, bootstrap.json deleted")
|
||||
}
|
||||
|
||||
// Create gateway with NATS connection
|
||||
gw := gateway.GatewayConfig{
|
||||
Debug: nodeConfig.AWSGW.Debug,
|
||||
@@ -78,17 +110,11 @@ func launchService(config *config.ClusterConfig) (err error) {
|
||||
ExpectedNodes: len(config.Nodes),
|
||||
Region: nodeConfig.Region,
|
||||
AZ: nodeConfig.AZ,
|
||||
AccessKey: nodeConfig.AccessKey,
|
||||
SecretKey: nodeConfig.SecretKey,
|
||||
IAMService: iamService,
|
||||
}
|
||||
|
||||
app := gw.SetupRoutes()
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("Failed to setup gateway routes", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey); err != nil {
|
||||
slog.Error("Failed to start TLS listener", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -97,3 +123,60 @@ func launchService(config *config.ClusterConfig) (err error) {
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// connectNATS establishes a connection to NATS with retry/backoff. On concurrent
|
||||
// startup the local NATS server may not be listening yet.
|
||||
func connectNATS(host, token string) (*nats.Conn, error) {
|
||||
const maxWait = 5 * time.Minute
|
||||
retryDelay := 500 * time.Millisecond
|
||||
start := time.Now()
|
||||
|
||||
for {
|
||||
nc, err := utils.ConnectNATS(host, token)
|
||||
if err == nil {
|
||||
if time.Since(start) > time.Second {
|
||||
slog.Info("NATS connection established", "elapsed", time.Since(start).Round(time.Second))
|
||||
}
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
if elapsed >= maxWait {
|
||||
return nil, fmt.Errorf("NATS connect failed after %s: %w", elapsed.Round(time.Second), err)
|
||||
}
|
||||
|
||||
slog.Warn("NATS not ready, retrying...", "error", err, "elapsed", elapsed.Round(time.Second), "retryIn", retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay = min(retryDelay*2, 10*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// initIAMService initializes the IAM service with retry/backoff. On multi-node
|
||||
// clusters, JetStream requires NATS cluster quorum before KV buckets can be
|
||||
// created. This retries for up to 5 minutes to allow late-joining nodes.
|
||||
func initIAMService(natsConn *nats.Conn, masterKey []byte) (*handlers_iam.IAMServiceImpl, error) {
|
||||
const maxWait = 5 * time.Minute
|
||||
retryDelay := 500 * time.Millisecond
|
||||
start := time.Now()
|
||||
attempt := 0
|
||||
|
||||
for {
|
||||
attempt++
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(natsConn, masterKey)
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
slog.Info("IAM service initialized after retry", "attempts", attempt, "elapsed", time.Since(start).Round(time.Second))
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
if elapsed >= maxWait {
|
||||
return nil, fmt.Errorf("IAM service unavailable after %s (%d attempts): %w", elapsed.Round(time.Second), attempt, err)
|
||||
}
|
||||
|
||||
slog.Warn("IAM service not ready (waiting for JetStream cluster quorum)", "error", err, "attempt", attempt, "elapsed", elapsed.Round(time.Second), "retryIn", retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay = min(retryDelay*2, 10*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,12 +41,6 @@ Note: This is not needed for actually building hive-ui and running it. We commit
|
||||
|
||||
5. **Launch Server**
|
||||
|
||||
For production, build the static assets and launch the `go` webservice for serving traffic:
|
||||
|
||||
```sh
|
||||
make run
|
||||
```
|
||||
|
||||
For development use:
|
||||
|
||||
```sh
|
||||
|
||||
+21
-21
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{c as b,a as K,j as c,u as P,b as R,s as L,B as X}from"./index.js";import{a as B,u as G}from"./zod.js";import{F,a as z,b as $,c as O}from"./field.js";import{I as D}from"./input.js";function T(e){const s=b.c(10);let l,t,r;s[0]!==e?({className:l,size:r,...t}=e,s[0]=e,s[1]=l,s[2]=t,s[3]=r):(l=s[1],t=s[2],r=s[3]);const a=r===void 0?"default":r;let i;s[4]!==l?(i=K("group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-card-foreground text-xs/relaxed ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",l),s[4]=l,s[5]=i):i=s[5];let o;return s[6]!==t||s[7]!==a||s[8]!==i?(o=c.jsx("div",{className:i,"data-size":a,"data-slot":"card",...t}),s[6]=t,s[7]=a,s[8]=i,s[9]=o):o=s[9],o}function Y(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=K("group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",l),s[3]=l,s[4]=r):r=s[4];let a;return s[5]!==t||s[6]!==r?(a=c.jsx("div",{className:r,"data-slot":"card-header",...t}),s[5]=t,s[6]=r,s[7]=a):a=s[7],a}function H(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=K("font-medium text-sm",l),s[3]=l,s[4]=r):r=s[4];let a;return s[5]!==t||s[6]!==r?(a=c.jsx("div",{className:r,"data-slot":"card-title",...t}),s[5]=t,s[6]=r,s[7]=a):a=s[7],a}function J(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=K("text-muted-foreground text-xs/relaxed",l),s[3]=l,s[4]=r):r=s[4];let a;return s[5]!==t||s[6]!==r?(a=c.jsx("div",{className:r,"data-slot":"card-description",...t}),s[5]=t,s[6]=r,s[7]=a):a=s[7],a}function U(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=K("px-4 group-data-[size=sm]/card:px-3",l),s[3]=l,s[4]=r):r=s[4];let a;return s[5]!==t||s[6]!==r?(a=c.jsx("div",{className:r,"data-slot":"card-content",...t}),s[5]=t,s[6]=r,s[7]=a):a=s[7],a}function Z(){const e=b.c(39),s=P();let l;e[0]===Symbol.for("react.memo_cache_sentinel")?(l={resolver:B(R)},e[0]=l):l=e[0];const{register:t,handleSubmit:r,formState:a}=G(l),{errors:i,isSubmitting:o}=a;let N;e[1]!==s?(N=function(M){L(M),s({to:"/"})},e[1]=s,e[2]=N):N=e[2];const _=N;let S;e[3]===Symbol.for("react.memo_cache_sentinel")?(S=c.jsx(H,{children:"AWS Credentials"}),e[3]=S):S=e[3];let v;e[4]===Symbol.for("react.memo_cache_sentinel")?(v=c.jsxs(Y,{children:[S,c.jsxs(J,{children:["Run ",c.jsx("code",{children:"cat ~/hive/config/hive.toml"})," to get your credentials"]})]}),e[4]=v):v=e[4];let d;e[5]!==r||e[6]!==_?(d=r(_),e[5]=r,e[6]=_,e[7]=d):d=e[7];let A;e[8]===Symbol.for("react.memo_cache_sentinel")?(A=c.jsx(z,{children:c.jsx("label",{htmlFor:"accessKeyId",children:"Access Key ID"})}),e[8]=A):A=e[8];const w=!!i.accessKeyId;let n;e[9]!==t?(n=t("accessKeyId"),e[9]=t,e[10]=n):n=e[10];let m;e[11]!==w||e[12]!==n?(m=c.jsx(D,{"aria-invalid":w,autoComplete:"username",id:"accessKeyId",placeholder:"AKIAIOSFODNN7EXAMPLE",...n}),e[11]=w,e[12]=n,e[13]=m):m=e[13];let f;e[14]!==i.accessKeyId?(f=c.jsx(F,{errors:[i.accessKeyId]}),e[14]=i.accessKeyId,e[15]=f):f=e[15];let u;e[16]!==f||e[17]!==m?(u=c.jsxs($,{children:[A,m,f]}),e[16]=f,e[17]=m,e[18]=u):u=e[18];let I;e[19]===Symbol.for("react.memo_cache_sentinel")?(I=c.jsx(z,{children:c.jsx("label",{htmlFor:"secretAccessKey",children:"Secret Access Key"})}),e[19]=I):I=e[19];const E=!!i.secretAccessKey;let p;e[20]!==t?(p=t("secretAccessKey"),e[20]=t,e[21]=p):p=e[21];let x;e[22]!==E||e[23]!==p?(x=c.jsx(D,{"aria-invalid":E,autoComplete:"current-password",id:"secretAccessKey",placeholder:"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",type:"password",...p}),e[22]=E,e[23]=p,e[24]=x):x=e[24];let h;e[25]!==i.secretAccessKey?(h=c.jsx(F,{errors:[i.secretAccessKey]}),e[25]=i.secretAccessKey,e[26]=h):h=e[26];let g;e[27]!==x||e[28]!==h?(g=c.jsxs($,{children:[I,x,h]}),e[27]=x,e[28]=h,e[29]=g):g=e[29];let j;e[30]!==o?(j=c.jsx(X,{className:"w-full",disabled:o,type:"submit",children:"Sign In"}),e[30]=o,e[31]=j):j=e[31];let y;e[32]!==u||e[33]!==g||e[34]!==j?(y=c.jsxs(O,{children:[u,g,j]}),e[32]=u,e[33]=g,e[34]=j,e[35]=y):y=e[35];let C;return e[36]!==y||e[37]!==d?(C=c.jsx("div",{className:"flex flex-1 items-center justify-center",children:c.jsxs(T,{className:"w-full max-w-sm",children:[v,c.jsx(U,{children:c.jsx("form",{onSubmit:d,children:y})})]})}),e[36]=y,e[37]=d,e[38]=C):C=e[38],C}export{Z as component};
|
||||
import{c as b,a as v,j as a,u as P,b as D,s as L,B as R}from"./index.js";import{a as X,u as B}from"./zod.js";import{F,a as _,b as z,c as G}from"./field.js";import{I as $}from"./input.js";function O(e){const s=b.c(10);let l,t,r;s[0]!==e?({className:l,size:r,...t}=e,s[0]=e,s[1]=l,s[2]=t,s[3]=r):(l=s[1],t=s[2],r=s[3]);const c=r===void 0?"default":r;let i;s[4]!==l?(i=v("group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-card-foreground text-xs/relaxed ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",l),s[4]=l,s[5]=i):i=s[5];let o;return s[6]!==t||s[7]!==c||s[8]!==i?(o=a.jsx("div",{className:i,"data-size":c,"data-slot":"card",...t}),s[6]=t,s[7]=c,s[8]=i,s[9]=o):o=s[9],o}function T(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=v("group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",l),s[3]=l,s[4]=r):r=s[4];let c;return s[5]!==t||s[6]!==r?(c=a.jsx("div",{className:r,"data-slot":"card-header",...t}),s[5]=t,s[6]=r,s[7]=c):c=s[7],c}function Y(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=v("font-medium text-sm",l),s[3]=l,s[4]=r):r=s[4];let c;return s[5]!==t||s[6]!==r?(c=a.jsx("div",{className:r,"data-slot":"card-title",...t}),s[5]=t,s[6]=r,s[7]=c):c=s[7],c}function H(e){const s=b.c(8);let l,t;s[0]!==e?({className:l,...t}=e,s[0]=e,s[1]=l,s[2]=t):(l=s[1],t=s[2]);let r;s[3]!==l?(r=v("px-4 group-data-[size=sm]/card:px-3",l),s[3]=l,s[4]=r):r=s[4];let c;return s[5]!==t||s[6]!==r?(c=a.jsx("div",{className:r,"data-slot":"card-content",...t}),s[5]=t,s[6]=r,s[7]=c):c=s[7],c}function Q(){const e=b.c(38),s=P();let l;e[0]===Symbol.for("react.memo_cache_sentinel")?(l={resolver:X(D)},e[0]=l):l=e[0];const{register:t,handleSubmit:r,formState:c}=B(l),{errors:i,isSubmitting:o}=c;let K;e[1]!==s?(K=function(M){L(M),s({to:"/"})},e[1]=s,e[2]=K):K=e[2];const C=K;let N;e[3]===Symbol.for("react.memo_cache_sentinel")?(N=a.jsx(T,{children:a.jsx(Y,{children:"AWS Credentials"})}),e[3]=N):N=e[3];let d;e[4]!==r||e[5]!==C?(d=r(C),e[4]=r,e[5]=C,e[6]=d):d=e[6];let S;e[7]===Symbol.for("react.memo_cache_sentinel")?(S=a.jsx(_,{children:a.jsx("label",{htmlFor:"accessKeyId",children:"Access Key ID"})}),e[7]=S):S=e[7];const w=!!i.accessKeyId;let n;e[8]!==t?(n=t("accessKeyId"),e[8]=t,e[9]=n):n=e[9];let m;e[10]!==w||e[11]!==n?(m=a.jsx($,{"aria-invalid":w,autoComplete:"username",id:"accessKeyId",placeholder:"AKIAIOSFODNN7EXAMPLE",...n}),e[10]=w,e[11]=n,e[12]=m):m=e[12];let f;e[13]!==i.accessKeyId?(f=a.jsx(F,{errors:[i.accessKeyId]}),e[13]=i.accessKeyId,e[14]=f):f=e[14];let u;e[15]!==m||e[16]!==f?(u=a.jsxs(z,{children:[S,m,f]}),e[15]=m,e[16]=f,e[17]=u):u=e[17];let A;e[18]===Symbol.for("react.memo_cache_sentinel")?(A=a.jsx(_,{children:a.jsx("label",{htmlFor:"secretAccessKey",children:"Secret Access Key"})}),e[18]=A):A=e[18];const E=!!i.secretAccessKey;let p;e[19]!==t?(p=t("secretAccessKey"),e[19]=t,e[20]=p):p=e[20];let x;e[21]!==E||e[22]!==p?(x=a.jsx($,{"aria-invalid":E,autoComplete:"current-password",id:"secretAccessKey",placeholder:"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",type:"password",...p}),e[21]=E,e[22]=p,e[23]=x):x=e[23];let h;e[24]!==i.secretAccessKey?(h=a.jsx(F,{errors:[i.secretAccessKey]}),e[24]=i.secretAccessKey,e[25]=h):h=e[25];let g;e[26]!==x||e[27]!==h?(g=a.jsxs(z,{children:[A,x,h]}),e[26]=x,e[27]=h,e[28]=g):g=e[28];let j;e[29]!==o?(j=a.jsx(R,{className:"w-full",disabled:o,type:"submit",children:"Sign In"}),e[29]=o,e[30]=j):j=e[30];let y;e[31]!==u||e[32]!==g||e[33]!==j?(y=a.jsxs(G,{children:[u,g,j]}),e[31]=u,e[32]=g,e[33]=j,e[34]=y):y=e[34];let I;return e[35]!==y||e[36]!==d?(I=a.jsx("div",{className:"flex flex-1 items-center justify-center",children:a.jsxs(O,{className:"w-full max-w-sm",children:[N,a.jsx(H,{children:a.jsx("form",{onSubmit:d,children:y})})]})}),e[35]=y,e[36]=d,e[37]=I):I=e[37],I}export{Q as component};
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@
|
||||
"fix": "biome check --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ec2": "^3.995.0",
|
||||
"@aws-sdk/client-ec2": "^3.1000.0",
|
||||
"@aws-sdk/client-s3": "^3.995.0",
|
||||
"@aws-sdk/lib-storage": "^3.995.0",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
@@ -40,8 +40,13 @@
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"ultracite": "^7.2.3",
|
||||
"ultracite": "^7.2.4",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"fast-xml-parser": ">=5.3.8"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937"
|
||||
}
|
||||
|
||||
+930
-980
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@ import { S3Client } from "@aws-sdk/client-s3"
|
||||
|
||||
import { getCredentials } from "./auth"
|
||||
|
||||
const awsEndpoint = `${window.location.protocol}//${window.location.hostname}:9999`
|
||||
const s3Endpoint = `${window.location.protocol}//${window.location.hostname}:8443`
|
||||
|
||||
// Cached singleton clients
|
||||
let ec2Client: EC2Client | null = null
|
||||
let s3Client: S3Client | null = null
|
||||
@@ -14,7 +17,7 @@ export function getEc2Client(): EC2Client {
|
||||
throw new Error("AWS credentials not configured")
|
||||
}
|
||||
ec2Client = new EC2Client({
|
||||
endpoint: "https://localhost:9999",
|
||||
endpoint: awsEndpoint,
|
||||
region: "ap-southeast-2",
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
@@ -32,7 +35,7 @@ export function getS3Client(): S3Client {
|
||||
throw new Error("AWS credentials not configured")
|
||||
}
|
||||
s3Client = new S3Client({
|
||||
endpoint: "https://localhost:8443",
|
||||
endpoint: s3Endpoint,
|
||||
region: "ap-southeast-2",
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
|
||||
@@ -3,13 +3,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
@@ -53,9 +47,6 @@ function LoginPage() {
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>AWS Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
Run <code>cat ~/hive/config/hive.toml</code> to get your credentials
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -210,8 +211,10 @@ func (svc *Service) launchService() error {
|
||||
}
|
||||
|
||||
func securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
csp := buildCSP()
|
||||
slog.Info("Content-Security-Policy configured", "csp", csp)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self' data:; connect-src 'self' https://localhost:9999 https://localhost:8443; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;")
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), browsing-topics=()")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
@@ -220,6 +223,53 @@ func securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// getLocalIPs returns all non-loopback IPv4 addresses on the machine.
|
||||
func getLocalIPs() []string {
|
||||
var ips []string
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get network interfaces for CSP", "error", err)
|
||||
return ips
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// buildCSP constructs the Content-Security-Policy header, dynamically adding
|
||||
// each local IP with the AWS Gateway (:9999) and daemon (:8443) ports to connect-src.
|
||||
func buildCSP() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("'self' https://localhost:9999 https://localhost:8443")
|
||||
for _, ip := range getLocalIPs() {
|
||||
fmt.Fprintf(&b, " https://%s:9999 https://%s:8443", ip, ip)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self' data:; connect-src %s; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;",
|
||||
b.String(),
|
||||
)
|
||||
}
|
||||
|
||||
func gzipMiddleware(next http.Handler) http.Handler {
|
||||
g, err := gziphandler.GzipHandlerWithOpts(gziphandler.ContentTypes([]string{
|
||||
"text/html",
|
||||
|
||||
@@ -244,6 +244,34 @@ func TestGzipMiddleware_IgnoresNonTextContent(t *testing.T) {
|
||||
assert.NotEqual(t, "gzip", resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func TestGetLocalIPs_ReturnsNonLoopback(t *testing.T) {
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
assert.NotEqual(t, "127.0.0.1", ip, "should not include loopback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSP_ContainsSelf(t *testing.T) {
|
||||
csp := buildCSP()
|
||||
assert.Contains(t, csp, "connect-src 'self'")
|
||||
assert.Contains(t, csp, "default-src 'self'")
|
||||
}
|
||||
|
||||
func TestBuildCSP_ContainsLocalIPs(t *testing.T) {
|
||||
ips := getLocalIPs()
|
||||
csp := buildCSP()
|
||||
for _, ip := range ips {
|
||||
assert.Contains(t, csp, "https://"+ip+":9999")
|
||||
assert.Contains(t, csp, "https://"+ip+":8443")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSP_ContainsLocalhost(t *testing.T) {
|
||||
csp := buildCSP()
|
||||
assert.Contains(t, csp, "https://localhost:9999")
|
||||
assert.Contains(t, csp, "https://localhost:8443")
|
||||
}
|
||||
|
||||
func TestShutdown_WithServer(t *testing.T) {
|
||||
// Create a real server on a random port so Shutdown exercises the non-nil path
|
||||
mux := http.NewServeMux()
|
||||
|
||||
+1
-1
@@ -887,7 +887,7 @@ func CreateS3Client(cfg *config.Config) *s3.S3 {
|
||||
sess := session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String(cfg.Predastore.Region),
|
||||
Endpoint: aws.String(fmt.Sprintf("https://%s", cfg.Predastore.Host)),
|
||||
Credentials: credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Credentials: credentials.NewStaticCredentials(cfg.Predastore.AccessKey, cfg.Predastore.SecretKey, ""),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
DisableSSL: aws.Bool(false),
|
||||
HTTPClient: httpClient,
|
||||
|
||||
@@ -19,7 +19,7 @@ rm -rf ~/hive/nats/*
|
||||
rm -rf ~/hive/images/*
|
||||
|
||||
# Enable pprof for development
|
||||
PPROF_ENABLED=1 PPROF_OUTPUT=/tmp/hive-vm.prof ./scripts/start-dev.sh
|
||||
PPROF_ENABLED=1 PPROF_OUTPUT=/tmp/hive-vm.prof ./scripts/start-dev.sh --build
|
||||
|
||||
# Follow INSTALL.md steps
|
||||
echo "Importing AMI"
|
||||
|
||||
@@ -154,7 +154,7 @@ check_service() {
|
||||
local name="$1"
|
||||
local host="$2"
|
||||
local port="$3"
|
||||
local max_attempts=10
|
||||
local max_attempts="${4:-30}"
|
||||
local attempt=1
|
||||
|
||||
echo "🔍 Checking $name connectivity on $host:$port..."
|
||||
@@ -169,8 +169,8 @@ check_service() {
|
||||
((attempt++))
|
||||
done
|
||||
|
||||
echo " ⚠️ $name may not be responding on $host:$port (continuing anyway)"
|
||||
|
||||
echo " ❌ $name failed to start on $host:$port after $max_attempts attempts"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Pre-flight, compile latest (only with --build flag)
|
||||
@@ -353,7 +353,7 @@ if has_service "awsgw"; then
|
||||
|
||||
unset HIVE_NATS_HOST
|
||||
unset HIVE_PREDASTORE_HOST
|
||||
export HIVE_BASE_DIR=$CONFIG_DIR
|
||||
export HIVE_BASE_DIR=$DATA_DIR
|
||||
export HIVE_CONFIG_PATH=$CONFIG_DIR/hive.toml
|
||||
export HIVE_AWSGW_TLS_CERT=$CONFIG_DIR/server.pem
|
||||
export HIVE_AWSGW_TLS_KEY=$CONFIG_DIR/server.key
|
||||
|
||||
@@ -146,6 +146,65 @@
|
||||
- `delete-key-pair` on non-existent key (expect success — idempotent, matches AWS)
|
||||
- `modify-instance-attribute` on running instance (expect `InvalidInstanceID.NotFound` — running instances not in stopped KV)
|
||||
|
||||
### IAM Phase 1: User CRUD
|
||||
- Root auth via `iam list-users` (root user exists)
|
||||
- `create-user` (alice, bob with path)
|
||||
- Duplicate user (expect `EntityAlreadyExists`)
|
||||
- `get-user` (alice)
|
||||
- `get-user` non-existent (expect `NoSuchEntity`)
|
||||
- `list-users` (verify count)
|
||||
- `list-users` with `--path-prefix` filter
|
||||
|
||||
### IAM Phase 2: Access Key Lifecycle
|
||||
- `create-access-key` (alice key 1, key 2)
|
||||
- Third key exceeds limit (expect `LimitExceeded`)
|
||||
- `create-access-key` for non-existent user (expect `NoSuchEntity`)
|
||||
- `list-access-keys` (alice: 2 keys, bob: 0 keys)
|
||||
- `update-access-key` (Inactive, verify status, reactivate)
|
||||
- `delete-access-key` (verify count decremented)
|
||||
|
||||
### IAM Phase 3: User Authentication
|
||||
- Configure IAM user profile with access key
|
||||
- Deactivate key → auth rejected (`InvalidClientTokenId`)
|
||||
- Reactivate key
|
||||
- Wrong secret → `SignatureDoesNotMatch`
|
||||
- Non-existent key ID → `InvalidClientTokenId`
|
||||
- Multi-user simultaneous auth (root + bob)
|
||||
|
||||
### IAM Phase 4: Policy CRUD
|
||||
- `create-policy` (EC2ReadOnly, FullAdmin, DenyTerminate, IAMReadOnly, EC2DescribeAll)
|
||||
- Duplicate policy (expect `EntityAlreadyExists`)
|
||||
- Malformed JSON (expect `MalformedPolicyDocument`)
|
||||
- `get-policy` (by ARN)
|
||||
- `get-policy` non-existent (expect `NoSuchEntity`)
|
||||
- `get-policy-version` (v1)
|
||||
- `list-policies` (verify count)
|
||||
|
||||
### IAM Phase 5: Policy Attachment & Enforcement
|
||||
- Create charlie with access key
|
||||
- `attach-user-policy` (alice: EC2ReadOnly + IAMReadOnly, bob: DenyTerminate)
|
||||
- `list-attached-user-policies` (verify count)
|
||||
- Idempotent attach (no duplicate)
|
||||
- Attach non-existent policy (expect `NoSuchEntity`)
|
||||
- Attach to non-existent user (expect `NoSuchEntity`)
|
||||
- **Default Deny**: charlie (no policies) → `AccessDenied` on ec2 + iam
|
||||
- **Explicit Allow**: alice → ec2:Describe{Instances,Vpcs} allowed, ec2:DescribeKeyPairs denied, iam:ListUsers allowed, iam:CreateUser denied
|
||||
- **Deny Override**: bob → ec2:Describe allowed, ec2:TerminateInstances denied (explicit Deny), iam denied
|
||||
- **Root Bypass**: root user → all actions succeed
|
||||
- **Prefix Wildcard**: swap alice to EC2DescribeAll (ec2:Describe*) → Describe* allowed, non-Describe denied
|
||||
- **FullAdmin**: charlie with FullAdmin → all actions allowed
|
||||
|
||||
### IAM Phase 6: Policy Lifecycle — Detach & Delete
|
||||
- `detach-user-policy` → user loses access
|
||||
- `delete-policy` while attached (expect `DeleteConflict`)
|
||||
- Detach then delete → `get-policy` returns `NoSuchEntity`
|
||||
|
||||
### IAM Phase 7: IAM Cleanup
|
||||
- Delete all test users (remove keys + policies first)
|
||||
- Verify root-only remains (`list-users` count = 1)
|
||||
- Delete all test policies (`list-policies` count = 0)
|
||||
- Clean up AWS CLI profiles
|
||||
|
||||
### Phase 9: Terminate and Verify Cleanup
|
||||
- `delete-snapshot` (CreateImage backing snapshot, so DeleteOnTermination is not blocked)
|
||||
- `terminate-instances` (poll -> terminated)
|
||||
@@ -273,7 +332,45 @@
|
||||
- Verify crash loop is detected and restarts stop after max attempts (3 in 10-min window)
|
||||
- Verify instance reaches error state (won't restart further)
|
||||
|
||||
### Phase 5c: VPC Networking
|
||||
### Phase 5c: IAM Accounts & Cross-Account Isolation
|
||||
|
||||
#### Step 1: Create Accounts
|
||||
- `hive admin account create` (Team Alpha, Team Beta)
|
||||
- Verify sequential 12-digit account IDs
|
||||
- `hive admin account list` (verify both accounts)
|
||||
|
||||
#### Step 2: Account Admin Auth
|
||||
- Alpha admin authenticates via ec2 + iam
|
||||
- Beta admin authenticates via ec2 + iam
|
||||
|
||||
#### Step 3: Account-Scoped Users
|
||||
- `create-user` alice in Alpha + Beta (same name, different accounts)
|
||||
- `create-user` team-member (Alpha), dev-user (Beta)
|
||||
- `list-users` scoped per account (verify different user lists)
|
||||
- Cross-account isolation: Alpha cannot see Beta's users and vice versa (`NoSuchEntity`)
|
||||
|
||||
#### Step 4: Account-Scoped Access Keys
|
||||
- `create-access-key` for alice in Alpha + Beta
|
||||
- Configure separate AWS CLI profiles per account user
|
||||
|
||||
#### Step 5: Account-Scoped Policies & Enforcement
|
||||
- `create-policy` EC2ReadOnly in Alpha (narrow: DescribeInstances + DescribeVpcs)
|
||||
- `create-policy` EC2ReadOnly in Beta (broad: ec2:Describe*)
|
||||
- `attach-user-policy` to alice in both accounts
|
||||
- Alpha alice: DescribeInstances allowed, DescribeKeyPairs denied (narrow)
|
||||
- Beta alice: DescribeInstances allowed, DescribeKeyPairs allowed (broad Describe*)
|
||||
- Both denied: CreateKeyPair
|
||||
|
||||
#### Step 6: Cross-Account Delete Isolation
|
||||
- Delete Alpha's alice (detach policy, delete key, delete user)
|
||||
- Verify Alpha alice gone (`NoSuchEntity`)
|
||||
- Verify Beta alice unaffected (still exists, auth still works)
|
||||
|
||||
#### Step 7: IAM Account Cleanup
|
||||
- Delete all account users, policies, access keys
|
||||
- Clean up AWS CLI profiles
|
||||
|
||||
### Phase 5d: VPC Networking
|
||||
- Step 1: `create-vpc` (10.100.0.0/16) + `create-subnet` (10.100.1.0/24)
|
||||
- Step 2: `run-instances` x3 with `--subnet-id` (launch into VPC subnet)
|
||||
- Poll all VPC instances to running state
|
||||
|
||||
+623
-102
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,8 @@ CLUSTER_SERVICES_STARTED="false"
|
||||
|
||||
# Use Hive profile for AWS CLI
|
||||
export AWS_PROFILE=hive
|
||||
# Trust Hive CA for all profiles (AWS CLI v2 bundles its own Python/certifi, ignores system CA store)
|
||||
export AWS_CA_BUNDLE="$HOME/node1/config/ca.pem"
|
||||
|
||||
|
||||
echo "========================================"
|
||||
@@ -177,6 +179,7 @@ wait_for_daemon_ready "https://${NODE1_IP}:${AWSGW_PORT}"
|
||||
|
||||
# Define AWS CLI args pointing to node1's gateway
|
||||
AWS_EC2="aws --endpoint-url https://${NODE1_IP}:${AWSGW_PORT} ec2"
|
||||
AWS_IAM="aws --endpoint-url https://${NODE1_IP}:${AWSGW_PORT} iam"
|
||||
|
||||
# Phase 3b: Cluster Stats CLI (Multi-Node)
|
||||
echo ""
|
||||
@@ -447,10 +450,12 @@ for idx in "${!INSTANCE_IDS[@]}"; do
|
||||
-i "multinode-test-key.pem" \
|
||||
ec2-user@"$SSH_HOST" 'hostname' 2>/dev/null)
|
||||
echo " VM hostname: $VM_HOSTNAME"
|
||||
if echo "$VM_HOSTNAME" | grep -q "$instance_id"; then
|
||||
echo " Hostname contains instance ID"
|
||||
# Hostname uses truncated ID: hive-vm-<first 8 hex chars of instance ID>
|
||||
SHORT_ID=$(echo "$instance_id" | sed 's/^i-//' | cut -c1-8)
|
||||
if echo "$VM_HOSTNAME" | grep -q "$SHORT_ID"; then
|
||||
echo " Hostname contains instance ID prefix ($SHORT_ID)"
|
||||
else
|
||||
echo " WARNING: Hostname '$VM_HOSTNAME' does not contain instance ID '$instance_id' (non-fatal)"
|
||||
echo " WARNING: Hostname '$VM_HOSTNAME' does not contain instance ID prefix '$SHORT_ID' (non-fatal)"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1150,9 +1155,289 @@ fi
|
||||
|
||||
echo " Crash recovery tests passed"
|
||||
|
||||
# Phase 5c: VPC Networking
|
||||
# Phase 5c: IAM Accounts & Cross-Account Isolation
|
||||
echo ""
|
||||
echo "Phase 5c: VPC Networking"
|
||||
echo "Phase 5c: IAM Accounts & Cross-Account Isolation"
|
||||
echo "========================================"
|
||||
|
||||
AWS_EP="--endpoint-url https://${NODE1_IP}:${AWSGW_PORT}"
|
||||
|
||||
# Step 1: Create accounts
|
||||
echo ""
|
||||
echo "Step 1: Create Accounts"
|
||||
echo "----------------------------------------"
|
||||
|
||||
echo " Creating Team Alpha account..."
|
||||
ALPHA_OUTPUT=$(./bin/hive admin account create --name "Team Alpha" --config "$HOME/node1/config/hive.toml" 2>&1)
|
||||
echo "$ALPHA_OUTPUT"
|
||||
ALPHA_ACCOUNT=$(echo "$ALPHA_OUTPUT" | grep "Account ID:" | awk '{print $NF}')
|
||||
ALPHA_KEY_ID=$(echo "$ALPHA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}')
|
||||
ALPHA_SECRET=$(echo "$ALPHA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}')
|
||||
|
||||
if [ -z "$ALPHA_ACCOUNT" ] || [ -z "$ALPHA_KEY_ID" ]; then
|
||||
echo " ERROR: Failed to parse Team Alpha account output"
|
||||
exit 1
|
||||
fi
|
||||
echo " Team Alpha: account=$ALPHA_ACCOUNT key=$ALPHA_KEY_ID"
|
||||
|
||||
# Configure alpha profile (in case auto-config used a different endpoint)
|
||||
aws configure set aws_access_key_id "$ALPHA_KEY_ID" --profile hive-team-alpha
|
||||
aws configure set aws_secret_access_key "$ALPHA_SECRET" --profile hive-team-alpha
|
||||
aws configure set region us-east-1 --profile hive-team-alpha
|
||||
|
||||
echo " Creating Team Beta account..."
|
||||
BETA_OUTPUT=$(./bin/hive admin account create --name "Team Beta" --config "$HOME/node1/config/hive.toml" 2>&1)
|
||||
echo "$BETA_OUTPUT"
|
||||
BETA_ACCOUNT=$(echo "$BETA_OUTPUT" | grep "Account ID:" | awk '{print $NF}')
|
||||
BETA_KEY_ID=$(echo "$BETA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}')
|
||||
BETA_SECRET=$(echo "$BETA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}')
|
||||
|
||||
if [ -z "$BETA_ACCOUNT" ] || [ -z "$BETA_KEY_ID" ]; then
|
||||
echo " ERROR: Failed to parse Team Beta account output"
|
||||
exit 1
|
||||
fi
|
||||
echo " Team Beta: account=$BETA_ACCOUNT key=$BETA_KEY_ID"
|
||||
|
||||
aws configure set aws_access_key_id "$BETA_KEY_ID" --profile hive-team-beta
|
||||
aws configure set aws_secret_access_key "$BETA_SECRET" --profile hive-team-beta
|
||||
aws configure set region us-east-1 --profile hive-team-beta
|
||||
|
||||
# Verify sequential IDs
|
||||
echo " Verifying sequential account IDs..."
|
||||
if [ "$ALPHA_ACCOUNT" == "000000000001" ] && [ "$BETA_ACCOUNT" == "000000000002" ]; then
|
||||
echo " Sequential IDs correct: $ALPHA_ACCOUNT, $BETA_ACCOUNT"
|
||||
else
|
||||
echo " WARNING: Account IDs may not be sequential: $ALPHA_ACCOUNT, $BETA_ACCOUNT"
|
||||
fi
|
||||
|
||||
# List accounts
|
||||
echo " Listing accounts..."
|
||||
ACCOUNT_LIST=$(./bin/hive admin account list --config "$HOME/node1/config/hive.toml" 2>&1)
|
||||
echo "$ACCOUNT_LIST"
|
||||
if echo "$ACCOUNT_LIST" | grep -q "Team Alpha" && echo "$ACCOUNT_LIST" | grep -q "Team Beta"; then
|
||||
echo " Account list verified"
|
||||
else
|
||||
echo " ERROR: Account list missing expected accounts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Account admin auth
|
||||
echo ""
|
||||
echo "Step 2: Account Admin Auth"
|
||||
echo "----------------------------------------"
|
||||
|
||||
echo " Testing alpha admin auth..."
|
||||
aws $AWS_EP ec2 describe-instances --profile hive-team-alpha > /dev/null
|
||||
echo " Alpha admin: ec2 OK"
|
||||
aws $AWS_EP iam list-users --profile hive-team-alpha > /dev/null
|
||||
echo " Alpha admin: iam OK"
|
||||
|
||||
echo " Testing beta admin auth..."
|
||||
aws $AWS_EP ec2 describe-instances --profile hive-team-beta > /dev/null
|
||||
echo " Beta admin: ec2 OK"
|
||||
aws $AWS_EP iam list-users --profile hive-team-beta > /dev/null
|
||||
echo " Beta admin: iam OK"
|
||||
|
||||
# Step 3: Account-scoped users
|
||||
echo ""
|
||||
echo "Step 3: Account-Scoped Users"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Same username in different accounts
|
||||
echo " Creating alice in both accounts..."
|
||||
aws $AWS_EP iam create-user --user-name alice --profile hive-team-alpha > /dev/null
|
||||
ALPHA_ALICE_ARN=$(aws $AWS_EP iam get-user --user-name alice --profile hive-team-alpha | jq -r '.User.Arn')
|
||||
echo " Alpha alice: $ALPHA_ALICE_ARN"
|
||||
|
||||
aws $AWS_EP iam create-user --user-name alice --profile hive-team-beta > /dev/null
|
||||
BETA_ALICE_ARN=$(aws $AWS_EP iam get-user --user-name alice --profile hive-team-beta | jq -r '.User.Arn')
|
||||
echo " Beta alice: $BETA_ALICE_ARN"
|
||||
|
||||
# Additional users
|
||||
aws $AWS_EP iam create-user --user-name team-member --profile hive-team-alpha > /dev/null
|
||||
aws $AWS_EP iam create-user --user-name dev-user --profile hive-team-beta > /dev/null
|
||||
|
||||
# List users — scoped
|
||||
echo " Verifying scoped user lists..."
|
||||
ALPHA_USERS=$(aws $AWS_EP iam list-users --profile hive-team-alpha | jq -r '.Users[].UserName' | sort | tr '\n' ',')
|
||||
echo " Alpha users: $ALPHA_USERS"
|
||||
BETA_USERS=$(aws $AWS_EP iam list-users --profile hive-team-beta | jq -r '.Users[].UserName' | sort | tr '\n' ',')
|
||||
echo " Beta users: $BETA_USERS"
|
||||
|
||||
# Cross-account isolation
|
||||
echo " Verifying cross-account isolation..."
|
||||
set +e
|
||||
CROSS_CHECK=$(aws $AWS_EP iam get-user --user-name dev-user --profile hive-team-alpha 2>&1)
|
||||
set -e
|
||||
if echo "$CROSS_CHECK" | grep -q "NoSuchEntity"; then
|
||||
echo " Alpha cannot see Beta's dev-user — isolation OK"
|
||||
else
|
||||
echo " ERROR: Alpha can see Beta's user (cross-account leak)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
CROSS_CHECK2=$(aws $AWS_EP iam get-user --user-name team-member --profile hive-team-beta 2>&1)
|
||||
set -e
|
||||
if echo "$CROSS_CHECK2" | grep -q "NoSuchEntity"; then
|
||||
echo " Beta cannot see Alpha's team-member — isolation OK"
|
||||
else
|
||||
echo " ERROR: Beta can see Alpha's user (cross-account leak)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Account-scoped access keys
|
||||
echo ""
|
||||
echo "Step 4: Account-Scoped Access Keys"
|
||||
echo "----------------------------------------"
|
||||
|
||||
ALPHA_ALICE_KEY=$(aws $AWS_EP iam create-access-key --user-name alice --profile hive-team-alpha)
|
||||
ALPHA_ALICE_KEY_ID=$(echo "$ALPHA_ALICE_KEY" | jq -r '.AccessKey.AccessKeyId')
|
||||
ALPHA_ALICE_SECRET=$(echo "$ALPHA_ALICE_KEY" | jq -r '.AccessKey.SecretAccessKey')
|
||||
echo " Alpha alice key: $ALPHA_ALICE_KEY_ID"
|
||||
|
||||
aws configure set aws_access_key_id "$ALPHA_ALICE_KEY_ID" --profile hive-alpha-alice
|
||||
aws configure set aws_secret_access_key "$ALPHA_ALICE_SECRET" --profile hive-alpha-alice
|
||||
aws configure set region us-east-1 --profile hive-alpha-alice
|
||||
|
||||
BETA_ALICE_KEY=$(aws $AWS_EP iam create-access-key --user-name alice --profile hive-team-beta)
|
||||
BETA_ALICE_KEY_ID=$(echo "$BETA_ALICE_KEY" | jq -r '.AccessKey.AccessKeyId')
|
||||
BETA_ALICE_SECRET=$(echo "$BETA_ALICE_KEY" | jq -r '.AccessKey.SecretAccessKey')
|
||||
echo " Beta alice key: $BETA_ALICE_KEY_ID"
|
||||
|
||||
aws configure set aws_access_key_id "$BETA_ALICE_KEY_ID" --profile hive-beta-alice
|
||||
aws configure set aws_secret_access_key "$BETA_ALICE_SECRET" --profile hive-beta-alice
|
||||
aws configure set region us-east-1 --profile hive-beta-alice
|
||||
|
||||
# Step 5: Account-scoped policies & enforcement
|
||||
echo ""
|
||||
echo "Step 5: Account-Scoped Policies & Enforcement"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Alpha: narrow EC2 read-only
|
||||
echo " Creating EC2ReadOnly in Alpha (narrow)..."
|
||||
aws $AWS_EP iam create-policy \
|
||||
--policy-name EC2ReadOnly \
|
||||
--policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:DescribeInstances", "ec2:DescribeVpcs"],
|
||||
"Resource": "*"
|
||||
}]
|
||||
}' --profile hive-team-alpha > /dev/null
|
||||
|
||||
# Beta: broad EC2 Describe* wildcard
|
||||
echo " Creating EC2ReadOnly in Beta (broad Describe*)..."
|
||||
aws $AWS_EP iam create-policy \
|
||||
--policy-name EC2ReadOnly \
|
||||
--policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "ec2:Describe*",
|
||||
"Resource": "*"
|
||||
}]
|
||||
}' --profile hive-team-beta > /dev/null
|
||||
|
||||
# Attach policies
|
||||
echo " Attaching policies..."
|
||||
aws $AWS_EP iam attach-user-policy --user-name alice \
|
||||
--policy-arn "arn:aws:iam::${ALPHA_ACCOUNT}:policy/EC2ReadOnly" --profile hive-team-alpha
|
||||
aws $AWS_EP iam attach-user-policy --user-name alice \
|
||||
--policy-arn "arn:aws:iam::${BETA_ACCOUNT}:policy/EC2ReadOnly" --profile hive-team-beta
|
||||
|
||||
# Verify scoped enforcement
|
||||
echo " Verifying scoped enforcement..."
|
||||
|
||||
# Alpha alice: narrow policy
|
||||
aws $AWS_EP ec2 describe-instances --profile hive-alpha-alice > /dev/null
|
||||
echo " Alpha alice: describe-instances — allowed"
|
||||
aws $AWS_EP ec2 describe-vpcs --profile hive-alpha-alice > /dev/null
|
||||
echo " Alpha alice: describe-vpcs — allowed"
|
||||
expect_error "AccessDenied" aws $AWS_EP ec2 describe-key-pairs --profile hive-alpha-alice
|
||||
echo " Alpha alice: describe-key-pairs — denied (narrow policy)"
|
||||
|
||||
# Beta alice: broad policy
|
||||
aws $AWS_EP ec2 describe-instances --profile hive-beta-alice > /dev/null
|
||||
echo " Beta alice: describe-instances — allowed"
|
||||
aws $AWS_EP ec2 describe-key-pairs --profile hive-beta-alice > /dev/null
|
||||
echo " Beta alice: describe-key-pairs — allowed (Describe* wildcard)"
|
||||
|
||||
# Both denied non-Describe
|
||||
expect_error "AccessDenied" aws $AWS_EP ec2 create-key-pair --key-name x --profile hive-alpha-alice
|
||||
echo " Alpha alice: create-key-pair — denied"
|
||||
expect_error "AccessDenied" aws $AWS_EP ec2 create-key-pair --key-name x --profile hive-beta-alice
|
||||
echo " Beta alice: create-key-pair — denied"
|
||||
|
||||
echo " Scoped enforcement verified"
|
||||
|
||||
# Step 6: Cross-account delete isolation
|
||||
echo ""
|
||||
echo "Step 6: Cross-Account Delete Isolation"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Delete Alpha's alice
|
||||
echo " Deleting Alpha's alice..."
|
||||
aws $AWS_EP iam detach-user-policy --user-name alice \
|
||||
--policy-arn "arn:aws:iam::${ALPHA_ACCOUNT}:policy/EC2ReadOnly" --profile hive-team-alpha
|
||||
aws $AWS_EP iam delete-access-key --user-name alice \
|
||||
--access-key-id "$ALPHA_ALICE_KEY_ID" --profile hive-team-alpha
|
||||
aws $AWS_EP iam delete-user --user-name alice --profile hive-team-alpha
|
||||
|
||||
# Verify Alpha alice gone
|
||||
set +e
|
||||
ALPHA_CHECK=$(aws $AWS_EP iam get-user --user-name alice --profile hive-team-alpha 2>&1)
|
||||
set -e
|
||||
if echo "$ALPHA_CHECK" | grep -q "NoSuchEntity"; then
|
||||
echo " Alpha alice deleted"
|
||||
else
|
||||
echo " ERROR: Alpha alice still exists after delete"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify Beta alice unaffected
|
||||
aws $AWS_EP iam get-user --user-name alice --profile hive-team-beta > /dev/null
|
||||
echo " Beta alice still exists (isolation OK)"
|
||||
|
||||
aws $AWS_EP ec2 describe-instances --profile hive-beta-alice > /dev/null
|
||||
echo " Beta alice auth still works (isolation OK)"
|
||||
|
||||
# Step 7: IAM Account Cleanup
|
||||
echo ""
|
||||
echo "Step 7: IAM Account Cleanup"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Beta cleanup
|
||||
echo " Cleaning up Beta account resources..."
|
||||
aws $AWS_EP iam detach-user-policy --user-name alice \
|
||||
--policy-arn "arn:aws:iam::${BETA_ACCOUNT}:policy/EC2ReadOnly" --profile hive-team-beta
|
||||
aws $AWS_EP iam delete-access-key --user-name alice \
|
||||
--access-key-id "$BETA_ALICE_KEY_ID" --profile hive-team-beta
|
||||
aws $AWS_EP iam delete-user --user-name alice --profile hive-team-beta
|
||||
aws $AWS_EP iam delete-user --user-name dev-user --profile hive-team-beta
|
||||
aws $AWS_EP iam delete-policy \
|
||||
--policy-arn "arn:aws:iam::${BETA_ACCOUNT}:policy/EC2ReadOnly" --profile hive-team-beta
|
||||
|
||||
# Alpha cleanup (alice already deleted)
|
||||
echo " Cleaning up Alpha account resources..."
|
||||
aws $AWS_EP iam delete-user --user-name team-member --profile hive-team-alpha
|
||||
aws $AWS_EP iam delete-policy \
|
||||
--policy-arn "arn:aws:iam::${ALPHA_ACCOUNT}:policy/EC2ReadOnly" --profile hive-team-alpha
|
||||
|
||||
# Clean up AWS CLI profiles
|
||||
for p in hive-team-alpha hive-team-beta hive-alpha-alice hive-beta-alice; do
|
||||
aws configure set aws_access_key_id "" --profile $p 2>/dev/null || true
|
||||
aws configure set aws_secret_access_key "" --profile $p 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo " IAM account cleanup complete"
|
||||
echo ""
|
||||
echo " IAM Accounts & Cross-Account Isolation tests passed"
|
||||
|
||||
# Phase 5d: VPC Networking
|
||||
echo ""
|
||||
echo "Phase 5d: VPC Networking"
|
||||
echo "========================================"
|
||||
echo "Testing VPC instance launch, PrivateIpAddress, and same-subnet connectivity..."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user