diff --git a/cmd/cloudemu/admin_client.go b/cmd/cloudemu/admin_client.go new file mode 100644 index 000000000..76b845e54 --- /dev/null +++ b/cmd/cloudemu/admin_client.go @@ -0,0 +1,122 @@ +//go:build unix + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// adminTokenFileName is where `cloudemu start` asks serve to write the admin +// token, next to endpoints.json in the run dir. +const adminTokenFileName = "admin-token" + +// adminTokenEnv overrides the token file, e.g. for a server started by hand +// with --admin-token. +const adminTokenEnv = "CLOUDEMU_ADMIN_TOKEN" //nolint:gosec // G101 false positive: an env var name, not a credential + +var errAdminUnauthorized = errors.New("the server requires the admin token (it runs with --enforce-auth): " + + "set CLOUDEMU_ADMIN_TOKEN, or start it with `cloudemu start` so the token is written to the run dir") + +func adminTokenPath(dir string) string { return filepath.Join(dir, adminTokenFileName) } + +// adminAPI is a running daemon's control plane: its plain-HTTP base URL and +// the admin token to send ("" when there is none, i.e. auth is off). +type adminAPI struct { + base string + token string +} + +// newAdminAPI resolves the daemon's control plane from the run dir. The token +// comes from CLOUDEMU_ADMIN_TOKEN, else the run dir's admin-token file. +func newAdminAPI(dir string) (adminAPI, error) { + base, err := adminBaseURL(dir) + if err != nil { + return adminAPI{}, err + } + + return adminAPI{base: base, token: adminToken(dir)}, nil +} + +func adminToken(dir string) string { + if t := strings.TrimSpace(os.Getenv(adminTokenEnv)); t != "" { + return t + } + + b, err := os.ReadFile(adminTokenPath(dir)) + if err != nil { + return "" + } + + return strings.TrimSpace(string(b)) +} + +// adminBaseURL reads the daemon's endpoints file and returns a plain-HTTP base +// URL for the control plane (avoids the self-signed HTTPS endpoints). +func adminBaseURL(dir string) (string, error) { + eps, err := readEndpoints(endpointsPath(dir)) + if errors.Is(err, os.ErrNotExist) { + return "", errSnapDaemonDown + } + + if err != nil { + return "", err + } + + // Azure and Kubernetes are HTTPS, so this picks aws, then gcp. + for _, k := range endpointOrder() { + if ep := eps[k]; strings.HasPrefix(ep, "http://") { + return strings.TrimRight(ep, "/"), nil + } + } + + return "", errSnapNoEndpoint +} + +// do calls /_cloudemu/ with the admin token and returns the status and +// body. A 401 (missing or wrong token) and a 501 (control plane off) map to +// clear errors here so every subcommand reports them the same way. +func (a adminAPI) do(method, path string, body io.Reader, contentType string) (status int, respBody []byte, err error) { + ctx, cancel := context.WithTimeout(context.Background(), snapHTTPTimeout) + defer cancel() + + if body == nil { + body = http.NoBody + } + + req, err := http.NewRequestWithContext(ctx, method, a.base+"/_cloudemu/"+path, body) + if err != nil { + return 0, nil, err + } + + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + if a.token != "" { + req.Header.Set("Authorization", "Bearer "+a.token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("%w: %w", errSnapDaemonDown, err) + } + defer resp.Body.Close() + + rb, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case http.StatusUnauthorized: + return resp.StatusCode, rb, errAdminUnauthorized + case http.StatusNotImplemented: + return resp.StatusCode, rb, errSnapAdminOff + } + + return resp.StatusCode, rb, nil +} diff --git a/cmd/cloudemu/admin_client_test.go b/cmd/cloudemu/admin_client_test.go new file mode 100644 index 000000000..eca7b1793 --- /dev/null +++ b/cmd/cloudemu/admin_client_test.go @@ -0,0 +1,100 @@ +//go:build unix + +package main + +import ( + "context" + "errors" + "io" + "os" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/server/serverkit" +) + +// startEnforceAuthServer runs an in-process --enforce-auth server wired like +// `cloudemu start` does it: endpoints and admin token land in dir. +func startEnforceAuthServer(t *testing.T, dir string) { + t.Helper() + + app, err := serverkit.New(&serverkit.Config{ + Providers: []string{"aws"}, + Host: "127.0.0.1", + Ports: map[string]string{"aws": freePort(t)}, + Admin: true, + EnforceAuth: true, + AdminTokenFile: adminTokenPath(dir), + EndpointsFile: endpointsPath(dir), + Quiet: true, + Out: io.Discard, + }) + if err != nil { + t.Fatalf("serverkit.New: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + + go func() { done <- app.Serve(ctx) }() + + t.Cleanup(func() { + cancel() + <-done + }) + + if _, err := waitForEndpoints(endpointsPath(dir), 10*time.Second); err != nil { + t.Fatalf("server not ready: %v", err) + } +} + +// TestCLIAdminCommandsUnderEnforceAuth drives snapshot save/load and cost +// against an --enforce-auth server: they work with the run-dir token or +// CLOUDEMU_ADMIN_TOKEN, and fail with a clear error without one. +func TestCLIAdminCommandsUnderEnforceAuth(t *testing.T) { + t.Setenv(adminTokenEnv, "") + + dir := t.TempDir() + startEnforceAuthServer(t, dir) + + if err := snapshotSave(dir, "s1", false); err != nil { + t.Fatalf("snapshot save with the run-dir token: %v", err) + } + + if err := snapshotLoad(dir, "s1"); err != nil { + t.Fatalf("snapshot load with the run-dir token: %v", err) + } + + if err := runCost([]string{"--home", dir, "--json"}); err != nil { + t.Fatalf("cost with the run-dir token: %v", err) + } + + token := adminToken(dir) + if token == "" { + t.Fatal("serve did not write the admin token to the run dir") + } + + if err := os.Remove(adminTokenPath(dir)); err != nil { + t.Fatal(err) + } + + if err := snapshotSave(dir, "s2", false); !errors.Is(err, errAdminUnauthorized) { + t.Fatalf("snapshot save without a token = %v, want errAdminUnauthorized", err) + } + + if err := runCost([]string{"--home", dir}); !errors.Is(err, errAdminUnauthorized) { + t.Fatalf("cost without a token = %v, want errAdminUnauthorized", err) + } + + t.Setenv(adminTokenEnv, "wrong") + + if err := snapshotLoad(dir, "s1"); !errors.Is(err, errAdminUnauthorized) { + t.Fatalf("snapshot load with a wrong token = %v, want errAdminUnauthorized", err) + } + + t.Setenv(adminTokenEnv, token) + + if err := snapshotSave(dir, "s2", false); err != nil { + t.Fatalf("snapshot save with CLOUDEMU_ADMIN_TOKEN: %v", err) + } +} diff --git a/cmd/cloudemu/cost.go b/cmd/cloudemu/cost.go index b13c9b63e..ad36fd69b 100644 --- a/cmd/cloudemu/cost.go +++ b/cmd/cloudemu/cost.go @@ -27,12 +27,12 @@ func runCost(args []string) error { return err } - base, err := adminBaseURL(dir) + api, err := newAdminAPI(dir) if err != nil { return err } - body, err := netGET(base, "cost", url.Values{}) + body, err := netGET(api, "cost", url.Values{}) if err != nil { return err } diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go index d2868f245..8f78607fb 100644 --- a/cmd/cloudemu/lifecycle.go +++ b/cmd/cloudemu/lifecycle.go @@ -389,12 +389,17 @@ func runStart(args []string) error { // start also manages the persistence snapshot path under the run dir; drop a // user --state-file so it can't point elsewhere. rest = stripFlag(rest, "state-file", true) + // start also owns where the admin token goes, so the snapshot, net and cost + // commands can read it from the run dir under --enforce-auth. + rest = stripFlag(rest, "admin-token-file", true) dir, err := runDir(home) if err != nil { return err } + rest = append(rest, "--admin-token-file", adminTokenPath(dir)) + // Opt-in persistence: if the user asked to persist, point serve at a snapshot // file in the run dir (and imply --persist when only --persist-metadata-only // is given). @@ -427,6 +432,9 @@ func runStart(args []string) error { epPath := endpointsPath(dir) _ = os.Remove(epPath) // drop a stale file so waitForEndpoints sees the fresh one + // A token left by an earlier --enforce-auth run must not outlive it; serve + // writes a fresh one only when auth is on. + _ = os.Remove(adminTokenPath(dir)) eps, err := spawnServe(dir, rest, epPath) if err != nil { @@ -618,7 +626,7 @@ func runDelete(args []string) error { return err } - for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir), persistPath(dir)} { + for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir), persistPath(dir), adminTokenPath(dir)} { if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) { return rmErr } diff --git a/cmd/cloudemu/net.go b/cmd/cloudemu/net.go index 7c9f28b81..68dea4b3b 100644 --- a/cmd/cloudemu/net.go +++ b/cmd/cloudemu/net.go @@ -3,11 +3,9 @@ package main import ( - "context" "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strings" @@ -72,22 +70,22 @@ func runNet(args []string) error { return err } - base, err := adminBaseURL(dir) + api, err := newAdminAPI(dir) if err != nil { return err } switch args[0] { case "can-connect": - return netCanConnect(base, pos[0], pos[1], port, proto, jsonOut) + return netCanConnect(api, pos[0], pos[1], port, proto, jsonOut) case "trace": - return netTrace(base, pos[0], pos[1], jsonOut) + return netTrace(api, pos[0], pos[1], jsonOut) default: return errNetUsage } } -func netCanConnect(base, from, to, port, proto string, jsonOut bool) error { +func netCanConnect(api adminAPI, from, to, port, proto string, jsonOut bool) error { q := url.Values{} q.Set("from", from) q.Set("to", to) @@ -100,7 +98,7 @@ func netCanConnect(base, from, to, port, proto string, jsonOut bool) error { q.Set("protocol", proto) } - body, err := netGET(base, "net/can-connect", q) + body, err := netGET(api, "net/can-connect", q) if err != nil { return err } @@ -127,12 +125,12 @@ func netCanConnect(base, from, to, port, proto string, jsonOut bool) error { return nil } -func netTrace(base, from, dest string, jsonOut bool) error { +func netTrace(api adminAPI, from, dest string, jsonOut bool) error { q := url.Values{} q.Set("from", from) q.Set("to", dest) - body, err := netGET(base, "net/trace", q) + body, err := netGET(api, "net/trace", q) if err != nil { return err } @@ -175,34 +173,18 @@ func printHops(hops []topology.RouteHop) { // netGET calls a /_cloudemu/ control path and returns the body, // mapping the control plane's error statuses to clear CLI errors. -func netGET(base, endpoint string, q url.Values) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), snapHTTPTimeout) - defer cancel() - - u := base + "/_cloudemu/" + endpoint +func netGET(api adminAPI, endpoint string, q url.Values) ([]byte, error) { if len(q) > 0 { - u += "?" + q.Encode() + endpoint += "?" + q.Encode() } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody) + status, b, err := api.do(http.MethodGet, endpoint, nil, "") if err != nil { return nil, err } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %w", errSnapDaemonDown, err) - } - defer resp.Body.Close() - - b, _ := io.ReadAll(resp.Body) - - if resp.StatusCode == http.StatusNotImplemented { - return nil, errSnapAdminOff - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%w: %s", errNetServer, serverErrMsg(b, resp.Status)) + if status != http.StatusOK { + return nil, fmt.Errorf("%w: %s", errNetServer, serverErrMsg(b, fmt.Sprintf("%d %s", status, http.StatusText(status)))) } return b, nil diff --git a/cmd/cloudemu/snapshot.go b/cmd/cloudemu/snapshot.go index 8af6e3b01..5d7dd9a5a 100644 --- a/cmd/cloudemu/snapshot.go +++ b/cmd/cloudemu/snapshot.go @@ -4,7 +4,6 @@ package main import ( "bytes" - "context" "encoding/json" "errors" "fmt" @@ -113,12 +112,12 @@ func snapshotSave(dir, name string, force bool) error { } } - base, err := adminBaseURL(dir) + api, err := newAdminAPI(dir) if err != nil { return err } - body, err := snapshotRequest(http.MethodGet, base, nil) + body, err := snapshotRequest(api, http.MethodGet, nil) if err != nil { return err } @@ -165,12 +164,12 @@ func snapshotLoad(dir, name string) error { return err } - base, err := adminBaseURL(dir) + api, err := newAdminAPI(dir) if err != nil { return err } - if _, err := snapshotRequest(http.MethodPost, base, body); err != nil { + if _, err := snapshotRequest(api, http.MethodPost, body); err != nil { return err } @@ -283,61 +282,26 @@ func readSnapshotMeta(path string) *persist.Meta { return s.Meta } -// adminBaseURL reads the daemon's endpoints file and returns a plain-HTTP base -// URL for the control plane (avoids the self-signed HTTPS endpoints). -func adminBaseURL(dir string) (string, error) { - eps, err := readEndpoints(endpointsPath(dir)) - if errors.Is(err, os.ErrNotExist) { - return "", errSnapDaemonDown - } - - if err != nil { - return "", err - } - - for _, k := range []string{"aws", "gcp"} { - if ep := eps[k]; strings.HasPrefix(ep, "http://") { - return strings.TrimRight(ep, "/"), nil - } - } - - return "", errSnapNoEndpoint -} - // snapshotRequest calls the daemon's /_cloudemu/snapshot endpoint. For GET body // is nil and the response bytes are returned; for POST body is the snapshot. -func snapshotRequest(method, base string, body []byte) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), snapHTTPTimeout) - defer cancel() +func snapshotRequest(api adminAPI, method string, body []byte) ([]byte, error) { + var ( + reader io.Reader + ct string + ) - var reader io.Reader if body != nil { reader = bytes.NewReader(body) + ct = "application/json" } - req, err := http.NewRequestWithContext(ctx, method, base+"/_cloudemu/snapshot", reader) + status, rb, err := api.do(method, "snapshot", reader, ct) if err != nil { return nil, err } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %w", errSnapDaemonDown, err) - } - defer resp.Body.Close() - - rb, _ := io.ReadAll(resp.Body) - - if resp.StatusCode == http.StatusNotImplemented { - return nil, errSnapAdminOff - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%w: %s: %s", errSnapServer, resp.Status, strings.TrimSpace(string(rb))) + if status != http.StatusOK { + return nil, fmt.Errorf("%w: %d %s: %s", errSnapServer, status, http.StatusText(status), strings.TrimSpace(string(rb))) } return rb, nil diff --git a/contrib/server/README.md b/contrib/server/README.md index e1c0f3b07..26e932801 100644 --- a/contrib/server/README.md +++ b/contrib/server/README.md @@ -153,7 +153,9 @@ Flag names and defaults mirror `cloudemu serve`. | `--tls-host` | — | extra SAN host/IP for the self-signed cert (repeatable) | | `--log-requests` | `false` | log every HTTP request (method, path, status, duration) | | `--quiet` | `false` | suppress the startup banner | -| `--enforce-auth` | `false` | require authentication on each request (AWS SigV4 → 403 on an unregistered key; Azure Bearer-claims), then IAM authorization for AWS (see below) | +| `--enforce-auth` | `false` | require authentication on each request (AWS SigV4 → 403 on an unregistered key; Azure Bearer-claims), then IAM authorization for AWS (see below). The `/_cloudemu` admin endpoints then need `--admin-token` | +| `--admin-token` | *(random)* | with `--enforce-auth`, the bearer token for `/_cloudemu/*` (all but `health`). Unset: a random token is generated and printed once (env `CLOUDEMU_ADMIN_TOKEN`) | +| `--admin-token-file` | *(none)* | with `--enforce-auth`, write the admin token to this file (mode 0600) instead of printing it (env `CLOUDEMU_ADMIN_TOKEN_FILE`) | | `--endpoints-file` | *(none)* | write the resolved endpoints as JSON to this path | | `--shutdown-timeout` | `10s` | grace period for in-flight requests | @@ -186,9 +188,41 @@ actually run the request, so neither the SigV4 signing scope nor a forged - The account root and IAM users with no policies are unrestricted, so a freshly created user can bootstrap others. Role sessions are always evaluated on the role's policies. -- `/_cloudemu/*` admin endpoints, operations AWS serves without credentials - (Cognito sign-in, API Gateway invoke), and the Kubernetes data plane are not - IAM-authorized. +- Operations AWS serves without credentials (Cognito sign-in, API Gateway + invoke) and the Kubernetes data plane are not IAM-authorized. The + `/_cloudemu/*` admin endpoints use the admin token instead (next section). + +### Admin token and the first IAM user + +Under `--enforce-auth` every `/_cloudemu/*` endpoint except `health` needs +`Authorization: Bearer `, and answers `401` without it. That +covers `snapshot` (which returns IAM secret keys), `reset`, `seed`, `cost`, +`net/*` and the named snapshots. `health` stays open for liveness probes. + +Pass the token with `--admin-token` or `CLOUDEMU_ADMIN_TOKEN`. If you set +neither, serve generates a random one and prints it once on stderr, or writes +it to `--admin-token-file` (mode 0600) when that is set. Without +`--enforce-auth` the token is ignored and the control plane stays open, as +before. + +There is no unsigned way to create an IAM access key under `--enforce-auth`, +so use the admin token to seed the first user with a key you choose: + +```bash +export CLOUDEMU_ADMIN_TOKEN=$(openssl rand -hex 32) +go run . --enforce-auth & + +curl -X POST http://127.0.0.1:4566/_cloudemu/seed \ + -H "Authorization: Bearer $CLOUDEMU_ADMIN_TOKEN" \ + -d '{"iamUsers":[{"name":"admin","accessKeys":[ + {"accessKeyId":"AKIAADMIN00000000001","secretAccessKey":"change-me"}]}]}' + +AWS_ACCESS_KEY_ID=AKIAADMIN00000000001 AWS_SECRET_ACCESS_KEY=change-me \ + aws --endpoint-url http://127.0.0.1:4566 iam create-user --user-name app +``` + +A user with no policies is unrestricted, so this one can create the rest over +the normal IAM API. The same `iamUsers` fixture works in `--init-dir`. ## Admin, persistence & seeding @@ -216,7 +250,8 @@ go run . --init-dir ./fixtures `--admin` is on by default; on a non-loopback `--host` it prints a warning, since `POST /_cloudemu/reset` wipes all state and `GET /_cloudemu/snapshot` dumps it -(secrets included) to any caller. Pass `--admin=false` to disable it. +(secrets included) to any caller. Pass `--enforce-auth` to put it behind the +admin token, or `--admin=false` to disable it. The Azure endpoint serves HTTPS with an in-memory self-signed certificate (covering `localhost` and the loopback IPs); clients must trust it or skip diff --git a/contrib/server/enforce_authz_test.go b/contrib/server/enforce_authz_test.go index d43026edb..93717ca8f 100644 --- a/contrib/server/enforce_authz_test.go +++ b/contrib/server/enforce_authz_test.go @@ -115,7 +115,11 @@ func wantOK(t *testing.T, what string, err error) { } } -func adminCall(t *testing.T, method, endpoint string, body []byte) []byte { +const testAdminToken = "test-admin-token" + +// adminDo calls a /_cloudemu endpoint, sending token as a bearer token when it +// is non-empty, and returns the status and body. +func adminDo(t *testing.T, method, endpoint, token string, body []byte) (int, []byte) { t.Helper() req, err := http.NewRequestWithContext(context.Background(), method, endpoint, bytes.NewReader(body)) @@ -123,6 +127,10 @@ func adminCall(t *testing.T, method, endpoint string, body []byte) []byte { t.Fatalf("new request: %v", err) } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, endpoint, err) @@ -130,48 +138,111 @@ func adminCall(t *testing.T, method, endpoint string, body []byte) []byte { defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - t.Fatalf("unsigned %s %s under --enforce-auth: %d %s", method, endpoint, resp.StatusCode, raw) + + return resp.StatusCode, raw +} + +// adminCall calls a /_cloudemu endpoint with the admin token and requires 200. +func adminCall(t *testing.T, method, endpoint string, body []byte) []byte { + t.Helper() + + status, raw := adminDo(t, method, endpoint, testAdminToken, body) + if status != http.StatusOK { + t.Fatalf("%s %s with the admin token: %d %s", method, endpoint, status, raw) } return raw } -// bootstrapUser starts a server with auth off, creates a policy-less "boot" -// user with a key, and returns the whole-emulator snapshot holding it. -func bootstrapUser(t *testing.T) ([]byte, aws.Credentials) { +// enforceAuthServer starts an --enforce-auth server with a known admin token. +func enforceAuthServer(t *testing.T) (string, func()) { t.Helper() cfg := testConfig(t, allEnginesOff()) cfg.Admin = true + cfg.EnforceAuth = true + cfg.AdminToken = testAdminToken + + return startAWS(t, cfg, mustOptions(t, &cfg)) +} + +// seedBootUser creates the first IAM user through the admin seed endpoint, the +// documented bootstrap under --enforce-auth, and returns its key. +func seedBootUser(t *testing.T, endpoint string) aws.Credentials { + t.Helper() - url, stop := startAWS(t, cfg, mustOptions(t, &cfg)) + creds := aws.Credentials{AccessKeyID: "AKIABOOTSTRAP0000001", SecretAccessKey: "boot-secret-key"} + fixture := `{"iamUsers":[{"name":"boot","accessKeys":[{"accessKeyId":"` + creds.AccessKeyID + + `","secretAccessKey":"` + creds.SecretAccessKey + `"}]}]}` + adminCall(t, http.MethodPost, endpoint+"/_cloudemu/seed", []byte(fixture)) + + return creds +} + +// TestEnforceAuthAdminEndpointsNeedToken covers AUTHN-X3 end to end: under +// --enforce-auth the control plane refuses callers without the admin token, +// health stays open, and the token can bootstrap the first IAM user. +func TestEnforceAuthAdminEndpointsNeedToken(t *testing.T) { + endpoint, stop := enforceAuthServer(t) defer stop() - boot := clientsFor(t, url, aws.Credentials{AccessKeyID: "test", SecretAccessKey: "test"}).newUser(t, "boot", "") + gated := []struct { + method, path string + body []byte + }{ + {http.MethodGet, "/_cloudemu/snapshot", nil}, + {http.MethodPost, "/_cloudemu/snapshot", []byte(`{"schemaVersion":1}`)}, + {http.MethodPost, "/_cloudemu/reset", nil}, + {http.MethodPost, "/_cloudemu/seed", []byte(`{"buckets":[{"name":"x"}]}`)}, + {http.MethodGet, "/_cloudemu/cost", nil}, + } - return adminCall(t, http.MethodGet, url+"/_cloudemu/snapshot", nil), boot + for _, g := range gated { + for _, token := range []string{"", "wrong-token"} { + if status, _ := adminDo(t, g.method, endpoint+g.path, token, g.body); status != http.StatusUnauthorized { + t.Errorf("%s %s token=%q = %d, want 401", g.method, g.path, token, status) + } + } + } + + if status, _ := adminDo(t, http.MethodGet, endpoint+"/_cloudemu/health", "", nil); status != http.StatusOK { + t.Fatalf("health without a token = %d, want 200", status) + } + + boot := clientsFor(t, endpoint, seedBootUser(t, endpoint)) + + ctx := context.Background() + _, err := boot.iam.CreateUser(ctx, &iam.CreateUserInput{UserName: aws.String("second")}) + wantOK(t, "CreateUser signed by the seeded key", err) + + snap := adminCall(t, http.MethodGet, endpoint+"/_cloudemu/snapshot", nil) + if !strings.Contains(string(snap), "boot-secret-key") { + t.Fatal("authenticated snapshot is missing the key secret needed for restore") + } + + adminCall(t, http.MethodPost, endpoint+"/_cloudemu/reset", nil) + + _, err = boot.iam.ListUsers(ctx, &iam.ListUsersInput{}) + wantCode(t, "ListUsers after reset", err, "InvalidClientTokenId") + + adminCall(t, http.MethodPost, endpoint+"/_cloudemu/snapshot", snap) + + _, err = boot.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String("second")}) + wantOK(t, "GetUser after restore", err) } // TestEnforceAuthAuthorizesQueryAndREST drives real SDK clients against // cloudemu serve with --enforce-auth: IAM, EC2, Auto Scaling and SQS calls are -// authorized against the caller's policies, while the admin endpoints stay -// unsigned. +// authorized against the caller's policies, while the admin endpoints take the +// admin token instead of a signature. func TestEnforceAuthAuthorizesQueryAndREST(t *testing.T) { - snapshot, bootCreds := bootstrapUser(t) - - cfg := testConfig(t, allEnginesOff()) - cfg.Admin = true - cfg.EnforceAuth = true - - endpoint, stop := startAWS(t, cfg, mustOptions(t, &cfg)) + endpoint, stop := enforceAuthServer(t) defer stop() adminCall(t, http.MethodGet, endpoint+"/_cloudemu/health", nil) - adminCall(t, http.MethodPost, endpoint+"/_cloudemu/snapshot", snapshot) ctx := context.Background() - boot := clientsFor(t, endpoint, bootCreds) + boot := clientsFor(t, endpoint, seedBootUser(t, endpoint)) t.Run("iam", func(t *testing.T) { limited := clientsFor(t, endpoint, boot.newUser(t, "limited", allowDoc("dynamodb:*"))) @@ -247,7 +318,7 @@ func TestEnforceAuthAuthorizesQueryAndREST(t *testing.T) { } }) - t.Run("admin reset stays unsigned", func(t *testing.T) { + t.Run("admin reset with the token", func(t *testing.T) { adminCall(t, http.MethodPost, endpoint+"/_cloudemu/reset", nil) }) } diff --git a/contrib/testcontainers/README.md b/contrib/testcontainers/README.md index bb3093838..7ed0f083e 100644 --- a/contrib/testcontainers/README.md +++ b/contrib/testcontainers/README.md @@ -35,6 +35,14 @@ Methods: `AWSEndpoint`, `AzureEndpoint`, `GCPEndpoint`, `KubernetesEndpoint`, `Reset`, `Seed`, plus the embedded Testcontainers container (`Terminate`, …). Pin a version or use a local image with `cloudemu.WithImage("...")`. +`cloudemu.WithEnforceAuth(token)` starts the server with `--enforce-auth` and +the given admin token (pass `""` to have one generated); `Reset` and `Seed` +send it for you. The flag is appended to any command set by an earlier option. +Access key ids must look like real ones (`AKIA` plus 16 uppercase letters or +digits). Seed your first IAM +user with a known key (`{"iamUsers":[{"name":"admin","accessKeys":[{"accessKeyId":"AKIA...","secretAccessKey":"..."}]}]}`) +before making signed calls. + The module's own acceptance test builds the image from the repo `Dockerfile`, so it needs Docker; run it with `go test ./...` from this directory (skipped under `-short`). diff --git a/contrib/testcontainers/cloudemu.go b/contrib/testcontainers/cloudemu.go index 88ba0d73b..cec5f6cba 100644 --- a/contrib/testcontainers/cloudemu.go +++ b/contrib/testcontainers/cloudemu.go @@ -13,10 +13,13 @@ package cloudemu import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" + "slices" "time" "github.com/testcontainers/testcontainers-go" @@ -33,10 +36,19 @@ const ( k8sPort = "4570/tcp" ) +// adminTokenEnv is the server's environment fallback for --admin-token. +const adminTokenEnv = "CLOUDEMU_ADMIN_TOKEN" + +const startupTimeout = 90 * time.Second + // Container is a running cloudemu server. It embeds the testcontainers // container, so Terminate and the rest of that API are available directly. type Container struct { *testcontainers.DockerContainer + + // adminToken is sent with Reset and Seed when the server runs with + // --enforce-auth ("" otherwise). + adminToken string } // WithImage overrides the image (default DefaultImage), e.g. to pin a version @@ -45,12 +57,55 @@ func WithImage(image string) testcontainers.ContainerCustomizer { return testcontainers.WithImage(image) } +// defaultCmd mirrors the image's CMD, used as the base when WithEnforceAuth +// adds its flag and no command was set yet. +func defaultCmd() []string { return []string{"serve", "--host", "0.0.0.0"} } + +// WithEnforceAuth starts the server with --enforce-auth. The /_cloudemu +// control plane then needs adminToken, which Reset and Seed send for you; an +// empty adminToken gets a random one generated here and passed to the +// container. Seed your first IAM user (with a known access key) through Seed +// before making signed calls. --enforce-auth is appended to any command set by +// an earlier option (the image's default command otherwise). Readiness waits +// on /_cloudemu/health, which stays open. +func WithEnforceAuth(adminToken string) testcontainers.CustomizeRequestOption { + // Generate once, outside the closure: Run applies the options twice (once + // to learn the token), and both passes must agree on it. + if adminToken == "" { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return func(*testcontainers.GenericContainerRequest) error { + return fmt.Errorf("generate admin token: %w", err) + } + } + adminToken = hex.EncodeToString(buf) + } + + return func(req *testcontainers.GenericContainerRequest) error { + if len(req.Cmd) == 0 { + req.Cmd = defaultCmd() + } + if !slices.Contains(req.Cmd, "--enforce-auth") { + req.Cmd = append(req.Cmd, "--enforce-auth") + } + + if req.Env == nil { + req.Env = map[string]string{} + } + + req.Env[adminTokenEnv] = adminToken + req.WaitingFor = wait.ForHTTP("/_cloudemu/health").WithPort(awsPort).WithStartupTimeout(startupTimeout) + + return nil + } +} + // Run starts a cloudemu container and waits until the AWS endpoint answers. func Run(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*Container, error) { base := []testcontainers.ContainerCustomizer{ testcontainers.WithExposedPorts(awsPort, azurePort, gcpPort, k8sPort), testcontainers.WithWaitStrategy( - wait.ForHTTP("/").WithPort(awsPort).WithStartupTimeout(90 * time.Second), + wait.ForHTTP("/").WithPort(awsPort).WithStartupTimeout(startupTimeout), ), } @@ -58,7 +113,18 @@ func Run(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*Cont if err != nil { return nil, fmt.Errorf("run cloudemu: %w", err) } - return &Container{DockerContainer: ctr}, nil + return &Container{DockerContainer: ctr, adminToken: adminTokenFrom(opts)}, nil +} + +// adminTokenFrom finds the admin token the options hand the server, whether it +// came from WithEnforceAuth or a plain WithEnv, by applying them to a scratch +// request. +func adminTokenFrom(opts []testcontainers.ContainerCustomizer) string { + var req testcontainers.GenericContainerRequest + for _, o := range opts { + _ = o.Customize(&req) + } + return req.Env[adminTokenEnv] } // AWSEndpoint is the host URL for the AWS surface (point aws-sdk-go-v2 here with @@ -107,6 +173,9 @@ func (c *Container) control(ctx context.Context, op string, body []byte) error { return err } req.Header.Set("Content-Type", "application/json") + if c.adminToken != "" { + req.Header.Set("Authorization", "Bearer "+c.adminToken) + } resp, err := http.DefaultClient.Do(req) if err != nil { return err diff --git a/contrib/testcontainers/cloudemu_test.go b/contrib/testcontainers/cloudemu_test.go index 7b42c042f..7c5e3f827 100644 --- a/contrib/testcontainers/cloudemu_test.go +++ b/contrib/testcontainers/cloudemu_test.go @@ -18,14 +18,26 @@ import ( // on a published image. Override with CLOUDEMU_TEST_IMAGE to use another tag. const testImage = "cloudemu:tctest" +// dockerOK is set by TestMain when the container tests can run. Tests that need +// no Docker run either way. +var dockerOK bool //nolint:gochecknoglobals // set once in TestMain, read by tests + +func requireDocker(t *testing.T) { + t.Helper() + if !dockerOK { + t.Skip("needs Docker (skipped in -short, with CLOUDEMU_SKIP_DOCKER, or when docker is missing)") + } +} + func TestMain(m *testing.M) { flag.Parse() // so testing.Short() is readable here if testing.Short() || os.Getenv("CLOUDEMU_SKIP_DOCKER") != "" { - os.Exit(0) + os.Exit(m.Run()) } if _, err := exec.LookPath("docker"); err != nil { - os.Exit(0) // no docker → nothing to test here + os.Exit(m.Run()) // no docker → only the Docker-free tests run } + dockerOK = true if os.Getenv("CLOUDEMU_TEST_IMAGE") == "" { build := exec.Command("docker", "build", "-t", testImage, "../..") build.Stdout, build.Stderr = os.Stderr, os.Stderr @@ -47,9 +59,7 @@ func image() string { // TestRunResetSeed is the #248 acceptance: start the container, drive it over // its mapped endpoint, and exercise the reset/seed control plane. func TestRunResetSeed(t *testing.T) { - if testing.Short() { - t.Skip("starts a container; skipped in -short") - } + requireDocker(t) ctx := context.Background() ctr, err := cloudemu.Run(ctx, cloudemu.WithImage(image())) @@ -96,6 +106,48 @@ func TestRunResetSeed(t *testing.T) { } } +// TestEnforceAuthAdminToken runs the container with --enforce-auth: Reset and +// Seed carry the admin token, an unauthenticated reset is refused, and health +// (the readiness probe) stays open. +func TestEnforceAuthAdminToken(t *testing.T) { + requireDocker(t) + ctx := context.Background() + + ctr, err := cloudemu.Run(ctx, cloudemu.WithImage(image()), cloudemu.WithEnforceAuth("tc-admin-token")) + if err != nil { + t.Fatalf("Run: %v", err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = ctr.Terminate(ctx) + }) + + ep, err := ctr.AWSEndpoint(ctx) + if err != nil { + t.Fatalf("AWSEndpoint: %v", err) + } + + if code := status(t, ep+"/_cloudemu/health"); code != http.StatusOK { + t.Fatalf("health = %d, want 200", code) + } + if code := status(t, ep+"/_cloudemu/snapshot"); code != http.StatusUnauthorized { + t.Fatalf("unauthenticated snapshot = %d, want 401", code) + } + + if err := ctr.Reset(ctx); err != nil { + t.Fatalf("Reset with the token: %v", err) + } + + fixture := map[string]any{"iamUsers": []map[string]any{{ + "name": "boot", + "accessKeys": []map[string]any{{"accessKeyId": "AKIATESTCONTAINERS01", "secretAccessKey": "tc-secret"}}, + }}} + if err := ctr.Seed(ctx, fixture); err != nil { + t.Fatalf("Seed with the token: %v", err) + } +} + func put(t *testing.T, url, body string) { t.Helper() req, _ := http.NewRequest(http.MethodPut, url, strings.NewReader(body)) diff --git a/contrib/testcontainers/enforce_auth_test.go b/contrib/testcontainers/enforce_auth_test.go new file mode 100644 index 000000000..54d0ef559 --- /dev/null +++ b/contrib/testcontainers/enforce_auth_test.go @@ -0,0 +1,69 @@ +package cloudemu + +import ( + "regexp" + "slices" + "testing" + + "github.com/testcontainers/testcontainers-go" +) + +var hexToken = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// TestWithEnforceAuthGeneratesToken checks that an empty token is replaced by +// one generated on the client, which both the container env and Reset/Seed see. +func TestWithEnforceAuthGeneratesToken(t *testing.T) { + opt := WithEnforceAuth("") + + var req testcontainers.GenericContainerRequest + if err := opt.Customize(&req); err != nil { + t.Fatalf("Customize: %v", err) + } + + tok := req.Env[adminTokenEnv] + if !hexToken.MatchString(tok) { + t.Fatalf("container env token = %q, want 64 hex characters", tok) + } + + // Run applies the options once more to learn the token; it must match. + if got := adminTokenFrom([]testcontainers.ContainerCustomizer{opt}); got != tok { + t.Fatalf("client token %q != container token %q", got, tok) + } + + if other := adminTokenFrom([]testcontainers.ContainerCustomizer{WithEnforceAuth("")}); other == tok { + t.Fatal("two WithEnforceAuth(\"\") calls generated the same token") + } + + if got := adminTokenFrom([]testcontainers.ContainerCustomizer{WithEnforceAuth("fixed")}); got != "fixed" { + t.Fatalf("explicit token = %q, want fixed", got) + } +} + +// TestWithEnforceAuthAppendsCmd checks the flag is added to an existing command +// rather than replacing it, and that the image default is used when none is set. +func TestWithEnforceAuthAppendsCmd(t *testing.T) { + req := testcontainers.GenericContainerRequest{} + req.Cmd = []string{"serve", "--host", "0.0.0.0", "--log-requests"} + + opt := WithEnforceAuth("t") + if err := opt.Customize(&req); err != nil { + t.Fatal(err) + } + if err := opt.Customize(&req); err != nil { + t.Fatal(err) + } + + want := []string{"serve", "--host", "0.0.0.0", "--log-requests", "--enforce-auth"} + if !slices.Equal(req.Cmd, want) { + t.Fatalf("Cmd = %v, want %v", req.Cmd, want) + } + + var empty testcontainers.GenericContainerRequest + if err := WithEnforceAuth("t").Customize(&empty); err != nil { + t.Fatal(err) + } + + if want := append(defaultCmd(), "--enforce-auth"); !slices.Equal(empty.Cmd, want) { + t.Fatalf("Cmd with no prior command = %v, want %v", empty.Cmd, want) + } +} diff --git a/docs/coverage/aws/iam.md b/docs/coverage/aws/iam.md index cb14cb5c7..08be62176 100644 --- a/docs/coverage/aws/iam.md +++ b/docs/coverage/aws/iam.md @@ -52,6 +52,14 @@ AWS's `iam` service · portable interface `driver.IAM` · [AWS index](./README.m Discovered by type assertion; only some providers implement these. +### AccessKeyImporter + +AccessKeyImporter is an optional capability: an IAM implementation that can + +| Operation | Description | +| --- | --- | +| `ImportAccessKey` | | + ### AccessKeyResolver AccessKeyResolver is an optional capability: an IAM implementation that can diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 5503dd0ad..324d05dfb 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -8916,6 +8916,15 @@ } ], "optionalCapabilities": [ + { + "name": "AccessKeyImporter", + "doc": "AccessKeyImporter is an optional capability: an IAM implementation that can", + "operations": [ + { + "name": "ImportAccessKey" + } + ] + }, { "name": "AccessKeyResolver", "doc": "AccessKeyResolver is an optional capability: an IAM implementation that can", diff --git a/docs/persistence.md b/docs/persistence.md index c55281205..024f2e6b3 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -165,7 +165,10 @@ curl -X POST http://127.0.0.1:4566/_cloudemu/snapshot --data @state.json ``` Both return `501` when the server is started with `--admin=false`. A POST larger -than 512 MiB is rejected. See the control-plane section of +than 512 MiB is rejected. The export includes secret values such as IAM secret +access keys, so under `--enforce-auth` both directions (and the named snapshots +below) need `-H "Authorization: Bearer $CLOUDEMU_ADMIN_TOKEN"` and return `401` +without it. See the control-plane section of [standalone-server.md](standalone-server.md#resetting-state-between-tests-_cloudemu) for the related `reset`/`seed` endpoints. diff --git a/docs/standalone-server.md b/docs/standalone-server.md index 13f1833e6..96445798c 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -434,7 +434,9 @@ generated cert's SANs with `--tls-host ` and trust that cert in your clien | `--k8s-progression` | `false` | client-created Pods start Pending and move to Running on a ticker (env `CLOUDEMU_K8S_PROGRESSION`) | | `--k8s-progression-interval` | (built-in) | tick interval for `--k8s-progression` (env `CLOUDEMU_K8S_PROGRESSION_INTERVAL`) | | `--tick-interval` | `1s` | how often time-driven work runs, such as CloudWatch alarms, Azure Monitor metric alerts and GCP alert policies firing their actions; `0` turns it off (env `CLOUDEMU_TICK_INTERVAL`) | -| `--enforce-auth` | `false` | require authentication: SigV4 verification for AWS (long-term IAM keys and STS temporary credentials), Bearer-token claim checks for Azure (see `cloudemu serve -h` for the exact scope) | +| `--enforce-auth` | `false` | require authentication: SigV4 verification for AWS (long-term IAM keys and STS temporary credentials), Bearer-token claim checks for Azure (see `cloudemu serve -h` for the exact scope). The `/_cloudemu` endpoints then need the admin token | +| `--admin-token` | (random) | with `--enforce-auth`, the bearer token for `/_cloudemu/*` except `health`; unset means a random token printed once on stderr (env `CLOUDEMU_ADMIN_TOKEN`) | +| `--admin-token-file` | (none) | with `--enforce-auth`, write the admin token here (mode 0600) instead of printing it; `start` sets it to `~/.cloudemu/admin-token` (env `CLOUDEMU_ADMIN_TOKEN_FILE`) | | `--vcr` | (off) | record or replay the wire protocol: `record` \| `replay` (requires `--vcr-cassette`) | | `--vcr-cassette` | (none) | path to the cassette file to record into / replay from | | `--vcr-strict` | `true` | in `replay`, return `501` for a request with no recorded match (rather than passing through) | @@ -494,6 +496,29 @@ depending on which port you POST it to: } ``` +### Under `--enforce-auth` + +With `--enforce-auth` every `/_cloudemu` endpoint except `health` needs the +admin token as `Authorization: Bearer ` and returns `401` without it, so +nobody can dump state (IAM secret keys included) or replace it anonymously. +Set the token with `--admin-token` or `CLOUDEMU_ADMIN_TOKEN`; otherwise serve +generates one and prints it once on stderr. `cloudemu start` writes it to +`~/.cloudemu/admin-token`, where `cloudemu snapshot`, `net` and `cost` pick it +up (`CLOUDEMU_ADMIN_TOKEN` overrides the file). + +```sh +curl -X POST http://127.0.0.1:4566/_cloudemu/reset -H "Authorization: Bearer $CLOUDEMU_ADMIN_TOKEN" +``` + +There is no unsigned way to create an access key under `--enforce-auth`, so +seed the first IAM user with a key you pick. A user with no policies is +unrestricted and can create the others over the IAM API: + +```json +{ "iamUsers": [{ "name": "admin", "accessKeys": [ + { "accessKeyId": "AKIAADMIN00000000001", "secretAccessKey": "change-me" } ] }] } +``` + In-process tests can load the same fixtures with the [`seed`](https://pkg.go.dev/github.com/stackshy/cloudemu/v2/seed) package and `go:embed`: diff --git a/providers/aws/iam/iam.go b/providers/aws/iam/iam.go index fc7599f1f..509c612a2 100644 --- a/providers/aws/iam/iam.go +++ b/providers/aws/iam/iam.go @@ -28,6 +28,10 @@ var _ driver.IAM = (*Mock)(nil) // by the AWS SigV4 authentication gate. var _ driver.AccessKeyResolver = (*Mock)(nil) +// Compile-time check that Mock implements the optional access-key importer the +// seed fixtures use to bootstrap the first IAM user. +var _ driver.AccessKeyImporter = (*Mock)(nil) + // Compile-time check that Mock implements the optional policy inspector used by // the AWS IAM authorization gate. var _ driver.PolicyInspector = (*Mock)(nil) @@ -1272,6 +1276,38 @@ func (m *Mock) CreateAccessKey( return &info, nil } +// ImportAccessKey registers an Active access key with a caller-chosen id and +// secret for an existing user. It applies the same per-user quota as +// CreateAccessKey and refuses an id that is already registered. +func (m *Mock) ImportAccessKey(_ context.Context, userName, accessKeyID, secretAccessKey string) error { + if accessKeyID == "" || secretAccessKey == "" { + return errors.Newf(errors.InvalidArgument, "access key id and secret are required") + } + + if !m.users.Has(userName) { + return errors.Newf(errors.NotFound, "user %q not found", userName) + } + + if m.countAccessKeys(userName) >= maxAccessKeysPerUser { + return errors.Newf(errors.ResourceExhausted, + "Cannot exceed quota for AccessKeysPerUser: %d", maxAccessKeysPerUser) + } + + // SetIfAbsent checks and inserts under one lock, so two concurrent imports of + // the same id can't overwrite each other's secret. + if !m.accessKeys.SetIfAbsent(accessKeyID, &accessKeyData{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + UserName: userName, + Status: "Active", + CreatedAt: m.opts.Clock.Now().UTC().Format(timeFormat), + }) { + return errors.Newf(errors.AlreadyExists, "access key %q already exists", accessKeyID) + } + + return nil +} + // maxKeyIDAttempts bounds the retries when a freshly drawn access key id is // already taken. With 80 random bits a single collision is already unlikely. const maxKeyIDAttempts = 5 diff --git a/providers/aws/iam/import_access_key_test.go b/providers/aws/iam/import_access_key_test.go new file mode 100644 index 000000000..7f38f5784 --- /dev/null +++ b/providers/aws/iam/import_access_key_test.go @@ -0,0 +1,99 @@ +package iam + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +func TestImportAccessKey(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if err := m.ImportAccessKey(ctx, "ghost", "AKIAGHOST", "s"); !cerrors.IsNotFound(err) { + t.Fatalf("import for a missing user = %v, want NotFound", err) + } + + if _, err := m.CreateUser(ctx, driver.UserConfig{Name: "boot"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if err := m.ImportAccessKey(ctx, "boot", "", "s"); err == nil { + t.Fatal("import with an empty id: want an error") + } + + if err := m.ImportAccessKey(ctx, "boot", "AKIABOOT", "boot-secret"); err != nil { + t.Fatalf("ImportAccessKey: %v", err) + } + + got, ok := m.AccessKeyByID(ctx, "AKIABOOT") + if !ok || got.SecretAccessKey != "boot-secret" || got.UserName != "boot" { + t.Fatalf("AccessKeyByID = %+v ok=%v", got, ok) + } + + if err := m.ImportAccessKey(ctx, "boot", "AKIABOOT", "other"); !cerrors.IsAlreadyExists(err) { + t.Fatalf("duplicate import = %v, want AlreadyExists", err) + } + + if err := m.ImportAccessKey(ctx, "boot", "AKIABOOT2", "s2"); err != nil { + t.Fatalf("second key: %v", err) + } + + if err := m.ImportAccessKey(ctx, "boot", "AKIABOOT3", "s3"); err == nil { + t.Fatal("third key: want the per-user quota error") + } + + keys, err := m.ListAccessKeys(ctx, "boot") + if err != nil || len(keys) != 2 || keys[0].Status != "Active" { + t.Fatalf("ListAccessKeys = %+v, %v", keys, err) + } +} + +// TestImportAccessKeyConcurrentSameID checks that concurrent imports of one id +// leave exactly one winner and never overwrite the stored secret. +func TestImportAccessKeyConcurrentSameID(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + const n = 16 + + for i := range n { + if _, err := m.CreateUser(ctx, driver.UserConfig{Name: fmt.Sprintf("u%d", i)}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + } + + var ( + wg sync.WaitGroup + wins atomic.Int32 + ) + + for i := range n { + wg.Add(1) + + go func() { + defer wg.Done() + + if m.ImportAccessKey(ctx, fmt.Sprintf("u%d", i), "AKIARACE", fmt.Sprintf("s%d", i)) == nil { + wins.Add(1) + } + }() + } + + wg.Wait() + + if got := wins.Load(); got != 1 { + t.Fatalf("%d concurrent imports succeeded, want 1", got) + } + + ak, _ := m.AccessKeyByID(ctx, "AKIARACE") + if "s"+strings.TrimPrefix(ak.UserName, "u") != ak.SecretAccessKey { + t.Fatalf("stored key pairs owner %s with secret %s from another import", ak.UserName, ak.SecretAccessKey) + } +} diff --git a/seed/iam_users_test.go b/seed/iam_users_test.go new file mode 100644 index 000000000..366ae100e --- /dev/null +++ b/seed/iam_users_test.go @@ -0,0 +1,88 @@ +package seed_test + +import ( + "context" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/seed" +) + +func TestSeedIAMUserWithAccessKey(t *testing.T) { + ctx := context.Background() + cloud := cloudemu.NewAWS() + + f, err := seed.Load([]byte(`{"iamUsers":[{"name":"admin","accessKeys":[{"accessKeyId":"AKIASEED000000000001","secretAccessKey":"seed-secret"}]}]}`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if n := f.ResourceCount(); n != 2 { + t.Fatalf("ResourceCount = %d, want 2 (user + key)", n) + } + + target := seed.Target{IAM: cloud.IAM} + if err := seed.Apply(ctx, f, target); err != nil { + t.Fatalf("Apply: %v", err) + } + + ak, ok := cloud.IAM.AccessKeyByID(ctx, "AKIASEED000000000001") + if !ok || ak.SecretAccessKey != "seed-secret" || ak.UserName != "admin" { + t.Fatalf("seeded key = %+v ok=%v", ak, ok) + } + + // Re-applying fails on the existing user unless IgnoreExisting is set. + if err := seed.Apply(ctx, f, target); err == nil { + t.Fatal("re-apply without IgnoreExisting: want AlreadyExists") + } + + if err := seed.Apply(ctx, f, target, seed.IgnoreExisting()); err != nil { + t.Fatalf("re-apply with IgnoreExisting: %v", err) + } +} + +func TestSeedIAMUserValidation(t *testing.T) { + cloud := cloudemu.NewAWS() + + cases := map[string]struct { + fixture string + target seed.Target + }{ + "no IAM driver": {`{"iamUsers":[{"name":"a"}]}`, seed.Target{}}, + "missing name": {`{"iamUsers":[{"accessKeys":[]}]}`, seed.Target{IAM: cloud.IAM}}, + "missing secret": {`{"iamUsers":[{"name":"a","accessKeys":[{"accessKeyId":"AKIAAAAAAAAAAAAAAAAA"}]}]}`, seed.Target{IAM: cloud.IAM}}, + "short key id": {`{"iamUsers":[{"name":"a","accessKeys":[{"accessKeyId":"AKIA","secretAccessKey":"s"}]}]}`, seed.Target{IAM: cloud.IAM}}, + "lowercase id": { + `{"iamUsers":[{"name":"a","accessKeys":[{"accessKeyId":"AKIAaaaaaaaaaaaaaaaa","secretAccessKey":"s"}]}]}`, + seed.Target{IAM: cloud.IAM}, + }, + "temporary id prefix": { + `{"iamUsers":[{"name":"a","accessKeys":[{"accessKeyId":"ASIAAAAAAAAAAAAAAAAA","secretAccessKey":"s"}]}]}`, + seed.Target{IAM: cloud.IAM}, + }, + "secret with space": { + `{"iamUsers":[{"name":"a","accessKeys":[{"accessKeyId":"AKIAAAAAAAAAAAAAAAAA","secretAccessKey":"a b"}]}]}`, + seed.Target{IAM: cloud.IAM}, + }, + "no key import": {`{"iamUsers":[{"name":"a","accessKeys":[{"accessKeyId":"K","secretAccessKey":"S"}]}]}`, seed.Target{IAM: cloudemu.NewGCP().IAM}}, + } + + for name, tc := range cases { + f, err := seed.Load([]byte(tc.fixture)) + if err != nil { + t.Fatalf("%s: Load: %v", name, err) + } + + if err := seed.Apply(context.Background(), f, tc.target); err == nil { + t.Errorf("%s: Apply = nil, want a validation error", name) + } + } + + long := seed.Fixtures{IAMUsers: []seed.IAMUser{{Name: "a", AccessKeys: []seed.AccessKey{ + {AccessKeyID: "AKIAAAAAAAAAAAAAAAAA", SecretAccessKey: strings.Repeat("x", 129)}, + }}}} + if err := seed.Apply(context.Background(), long, seed.Target{IAM: cloud.IAM}); err == nil { + t.Error("129-character secret: Apply = nil, want a validation error") + } +} diff --git a/seed/seed.go b/seed/seed.go index d7a564550..3ef4d957e 100644 --- a/seed/seed.go +++ b/seed/seed.go @@ -11,12 +11,17 @@ package seed import ( "context" "encoding/json" + "errors" "fmt" "io/fs" + "regexp" + "strings" + "unicode" cerrors "github.com/stackshy/cloudemu/v2/errors" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" secretsdriver "github.com/stackshy/cloudemu/v2/services/secrets/driver" storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" ) @@ -28,6 +33,22 @@ type Fixtures struct { Tables []Table `json:"tables,omitempty"` Secrets []Secret `json:"secrets,omitempty"` Instances []Instance `json:"instances,omitempty"` + IAMUsers []IAMUser `json:"iamUsers,omitempty"` +} + +// IAMUser is an IAM user plus access keys whose id and secret the fixture sets. +// Under --enforce-auth this is how the first user gets a key: there is no +// signed request to call CreateAccessKey with until one exists. A user with no +// policies is unrestricted, so it can then create everyone else over the API. +type IAMUser struct { + Name string `json:"name"` + AccessKeys []AccessKey `json:"accessKeys,omitempty"` +} + +// AccessKey is a long-term access key with a fixed id and secret. +type AccessKey struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` } // Bucket is an object-storage bucket and its initial objects. @@ -74,6 +95,7 @@ type Target struct { Database dbdriver.Database Secrets secretsdriver.Secrets Compute computedriver.Compute + IAM iamdriver.IAM } // Load parses fixtures from JSON bytes. @@ -113,6 +135,10 @@ func (f Fixtures) ResourceCount() int { } n += c } + + for _, u := range f.IAMUsers { + n += 1 + len(u.AccessKeys) + } return n } @@ -161,6 +187,59 @@ func (f Fixtures) Validate(t Target) error { return fmt.Errorf("instance: imageId is required") } } + + return validateIAMUsers(f.IAMUsers, t.IAM) +} + +var ( + errNoIAMDriver = errors.New("fixtures declare iamUsers but Target.IAM is nil") + errIAMUserName = errors.New("iamUser: name is required") + errNoKeyImport = errors.New("this provider cannot seed access keys") + errAccessKeyID = errors.New("accessKeyId must be AKIA followed by 16 uppercase letters or digits, as AWS issues them") + errAccessSecret = errors.New("secretAccessKey must be 1 to 128 characters with no whitespace") +) + +// accessKeyIDPattern is the shape of a long-term AWS access key id. +var accessKeyIDPattern = regexp.MustCompile(`^AKIA[A-Z0-9]{16}$`) + +// maxSecretLen bounds a seeded secret; real AWS secrets are 40 characters. +const maxSecretLen = 128 + +func validSecret(s string) bool { + return s != "" && len(s) <= maxSecretLen && !strings.ContainsFunc(s, unicode.IsSpace) +} + +func validateIAMUsers(users []IAMUser, d iamdriver.IAM) error { + if len(users) == 0 { + return nil + } + + if d == nil { + return errNoIAMDriver + } + + _, canImport := d.(iamdriver.AccessKeyImporter) + + for _, u := range users { + if u.Name == "" { + return errIAMUserName + } + + if len(u.AccessKeys) > 0 && !canImport { + return fmt.Errorf("iamUser %q: %w", u.Name, errNoKeyImport) + } + + for _, k := range u.AccessKeys { + if !accessKeyIDPattern.MatchString(k.AccessKeyID) { + return fmt.Errorf("iamUser %q: accessKeyId %q: %w", u.Name, k.AccessKeyID, errAccessKeyID) + } + + if !validSecret(k.SecretAccessKey) { + return fmt.Errorf("iamUser %q, key %s: %w", u.Name, k.AccessKeyID, errAccessSecret) + } + } + } + return nil } @@ -180,7 +259,7 @@ func IgnoreExisting() Option { } // Apply validates the whole fixture set, then writes it through t's drivers in -// a fixed order (buckets, tables, secrets, instances). Validation runs first so +// a fixed order (buckets, tables, secrets, instances, IAM users). Validation runs first so // an invalid fixture is rejected before anything is created. Writes are not // transactional: on a mid-write failure (e.g. seeding a backend that isn't // empty), earlier resources remain. Reset and retry against a fresh backend, @@ -209,7 +288,34 @@ func Apply(ctx context.Context, f Fixtures, t Target, opts ...Option) error { return err } - return applyInstances(ctx, f.Instances, t.Compute, o.ignoreExisting) + if err := applyInstances(ctx, f.Instances, t.Compute, o.ignoreExisting); err != nil { + return err + } + + return applyIAMUsers(ctx, f.IAMUsers, t.IAM, o.ignoreExisting) +} + +func applyIAMUsers(ctx context.Context, users []IAMUser, d iamdriver.IAM, ignoreExisting bool) error { + for _, u := range users { + if _, err := d.CreateUser(ctx, iamdriver.UserConfig{Name: u.Name}); err != nil { + if !ignoreExisting || !cerrors.IsAlreadyExists(err) { + return fmt.Errorf("seed iam user %q: %w", u.Name, err) + } + } + + // Validate already confirmed d is an importer when any keys are declared. + importer, _ := d.(iamdriver.AccessKeyImporter) + + for _, k := range u.AccessKeys { + if err := importer.ImportAccessKey(ctx, u.Name, k.AccessKeyID, k.SecretAccessKey); err != nil { + if !ignoreExisting || !cerrors.IsAlreadyExists(err) { + return fmt.Errorf("seed access key %q for %q: %w", k.AccessKeyID, u.Name, err) + } + } + } + } + + return nil } func applyBuckets(ctx context.Context, buckets []Bucket, d storagedriver.Bucket, ignoreExisting bool) error { diff --git a/server/admin/admin.go b/server/admin/admin.go index dfa1f9d94..a2641d3f4 100644 --- a/server/admin/admin.go +++ b/server/admin/admin.go @@ -14,6 +14,8 @@ package admin import ( + "crypto/sha256" + "crypto/subtle" "encoding/json" "io" "net/http" @@ -32,6 +34,13 @@ const maxFixtureBytes = 32 << 20 // 32 MiB // bodies, so this is larger than a seed fixture. const maxSnapshotBytes = 512 << 20 // 512 MiB +// healthEndpoint is the liveness probe, the one control endpoint that stays +// open when RequireToken is set. +const healthEndpoint = "health" + +// errorKey is the JSON field every control-plane error body uses. +const errorKey = "error" + // Backend is a hot-swappable http.Handler. Requests read the current handler // under a read lock; Swap replaces it under a write lock. A zero Backend is not // usable; construct with NewBackend. @@ -73,6 +82,10 @@ type Control struct { snapshot func() ([]byte, error) restore func(snapshot []byte) error extra http.Handler + + // tokenSum is the SHA-256 of the admin bearer token, or nil when the + // control plane is open. Set through RequireToken. + tokenSum []byte } // NewControl wraps backend with the control plane. reset must rebuild every @@ -92,6 +105,41 @@ func NewControl( return &Control{backend: backend, reset: reset, seed: seed, snapshot: snapshot, restore: restore, extra: extra} } +// RequireToken makes every control endpoint except health demand +// "Authorization: Bearer ". serve turns it on with --enforce-auth, so a +// caller without the token can't dump state (IAM secrets included) or replace +// it. health stays open as a liveness probe. An empty token leaves the control +// plane open. Call it before serving. +func (c *Control) RequireToken(token string) { + if token == "" { + c.tokenSum = nil + + return + } + + sum := sha256.Sum256([]byte(token)) + c.tokenSum = sum[:] +} + +// authorized reports whether r carries the admin token. Both sides are hashed +// first so the constant-time compare also hides the token length. +func (c *Control) authorized(r *http.Request) bool { + if c.tokenSum == nil { + return true + } + + scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ") + token = strings.TrimSpace(token) + + if !ok || !strings.EqualFold(scheme, "Bearer") || token == "" { + return false + } + + sum := sha256.Sum256([]byte(token)) + + return subtle.ConstantTimeCompare(sum[:], c.tokenSum) == 1 +} + // ServeHTTP routes control-plane paths to the control handler and everything // else to the wrapped backend. func (c *Control) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -103,39 +151,49 @@ func (c *Control) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (c *Control) serveControl(w http.ResponseWriter, r *http.Request) { - switch strings.TrimPrefix(r.URL.Path, Prefix) { + endpoint := strings.TrimPrefix(r.URL.Path, Prefix) + if endpoint != healthEndpoint && !c.authorized(r) { + w.Header().Set("WWW-Authenticate", `Bearer realm="cloudemu-admin"`) + writeJSON(w, http.StatusUnauthorized, map[string]string{ + errorKey: "admin endpoints require the admin token: send Authorization: Bearer ", + }) + + return + } + + switch endpoint { case "reset": if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "reset requires POST"}) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{errorKey: "reset requires POST"}) return } c.reset() writeJSON(w, http.StatusOK, map[string]string{"status": "reset"}) - case "health": + case healthEndpoint: writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) case "seed": if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "seed requires POST"}) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{errorKey: "seed requires POST"}) return } if c.seed == nil { - writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "seeding is not available on this server"}) + writeJSON(w, http.StatusNotImplemented, map[string]string{errorKey: "seeding is not available on this server"}) return } // Read one byte past the cap so an over-limit body is a clear 413 // rather than a silently-truncated body that fails JSON parsing. fixture, err := io.ReadAll(io.LimitReader(r.Body, maxFixtureBytes+1)) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read fixture: " + err.Error()}) + writeJSON(w, http.StatusBadRequest, map[string]string{errorKey: "read fixture: " + err.Error()}) return } if len(fixture) > maxFixtureBytes { - writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "fixture exceeds 32 MiB"}) + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{errorKey: "fixture exceeds 32 MiB"}) return } applied, err := c.seed(fixture) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusBadRequest, map[string]string{errorKey: err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"status": "seeded", "applied": applied}) @@ -147,7 +205,7 @@ func (c *Control) serveControl(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown control endpoint"}) + writeJSON(w, http.StatusNotFound, map[string]string{errorKey: "unknown control endpoint"}) } } @@ -157,7 +215,7 @@ func (c *Control) serveControl(w http.ResponseWriter, r *http.Request) { // whole emulator. func (c *Control) serveSnapshot(w http.ResponseWriter, r *http.Request) { if c.snapshot == nil || c.restore == nil { - writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "snapshots are not available on this server"}) + writeJSON(w, http.StatusNotImplemented, map[string]string{errorKey: "snapshots are not available on this server"}) return } @@ -165,7 +223,7 @@ func (c *Control) serveSnapshot(w http.ResponseWriter, r *http.Request) { case http.MethodGet: data, err := c.snapshot() if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusInternalServerError, map[string]string{errorKey: err.Error()}) return } @@ -175,23 +233,23 @@ func (c *Control) serveSnapshot(w http.ResponseWriter, r *http.Request) { case http.MethodPost: body, err := io.ReadAll(io.LimitReader(r.Body, maxSnapshotBytes+1)) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read snapshot: " + err.Error()}) + writeJSON(w, http.StatusBadRequest, map[string]string{errorKey: "read snapshot: " + err.Error()}) return } if len(body) > maxSnapshotBytes { - writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "snapshot exceeds 512 MiB"}) + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{errorKey: "snapshot exceeds 512 MiB"}) return } if err := c.restore(body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusBadRequest, map[string]string{errorKey: err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "restored"}) default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "snapshot requires GET or POST"}) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{errorKey: "snapshot requires GET or POST"}) } } diff --git a/server/admin/token_test.go b/server/admin/token_test.go new file mode 100644 index 000000000..ddc3f9da7 --- /dev/null +++ b/server/admin/token_test.go @@ -0,0 +1,129 @@ +package admin_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/server/admin" +) + +const testToken = "s3cret-admin-token" + +// tokenControl builds a Control with every endpoint wired, so a 401 can only +// come from the token gate and a 200 proves the request reached the handler. +func tokenControl(token string) (c *admin.Control, resets *int) { + n := 0 + b := admin.NewBackend(handler("backend")) + extra := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + c = admin.NewControl(b, + func() { n++ }, + func([]byte) (int, error) { return 1, nil }, + func() ([]byte, error) { return []byte(`{"secret":"AKIA"}`), nil }, + func([]byte) error { return nil }, + extra, + ) + + if token != "" { + c.RequireToken(token) + } + + return c, &n +} + +var gatedEndpoints = []struct{ method, path string }{ + {http.MethodGet, admin.Prefix + "snapshot"}, + {http.MethodPost, admin.Prefix + "snapshot"}, + {http.MethodPost, admin.Prefix + "reset"}, + {http.MethodPost, admin.Prefix + "seed"}, + {http.MethodGet, admin.Prefix + "cost"}, + {http.MethodGet, admin.Prefix + "net/can-connect"}, + {http.MethodGet, admin.Prefix + "snapshot/"}, + {http.MethodGet, admin.Prefix + "bogus"}, +} + +func serve(c http.Handler, method, path, authz string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(`{}`)) + if authz != "" { + req.Header.Set("Authorization", authz) + } + + rec := httptest.NewRecorder() + c.ServeHTTP(rec, req) + + return rec +} + +func TestAdminTokenRejectsUnauthenticated(t *testing.T) { + c, resets := tokenControl(testToken) + + for _, authz := range []string{"", "Bearer wrong-token", "Bearer " + testToken + "x", "Basic " + testToken, testToken, "Bearer"} { + for _, ep := range gatedEndpoints { + rec := serve(c, ep.method, ep.path, authz) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s with %q = %d, want 401", ep.method, ep.path, authz, rec.Code) + } + + if strings.Contains(rec.Body.String(), "AKIA") { + t.Errorf("%s %s with %q leaked the snapshot body", ep.method, ep.path, authz) + } + + if got := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(got, "Bearer") { + t.Errorf("%s %s: WWW-Authenticate = %q, want a Bearer challenge", ep.method, ep.path, got) + } + } + } + + if *resets != 0 { + t.Fatalf("an unauthenticated reset ran %d times", *resets) + } +} + +func TestAdminTokenAcceptsValidToken(t *testing.T) { + c, resets := tokenControl(testToken) + + for _, ep := range gatedEndpoints[:6] { + if rec := serve(c, ep.method, ep.path, "Bearer "+testToken); rec.Code != http.StatusOK { + t.Errorf("%s %s with the admin token = %d, want 200 (%s)", ep.method, ep.path, rec.Code, rec.Body.String()) + } + } + + // The auth scheme is case-insensitive, as in RFC 7235. + if rec := serve(c, http.MethodPost, admin.Prefix+"reset", "bearer "+testToken); rec.Code != http.StatusOK { + t.Errorf("lower-case bearer scheme = %d, want 200", rec.Code) + } + + if *resets != 2 { + t.Fatalf("reset ran %d times, want 2", *resets) + } +} + +func TestAdminTokenHealthStaysOpen(t *testing.T) { + c, _ := tokenControl(testToken) + + if rec := serve(c, http.MethodGet, admin.Prefix+"health", ""); rec.Code != http.StatusOK { + t.Fatalf("health without a token = %d, want 200", rec.Code) + } +} + +func TestAdminTokenLeavesCloudAPIAlone(t *testing.T) { + c, _ := tokenControl(testToken) + + // Requests outside /_cloudemu/ are the emulated cloud APIs, which carry their + // own SigV4/Bearer credentials; the admin gate must not touch them. + rec := serve(c, http.MethodGet, "/some/aws/request", "AWS4-HMAC-SHA256 Credential=AKIA/...") + if rec.Code != http.StatusOK || rec.Body.String() != "backend" { + t.Fatalf("cloud API request = %d %q, want 200 backend", rec.Code, rec.Body.String()) + } +} + +func TestAdminTokenUnsetKeepsEndpointsOpen(t *testing.T) { + c, _ := tokenControl("") + + for _, ep := range gatedEndpoints[:6] { + if rec := serve(c, ep.method, ep.path, ""); rec.Code != http.StatusOK { + t.Errorf("%s %s with no token configured = %d, want 200", ep.method, ep.path, rec.Code) + } + } +} diff --git a/server/serveflags/serveflags.go b/server/serveflags/serveflags.go index b079e6eb3..6bac061f5 100644 --- a/server/serveflags/serveflags.go +++ b/server/serveflags/serveflags.go @@ -87,12 +87,14 @@ type CommonConfig struct { TLSKey string TLSHosts StringList - EndpointsFile string - Admin bool - LogRequests bool - Quiet bool - EnforceAuth bool - AsyncSettle bool + EndpointsFile string + Admin bool + AdminToken string + AdminTokenFile string + LogRequests bool + Quiet bool + EnforceAuth bool + AsyncSettle bool ShutdownTimeout time.Duration @@ -143,7 +145,9 @@ func RegisterCommon(fs *flag.FlagSet, c *CommonConfig, getenv func(string) strin fs.StringVar(&c.TLSKey, "tls-key", "", "PEM key file matching --tls-cert") fs.Var(&c.TLSHosts, "tls-host", "extra SAN host/IP for the generated self-signed cert (repeatable)") fs.StringVar(&c.EndpointsFile, "endpoints-file", "", "write the resolved endpoints as JSON to this path") - fs.BoolVar(&c.Admin, "admin", true, "mount the /_cloudemu control plane (reset, health) for test isolation") + fs.BoolVar(&c.Admin, "admin", true, + "mount the /_cloudemu control plane (reset, seed, snapshot, health) for test isolation; "+ + "under --enforce-auth it needs --admin-token") fs.BoolVar(&c.LogRequests, "log-requests", false, "log every HTTP request (method, path, status, duration)") fs.BoolVar(&c.Quiet, "quiet", false, "suppress the startup banner") fs.DurationVar(&c.ShutdownTimeout, "shutdown-timeout", defaultShutdownTimeout, "grace period for in-flight requests on shutdown") @@ -156,9 +160,37 @@ func RegisterCommon(fs *flag.FlagSet, c *CommonConfig, getenv func(string) strin registerK8sProgressionFlags(fs, c, getenv) registerTickFlag(fs, c, getenv) registerEnforceAuthFlag(fs, c) + registerAdminTokenFlags(fs, c, getenv) registerVCRFlags(fs, c) } +// secretFlag is a string flag whose value never shows up in -h output, so an +// admin token taken from the environment is not echoed as the "default". +type secretFlag struct{ p *string } + +func (secretFlag) String() string { return "" } + +func (s secretFlag) Set(v string) error { + *s.p = v + + return nil +} + +// registerAdminTokenFlags registers the credential that guards the /_cloudemu +// control plane under --enforce-auth. +func registerAdminTokenFlags(fs *flag.FlagSet, c *CommonConfig, getenv func(string) string) { + c.AdminToken = getenv("CLOUDEMU_ADMIN_TOKEN") + fs.Var(secretFlag{&c.AdminToken}, "admin-token", + "with --enforce-auth, the bearer token every /_cloudemu endpoint except health requires "+ + "(Authorization: Bearer ). Default: a random token generated at startup and printed once, or written "+ + "to --admin-token-file. Ignored without --enforce-auth, where the control plane stays open (env CLOUDEMU_ADMIN_TOKEN). "+ + "Under --enforce-auth there is no unsigned way to create the first IAM access key, so seed it with this token: "+ + `POST /_cloudemu/seed {"iamUsers":[{"name":"admin","accessKeys":[{"accessKeyId":"AKIA...","secretAccessKey":"..."}]}]}`) + fs.StringVar(&c.AdminTokenFile, "admin-token-file", getenv("CLOUDEMU_ADMIN_TOKEN_FILE"), + "with --enforce-auth, write the admin token to this file (mode 0600) instead of printing it; "+ + "cloudemu start sets it so the snapshot, net and cost commands find the token (env CLOUDEMU_ADMIN_TOKEN_FILE)") +} + // registerVCRFlags registers the record/replay (VCR) flag group. --vcr selects // the mode; the cassette path and strict-match knob only matter when it is set. func registerVCRFlags(fs *flag.FlagSet, c *CommonConfig) { @@ -214,7 +246,8 @@ func registerEnforceAuthFlag(fs *flag.FlagSet, c *CommonConfig) { "checks land). Root and users with no policies are unrestricted. Azure: "+ "validate each request's Bearer token claims (accepted audience, expiry, a principal claim) and reject "+ "missing/malformed/expired/wrong-audience tokens with 401. The token signature is not verified (no Azure AD signing "+ - "key), so this is claims-based authentication only; RBAC authorization is a follow-up") + "key), so this is claims-based authentication only; RBAC authorization is a follow-up. The /_cloudemu admin endpoints "+ + "(all but health) then require the --admin-token bearer token") } // Validate checks the cross-field constraints both entrypoints share: --tls-cert @@ -268,6 +301,8 @@ func (c *CommonConfig) ToServerkitConfig(providers []string) serverkit.Config { TickInterval: c.TickInterval, AzureSubscription: c.AzureSubscription, Admin: c.Admin, + AdminToken: c.AdminToken, + AdminTokenFile: c.AdminTokenFile, Persist: c.Persist, StateFile: c.StateFile, PersistMetadataOnly: c.PersistMetadataOnly, diff --git a/server/serveflags/serveflags_test.go b/server/serveflags/serveflags_test.go index 6e62a8b0d..4d68d5fd4 100644 --- a/server/serveflags/serveflags_test.go +++ b/server/serveflags/serveflags_test.go @@ -23,7 +23,8 @@ func noEnv(string) string { return "" } // //nolint:gochecknoglobals // test fixture: the pinned common-flag name set var commonFlagNames = []string{ - "account-id", "admin", "advertise-host", "async-settle", "aws-port", "azure-port", "azure-subscription", + "account-id", "admin", "admin-token", "admin-token-file", "advertise-host", "async-settle", + "aws-port", "azure-port", "azure-subscription", "endpoints-file", "enforce-auth", "gcp-grpc-port", "gcp-port", "host", "init-dir", "k8s-nodes", "k8s-port", "k8s-progression", "k8s-progression-interval", "latency", "log-requests", "oci-port", "persist", "persist-interval", "persist-metadata-only", "persist-strategy", "project-id", @@ -130,6 +131,32 @@ func TestRegisterCommonEnvFallback(t *testing.T) { } } +// TestAdminTokenFromEnvNotEchoed checks the admin token can come from the +// environment and that -h never prints it as the flag default. +func TestAdminTokenFromEnvNotEchoed(t *testing.T) { + env := map[string]string{"CLOUDEMU_ADMIN_TOKEN": "env-secret", "CLOUDEMU_ADMIN_TOKEN_FILE": "/run/tok"} + + var c CommonConfig + + fs := flag.NewFlagSet("t", flag.ContinueOnError) + RegisterCommon(fs, &c, func(k string) string { return env[k] }) + + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + + assertEqual(t, "admin-token (env)", c.AdminToken, "env-secret") + assertEqual(t, "admin-token-file (env)", c.AdminTokenFile, "/run/tok") + assertEqual(t, "admin-token default shown in -h", fs.Lookup("admin-token").DefValue, "") + + if err := fs.Parse([]string{"--admin-token", "flag-secret"}); err != nil { + t.Fatalf("parse: %v", err) + } + + assertEqual(t, "admin-token (flag wins)", c.AdminToken, "flag-secret") + assertEqual(t, "admin-token String()", fs.Lookup("admin-token").Value.String(), "") +} + // TestToServerkitConfigRoundTrip parses a representative arg set and asserts the // resulting serverkit.Config carries every value through: ports, persistence, // TLS, k8s progression, and the identity BaseOptions. @@ -147,7 +174,7 @@ func TestToServerkitConfigRoundTrip(t *testing.T) { "--azure-subscription", "11111111-1111-1111-1111-111111111111", "--latency", "20ms", "--tls-cert", "/c.pem", "--tls-key", "/k.pem", "--tls-host", "a", "--tls-host", "b", - "--endpoints-file", "/eps.json", + "--endpoints-file", "/eps.json", "--admin-token", "tok-1", "--admin-token-file", "/tok", "--admin=false", "--log-requests", "--quiet", "--enforce-auth", "--async-settle", "--shutdown-timeout", "3s", "--persist", "--state-file", "/s.json", "--persist-metadata-only", @@ -185,6 +212,8 @@ func TestToServerkitConfigRoundTrip(t *testing.T) { assertEqual(t, "tls-key", sk.TLSKey, "/k.pem") assertEqual(t, "endpoints-file", sk.EndpointsFile, "/eps.json") assertEqual(t, "admin", sk.Admin, false) + assertEqual(t, "admin-token", sk.AdminToken, "tok-1") + assertEqual(t, "admin-token-file", sk.AdminTokenFile, "/tok") assertEqual(t, "log-requests", sk.LogRequests, true) assertEqual(t, "quiet", sk.Quiet, true) assertEqual(t, "enforce-auth", sk.EnforceAuth, true) diff --git a/server/serverkit/admin_auth_test.go b/server/serverkit/admin_auth_test.go new file mode 100644 index 000000000..19de24f50 --- /dev/null +++ b/server/serverkit/admin_auth_test.go @@ -0,0 +1,266 @@ +package serverkit + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func enforceAuthApp(t *testing.T, mutate func(*Config)) *App { + t.Helper() + + cfg := Config{ + Providers: []string{"aws"}, + Host: "127.0.0.1", + Ports: map[string]string{"aws": "0"}, + Admin: true, + EnforceAuth: true, + Out: io.Discard, + } + if mutate != nil { + mutate(&cfg) + } + + return newTestApp(t, cfg) +} + +func adminDo(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + return rec +} + +// TestEnforceAuthAdminEndpointsRequireToken is the AUTHN-X3 guard: with +// --enforce-auth on, snapshot (GET and POST), reset and seed must refuse a +// caller without the admin token, so nobody can dump IAM secrets or replace +// the whole state anonymously. +func TestEnforceAuthAdminEndpointsRequireToken(t *testing.T) { + app := enforceAuthApp(t, nil) + h := app.handlerFor(app.backends["aws"], app.seedFor("aws")) + + cases := []struct{ method, path, body string }{ + {http.MethodGet, "/_cloudemu/snapshot", ""}, + {http.MethodPost, "/_cloudemu/snapshot", `{"schemaVersion":1}`}, + {http.MethodPost, "/_cloudemu/reset", ""}, + {http.MethodPost, "/_cloudemu/seed", `{"buckets":[{"name":"b"}]}`}, + {http.MethodGet, "/_cloudemu/cost", ""}, + {http.MethodGet, "/_cloudemu/net/can-connect?from=a&to=b", ""}, + {http.MethodGet, "/_cloudemu/snapshot/", ""}, + } + + for _, tc := range cases { + for _, token := range []string{"", "not-the-token"} { + if rec := adminDo(h, tc.method, tc.path, token, tc.body); rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s token=%q under --enforce-auth = %d, want 401", tc.method, tc.path, token, rec.Code) + } + } + } + + if rec := adminDo(h, http.MethodGet, "/_cloudemu/health", "", ""); rec.Code != http.StatusOK { + t.Fatalf("health without a token = %d, want 200", rec.Code) + } + + // The Kubernetes listener shares the same gate. + k8s := app.handlerFor(app.backends["aws"], nil) + if rec := adminDo(k8s, http.MethodGet, "/_cloudemu/snapshot", "", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("snapshot via a seedless listener = %d, want 401", rec.Code) + } +} + +func TestEnforceAuthAdminTokenAccepted(t *testing.T) { + app := enforceAuthApp(t, func(c *Config) { c.AdminToken = "fixed-token" }) + h := app.handlerFor(app.backends["aws"], app.seedFor("aws")) + + rec := adminDo(h, http.MethodPost, "/_cloudemu/seed", "fixed-token", `{"buckets":[{"name":"seeded"}]}`) + if rec.Code != http.StatusOK { + t.Fatalf("seed with the token = %d %s", rec.Code, rec.Body.String()) + } + + rec = adminDo(h, http.MethodGet, "/_cloudemu/snapshot", "fixed-token", "") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "seeded") { + t.Fatalf("snapshot with the token = %d, want 200 holding the seeded bucket", rec.Code) + } + + snap := rec.Body.String() + + if rec = adminDo(h, http.MethodPost, "/_cloudemu/reset", "fixed-token", ""); rec.Code != http.StatusOK { + t.Fatalf("reset with the token = %d", rec.Code) + } + + if rec = adminDo(h, http.MethodPost, "/_cloudemu/snapshot", "fixed-token", snap); rec.Code != http.StatusOK { + t.Fatalf("restore with the token = %d %s", rec.Code, rec.Body.String()) + } +} + +func TestEnforceAuthGeneratedTokenWrittenToFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "admin-token") + app := enforceAuthApp(t, func(c *Config) { c.AdminTokenFile = path }) + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("token file not written: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("token file mode = %o, want 600", perm) + } + + token := strings.TrimSpace(string(raw)) + if len(token) < 32 { + t.Fatalf("generated token %q is too short", token) + } + + h := app.handlerFor(app.backends["aws"], app.seedFor("aws")) + if rec := adminDo(h, http.MethodGet, "/_cloudemu/snapshot", token, ""); rec.Code != http.StatusOK { + t.Fatalf("snapshot with the generated token = %d", rec.Code) + } + + // A second server gets a different token. + path2 := filepath.Join(t.TempDir(), "admin-token") + enforceAuthApp(t, func(c *Config) { c.AdminTokenFile = path2 }) + + raw2, _ := os.ReadFile(path2) + if strings.TrimSpace(string(raw2)) == token { + t.Fatal("two servers generated the same admin token") + } +} + +// TestAdminTokenFileResistsPreCreation covers a shared directory where someone +// planted the token path first: a world-readable file they hold open, or a +// symlink to a file they can read. Neither may receive the token. +func TestAdminTokenFileResistsPreCreation(t *testing.T) { + dir := t.TempDir() + + planted := filepath.Join(dir, "planted") + if err := os.WriteFile(planted, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + + held, err := os.Open(planted) + if err != nil { + t.Fatal(err) + } + defer held.Close() + + victim := filepath.Join(dir, "victim") + if err := os.WriteFile(victim, []byte("victim\n"), 0o644); err != nil { + t.Fatal(err) + } + + link := filepath.Join(dir, "linked") + if err := os.Symlink(victim, link); err != nil { + t.Fatal(err) + } + + for _, path := range []string{planted, link} { + if err := writeAdminTokenFile(path, "the-token"); err != nil { + t.Fatalf("writeAdminTokenFile(%s): %v", path, err) + } + + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("%s: mode %v, want a regular 0600 file", path, info.Mode()) + } + + if got, _ := os.ReadFile(path); string(got) != "the-token\n" { + t.Fatalf("%s holds %q", path, got) + } + } + + if got, _ := io.ReadAll(held); string(got) != "old\n" { + t.Fatalf("a descriptor opened before the write sees %q, want the old content", got) + } + + if got, _ := os.ReadFile(victim); string(got) != "victim\n" { + t.Fatalf("the symlink target was written through: %q", got) + } + + entries, _ := os.ReadDir(dir) + if len(entries) != 3 { + t.Fatalf("dir has %d entries, want 3: planted, victim, linked (no leftover temp files)", len(entries)) + } +} + +func TestDangerWarningQuietUnderEnforceAuth(t *testing.T) { + if w := dangerWarning(&Config{Admin: true, EnforceAuth: true, Host: "0.0.0.0"}); w != "" { + t.Fatalf("token-gated admin on a public host: want no warning, got %q", w) + } +} + +// TestAuthOffAdminEndpointsUnchanged pins the documented developer default: +// without --enforce-auth the control plane stays open, even if a token is set. +func TestAuthOffAdminEndpointsUnchanged(t *testing.T) { + path := filepath.Join(t.TempDir(), "admin-token") + app := newTestApp(t, Config{ + Providers: []string{"aws"}, + Host: "127.0.0.1", + Ports: map[string]string{"aws": "0"}, + Admin: true, + AdminToken: "ignored", + AdminTokenFile: path, + Out: io.Discard, + }) + h := app.handlerFor(app.backends["aws"], app.seedFor("aws")) + + for _, p := range []string{"/_cloudemu/snapshot", "/_cloudemu/cost", "/_cloudemu/health"} { + if rec := adminDo(h, http.MethodGet, p, "", ""); rec.Code != http.StatusOK { + t.Errorf("GET %s with auth off = %d, want 200", p, rec.Code) + } + } + + if rec := adminDo(h, http.MethodPost, "/_cloudemu/reset", "", ""); rec.Code != http.StatusOK { + t.Errorf("reset with auth off = %d, want 200", rec.Code) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("token file written with auth off: %v", err) + } +} + +// TestSeedBootstrapsIAMUserUnderEnforceAuth covers the first-key bootstrap: the +// admin token seeds an IAM user with a known access key, which SigV4 then +// resolves. +func TestSeedBootstrapsIAMUserUnderEnforceAuth(t *testing.T) { + app := enforceAuthApp(t, func(c *Config) { c.AdminToken = "tok" }) + h := app.handlerFor(app.backends["aws"], app.seedFor("aws")) + + fixture := `{"iamUsers":[{"name":"admin","accessKeys":[{"accessKeyId":"AKIABOOTSTRAP0000001","secretAccessKey":"boot-secret"}]}]}` + if rec := adminDo(h, http.MethodPost, "/_cloudemu/seed", "tok", fixture); rec.Code != http.StatusOK { + t.Fatalf("seed iam user = %d %s", rec.Code, rec.Body.String()) + } + + bad := `{"iamUsers":[{"name":"x","accessKeys":[{"accessKeyId":"not-a-key","secretAccessKey":"s"}]}]}` + + rec := adminDo(h, http.MethodPost, "/_cloudemu/seed", "tok", bad) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "AKIA followed by 16") { + t.Fatalf("malformed access key id = %d %s, want 400 naming the expected format", rec.Code, rec.Body.String()) + } + + app.rebuildMu.Lock() + base := app.awsMux.GetOrCreate("") + app.rebuildMu.Unlock() + + ak, ok := base.IAM.AccessKeyByID(t.Context(), "AKIABOOTSTRAP0000001") + if !ok || ak.SecretAccessKey != "boot-secret" || ak.UserName != "admin" { + t.Fatalf("seeded key = %+v ok=%v", ak, ok) + } +} diff --git a/server/serverkit/admintoken.go b/server/serverkit/admintoken.go new file mode 100644 index 000000000..a3a4758db --- /dev/null +++ b/server/serverkit/admintoken.go @@ -0,0 +1,99 @@ +package serverkit + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" +) + +// adminTokenBytes is the entropy of a generated admin token (hex-encoded to 64 +// characters). +const adminTokenBytes = 32 + +// adminTokenFileMode keeps the token file private to the user running serve. +const adminTokenFileMode = 0o600 + +// setupAdminToken picks the bearer token that gates the /_cloudemu control +// plane under EnforceAuth: the configured one, or a fresh random one. With +// AdminTokenFile set the token is written there (0600) and only the path is +// logged; otherwise a generated token is printed once on stderr so the operator +// can use it. It is a no-op when EnforceAuth or the admin plane is off, which +// keeps the documented open developer mode unchanged. +func (a *App) setupAdminToken() error { + if !a.cfg.EnforceAuth || !a.cfg.Admin { + return nil + } + + token := a.cfg.AdminToken + generated := token == "" + + if generated { + buf := make([]byte, adminTokenBytes) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("generate admin token: %w", err) + } + + token = hex.EncodeToString(buf) + } + + a.adminToken = token + + if path := a.cfg.AdminTokenFile; path != "" { + if err := writeAdminTokenFile(path, token); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "cloudemu: admin token written to %s (send it as Authorization: Bearer )\n", path) + + return nil + } + + if generated { + fmt.Fprintf(os.Stderr, "cloudemu: admin token for /_cloudemu/*: %s (send it as Authorization: Bearer )\n", token) + } + + return nil +} + +// writeAdminTokenFile writes token to path with owner-only permissions. The +// token goes into a fresh temp file in the same directory (os.CreateTemp opens +// it O_CREATE|O_EXCL with mode 0600, so nobody can pre-create it or hold it +// open), which is then renamed over path. Rename replaces whatever sits at path, +// a symlink included, without following it, so a file or link planted there in +// a shared directory can neither redirect the write nor read the token. +func writeAdminTokenFile(path, token string) error { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("write admin token file: %w", err) + } + + tmpName := tmp.Name() + + // Remove the temp file on any failure before the rename. After a successful + // rename it no longer exists under this name, so the Remove is a no-op. + defer os.Remove(tmpName) + + if err := tmp.Chmod(adminTokenFileMode); err != nil { + _ = tmp.Close() + + return fmt.Errorf("write admin token file: %w", err) + } + + if _, err := tmp.WriteString(token + "\n"); err != nil { + _ = tmp.Close() + + return fmt.Errorf("write admin token file: %w", err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("write admin token file: %w", err) + } + + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("write admin token file: %w", err) + } + + return nil +} diff --git a/server/serverkit/serverkit.go b/server/serverkit/serverkit.go index a33bc6e8a..267cc79cc 100644 --- a/server/serverkit/serverkit.go +++ b/server/serverkit/serverkit.go @@ -121,6 +121,13 @@ type Config struct { Admin bool // mount the /_cloudemu control plane + // AdminToken is the bearer token the /_cloudemu endpoints (all but health) + // demand when EnforceAuth is on. Empty means serve generates a random one. + // AdminTokenFile, when set, receives the token (mode 0600) so local tooling + // can pick it up. Both are ignored when EnforceAuth is off. + AdminToken string + AdminTokenFile string + Persist bool // save/restore state around the process lifetime StateFile string // path to the JSON state snapshot PersistMetadataOnly bool // omit object bodies from the snapshot @@ -167,6 +174,9 @@ type App struct { backends map[string]*admin.Backend k8sBackend *admin.Backend + // adminToken gates the control plane under EnforceAuth ("" leaves it open). + adminToken string + // k8s is the current Kubernetes data-plane server (rebuilt on reset, guarded // by rebuildMu). The progression ticker reads it each tick. k8s *kubernetes.APIServer @@ -262,6 +272,10 @@ func New(cfg *Config) (*App, error) { a.flusher = a.newFlusher() } + if err := a.setupAdminToken(); err != nil { + return nil, err + } + // Build the VCR engine BEFORE Rebuild, since swapFresh wraps each fresh // handler with it. if err := a.configureVCR(); err != nil { @@ -610,9 +624,11 @@ func (a *App) buildProvider(p string, k8s *kubernetes.APIServer) builtProvider { return builtProvider{ handler: a.wrapLatency(a.wrapVCR(a.wrapDirty(wrap(mux, providerAWS, a.cfg.LogRequests)), providerAWS)), - target: seed.Target{Storage: base.S3, Database: base.DynamoDB, Secrets: base.SecretsManager, Compute: base.EC2}, - engine: topology.New(base.EC2, base.VPC, base.Route53), - mux: mux, + target: seed.Target{ + Storage: base.S3, Database: base.DynamoDB, Secrets: base.SecretsManager, Compute: base.EC2, IAM: base.IAM, + }, + engine: topology.New(base.EC2, base.VPC, base.Route53), + mux: mux, } case providerGCP: cloud := cloudemu.NewGCP(a.baseOpts...) @@ -993,7 +1009,10 @@ func (a *App) handlerFor(b *admin.Backend, seedFn func([]byte) (int, error)) htt } } - return admin.NewControl(b, reset, seedFn, a.snapshot, a.restore, a.extraHandler()) + c := admin.NewControl(b, reset, seedFn, a.snapshot, a.restore, a.extraHandler()) + c.RequireToken(a.adminToken) + + return c } // Serve binds every listener, starts serving, and blocks until ctx is canceled @@ -1359,9 +1378,11 @@ func serveAll(servers []listenerServer, listeners []net.Listener) <-chan error { // dangerWarning returns the non-loopback admin warning, or "" when it doesn't // apply. reset wipes all state and snapshot dumps it (secrets included), so an -// admin control plane reachable off the loopback is called out. +// open admin control plane reachable off the loopback is called out. Under +// --enforce-auth the plane needs the admin token, so there is nothing to warn +// about. func dangerWarning(cfg *Config) string { - if !cfg.Admin || isLoopbackHost(cfg.Host) { + if !cfg.Admin || cfg.EnforceAuth || isLoopbackHost(cfg.Host) { return "" } @@ -1369,6 +1390,6 @@ func dangerWarning(cfg *Config) string { "warning: --admin control plane is reachable on non-loopback host %q: "+ "POST /_cloudemu/reset wipes all state, and GET /_cloudemu/snapshot dumps "+ "all emulated state (including secret values) to any caller; "+ - "pass --admin=false to disable it", + "pass --enforce-auth to require an admin token, or --admin=false to disable it", cfg.Host) } diff --git a/services/iam/driver/driver.go b/services/iam/driver/driver.go index 0a961f418..f01cbdc11 100644 --- a/services/iam/driver/driver.go +++ b/services/iam/driver/driver.go @@ -219,6 +219,15 @@ type AccessKeyResolver interface { AccessKeyByID(ctx context.Context, id string) (AccessKeyAuth, bool) } +// AccessKeyImporter is an optional capability: an IAM implementation that can +// register an access key whose id and secret the caller chooses. The seed +// fixtures use it so the first IAM user under --enforce-auth gets a key the +// operator already knows (CreateAccessKey would need a signed request, and +// there is no key to sign it with yet). AWS-only, like AccessKeyResolver. +type AccessKeyImporter interface { + ImportAccessKey(ctx context.Context, userName, accessKeyID, secretAccessKey string) error +} + // PolicyInspector is an optional capability: an IAM implementation that can // report whether a principal has any policies in effect. The AWS authorization // gate type-asserts for it so it can leave a principal with no policies defined