From 451da3d26dcd59c00d1918abd53399d5f8cfe35b Mon Sep 17 00:00:00 2001 From: Jay Brown Date: Thu, 21 May 2026 19:40:04 +0000 Subject: [PATCH] Merged in feature/service-accounts-1 (pull request #227) Support for service accounts with static credentials * feature complete * tests and docs * lint fix --- Taskfile.yml | 11 + api/queryAPI/test_helpers.go | 6 + check.aws.login.sh | 1 + check.login.sh | 2 + cmd/auth_related/service.account.tool/main.go | 547 +++++ .../service.account.tool/main_test.go | 59 + .../service.account.tool/permit.go | 169 ++ .../service.account.tool/run.tool.sh | 30 + .../create.pool.and.client.sh | 202 ++ .../create.test.super.admin.sh | 254 +++ cmd/auth_related/service.accounts/readme.md | 81 + .../test.scripts/test.service.account.auth.py | 396 ++++ deployments/compose.local.yaml | 3 + devbox.json | 3 + docs/README.md | 16 + .../07-configuration-management.md | 5 + docs/ai.generated/09-authentication-guide.md | 2 + .../13-two-pool-service-accounts.md | 215 ++ docs/ai.generated/README.md | 12 + internal/cognitoauth/envcheck.go | 18 +- internal/cognitoauth/envcheck_test.go | 75 + internal/cognitoauth/handler.go | 8 +- internal/cognitoauth/jwks.go | 35 +- internal/cognitoauth/jwks_test.go | 54 +- internal/cognitoauth/middleware.go | 53 +- internal/cognitoauth/models.go | 32 +- internal/cognitoauth/multi_issuer_test.go | 214 ++ internal/cognitoauth/token.go | 111 +- internal/cognitoauth/token_test.go | 14 + internal/serviceconfig/auth/config.go | 50 + internal/serviceconfig/auth/config_test.go | 80 + plans/mutable.metadata.plan.claude.md | 1100 --------- ...e.metadata.plan.codex.comparison.claude.md | 242 -- plans/mutable.metadata.plan.codex.md | 539 ----- plans/mutable.metadata.plan.combo.md | 1364 ----------- plans/mutable.metadata.plan.combo.v2.md | 1566 ------------- plans/mutable.metadata.plan.combo.v3.md | 1729 -------------- ...mutable.metadata.plan.combo.v3.tracking.md | 652 ------ plans/mutable.metadata.plan.combo.v4.md | 2008 ----------------- ...ta.plan.combo.v4.review.claude-response.md | 140 -- ...metadata.plan.combo.v4.review.coded.old.md | 55 - ...ble.metadata.plan.combo.v4.review.codex.md | 62 - ....metadata.plan.combo.v4.review.findings.md | 270 --- ...mutable.metadata.plan.combo.v4.tracking.md | 575 ----- plans/mutable.metadata.requirments.md | 30 - .../mutable.metadata.review.combo.v3.codex.md | 86 - ...utable.metadata.simplify. plan.codex.v3.md | 544 ----- .../mutable.metadata.testing.review.codex.md | 79 - scripts/Taskfile.yml | 72 +- scripts/database.yml | 3 + scripts/docker.yml | 3 + scripts/local-deployments.yml | 15 + .../aws.config.scripts/bash.script.rules.md | 57 + .../run_test_two_pool_lifecycle.sh | 66 + .../manual_tests/test_chatbot_features.1.py | 19 +- scripts/manual_tests/test_two_pool_auth.py | 275 +++ scripts/manual_tests/test_two_pool_auth.sh | 51 + .../manual_tests/test_two_pool_lifecycle.py | 303 +++ scripts/manual_tests/test_zip_upload_cloud.sh | 325 +++ scripts/manual_tests/test_zip_upload_mock.sh | 306 +++ scripts/openapi-scripts.yml | 53 +- scripts/schema_inventory.sql | 144 ++ scripts/tests.yml | 35 +- 63 files changed, 4347 insertions(+), 11179 deletions(-) create mode 100755 check.aws.login.sh create mode 100755 check.login.sh create mode 100644 cmd/auth_related/service.account.tool/main.go create mode 100644 cmd/auth_related/service.account.tool/main_test.go create mode 100644 cmd/auth_related/service.account.tool/permit.go create mode 100755 cmd/auth_related/service.account.tool/run.tool.sh create mode 100755 cmd/auth_related/service.accounts/creation.scripts/create.pool.and.client.sh create mode 100755 cmd/auth_related/service.accounts/creation.scripts/create.test.super.admin.sh create mode 100644 cmd/auth_related/service.accounts/readme.md create mode 100755 cmd/auth_related/service.accounts/test.scripts/test.service.account.auth.py create mode 100644 docs/ai.generated/13-two-pool-service-accounts.md create mode 100644 internal/cognitoauth/multi_issuer_test.go delete mode 100644 plans/mutable.metadata.plan.claude.md delete mode 100644 plans/mutable.metadata.plan.codex.comparison.claude.md delete mode 100644 plans/mutable.metadata.plan.codex.md delete mode 100644 plans/mutable.metadata.plan.combo.md delete mode 100644 plans/mutable.metadata.plan.combo.v2.md delete mode 100644 plans/mutable.metadata.plan.combo.v3.md delete mode 100644 plans/mutable.metadata.plan.combo.v3.tracking.md delete mode 100644 plans/mutable.metadata.plan.combo.v4.md delete mode 100644 plans/mutable.metadata.plan.combo.v4.review.claude-response.md delete mode 100644 plans/mutable.metadata.plan.combo.v4.review.coded.old.md delete mode 100644 plans/mutable.metadata.plan.combo.v4.review.codex.md delete mode 100644 plans/mutable.metadata.plan.combo.v4.review.findings.md delete mode 100644 plans/mutable.metadata.plan.combo.v4.tracking.md delete mode 100644 plans/mutable.metadata.requirments.md delete mode 100644 plans/mutable.metadata.review.combo.v3.codex.md delete mode 100644 plans/mutable.metadata.simplify. plan.codex.v3.md delete mode 100644 plans/mutable.metadata.testing.review.codex.md create mode 100644 scripts/manual_tests/aws.config.scripts/bash.script.rules.md create mode 100755 scripts/manual_tests/run_test_two_pool_lifecycle.sh create mode 100755 scripts/manual_tests/test_two_pool_auth.py create mode 100755 scripts/manual_tests/test_two_pool_auth.sh create mode 100755 scripts/manual_tests/test_two_pool_lifecycle.py create mode 100755 scripts/manual_tests/test_zip_upload_cloud.sh create mode 100755 scripts/manual_tests/test_zip_upload_mock.sh create mode 100644 scripts/schema_inventory.sql diff --git a/Taskfile.yml b/Taskfile.yml index 320206a6..f95bdfa7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -11,3 +11,14 @@ includes: IMAGE_NAME: queryorchestration COVERAGE_THRESHOLD: 80 FUNCTION_COVERAGE_THRESHOLD: 60 + +tasks: + default: + desc: "Show every available task (alias for `task help`)" + cmds: + - task: help + help: + desc: "Show every available task in this project" + silent: true + cmds: + - task --list-all --sort=alphanumeric diff --git a/api/queryAPI/test_helpers.go b/api/queryAPI/test_helpers.go index 4f8ea250..65532ca7 100644 --- a/api/queryAPI/test_helpers.go +++ b/api/queryAPI/test_helpers.go @@ -56,6 +56,12 @@ func (m *mockAuthConfig) SetCognitoEnvironmentID(string) {} func (m *mockAuthConfig) InitializeAuthConfig(string, *slog.Logger) error { return nil } func (m *mockAuthConfig) GetNoJWTValidation() bool { return false } func (m *mockAuthConfig) SetNoJWTValidation(bool) {} +func (m *mockAuthConfig) GetAuthSvcUserPoolID() string { return "" } +func (m *mockAuthConfig) SetAuthSvcUserPoolID(string) {} +func (m *mockAuthConfig) GetAuthSvcAppClientID() string { return "" } +func (m *mockAuthConfig) SetAuthSvcAppClientID(string) {} +func (m *mockAuthConfig) GetAuthSvcJwksURL() string { return "" } +func (m *mockAuthConfig) SetAuthSvcJwksURL(string) {} // Objectstore interface methods func (m *mockAuthConfig) GetAWSKeyID() string { return "test-key" } diff --git a/check.aws.login.sh b/check.aws.login.sh new file mode 100755 index 00000000..a27b4c4c --- /dev/null +++ b/check.aws.login.sh @@ -0,0 +1 @@ +env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_ENDPOINT_URL aws sts get-caller-identity --profile aarete diff --git a/check.login.sh b/check.login.sh new file mode 100755 index 00000000..a93d8b66 --- /dev/null +++ b/check.login.sh @@ -0,0 +1,2 @@ +aws sts get-caller-identity --profile aarete + diff --git a/cmd/auth_related/service.account.tool/main.go b/cmd/auth_related/service.account.tool/main.go new file mode 100644 index 00000000..5462bae0 --- /dev/null +++ b/cmd/auth_related/service.account.tool/main.go @@ -0,0 +1,547 @@ +// Package main implements the service-account lifecycle CLI for the +// two-pool Cognito plan +// (plans/api.key.support.cognito.two-pool.plan.3.claude.md §4.5). +// +// Subcommands: +// +// create create Cognito user in pool B + Permit registration +// show print Cognito attributes + Permit roles +// list list users in pool B +// disable AdminDisableUser + AdminUserGlobalSignOut +// enable AdminEnableUser +// rotate-password AdminSetUserPassword (permanent, new random) +// delete AdminDeleteUser + DeletePermitUser +// register-permit [--roles r1,r2] +// register an existing Cognito service-account +// user in Permit and assign roles (used to +// bind the operator-pre-created test account +// to a super_admin role without re-creating +// it). +// +// Configuration comes from environment variables: +// +// COGNITO_REGION AWS region (defaults to us-east-2) +// COGNITO_SVC_USER_POOL_ID pool B id (required; tool refuses to run when empty) +// COGNITO_SVC_APP_CLIENT_ID pool B app client id (only used by create flow when --print-test-auth is set) +// PERMIT_IO_API_KEY permit.io API key (project or env scope) +// PERMIT_IO_BASE_URL permit.io API base URL (defaults to https://api.permit.io) +// AWS_PROFILE optional AWS profile name +// +// The tool is deliberately small — it wraps the AWS SDK and the +// Permit.io HTTP API directly and reuses the JSON shapes the +// user.creation.tool already proved work. +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" +) + +// Settings captures the runtime configuration read from env vars. It +// is intentionally small — every consumer reads what it needs and +// nothing more. +type Settings struct { + Region string + UserPoolID string + AppClientID string + PermitAPIKey string + PermitBaseURL string + AWSProfile string +} + +// load pulls Settings from environment and validates the +// must-be-set fields. Missing values produce a single error rather +// than a partial run. +func load() (*Settings, error) { + s := &Settings{ + Region: envDefault("COGNITO_REGION", "us-east-2"), + UserPoolID: os.Getenv("COGNITO_SVC_USER_POOL_ID"), + AppClientID: os.Getenv("COGNITO_SVC_APP_CLIENT_ID"), + PermitAPIKey: os.Getenv("PERMIT_IO_API_KEY"), + PermitBaseURL: envDefault("PERMIT_IO_BASE_URL", "https://api.permit.io"), + AWSProfile: os.Getenv("AWS_PROFILE"), + } + if s.UserPoolID == "" { + return nil, fmt.Errorf("COGNITO_SVC_USER_POOL_ID is required (service-accounts pool id)") + } + return s, nil +} + +func envDefault(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// newCognitoClient builds an SDK client honoring AWS_PROFILE when +// present. We deliberately do not embed credentials in flags — the +// tool runs from an operator workstation with `aws sso login` or an +// equivalent shared-credentials setup. +func newCognitoClient(ctx context.Context, s *Settings) (*cognitoidentityprovider.Client, error) { + opts := []func(*config.LoadOptions) error{ + config.WithRegion(s.Region), + } + if s.AWSProfile != "" { + opts = append(opts, config.WithSharedConfigProfile(s.AWSProfile)) + } + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("load aws config: %w", err) + } + return cognitoidentityprovider.NewFromConfig(cfg), nil +} + +// generatePassword returns a 32-character password meeting the pool +// B password policy (upper, lower, digit, symbol). We use crypto/rand +// for the random bytes and stitch in one of each required class to +// guarantee compliance — naive base64 would occasionally miss a class +// and fail AdminSetUserPassword. +func generatePassword() (string, error) { + // 24 random bytes -> 32 base64 chars; we prepend four mandatory + // characters (Aa1!) so AdminSetUserPassword never rejects the + // password for failing a class requirement. + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + enc := base64.RawURLEncoding.EncodeToString(b) + if len(enc) > 28 { + enc = enc[:28] + } + return "A1a!" + enc, nil +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + cmd := os.Args[1] + args := os.Args[2:] + + s, err := load() + if err != nil { + exit(err) + } + + ctx := context.Background() + client, err := newCognitoClient(ctx, s) + if err != nil { + exit(err) + } + + switch cmd { + case "create": + exit(cmdCreate(ctx, client, s, args)) + case "show": + exit(cmdShow(ctx, client, s, args)) + case "list": + exit(cmdList(ctx, client, s, args)) + case "disable": + exit(cmdDisable(ctx, client, s, args)) + case "enable": + exit(cmdEnable(ctx, client, s, args)) + case "rotate-password": + exit(cmdRotate(ctx, client, s, args)) + case "delete": + exit(cmdDelete(ctx, client, s, args)) + case "register-permit": + exit(cmdRegisterPermit(ctx, client, s, args)) + case "-h", "--help", "help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: service.account.tool [args]") + fmt.Fprintln(os.Stderr, "commands:") + fmt.Fprintln(os.Stderr, " create [--description text] [--env-id id] [--roles r1,r2]") + fmt.Fprintln(os.Stderr, " show ") + fmt.Fprintln(os.Stderr, " list") + fmt.Fprintln(os.Stderr, " disable ") + fmt.Fprintln(os.Stderr, " enable ") + fmt.Fprintln(os.Stderr, " rotate-password ") + fmt.Fprintln(os.Stderr, " delete ") + fmt.Fprintln(os.Stderr, " register-permit [--roles r1,r2]") +} + +func exit(err error) { + if err == nil { + return + } + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) +} + +// --- commands ----------------------------------------------------- + +// cmdCreate creates a Cognito user in pool B, sets a permanent +// password, and registers a matching Permit.io user. The password is +// printed once on success and discarded by the tool — the operator is +// responsible for copying it into their secret store. +func cmdCreate(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + fs := flag.NewFlagSet("create", flag.ExitOnError) + desc := fs.String("description", "", "human description of the service account") + envID := fs.String("env-id", "", "Cognito environment id to tag the user with") + rolesArg := fs.String("roles", "", "comma-separated Permit.io roles to assign") + _ = fs.Parse(args) + if fs.NArg() < 1 { + return fmt.Errorf("create: is required") + } + username := fs.Arg(0) + + // 1. AdminCreateUser + attrs := []types.AttributeType{ + {Name: aws.String("email"), Value: aws.String(username + "@svcacct.local")}, + {Name: aws.String("email_verified"), Value: aws.String("true")}, + {Name: aws.String("name"), Value: aws.String(username)}, + } + if *desc != "" { + attrs = append(attrs, types.AttributeType{ + Name: aws.String("custom:description"), + Value: aws.String(*desc), + }) + } + if *envID != "" { + attrs = append(attrs, types.AttributeType{ + Name: aws.String("custom:environment_id"), + Value: aws.String(*envID), + }) + } + attrs = append(attrs, types.AttributeType{ + Name: aws.String("custom:is_service_account"), + Value: aws.String("true"), + }) + + created, err := c.AdminCreateUser(ctx, &cognitoidentityprovider.AdminCreateUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + UserAttributes: attrs, + MessageAction: types.MessageActionTypeSuppress, + }) + if err != nil { + return fmt.Errorf("AdminCreateUser: %w", err) + } + + sub := extractAttr(created.User.Attributes, "sub") + + // 2. Permanent password + password, err := generatePassword() + if err != nil { + // Clean up the half-created user so we don't leave debris. + _, _ = c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }) + return fmt.Errorf("generatePassword: %w", err) + } + _, err = c.AdminSetUserPassword(ctx, &cognitoidentityprovider.AdminSetUserPasswordInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + Password: aws.String(password), + Permanent: true, + }) + if err != nil { + _, _ = c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }) + return fmt.Errorf("AdminSetUserPassword: %w", err) + } + + // 3. Register in Permit. Any failure beyond this point must roll + // back the Cognito user so we never leave an enabled service + // account with a permanent password that was never surfaced to + // the operator (plan §6.2). assignRole failures additionally + // roll back the Permit user record so Permit state stays in sync + // with Cognito. + rollbackCognito := func() { + _, _ = c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }) + } + if s.PermitAPIKey != "" { + scope, err := getAPIKeyScope(s) + if err != nil { + rollbackCognito() + return fmt.Errorf("permit scope: %w", err) + } + if err := createPermitUser(s, scope, sub, username, *envID); err != nil { + rollbackCognito() + return fmt.Errorf("createPermitUser: %w", err) + } + if *rolesArg != "" { + for _, r := range splitCSV(*rolesArg) { + if err := assignRole(s, scope, sub, r); err != nil { + _ = deletePermitUser(s, scope, sub) + rollbackCognito() + return fmt.Errorf("assignRole %s: %w", r, err) + } + } + } + } else { + fmt.Fprintln(os.Stderr, "warning: PERMIT_IO_API_KEY not set; skipping Permit registration") + } + + fmt.Println(strings.Repeat("=", 64)) + fmt.Println("Service account created. RECORD THE PASSWORD NOW.") + fmt.Println(strings.Repeat("=", 64)) + fmt.Printf("Username : %s\n", username) + fmt.Printf("Sub : %s\n", sub) + fmt.Printf("Pool id : %s\n", s.UserPoolID) + fmt.Printf("Client id: %s\n", s.AppClientID) + fmt.Printf("Password : %s\n", password) + return nil +} + +// cmdShow prints the user's Cognito attributes and Permit roles. +func cmdShow(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + if len(args) < 1 { + return fmt.Errorf("show: required") + } + username := args[0] + resp, err := c.AdminGetUser(ctx, &cognitoidentityprovider.AdminGetUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }) + if err != nil { + return fmt.Errorf("AdminGetUser: %w", err) + } + fmt.Printf("Username : %s\n", aws.ToString(resp.Username)) + fmt.Printf("Status : %s\n", resp.UserStatus) + fmt.Printf("Enabled : %v\n", resp.Enabled) + for _, a := range resp.UserAttributes { + fmt.Printf(" %-30s = %s\n", aws.ToString(a.Name), aws.ToString(a.Value)) + } + sub := extractAttr(resp.UserAttributes, "sub") + if s.PermitAPIKey != "" && sub != "" { + scope, err := getAPIKeyScope(s) + if err != nil { + fmt.Fprintf(os.Stderr, "permit scope: %v\n", err) + return nil + } + roles, err := getPermitRoles(s, scope, sub) + if err != nil { + fmt.Fprintf(os.Stderr, "permit roles: %v\n", err) + return nil + } + fmt.Printf("Permit roles : %s\n", strings.Join(roles, ", ")) + } + return nil +} + +// cmdList enumerates users in pool B. ListUsers is paginated, so we +// follow PaginationToken; we cap at 1000 entries to keep the tool +// from accidentally pulling massive output on a misconfigured pool. +func cmdList(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, _ []string) error { + const max = 1000 + var token *string + count := 0 + for count < max { + resp, err := c.ListUsers(ctx, &cognitoidentityprovider.ListUsersInput{ + UserPoolId: aws.String(s.UserPoolID), + PaginationToken: token, + }) + if err != nil { + return fmt.Errorf("ListUsers: %w", err) + } + for _, u := range resp.Users { + fmt.Printf("%-30s sub=%s status=%s enabled=%v\n", + aws.ToString(u.Username), + extractAttr(u.Attributes, "sub"), + u.UserStatus, u.Enabled) + count++ + if count >= max { + break + } + } + if resp.PaginationToken == nil || *resp.PaginationToken == "" { + break + } + token = resp.PaginationToken + } + return nil +} + +// cmdDisable disables the user and revokes outstanding refresh +// tokens. Outstanding access tokens remain valid until their iat+TTL +// elapses; the pool's AccessTokenValidity bounds the window. +func cmdDisable(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + if len(args) < 1 { + return fmt.Errorf("disable: required") + } + username := args[0] + if _, err := c.AdminDisableUser(ctx, &cognitoidentityprovider.AdminDisableUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }); err != nil { + return fmt.Errorf("AdminDisableUser: %w", err) + } + if _, err := c.AdminUserGlobalSignOut(ctx, &cognitoidentityprovider.AdminUserGlobalSignOutInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }); err != nil { + return fmt.Errorf("AdminUserGlobalSignOut: %w", err) + } + fmt.Printf("Disabled %s. Outstanding access tokens valid until exp.\n", username) + return nil +} + +func cmdEnable(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + if len(args) < 1 { + return fmt.Errorf("enable: required") + } + username := args[0] + if _, err := c.AdminEnableUser(ctx, &cognitoidentityprovider.AdminEnableUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }); err != nil { + return fmt.Errorf("AdminEnableUser: %w", err) + } + fmt.Printf("Enabled %s\n", username) + return nil +} + +func cmdRotate(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + if len(args) < 1 { + return fmt.Errorf("rotate-password: required") + } + username := args[0] + password, err := generatePassword() + if err != nil { + return err + } + if _, err := c.AdminSetUserPassword(ctx, &cognitoidentityprovider.AdminSetUserPasswordInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + Password: aws.String(password), + Permanent: true, + }); err != nil { + return fmt.Errorf("AdminSetUserPassword: %w", err) + } + fmt.Printf("New password for %s: %s\n", username, password) + return nil +} + +func cmdDelete(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + if len(args) < 1 { + return fmt.Errorf("delete: required") + } + username := args[0] + // Look up sub first so we can clean up Permit afterwards. + resp, err := c.AdminGetUser(ctx, &cognitoidentityprovider.AdminGetUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }) + if err != nil { + return fmt.Errorf("AdminGetUser: %w", err) + } + sub := extractAttr(resp.UserAttributes, "sub") + if _, err := c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }); err != nil { + return fmt.Errorf("AdminDeleteUser: %w", err) + } + if s.PermitAPIKey != "" && sub != "" { + scope, err := getAPIKeyScope(s) + if err == nil { + _ = deletePermitUser(s, scope, sub) + } + } + fmt.Printf("Deleted %s (sub=%s)\n", username, sub) + return nil +} + +// cmdRegisterPermit attaches an already-existing Cognito user (e.g. +// a service account created via shell scripts during the v1 bring-up) +// to a Permit.io project + roles. This is the path the integration +// test uses to make the test account a super_admin without creating +// a new Cognito user. +func cmdRegisterPermit(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error { + fs := flag.NewFlagSet("register-permit", flag.ExitOnError) + rolesArg := fs.String("roles", "", "comma-separated Permit.io roles to assign") + _ = fs.Parse(args) + if fs.NArg() < 1 { + return fmt.Errorf("register-permit: required") + } + username := fs.Arg(0) + if s.PermitAPIKey == "" { + return fmt.Errorf("PERMIT_IO_API_KEY must be set") + } + resp, err := c.AdminGetUser(ctx, &cognitoidentityprovider.AdminGetUserInput{ + UserPoolId: aws.String(s.UserPoolID), + Username: aws.String(username), + }) + if err != nil { + return fmt.Errorf("AdminGetUser: %w", err) + } + sub := extractAttr(resp.UserAttributes, "sub") + envID := extractAttr(resp.UserAttributes, "custom:environment_id") + + scope, err := getAPIKeyScope(s) + if err != nil { + return fmt.Errorf("permit scope: %w", err) + } + if err := createPermitUser(s, scope, sub, username, envID); err != nil { + return fmt.Errorf("createPermitUser: %w", err) + } + for _, r := range splitCSV(*rolesArg) { + if err := assignRole(s, scope, sub, r); err != nil { + return fmt.Errorf("assignRole %s: %w", r, err) + } + } + fmt.Printf("Registered %s (sub=%s) in Permit with roles=[%s]\n", username, sub, *rolesArg) + return nil +} + +// --- helpers ------------------------------------------------------ + +func extractAttr(attrs []types.AttributeType, name string) string { + for _, a := range attrs { + if aws.ToString(a.Name) == name { + return aws.ToString(a.Value) + } + } + return "" +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// httpClient returns a shared HTTP client with a sensible timeout +// for short-lived Permit.io API calls. +func httpClient() *http.Client { + return &http.Client{Timeout: 15 * time.Second} +} diff --git a/cmd/auth_related/service.account.tool/main_test.go b/cmd/auth_related/service.account.tool/main_test.go new file mode 100644 index 00000000..6486fbf0 --- /dev/null +++ b/cmd/auth_related/service.account.tool/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "strings" + "testing" +) + +// TestGeneratePassword sanity-checks the generated password meets +// the pool B policy: length >= 32 and every character class is +// present. Cognito will reject anything that misses a class so we +// verify in-process to catch regressions early. +func TestGeneratePassword(t *testing.T) { + for i := 0; i < 20; i++ { + p, err := generatePassword() + if err != nil { + t.Fatalf("generatePassword: %v", err) + } + if len(p) < 32 { + t.Errorf("password too short: %d chars", len(p)) + } + if !strings.ContainsAny(p, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + t.Errorf("missing uppercase in %q", p) + } + if !strings.ContainsAny(p, "abcdefghijklmnopqrstuvwxyz") { + t.Errorf("missing lowercase in %q", p) + } + if !strings.ContainsAny(p, "0123456789") { + t.Errorf("missing digit in %q", p) + } + if !strings.ContainsAny(p, "!@#$%^&*()_+-=[]{}|;:,.<>?/") { + t.Errorf("missing symbol in %q", p) + } + } +} + +func TestSplitCSV(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"a", []string{"a"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {" a , b , c ", []string{"a", "b", "c"}}, + {",,,", nil}, + } + for _, tc := range cases { + got := splitCSV(tc.in) + if len(got) != len(tc.want) { + t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("splitCSV(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) + } + } + } +} diff --git a/cmd/auth_related/service.account.tool/permit.go b/cmd/auth_related/service.account.tool/permit.go new file mode 100644 index 00000000..c79dc248 --- /dev/null +++ b/cmd/auth_related/service.account.tool/permit.go @@ -0,0 +1,169 @@ +// permit.go wraps the small slice of the Permit.io HTTP API the +// service-account CLI needs. We keep it self-contained rather than +// importing user.creation.tool's helpers because that package is a +// `main` and can't be reused. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// permitScope is what /v2/api-key/scope returns. We use it to figure +// out which project + environment a project-or-env-scoped API key is +// bound to. +type permitScope struct { + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` + EnvironmentID string `json:"environment_id"` +} + +func getAPIKeyScope(s *Settings) (*permitScope, error) { + req, err := http.NewRequest("GET", s.PermitBaseURL+"/v2/api-key/scope", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+s.PermitAPIKey) + resp, err := httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("permit scope status=%d body=%s", resp.StatusCode, string(body)) + } + var sc permitScope + if err := json.Unmarshal(body, &sc); err != nil { + return nil, err + } + if sc.ProjectID == "" || sc.EnvironmentID == "" { + return nil, fmt.Errorf("permit scope must be project+env; got project=%q env=%q", sc.ProjectID, sc.EnvironmentID) + } + return &sc, nil +} + +// createPermitUser idempotently registers the service account's +// Cognito sub as a Permit user. Conflicts are treated as success so +// repeated runs of the tool stay safe. +func createPermitUser(s *Settings, scope *permitScope, sub, username, envID string) error { + attrs := map[string]interface{}{ + "cognito_username": username, + "is_service_account": true, + } + if envID != "" { + attrs["environment_id"] = envID + } + // Permit.io validates the email field against a real-TLD list and + // rejects the @svcacct.local synthetic address with 422. The + // username is captured in attrs.cognito_username, so we omit + // `email` entirely from the registration payload. + body := map[string]interface{}{ + "key": sub, + "first_name": username, + "last_name": "service-account", + "attributes": attrs, + } + return doJSON(s, "POST", + fmt.Sprintf("%s/v2/facts/%s/%s/users", s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID), + body, []int{200, 201, 409}) +} + +func deletePermitUser(s *Settings, scope *permitScope, sub string) error { + url := fmt.Sprintf("%s/v2/facts/%s/%s/users/%s", s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID, sub) + return doJSON(s, "DELETE", url, nil, []int{200, 204, 404}) +} + +func assignRole(s *Settings, scope *permitScope, sub, role string) error { + body := map[string]interface{}{ + "user": sub, + "role": role, + "tenant": "default", + } + return doJSON(s, "POST", + fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID), + body, []int{200, 201, 409}) +} + +// getPermitRoles enumerates the user's role assignments. Used by the +// `show` command for human-readable output. +func getPermitRoles(s *Settings, scope *permitScope, sub string) ([]string, error) { + url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments?user=%s", + s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID, sub) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+s.PermitAPIKey) + resp, err := httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("get roles status=%d body=%s", resp.StatusCode, string(body)) + } + // Permit returns a direct array of assignments. + var arr []map[string]interface{} + if err := json.Unmarshal(body, &arr); err == nil { + out := make([]string, 0, len(arr)) + for _, a := range arr { + if r, ok := a["role"].(string); ok { + out = append(out, r) + } + } + return out, nil + } + // Some Permit shards wrap in { "data": [...] } — fall back. + var wrapped struct { + Data []map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, fmt.Errorf("unexpected role assignment response shape") + } + out := make([]string, 0, len(wrapped.Data)) + for _, a := range wrapped.Data { + if r, ok := a["role"].(string); ok { + out = append(out, r) + } + } + return out, nil +} + +// doJSON sends a JSON request and accepts any status in `okStatus`. +// Non-accepted statuses are surfaced verbatim so callers can see what +// Permit returned. +func doJSON(s *Settings, method, url string, payload interface{}, okStatus []int) error { + var bodyReader io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return err + } + bodyReader = bytes.NewReader(b) + } + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.PermitAPIKey) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + for _, code := range okStatus { + if resp.StatusCode == code { + return nil + } + } + return fmt.Errorf("%s %s status=%d body=%s", method, url, resp.StatusCode, string(body)) +} diff --git a/cmd/auth_related/service.account.tool/run.tool.sh b/cmd/auth_related/service.account.tool/run.tool.sh new file mode 100755 index 00000000..4c1db12a --- /dev/null +++ b/cmd/auth_related/service.account.tool/run.tool.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# run.tool.sh wraps service.account.tool with the env vars it needs +# against the local/dev pool B. Real Permit + AWS credentials must be +# present in the environment before invocation. Safe to invoke from any +# working directory — it anchors `go run` to its own location below. +set -euo pipefail + +# Anchor `go run ./...` to the package this script lives in. Without +# this cd, callers in other directories (e.g. the python wrappers under +# scripts/manual_tests/ that shell out via subprocess.run) end up +# resolving ./... against their own CWD and hit +# `go: no packages loaded from ./...`. +cd "$(dirname "${BASH_SOURCE[0]}")" + +# Service-accounts (pool B) — values from +# cmd/auth_related/service.accounts/creation.scripts/log.txt +export COGNITO_REGION="us-east-2" +export COGNITO_SVC_USER_POOL_ID="us-east-2_Qom1i9qIG" +export COGNITO_SVC_APP_CLIENT_ID="6r7chkvjotgs2bsfbd5ibaaft7" + +# Permit.io API key — operator supplies via shell. The dev key from +# cmd/auth_related/user.creation.tool/run.tool.sh is the common one +# during local bring-up. +: "${PERMIT_IO_API_KEY:?set PERMIT_IO_API_KEY before running}" +export PERMIT_IO_BASE_URL="${PERMIT_IO_BASE_URL:-https://api.permit.io}" + +# AWS — falls back to whatever the operator has authenticated as. +export AWS_REGION="${AWS_REGION:-us-east-2}" + +go run ./... "$@" diff --git a/cmd/auth_related/service.accounts/creation.scripts/create.pool.and.client.sh b/cmd/auth_related/service.accounts/creation.scripts/create.pool.and.client.sh new file mode 100755 index 00000000..7e67407a --- /dev/null +++ b/cmd/auth_related/service.accounts/creation.scripts/create.pool.and.client.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# +# create.pool.and.client.sh +# +# Provision the service-accounts Cognito user pool ("Pool B") and its single +# app client, per plans/api.key.support.cognito.two-pool.plan.3.claude.md §4.1. +# +# Run this from AWS CloudShell while authenticated as an admin in the target +# account. No --profile flag is passed because CloudShell uses the console +# session's credentials. +# +# Idempotency: this script does NOT check for an existing pool of the same +# name. Cognito permits duplicate pool names and will silently create a second +# one. If a re-run is needed, delete the previous pool first or change +# POOL_NAME below. +# +# Outputs (to stdout and to LOG_FILE): +# - User pool id +# - User pool issuer URL (use as COGNITO_SVC_USER_POOL_ID source) +# - JWKS URL (use as COGNITO_SVC_JWKS_URL) +# - App client id (use as COGNITO_SVC_APP_CLIENT_ID) +# +# Requires: aws cli v2, jq (both pre-installed in CloudShell). +# +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration. Adjust AWS_REGION if the target account does not run in +# us-east-2. +# --------------------------------------------------------------------------- +AWS_REGION="${AWS_REGION:-us-east-2}" +POOL_NAME="aarete-service-test-pool-1" +APP_CLIENT_NAME="AareteServiceTestClient" +LOG_FILE="create.pool.and.client.log.txt" + +# Per plan §4.1: 60-minute access/id tokens, 10-year refresh, four-class +# 32-char password policy. These match the values queryAPI expects. +ACCESS_TOKEN_VALIDITY_MIN=60 +ID_TOKEN_VALIDITY_MIN=60 +REFRESH_TOKEN_VALIDITY_DAYS=3650 +PASSWORD_MIN_LENGTH=32 + +# --------------------------------------------------------------------------- +> "$LOG_FILE" + +log() { + echo "$@" | tee -a "$LOG_FILE" +} + +log "Region: $AWS_REGION" +log "Pool name: $POOL_NAME" +log "App client name: $APP_CLIENT_NAME" +log "---" + +# --------------------------------------------------------------------------- +# 1. Create the user pool. +# +# Schema attributes (custom:*) declared up-front because Cognito will not let +# you add new custom attributes to an existing pool without re-creating +# clients. All four are mutable strings so the service-account CLI can set +# them at AdminCreateUser time. +# +# Notes: +# - --mfa-configuration OFF : no MFA on this pool +# - --admin-create-user-config : self-signup forbidden +# - --account-recovery-setting admin_only : no email recovery +# - --policies : 32-char four-class passwords +# --------------------------------------------------------------------------- +log "Creating Cognito user pool '$POOL_NAME'..." + +USER_POOL_ID=$(aws cognito-idp create-user-pool \ + --region "$AWS_REGION" \ + --pool-name "$POOL_NAME" \ + --mfa-configuration OFF \ + --admin-create-user-config "AllowAdminCreateUserOnly=true" \ + --account-recovery-setting 'RecoveryMechanisms=[{Name=admin_only,Priority=1}]' \ + --policies "PasswordPolicy={MinimumLength=${PASSWORD_MIN_LENGTH},RequireUppercase=true,RequireLowercase=true,RequireNumbers=true,RequireSymbols=true,TemporaryPasswordValidityDays=1}" \ + --schema \ + 'Name=environment_id,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=1,MaxLength=256}' \ + 'Name=description,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=0,MaxLength=2048}' \ + 'Name=created_by,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=0,MaxLength=256}' \ + 'Name=is_service_account,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=0,MaxLength=8}' \ + --query "UserPool.Id" --output text) + +if [ -z "$USER_POOL_ID" ] || [ "$USER_POOL_ID" = "None" ]; then + log "ERROR: create-user-pool did not return a pool id." + exit 1 +fi + +log "Pool created: $USER_POOL_ID" + +ISSUER_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" +JWKS_URL="${ISSUER_URL}/.well-known/jwks.json" + +# --------------------------------------------------------------------------- +# 2. Create the single app client for this pool. +# +# Notes: +# - --no-generate-secret : callers authenticate with user/pass, +# not a client secret. +# - --explicit-auth-flows : USER_PASSWORD_AUTH for the initial +# InitiateAuth, REFRESH_TOKEN_AUTH so the +# caller can refresh. +# - --enable-token-revocation : lets disable+global-signout invalidate +# refresh tokens immediately. +# - --prevent-user-existence-errors ENABLED +# : do not leak whether a username exists. +# - --token-validity-units : access/id in minutes, refresh in days. +# --------------------------------------------------------------------------- +log "---" +log "Creating app client '$APP_CLIENT_NAME'..." + +APP_CLIENT_OUTPUT=$(aws cognito-idp create-user-pool-client \ + --region "$AWS_REGION" \ + --user-pool-id "$USER_POOL_ID" \ + --client-name "$APP_CLIENT_NAME" \ + --no-generate-secret \ + --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \ + --access-token-validity "$ACCESS_TOKEN_VALIDITY_MIN" \ + --id-token-validity "$ID_TOKEN_VALIDITY_MIN" \ + --refresh-token-validity "$REFRESH_TOKEN_VALIDITY_DAYS" \ + --token-validity-units "AccessToken=minutes,IdToken=minutes,RefreshToken=days" \ + --enable-token-revocation \ + --prevent-user-existence-errors ENABLED) + +APP_CLIENT_ID=$(echo "$APP_CLIENT_OUTPUT" | jq -r '.UserPoolClient.ClientId') + +if [ -z "$APP_CLIENT_ID" ] || [ "$APP_CLIENT_ID" = "null" ]; then + log "ERROR: create-user-pool-client did not return a client id." + log "Raw output:" + log "$APP_CLIENT_OUTPUT" + exit 1 +fi + +log "App client created: $APP_CLIENT_ID" + +# --------------------------------------------------------------------------- +# 3. Sanity-check the JWKS endpoint. If this fails, the multi-issuer verifier +# in internal/cognitoauth/token.go will not be able to validate tokens from +# this pool. Failure here is fatal because nothing downstream will work. +# --------------------------------------------------------------------------- +log "---" +log "Checking JWKS endpoint $JWKS_URL ..." +JWKS_HTTP_CODE=$(curl -s -o /tmp/svc-pool-jwks.json -w "%{http_code}" "$JWKS_URL") +if [ "$JWKS_HTTP_CODE" != "200" ]; then + log "ERROR: JWKS endpoint returned HTTP $JWKS_HTTP_CODE" + exit 1 +fi +JWKS_KEY_COUNT=$(jq '.keys | length' /tmp/svc-pool-jwks.json) +log "JWKS reachable (HTTP 200); key count: $JWKS_KEY_COUNT" + +# --------------------------------------------------------------------------- +# 4. Summary block. Copy these values into the test environment's settings +# when wiring queryAPI to the new pool: +# +# COGNITO_SVC_USER_POOL_ID= +# COGNITO_SVC_JWKS_URL= +# COGNITO_SVC_APP_CLIENT_ID= +# --------------------------------------------------------------------------- +log "===================================================================" +log "Service-accounts pool provisioned" +log "===================================================================" +log "AWS region : $AWS_REGION" +log "User pool name : $POOL_NAME" +log "User pool id : $USER_POOL_ID" +log "Issuer URL : $ISSUER_URL" +log "JWKS URL : $JWKS_URL" +log "App client name : $APP_CLIENT_NAME" +log "App client id : $APP_CLIENT_ID" +log "Access token TTL : ${ACCESS_TOKEN_VALIDITY_MIN} minutes" +log "Id token TTL : ${ID_TOKEN_VALIDITY_MIN} minutes" +log "Refresh token TTL : ${REFRESH_TOKEN_VALIDITY_DAYS} days" +log "Password min length : ${PASSWORD_MIN_LENGTH}" +log "MFA : OFF" +log "Self-signup : DISABLED (admin-create-user only)" +log "Account recovery : admin_only" +log "Token revocation : ENABLED" +log "===================================================================" +log "" +log "Next step: create a test service-account user with" +log "" +log " aws cognito-idp admin-create-user \\" +log " --region $AWS_REGION \\" +log " --user-pool-id $USER_POOL_ID \\" +log " --username svc-test-1 \\" +log " --user-attributes Name=email,Value=svc-test-1@svcacct.local \\" +log " --message-action SUPPRESS" +log "" +log " aws cognito-idp admin-set-user-password \\" +log " --region $AWS_REGION \\" +log " --user-pool-id $USER_POOL_ID \\" +log " --username svc-test-1 \\" +log " --password '<32-char-strong-password>' \\" +log " --permanent" +log "" +log " aws cognito-idp initiate-auth \\" +log " --region $AWS_REGION \\" +log " --client-id $APP_CLIENT_ID \\" +log " --auth-flow USER_PASSWORD_AUTH \\" +log " --auth-parameters USERNAME=svc-test-1,PASSWORD=''" +log "" +log "Log of this run saved to: $LOG_FILE" diff --git a/cmd/auth_related/service.accounts/creation.scripts/create.test.super.admin.sh b/cmd/auth_related/service.accounts/creation.scripts/create.test.super.admin.sh new file mode 100755 index 00000000..b248c01f --- /dev/null +++ b/cmd/auth_related/service.accounts/creation.scripts/create.test.super.admin.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# +# create.test.super.admin.sh +# +# Provision one test service-account user in the pool created by +# create.pool.and.client.sh, set a permanent random password, and run a +# round-trip InitiateAuth so we know the credentials work end-to-end. +# +# Run from AWS CloudShell while authenticated as an admin in the target +# account. +# +# Outputs (to stdout AND to LOG_FILE): +# - Username, email, sub +# - Password (printed ONCE — Cognito stores only the hash) +# - AccessToken, IdToken, RefreshToken from a test InitiateAuth +# - Decoded JWT claims (so you can verify the shape queryAPI will see) +# - Pool-level identifiers echoed back for convenience +# +# Note on "super admin": super_admin is a Permit.io role, not a Cognito +# attribute. This script creates only the Cognito identity. To actually +# grant super_admin, use the printed `sub` as the Permit user key and +# assign the role via Permit.io (console, /admin/users/{sub}/roles, or +# whatever role-assignment path you already use for human users). +# +# Requires: aws cli v2, jq, openssl, python3 (all pre-installed in +# CloudShell). +# +set -euo pipefail + +# --------------------------------------------------------------------------- +# Constants from the prior create.pool.and.client.sh run. +# Edit if you re-provisioned the pool. +# --------------------------------------------------------------------------- +AWS_REGION="us-east-2" +USER_POOL_ID="us-east-2_Qom1i9qIG" +APP_CLIENT_ID="6r7chkvjotgs2bsfbd5ibaaft7" +ISSUER_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" +JWKS_URL="${ISSUER_URL}/.well-known/jwks.json" + +# --------------------------------------------------------------------------- +# Test user configuration. +# --------------------------------------------------------------------------- +USERNAME="svc-test-admin-1" +LABEL="$USERNAME" +EMAIL="${LABEL}@svcacct.local" +DESCRIPTION="Test service account for super-admin role validation" +CREATED_BY="cloudshell-admin" + +LOG_FILE="create.test.super.admin.log.txt" +> "$LOG_FILE" + +log() { + echo "$@" | tee -a "$LOG_FILE" +} + +# --------------------------------------------------------------------------- +# 1. Refuse to clobber an existing user. Re-running this script is the most +# likely operator mistake; failing loudly is friendlier than silently +# replacing the password and breaking whatever is using the prior one. +# --------------------------------------------------------------------------- +if aws cognito-idp admin-get-user \ + --region "$AWS_REGION" \ + --user-pool-id "$USER_POOL_ID" \ + --username "$USERNAME" >/dev/null 2>&1; then + log "ERROR: user '$USERNAME' already exists in pool $USER_POOL_ID." + log "Delete it first or change USERNAME at the top of this script:" + log " aws cognito-idp admin-delete-user \\" + log " --region $AWS_REGION \\" + log " --user-pool-id $USER_POOL_ID \\" + log " --username $USERNAME" + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2. Generate a 32-char password that satisfies the pool's policy +# (>=32 chars, upper, lower, digit, symbol). +# +# Structure: a guaranteed sample of each required class ("A1a!") plus 28 +# random characters from a base64 alphabet (mix of upper/lower/digits). +# Total = 32 chars. Each class is present at least once; the random tail +# adds entropy. +# --------------------------------------------------------------------------- +RAND_TAIL=$(openssl rand -base64 48 | tr -d '/=+\n' | head -c 28) +PASSWORD="A1a!${RAND_TAIL}" + +if [ "${#PASSWORD}" -ne 32 ]; then + log "ERROR: generated password is ${#PASSWORD} chars; expected 32." + exit 1 +fi + +# --------------------------------------------------------------------------- +# 3. AdminCreateUser. Custom attributes match plan §4.5: +# +# name = label (human-friendly identifier) +# email = synthetic