Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/RELEASE_NOTES_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

### Highlights

- Generated payload, manual, and audit files are now atomically replaced with owner-only `0600` permissions, including outputs that may contain static AWS credentials.
- Forward API reads, NQE queries, and full-state PATCH operations retry bounded transient `429`, `502`, `503`, and `504` responses. Non-idempotent create POSTs remain single-attempt.
- `awssync --version` now reports the release, source commit, and build date.
- CI now runs formatting, vet, tests, the race detector, and `govulncheck` with read-only repository permissions and commit-pinned actions.
- Release jobs use least-privilege permissions and continue to publish checksums and build-provenance attestations.
- The README now starts with the workflow decision diagram and routes detailed operator procedures to focused runbooks.
- Contribution guidance requires human attribution and excludes automation/tool identities from contributor metadata.
- `awssync external-id` can now target one or more `--account-id` values while preserving every unselected account.
- A reviewed `--external-id-file` CSV supports different values and explicit set/clear actions per setup and account.
- Normal NQE, webhook, and authoritative-manifest syncs preserve mixed per-account External IDs instead of flattening them to the first configured value.
- New accounts in a mixed-ID setup fail closed unless the plan receives an explicit per-account CSV assignment.
- CSV validation rejects malformed IDs, duplicates, implicit clears, wrong setups, and accounts outside the planned inventory before PATCH.
- Dry-run summaries report selected, changed, unchanged, set, and cleared account counts plus per-account change metadata.
- Release assets remain available for Linux and macOS on amd64 and arm64 with SHA-256 checksums and GitHub build-provenance attestations.

### Download and verify

Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
flowchart TD
A[What are you doing?] -->|Update an existing setup| B{Complete Organizations inventory<br/>is visible in Forward NQE?}
A -->|Create a new setup| C{Can the customer use<br/>AWS Organizations?}
A -->|Change External ID only| X[Run external-id dry plan<br/>then apply once]
A -->|Change External ID only| X[Choose all accounts, selected IDs,<br/>or a reviewed CSV; dry-run first]

B -->|Yes| D[Use preflight and the default NQE sync]
B -->|No or standalone GovCloud accounts| E[Use a reviewed authoritative manifest]
Expand All @@ -33,6 +33,7 @@ The key choice is the inventory source. Use Forward NQE only when a current snap
- Additions can be automated. Removals are blocked unless `--allow-removals` is explicit.
- `--max-removals` and `--max-removal-percent` impose independent blast-radius ceilings.
- Empty candidate inventory, stale snapshots, missing Organizations evidence, and unsafe GovCloud removal plans fail closed.
- Existing per-account External IDs are preserved. Adding accounts to a mixed-ID setup fails unless the new accounts have explicit CSV assignments.
- Saved plans are revalidated against current Forward state before apply.
- Generated payload and audit files are written atomically with owner-only `0600` permissions.
- Transient API failures are retried only for idempotent reads and full-state updates; create operations are never automatically retried.
Expand Down Expand Up @@ -118,7 +119,7 @@ Never remove an account only because collection fails. If it remains visible in

## Customer-Defined External ID

Changing an External ID is a separate, one-time workflow and works with an existing IAM-user/access-key setup. First review the Forward payload without changing anything:
Changing an External ID is a separate workflow and works with an existing IAM-user/access-key setup. With no `--account-id`, the command retains its setup-wide behavior. First review the Forward payload without changing anything:

```bash
./bin/awssync external-id \
Expand All @@ -140,6 +141,40 @@ Update the target-role trust policies to require the identical `sts:ExternalId`,

Later syncs preserve the value. To roll back, first relax the AWS trust policies, verify role assumption, then run the same command with `--clear` instead of `--value`.

For a representative-account test or different values per account, scope the command with one or more account IDs:

```bash
./bin/awssync external-id \
--setup-id AWS-PROD \
--account-id 111111111111 \
--value test-external-id \
--output aws_external_id_test.json \
--format human
```

For a reviewed batch, use CSV. `set` requires a non-empty value; `clear` requires an empty value. A blank cell by itself never means clear.

```csv
setup_id,account_id,action,external_id
AWS-PROD,111111111111,set,account-one-value
AWS-PROD,222222222222,set,account-two-value
AWS-PROD,333333333333,clear,
```

```bash
./bin/awssync external-id \
--setup-id AWS-PROD \
--external-id-file external-ids.csv \
--output aws_external_id_payload.json \
--format human
```

Omitted accounts remain unchanged. Duplicate, malformed, wrong-setup, and unknown account rows stop before any PATCH. The generated payload still contains the complete current account list because Forward updates this field as full state.

Scoped rollback uses the same `--account-id` selection. Dry-run `--clear` when the account previously had no External ID, or `--value PREVIOUS_VALUE` when restoring a prior non-null value; then repeat the reviewed command with `--apply --yes`. Record any prior non-null value before testing because the command reports whether a previous value was configured but does not retain that value as an automatic rollback artifact. Relax the matching AWS trust-policy condition before clearing or replacing the Forward value.

Ordinary NQE, webhook, and `sync-accounts` runs preserve each existing account's value. If a mixed-ID setup discovers a new account, preflight and dry-run fail closed until that account is assigned in the same CSV passed with `--external-id-file`.

## Onboarding and GovCloud

| Environment | Inventory source | Recommended command or workflow |
Expand Down
52 changes: 33 additions & 19 deletions cmd/awssync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func newRootCommand() *cobra.Command {
AllowNoCandidates: flagBool(cmd, v, "allow-no-candidates"),
AllowNoOrgEvidence: flagBool(cmd, v, "allow-no-org-evidence"),
MaxSnapshotAge: flagDuration(cmd, v, "max-snapshot-age"),
ExternalIDFile: flagString(cmd, v, "external-id-file"),
}
previewSummary, err := app.Run(cmd.Context(), preview)
if err != nil {
Expand Down Expand Up @@ -126,6 +127,7 @@ func newRootCommand() *cobra.Command {
AllowNoCandidates: flagBool(cmd, v, "allow-no-candidates"),
AllowNoOrgEvidence: flagBool(cmd, v, "allow-no-org-evidence"),
MaxSnapshotAge: flagDuration(cmd, v, "max-snapshot-age"),
ExternalIDFile: flagString(cmd, v, "external-id-file"),
}
summary, err := app.Run(cmd.Context(), cfg)
if err != nil {
Expand Down Expand Up @@ -171,25 +173,29 @@ func newExternalIDCommand(v *viper.Viper) *cobra.Command {
setupID, _ := cmd.Flags().GetString("setup-id")
value, _ := cmd.Flags().GetString("value")
clearValue, _ := cmd.Flags().GetBool("clear")
accountIDs, _ := cmd.Flags().GetStringSlice("account-id")
externalIDFile, _ := cmd.Flags().GetString("external-id-file")
apply, _ := cmd.Flags().GetBool("apply")
yes, _ := cmd.Flags().GetBool("yes")
if err := confirmApply(apply, yes, os.Stdin, os.Stderr); err != nil {
return err
}
output, _ := cmd.Flags().GetString("output")
summary, err := app.ChangeExternalID(cmd.Context(), app.ExternalIDConfig{
Host: v.GetString("host"),
Username: v.GetString("username"),
Password: password,
NetworkID: networkID,
SetupID: setupID,
ExternalID: value,
Clear: clearValue,
Output: output,
APIPrefix: v.GetString("api-prefix"),
Insecure: v.GetBool("insecure"),
Timeout: v.GetDuration("timeout"),
Apply: apply,
Host: v.GetString("host"),
Username: v.GetString("username"),
Password: password,
NetworkID: networkID,
SetupID: setupID,
AccountIDs: accountIDs,
ExternalID: value,
Clear: clearValue,
ExternalIDFile: externalIDFile,
Output: output,
APIPrefix: v.GetString("api-prefix"),
Insecure: v.GetBool("insecure"),
Timeout: v.GetDuration("timeout"),
Apply: apply,
})
if err != nil {
return err
Expand All @@ -201,6 +207,8 @@ func newExternalIDCommand(v *viper.Viper) *cobra.Command {
cmd.Flags().String("setup-id", "", "existing Forward AWS setup ID")
cmd.Flags().String("value", "", "customer-defined External ID to set")
cmd.Flags().Bool("clear", false, "clear the External ID back to null")
cmd.Flags().StringSlice("account-id", nil, "optional AWS account ID to change; repeatable or comma-separated")
cmd.Flags().String("external-id-file", "", "CSV file of per-account External ID set/clear actions")
cmd.Flags().String("output", "", "output JSON path for the generated PATCH payload")
cmd.Flags().Bool("apply", false, "PATCH the generated setup payload into Forward")
cmd.Flags().Bool("yes", false, "skip apply confirmation prompt")
Expand Down Expand Up @@ -244,6 +252,7 @@ func bindPreflightFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.Int("max-removals", 0, "maximum aggregate account removals allowed during apply; 0 disables the limit")
flags.Float64("max-removal-percent", 0, "maximum removal percentage allowed per setup during apply; 0 disables the limit")
flags.Duration("max-snapshot-age", 0, "fail if latest processed snapshot is older than this duration; 0 disables the check")
flags.String("external-id-file", "", "CSV file of explicit per-account External IDs for mixed-ID setups")
mustBind(v, flags, "snapshot-id")
mustBind(v, flags, "query-id")
mustBind(v, flags, "query-setup-param")
Expand All @@ -252,6 +261,7 @@ func bindPreflightFlags(v *viper.Viper, flags *pflag.FlagSet) {
mustBind(v, flags, "max-removals")
mustBind(v, flags, "max-removal-percent")
mustBind(v, flags, "max-snapshot-age")
mustBind(v, flags, "external-id-file")
}

func bindProcessingFlags(v *viper.Viper, flags *pflag.FlagSet) {
Expand All @@ -268,6 +278,7 @@ func bindProcessingFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.Bool("allow-no-candidates", false, "allow removals when no uncollected candidate accounts are visible")
flags.Bool("allow-no-org-evidence", false, "allow removals when no AWS Organizations evidence is visible in NQE")
flags.Duration("max-snapshot-age", 0, "fail if latest processed snapshot is older than this duration; 0 disables the check")
flags.String("external-id-file", "", "CSV file of explicit per-account External IDs for mixed-ID setups")
mustBind(v, flags, "query-id")
mustBind(v, flags, "query-setup-param")
mustBind(v, flags, "setup-id")
Expand All @@ -281,6 +292,7 @@ func bindProcessingFlags(v *viper.Viper, flags *pflag.FlagSet) {
mustBind(v, flags, "allow-no-candidates")
mustBind(v, flags, "allow-no-org-evidence")
mustBind(v, flags, "max-snapshot-age")
mustBind(v, flags, "external-id-file")
}

func newPreflightCommand(v *viper.Viper) *cobra.Command {
Expand Down Expand Up @@ -318,6 +330,7 @@ func newPreflightCommand(v *viper.Viper) *cobra.Command {
MaxRemovals: flagInt(cmd, v, "max-removals"),
MaxRemovalPercent: flagFloat64(cmd, v, "max-removal-percent"),
MaxSnapshotAge: flagDuration(cmd, v, "max-snapshot-age"),
ExternalIDFile: flagString(cmd, v, "external-id-file"),
})
if err != nil {
return err
Expand Down Expand Up @@ -697,6 +710,7 @@ func newSyncAccountsCommand(v *viper.Viper) *cobra.Command {
AllowRemovals: flagBool(cmd, v, "allow-removals"),
MaxRemovals: flagInt(cmd, v, "max-removals"),
MaxRemovalPercent: flagFloat64(cmd, v, "max-removal-percent"),
ExternalIDFile: flagString(cmd, v, "external-id-file"),
}, accounts)
if err != nil {
return err
Expand All @@ -714,7 +728,8 @@ func newSyncAccountsCommand(v *viper.Viper) *cobra.Command {
cmd.Flags().Bool("allow-removals", false, "allow reviewed manifest entries to remove accounts from the setup")
cmd.Flags().Int("max-removals", 0, "maximum aggregate account removals allowed; 0 disables the limit")
cmd.Flags().Float64("max-removal-percent", 0, "maximum removal percentage allowed for the setup; 0 disables the limit")
for _, name := range []string{"accounts-file", "setup-id", "output", "manual-output", "apply", "yes", "allow-removals", "max-removals", "max-removal-percent"} {
cmd.Flags().String("external-id-file", "", "CSV file of explicit per-account External IDs")
for _, name := range []string{"accounts-file", "setup-id", "output", "manual-output", "apply", "yes", "allow-removals", "max-removals", "max-removal-percent", "external-id-file"} {
mustBind(v, cmd.Flags(), name)
}
return cmd
Expand Down Expand Up @@ -758,6 +773,7 @@ func newServeWebhookCommand(v *viper.Viper) *cobra.Command {
AllowNoCandidates: flagBool(cmd, v, "allow-no-candidates"),
AllowNoOrgEvidence: flagBool(cmd, v, "allow-no-org-evidence"),
MaxSnapshotAge: flagDuration(cmd, v, "max-snapshot-age"),
ExternalIDFile: flagString(cmd, v, "external-id-file"),
},
})
if err != nil {
Expand Down Expand Up @@ -1182,17 +1198,15 @@ func emitResult(cmd *cobra.Command, v *viper.Viper, value any) error {
}

func emitExternalIDHuman(summary *app.ExternalIDSummary) error {
action := "set"
if !summary.TargetExternalIDConfigured {
action = "clear"
}
fmt.Fprintln(os.Stdout, "External ID migration report")
fmt.Fprintf(os.Stdout, " host: %s\n", summary.Host)
fmt.Fprintf(os.Stdout, " network: %s\n", summary.NetworkID)
fmt.Fprintf(os.Stdout, " setup: %s\n", summary.SetupID)
fmt.Fprintf(os.Stdout, " action: %s\n", action)
fmt.Fprintf(os.Stdout, " accounts: %d\n", summary.AccountCount)
fmt.Fprintf(os.Stdout, " mode: %s\n", summary.Mode)
fmt.Fprintf(os.Stdout, " accounts: %d total, %d selected, %d changed, %d unchanged\n", summary.AccountCount, summary.SelectedAccountCount, summary.ChangedAccountCount, summary.UnchangedAccountCount)
fmt.Fprintf(os.Stdout, " actions: %d set, %d clear\n", summary.SetAccountCount, summary.ClearedAccountCount)
fmt.Fprintf(os.Stdout, " prior ID: configured=%t consistent=%t\n", summary.PreviousExternalIDConfigured, summary.PreviousExternalIDConsistent)
fmt.Fprintf(os.Stdout, " target ID: configured=%t consistent=%t\n", summary.TargetExternalIDConfigured, summary.TargetExternalIDConsistent)
fmt.Fprintf(os.Stdout, " apply: %t\n", summary.Apply)
fmt.Fprintf(os.Stdout, " patched: %t\n", summary.Patched)
fmt.Fprintf(os.Stdout, " output: %s\n", summary.Output)
Expand Down
7 changes: 5 additions & 2 deletions docs/architecture-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ flowchart TB

subgraph plan_apply["awssync — plan and apply"]
plan["plan / dry-run\nPOST /nqe + GET /cloudAccounts"]
external_ids["Per-account External ID merge\npreserve existing values\nexplicit CSV for ambiguous additions"]
disk["payload.json\nwritten to disk before any change"]
safety["Removal gates\nexplicit approval + count/% ceilings"]
apply["--apply\nPATCH /cloudAccounts/{setupId}"]
Expand All @@ -84,7 +85,7 @@ flowchart TB
cron --> plan

preflight -- "read-only" --> fwd
plan --> disk
plan --> external_ids --> disk
disk --> safety --> apply
disk --> apply_plan
apply_plan --> safety
Expand All @@ -99,11 +100,13 @@ flowchart TB
classDef artifact fill:#FAEEDA,stroke:#854F0B,color:#412402;

class cli,cron,preflight neutral;
class plan,safety,apply,apply_plan neutral;
class plan,external_ids,safety,apply,apply_plan neutral;
class nqe,get_accts,patch_accts,get_snap fwdnode;
class disk artifact;
```

The account list is full-state, but External IDs are merged by AWS account ID. Existing mixed values are preserved. When a mixed-ID setup gains an account, planning stops unless `--external-id-file` explicitly supplies the new account's value; omitted existing accounts remain unchanged.

---

## Mode 2 — Initial AWS Organizations Onboarding
Expand Down
Loading