feat(organizations): support many independent organizations per process - #2549
Merged
Merged
Conversation
Organizations state was a process-wide `Option<OrganizationState>` --
at most one organization could ever exist. `CreateOrganization`
rejected on "an organization exists" rather than "the caller is already
in one", so once any account created an organization, every other
account in the process was permanently locked out of creating its own:
if guard.is_some() {
return Err(... "AlreadyInOrganizationException" ...);
}
That is the remaining half of #2543. The reporter's script vends two
management accounts and expects two independent organizations with two
child accounts each; the second `CreateOrganization` failed purely
because the first organization existed.
The state becomes an `OrganizationsRegistry` keyed by organization id.
An account still belongs to at most one organization, which is what
makes "the caller's organization" well defined, so almost every handler
resolves through `org_of_account` instead of reaching for the singleton.
Organizations never see each other: a caller in none gets the same
`AWSOrganizationsNotInUseException` as a process with no organizations
at all.
Behavior:
- `CreateOrganization` rejects only when the CALLING account is already
a member. Same for the CloudFormation `AWS::Organizations::Organization`
provisioner, and `DeleteOrganization` now removes exactly the caller's
organization instead of clearing the world.
- An account can be in one organization only. Inviting an account another
organization already holds is `AccountAlreadyRegisteredException`, and
that is re-checked when the handshake is ACCEPTED -- the target may have
joined elsewhere while the invitation sat open.
- Handshakes resolve by id across organizations, because the account
answering an invitation is not yet a member of the inviting one.
`ListHandshakesForAccount` likewise spans organizations: a standalone
account's invitations all live in organizations it does not belong to.
- SCP ceilings and `AssumeRoot` resolve the principal's/caller's own
organization, so one organization's SCPs can never reach another's
accounts.
- StackSets auto-deployment reconciles each stack set against ITS OWN
administrator's organization. Reconciling every stack set against one
organization would have deployed into another organization's accounts.
Reads that were satisfied by "an organization exists" now require
membership -- `ListParents`, `ListChildren`, `ListTagsForResource`,
`DescribeEffectivePolicy`, `DescribeResourcePolicy` (which ignored the
caller entirely) and the responsibility-transfer ops. `DescribeHandshake`
is limited to the handshake's two parties. With one organization these
were harmless; with two they are cross-organization reads.
Persistence: snapshot schema goes to v2, holding the registry. v1's
single-organization field is still read and folded in, so an existing
snapshot keeps loading across the upgrade; it is never written again.
Introspection `GET /_fakecloud/organizations/accounts` now spans every
organization: each account carries its `organizationId`, and a new
`organizations` array summarises each one. The flat
`managementAccountId`/`masterAccountId` stay populated when exactly one
organization exists, so single-organization callers are unaffected, and
are null with several rather than reporting an arbitrary pick. All seven
SDKs and the docs follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
… gates Follow-ups from reviewing the multi-organization change, three of them regressions it introduced: - `TerminateResponsibilityTransfer` refused an ACCEPTED transfer. Syncing the transfer's status with its handshake meant accepting moved it REQUESTED -> ACCEPTED, and terminate still required REQUESTED, so a transfer could never be ended once it started -- which is the case the operation exists for. It now ends a REQUESTED or ACCEPTED transfer and refuses only one already in a terminal state. - An ACCEPTED transfer was stamped with an `EndTimestamp`, reporting it as simultaneously running and already over. Only DECLINED, CANCELED and EXPIRED end a transfer. - `InviteAccountToOrganization` rejected any EMAIL target that was not an address fakecloud had minted. Inviting the account owner's real address is AWS's primary flow; an address fakecloud cannot resolve now yields an OPEN handshake its source can read and cancel, exactly as before the owner acts on it. The type/shape checks stay. - `DescribeHandshake` allowed only the two parties. AWS documents it as callable "from any account in the organization", so membership of the organization that owns the handshake is enough -- it is another organization's handshakes that must stay invisible, and they do. - `InviteOrganizationToTransferResponsibility` validated nothing. It now applies the same `Target.Type`/`Target.Id` check as the account-invite path, so a mismatched target cannot open a handshake keyed by a string no caller can authenticate as. - `LeaveOrganization`'s doc comment still described the deleted `AccountNotFoundException` branch; `ResponsibilityTransferRow.direction` now documents that it is relative to the row's own organization and therefore always OUTBOUND. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…nd a transfer - The duplicate-handshake scan matched any handshake to the target, including a `TRANSFER_RESPONSIBILITY` one. An outstanding billing transfer to an account therefore blocked a membership invitation to that same account with `DuplicateHandshakeException`. AWS keeps the two handshake actions independent, so the scan now looks at `INVITE` handshakes only. - `TerminateResponsibilityTransfer` was source-only. Once a transfer is accepted its riding handshake is gone, so the target management account -- actively carrying the delegated responsibility -- had no operation left that could end the arrangement. Either party can now end it; renaming stays the source's, since the source named it. - `validate_invite_target`'s doc still described the resolvability rule that was dropped, and the .NET `OrganizationId` doc comment sat inside the record's positional parameter list, where C# emits CS1587 and discards it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
`target_account_id` only recognised the synthetic `<account-id>@example.com` form, but `CreateAccount` stores the caller's own `Email` verbatim. An account created with a real address was therefore invisible to the "one organization per account" guards: it could be invited again while already a member, and the cross-organization checks on invite and accept were both bypassed. Resolution now matches the address each member account is actually registered with, falling back to the synthetic form. Also from review: - A responsibility transfer names existing management accounts on both sides, so an EMAIL target must resolve to one. `Target.ManagementAccountId` is an account id in the Smithy shape, and an unresolved address was being written into it; an `ACCOUNT` target was also being given a synthesized `target_email`, which `handshake_payload` then preferred, so the response echoed an address under `Type: ACCOUNT`. - Terminating a transfer that is still `REQUESTED` is the source's alone: `WITHDRAWN` means the inviter pulled the offer, and the target's answer to an open offer is `DeclineHandshake`. Once ACCEPTED the riding handshake is gone, so either party can end it. - The Organizations reference claimed organizations never see each other; the handshake and responsibility-transfer ops are cross-organization by nature, so the page now says which and how they stay scoped to the caller's own involvement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…unique registry-wide - A responsibility transfer resolved its EMAIL target to an account id but still recorded `target_kind: "EMAIL"`, so resolving it again returned `None`: the target could neither accept nor describe its own handshake, and the transfer was stuck REQUESTED forever. The stored kind now matches what was stored. - The target was resolved BEFORE the management gate, so the difference between `InvalidInputException` and `AWSOrganizationsNotInUseException` told any caller whether an address was registered in some other organization -- the kind of cross-organization leak the rest of this work closes. Authorization now runs first. - An EMAIL target that resolves to no account is rejected: nothing could prove it is the target, so the handshake would sit OPEN until it expired. An ACCOUNT target still needs no prior existence, since any account can authenticate as itself. - `next_account_id` only avoided collisions within one organization. `CreateAccount` could therefore mint an id another organization already owned, and `org_of_account` would resolve it to whichever organization sorted first -- breaking the one-organization-per-account invariant. Ids are now minted from the registry; `begin_create_account` takes the id so the caller, which holds the registry, guarantees uniqueness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…es by type - `resolve_target_account` scanned every organization for a matching member email, which turned an invitation into an email-to-account-id oracle: name an address, be told "already a member of an organization", and read back a 12-digit id from an organization the caller has no relationship with. The address lookup is now scoped to the one organization the call is about. - `handshake_payload` preferred `target_email` whenever it was recorded, so a handshake that stores both rendered an address under `Type: ACCOUNT` -- the id/type pairing `validate_invite_target` rejects on input. The party's id is now picked by its own type. - `party_org_of_transfer` treated every non-ACCEPTED status as "still open", so terminating an already DECLINED or WITHDRAWN transfer told the target to decline a handshake that no longer exists instead of returning the real transition error. Only a REQUESTED offer is the source's alone to withdraw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ation `InviteOrganizationToTransferResponsibility` accepted any account id as the target, and resolved an EMAIL target against the SOURCE organization's member addresses -- exactly backwards. A management account could open (and, once accepted, act on) a billing transfer pointed back into its own organization, while a genuine other- organization target with a real address could never resolve at all. The target is now looked up among management accounts, and must belong to a different organization. Every rejection -- unknown address, not a management account, or the caller's own organization -- reports the same message, so the error cannot be read as an oracle for what exists elsewhere. Also: - `is_transfer_target` decoded the recorded target EMAIL as if it encoded an account id, so a stranger whose id an address happened to spell (`333333333333@example.com`) passed the party check: it could read both parties' ids and emails, and end an accepted transfer. The target is resolved before it is stored, so this is now a plain comparison. - `handshake_payload` fixed the address-under-`Type: ACCOUNT` bug in `Parties` but left it in `Resources`. `HandshakeResourceType` models EMAIL separately, so an email-target invite now reports the address as EMAIL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ler instead The previous round over-corrected. Resolving an EMAIL transfer target against other organizations' management accounts answered "does this address exist there, and what is its account id?" for organizations the caller has nothing to do with -- the oracle the registry's own doc comment says must not exist -- and requiring the target to already be another organization's management account narrowed behavior that used to work in a single-organization process. AWS echoes the target party back as named, so fakecloud does too: the target is recorded verbatim with the type the caller declared, and the party check runs the other way -- it asks the CALLER what its own registered address is and compares that. Nothing is looked up in an organization the caller is not part of, and no target is rejected for not existing. An organization still cannot transfer responsibility to itself, by id or by its own address; AWS models that as `HandshakeConstraintViolationException` with `Reason: SOURCE_AND_TARGET_CANNOT_MATCH`. Also from review: - `CreateGovCloudAccount` minted two ids back to back with nothing reserving the first, so both could be the same id; minting also ignored ids already reserved by in-flight `CreateAccount` requests, where a second request could reserve one already promised and overwrite the first account on completion. - Both responsibility-transfer list ops model an optional `Id` to fetch a single transfer, which was ignored. - `OrganizationsSummary.from_dict` indexed its fields directly, so a payload missing one raised `KeyError` for the whole `get_accounts()` call instead of returning what it did have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
Three places answered "does this target name that account?" and two of them disagreed. An account invited by the synthetic `<account-id>@example.com` form could accept the handshake — the handshake gate resolved the address — and then find the transfer it had just accepted invisible to `ListInboundResponsibilityTransfers`, unreadable through `DescribeResponsibilityTransfer`, and unendable, because the transfer gate compared the stored string raw. The same hole hid an invitation from `ListHandshakesForAccount` for any account whose registered address was the stored target, so it could act on an invitation it had no way to discover. `OrganizationsRegistry::account_matches_target` is now the single answer, used by the handshake party gate, the transfer party gate and the account's own handshake listing. It resolves the synthetic form or matches the address the CALLER is registered with — never a scan of other organizations, so it still reports nothing about who else exists. Also: - `next_account_id_besides` skipped the `gov_cloud_account_id` of an in-flight request, so a concurrent `CreateAccount` could mint an id a pending GovCloud pairing had already reserved and overwrite it on completion. - Dropped three comment blocks left over from the previous round that described resolution the code no longer does, one of which contradicted the block directly below it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…n transfer targets The self-target check only rejected the source's own management account, so a plain member of the SAME organization could be named: the handshake opened, that member could accept it, and the result was a "cross-organization" billing transfer whose two ends were one organization. The check now asks whether the target belongs to the caller's organization at all. Also from review: - `ListInbound/OutboundResponsibilityTransfers` required membership while `DescribeResponsibilityTransfer` did not, so an invited account that had not yet created an organization of its own could read the transfer if handed its id but had no way to discover that id. Both now answer any party. - `list_handshakes` kept a target filter that every caller passed `None` to, and which encoded the id-only rule the shared predicate replaced -- dead code that would silently mismatch the next caller. Dropped. - `OrganizationState::next_account_id` carried two concatenated doc paragraphs after the registry form was added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
The self-target guard compared the raw target against the management account's address and the account-id keys, so it only caught the `ACCOUNT` spelling. Naming a plain member of the caller's own organization by address -- `333333333333@example.com`, or that member's registered email -- still opened a `TRANSFER_RESPONSIBILITY` handshake that the member could then accept, which is the same-organization "transfer" the previous commit meant to close. The guard now runs the shared matcher over the organization's members, so all three spellings resolve identically. Also from review: - `TransferParticipant.ManagementAccountId` is modeled as an `AccountId` (exactly 12 digits), but an email-targeted transfer records the address there and `transfer_payload` emitted it verbatim, so a validating SDK would reject the response. The target party now carries the id only when it resolves to one, and the email otherwise. - Only `ListInboundResponsibilityTransfers` models the optional `Id` filter, and it models a not-found error; the filter was being read on the outbound op too and an unmatched id returned an empty page. - `list_handshakes` kept its old "optionally filtered by destination account id" paragraph above the new one after the parameter was dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…bound membership gate
- `target_participant` asked `target_account_id("ACCOUNT", ...)`, which
returns its input unchanged, so the only real filter was the 12-digit
test and an email-targeted transfer never reported a
`ManagementAccountId` -- not even for the synthetic
`<account-id>@example.com` form that resolves everywhere else. It now
resolves the address form, matching its own doc comment.
- The inbound/outbound list ops still called `require_member`, so an
account that accepted a transfer without running an organization of
its own could not enumerate the transfer it was carrying; it could
only reach it by an id it had no way to obtain. Listing resolves by
party, like `DescribeResponsibilityTransfer`.
- `reconcile_auto_deployments_once` deep-cloned the administrator's whole
organization -- accounts, OUs, policies, attachments, handshakes -- once
per stack set. Candidates are grouped by administrator, so the clone is
now taken once per administrator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…lls who it proved - `account_is_enrolled` looked only at enrolled members, so an id reserved by an in-flight `CreateAccount` was free for a second or two. A client that bootstrapped credentials for that id and called `CreateOrganization` inside the completion delay got its own organization; the background tick then enrolled the same id into the creating organization, and it was in two at once. Reserved ids now count as claimed. - `resolve_handshake` re-derived the account to enroll from the stored target, id-only, while the party gate had already resolved it registry-aware. The two could disagree: an accept by an account registered with the target address enrolled a different, phantom account, and an accept on an external address reported ACCEPTED while enrolling nobody. The caller now passes the account its gate proved. - Dropping the membership gate from the transfer list ops went one op too far: the OUTBOUND caller is always a source management account, so AWS's modeled `AWSOrganizationsNotInUseException` still applies there. INBOUND stays ungated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…path
`account_is_enrolled` learned about ids reserved by an in-flight
`CreateAccount`, but only `CreateOrganization` asked it. The invite
gate, the accept gate and the `create-admin` shortcut each did their own
`org_of_account` lookup, which sees an enrolled member and not a
reserved one. So during the completion delay an id org A had just
minted could still be invited into org B, accept that invitation, or be
enrolled straight into B by the bootstrap shortcut -- and A's tick then
enrolled it too, leaving it in two organizations.
All four paths now go through `claimed_by_other_org`, which answers
"enrolled somewhere else, or about to be".
`HandshakeParty.Type` is also modeled required. Defaulting it to
`ACCOUNT` meant an omitted type had the id validated against a type the
caller never declared, so `{Id: "bob@corp.com"}` failed as a malformed
account id rather than as the missing field it was. Both invite paths
now require it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…e-policy targets - Target resolution decoded the synthetic `<account-id>@example.com` form before consulting the address members are actually registered with, so an account created with `CreateAccount(Email = "222222222222@example.com")` -- which mints a random id -- resolved to `222222222222`, an account nobody owns. Neither the cross-organization guard nor the already-a-member check fired, and the invitation opened for a member already enrolled; the party gate, which did consult the registered address, then let that member accept it. Both matchers now try the registered address first, as their docs always said. - `DescribeEffectivePolicy` resolved the caller's organization but never checked that `TargetId` belonged to it. A target in another organization walked no hierarchy and came back as an empty, successful "no effective policy" -- the worst possible answer for a caller auditing one. It is now `TargetNotFoundException`, as modeled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
`CreateAccount` stores the caller's own `Email`, so an account can be registered with an address that happens to spell a different account id (`222222222222@example.com` on an account whose id is random). The resolvers accepted BOTH readings: - `account_matches_target` returned true for the registered owner AND for the account the address merely spells, so an invitation could be read and accepted by an account it was never addressed to, enrolling the wrong one. - `invite_account` resolved the synthetic form first -- the opposite order to the service-layer guard -- so an invitation to an address already held by a member resolved to an account nobody owns, and both the already-a-member and duplicate-handshake guards missed it. The account could then hold two live invitations under its two spellings. The registered address now wins everywhere, and the synthetic decode is a fallback used only when no account claims that address, so one target resolves to one account. The invite-side resolver stays scoped to a single organization: resolving across the registry would let a caller read a foreign account id back out of the "already a member" error, while the boolean matcher may look wider because it only ever confirms an account the caller already named. Root ids also widen from 4 to 12 random characters. Four was fine while one organization could exist; across a registry two organizations collided at 1/65536, and another organization's root id would then pass the new `DescribeEffectivePolicy` target check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…applies No behavior change; three comments and one doc page said things the code does not do. - `account_matches_target` claimed it "never scans other organizations". It does, and that is safe there: it only ever CONFIRMS an account the caller already named, never hands one back. The resolvers that return an id stay scoped to one organization, which is what keeps the error from being read as an oracle. The precedence (registered address wins, synthetic decode is the fallback) and its one cost -- registering an address elsewhere takes over its synthetic reading -- are now stated. - The cross-organization invite guard said it "applies to an EMAIL target too, once it resolves". It resolves only within the caller's own organization, so an address registered elsewhere slips past and is caught at accept time instead, leaving a handshake OPEN until it expires. That is the deliberate price of the anti-oracle scoping, and the comment now says so rather than promising a guard that does not fire. - `managementAccountId`/`masterAccountId` being null used to mean "no organization exists" and now also means "more than one, so there is no single answer". The services page says to test `organizations` instead, which is empty only in the first case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…n the transfer target - `CreateGovCloudAccount` enrolled the paired account with the SAME email as its commercial twin, so one address named two accounts and every target resolution picked whichever id sorted first. The mirror now carries its own synthesized address. - `target_participant` decoded only the synthetic address form while the party gate resolves registered addresses too, so an account that could accept, describe, list and end a transfer was missing from the payload that names its target. It now resolves the same way; these ops are already party-gated, so the anti-oracle scoping that applies to invitation-time resolution is not needed here. - A responsibility transfer could name a plain member of another organization, which cannot take over billing for one; AWS reports that as a handshake constraint violation. A standalone account is still a valid target -- it may create an organization before accepting, as AWS invites an owner it has not seen yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…d scope the id it returns - The "not another organization's management account" guard asked `org_of_account` about the raw target, which for an EMAIL target is an address and never an account-id key -- so the guard never fired for that spelling. `333333333333@example.com` got through where `333333333333` was refused, and the plain member could then accept the transfer, since the cross-organization re-check on accept only runs for `INVITE` handshakes. The target is now resolved once, the way every party gate resolves it, before either check. That scan drives a rejection and never hands an id back, so it is not the oracle the id-returning resolvers are scoped to avoid. - `target_participant` did hand an id back, from a registry-wide address lookup: once another organization registered the address a transfer had been opened to, the source could read that organization's account id straight out of `DescribeResponsibilityTransfer`. The lookup is now scoped to the organization holding the transfer, falling back to the synthetic form, which decodes only an id the caller already spelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…eled not-in-use errors Resolving a target by address is authorization-relevant -- it decides who may accept an EMAIL-targeted invitation -- so the address has to name one account. AWS already requires that (`EMAIL_ALREADY_EXISTS`), and fakecloud was not enforcing it: two accounts could share one, and whichever id sorted first won. - `CreateAccount` / `CreateGovCloudAccount` reject an address already in use, including one reserved by an in-flight request. - The GovCloud mirror keeps the owner's address, as AWS returns it -- the previous commit had given it a synthesized one to dodge the duplicate. That pair is the one legitimate sharing case, so `account_registered_with` skips the mirror instead. - `AcceptHandshake` now rejects an account that joined the INVITING organization while its invitation sat open. `invite_account` already rejects an existing member at invite time; the accept path flipped the handshake to ACCEPTED with no error and no effect. - `ListInbound/OutboundResponsibilityTransfers` and `DescribeResponsibilityTransfer` require membership again. Both parties to a transfer are management accounts, so a caller in no organization has no transfers either way, and all three model `AWSOrganizationsNotInUseException`. - `target_participant` keeps only the synthetic decode: the source-org lookup it had could never hit, since a target inside the source organization is rejected at invite time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…fer-target rule - `CreateAccount` / `CreateGovCloudAccount` ran the registry-wide address check BEFORE the management gate, so any caller could learn whether an address was registered in an organization it has nothing to do with -- the same oracle the transfer-invite path was reordered to close. The management gate now runs first. - The CloudFormation account provisioner minted from the registry but skipped the address-uniqueness check, so two stacks could register one address in two organizations and make resolution depend on id order. - `invite_account` and `resolve_target_account` matched addresses without skipping the GovCloud mirror, unlike `account_registered_with`. A GovCloud pair shares one address by design, so the three resolvers could name different ids for it. The transfer-target rule has flip-flopped across reviews, so it is now stated once, with its reasoning, next to both halves of the code: reject the caller's OWN organization and a plain MEMBER of another (neither has billing responsibility to hand over), and allow an account that belongs to no organization -- AWS's invite takes an `EMAIL` party precisely to address an owner it has not seen yet. That is why the inbound reads are party-scoped rather than membership-gated: the target has to be able to find the transfer addressed to it before it has an organization. `ListOutbound...` stays gated, since its caller is a source management account by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…lease it on close The address-uniqueness rule added last round was enforced in the wrong places. - `CreateAccount` / `CreateGovCloudAccount` rejected a duplicate synchronously with an error neither op models. Clients hand back a request id and poll -- Terraform's `aws_organizations_account` does -- so an unmodeled exception breaks them. The request is now accepted and the completion tick fails it with `EMAIL_ALREADY_EXISTS`, which is the `CreateAccountFailureReason` AWS defines for exactly this. The check ignores the request's own reservation, or every create would fail. - A closed account keeps its record but now releases its address. `CloseAccount` -- and the CloudFormation delete that calls it -- only suspends, so counting suspended accounts made a deleted stack impossible to re-deploy with the same address. - Accepting an invitation enrolled the new member with whatever address the invitation named, with no uniqueness check, so an address registered elsewhere while the invitation sat open would land on two accounts. The accept now picks a free address -- the named one, else the account's own synthetic one -- rather than punishing an invitee for somebody else's registration. - `DescribeResourcePolicy` is management-only, like `PutResourcePolicy` and `DeleteResourcePolicy`. The document can carry cross-account grants, and AWS restricts the read the same way; it was readable by any member. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ounts release theirs - Two in-flight `CreateAccount` requests for one address made whichever completion tick fired FIRST fail itself, since it still saw the other as `IN_PROGRESS` -- so the caller that asked first was the one refused. Only requests made before this one count now. - The `EMAIL_ALREADY_EXISTS` failure was not persisted. The request had already been written as `IN_PROGRESS`, so a restart re-armed it and a poller watched it go FAILED -> IN_PROGRESS -> FAILED. - `invite_account` and `resolve_target_account` matched addresses on SUSPENDED accounts while `email_in_use` and `account_registered_with` skip them. A closed account's address could therefore be re-used by `CreateAccount` yet never invited again -- refused as "already a member" by one resolver while another reported it named nobody. - The accept-time staleness re-check covered `INVITE` only, so a responsibility-transfer target that had since become a plain member of some organization could still accept, recording a non-management account as the transfer's target. That is exactly what the invite-time guard rejects, and the same "the world moved while this sat open" reasoning applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
`<account-id>@example.com` is the address fakecloud mints for the accounts it creates, so it belongs to that id whether or not the account exists yet. Nothing enforced that: `CreateAccount` could register another id's synthetic address (minting a random id to hold it), and `CreateOrganization` never checked at all before registering its own management account with one. Either way two live accounts ended up sharing an address, and `account_registered_with` then answered with whichever organization sorted first -- the answer that decides who may accept an EMAIL-targeted handshake. `CreateAccount` now fails such a request with `EMAIL_ALREADY_EXISTS`, and `CreateOrganization` refuses when the management account's own address is taken. That also removes the trap in the accept path, where an `ACCOUNT` handshake's "named" and "synthetic" addresses are the same string: with both seen as taken, the accept failed as "already a member of this organization", which the invitee was not and could never fix. It now enrolls with the synthetic address, which nothing else may hold. Also: `target_participant` and `transfer_payload` carried a `registry` argument that was discarded with `let _ =`, and two doc comments had been mis-merged onto the wrong functions. Not changed, deliberately: a SUSPENDED account keeps its account id claimed even though it releases its address. AWS keeps a closed account in the organization, and the address release exists only because CloudFormation's delete path suspends rather than removes -- a re-deployed stack mints a fresh id, so the id needs no release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…hs too The CloudFormation provisioner creates the same resources as the API and must apply the same rules; it had picked up only half of them. - `AWS::Organizations::Account` checked that the address was unused but not that it was the synthetic form reserved for a different id, so a template could squat `999999999999@example.com`. Resolution by address then named the squatter, and the real `999999999999` was permanently locked out of `CreateOrganization`. - `AWS::Organizations::Organization` never checked the management account's own address at all. Also: - `DescribeResourcePolicy` is management OR delegated administrator, not management-only. The model documents Put/Delete as management-only but the read as "the management account or a member account that is a delegated administrator"; the previous commit tightened it too far. - The accept path's "no free address" case reported `AccountAlreadyMember`, which is false there -- the gates above have just proved the caller is a member of no organization. It now says what actually happened. - Dropped an accessor nothing calls and a comment describing a check that has since moved into the completion tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ze the CFN path first - `is_transfer_target` asked the matcher to read the stored target as an address even when it is a bare 12-digit account id. `CreateAccount` does not validate the address format, so an account registered with the literal string "222222222222" passed as the target of a transfer addressed to ACCOUNT 222222222222 -- it could read the transfer, see it in the inbound listing, and end it once accepted. Only the EMAIL-form target resolves further now, matching `target_participant`. - The CloudFormation account provisioner ran its registry-wide address checks before resolving the stack account's own organization, so a stack in no organization could learn whether an address was registered in one it has nothing to do with -- the oracle the API paths authorize first to avoid. It now resolves the organization first and reports "Organization not yet created" for that case, as it did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
… gates Re-accepting an already-accepted handshake reported `HandshakeConstraintViolationException` / `ALREADY_IN_AN_ORGANIZATION` instead of the modeled transition error. The membership gates added for multi-organization run before `resolve_handshake`, which is the only place terminal state was checked -- and a successful accept necessarily makes the caller a member, so the second accept always tripped them. A client retrying after a timeout (the AWS SDKs retry by default) read a join that had actually succeeded as a hard constraint failure, and Decline and Cancel disagreed, since their gates are keyed on ACCEPTED. Also: `CreateAccount` applies create-time tags to the reserved account id synchronously, on the assumption that every request ends SUCCEEDED. The `EMAIL_ALREADY_EXISTS` failure introduced here breaks that, leaving tags on an id that never becomes an account and that AWS answers for with `TargetNotFoundException`. `fail_create_account` now drops them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…an account sent - Moving the terminal-state check ahead of the membership gates put it ahead of the PARTY gate too, so an account outside the organization could tell "exists, resolved" from "exists, open" from "no such handshake" by guessing ids -- the enumeration the new `DescribeHandshake` gate exists to prevent. The order is now party, then state, then membership: a bystander learns only that it is not a party, while a party still gets the transition error a retry needs. - `is_transfer_target` asked the matcher to read the stored target as an address even for a 12-digit account id. `CreateAccount` does not check that `Email` is address-shaped, so an account registered with the literal string "222222222222" passed as the target of a transfer addressed to ACCOUNT 222222222222. - `ListHandshakesForAccount` returned only handshakes an account was the TARGET of. AWS lists those "associated with the account of the requesting user", which includes the invitations it sent -- a management account got an empty list right after inviting someone. Not changed: `Target.Type` stays required. `HandshakeParty.Type` does carry `smithy.api#required` in `aws-models/organizations.json`, so rejecting an omitted type is what the model asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ter, name the caller in its own transfer - `Filter.ActionType = TRANSFER_RESPONSIBILITY` was rejected as an unrecognized action. It is a real `ActionType`, and this work is what makes those handshakes visible to both parties -- a source management account now sees its own outbound transfer handshakes, so filtering for them is the obvious next call. - `target_participant` resolved an EMAIL target only through the synthetic form, so a recipient whose registered address the transfer names read back `Target.ManagementAccountId: null` -- its own id withheld, even though the party gate had just used that very resolution to let it in. A registered address now resolves, but only to the CALLER itself: telling a caller its own id reveals nothing, while an address naming somebody else still reports no id, so the lookup cannot be read as an oracle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…omplete the transfer handshake Four fixes from a self-review of this branch. Delegated administrators were locked out of the organization they administer. `ListHandshakesForOrganization`, `ListAWSServiceAccessForOrganization`, `ListDelegatedAdministrators` and `ListDelegatedServicesForAccount` gated on the management account alone, so the account AWS registers precisely to run a service's org-wide integration could not read the organization it was registered for. They now go through one shared `management_or_delegated_org` gate, which `DescribeResourcePolicy` already needed and now shares. Mutating operations stay management-only via `management_org_mut`; with no management-only read left, the read-side `management_org` is gone. `InviteOrganizationToTransferResponsibility` had no duplicate guard, though `DuplicateHandshakeException` is modeled on it. A caller could stack OPEN transfers on one target, and accepting any of them would move billing while the rest stayed OPEN against an organization that no longer owned it. It now enforces one live offer per target, comparing the RESOLVED target so the same account named by id and by its address collides. Resolution falls back to the synthetic address form, since the target of this invite usually exists in no organization yet. A TRANSFER_RESPONSIBILITY handshake reported only ORGANIZATION and its target, leaving the invited account unable to tell a billing transfer from a plain invitation without a second call. `HandshakeResourceType` models RESPONSIBILITY_TRANSFER, TRANSFER_TYPE, TRANSFER_START_TIMESTAMP, MANAGEMENT_ACCOUNT and MANAGEMENT_EMAIL for exactly that, so the handshake now carries them nested under the transfer id. The handshake stores the transfer id itself rather than reading the link from the transfer's `ActiveHandshakeId`, which is cleared on resolution -- reading it from that side made an ACCEPTED handshake report no transfer at all. An in-flight `CreateAccount` whose address is the synthetic form of an id OTHER than the one it reserved is already doomed: the completion tick fails it with EMAIL_ALREADY_EXISTS. It was still holding the address meanwhile, so any caller could park another account's address for the length of the creation delay and block that account's own `CreateOrganization`. `email_in_use` and `email_in_use_besides` now share one `reservation_holds` predicate that skips a doomed reservation. Tests: four regression tests covering each fix, including that the transfer resource survives the handshake's resolution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ership it was granted to Four fixes from a self-review of the previous commit. A delegated-administrator registration is an organization's grant to one of its OWN members, so it cannot outlive the membership. `remove_account` detached the account's policy attachments but left its registrations behind, so `ListDelegatedServicesForAccount` still answered for an account the organization no longer contains -- and, now that the same registration unlocks the organization's read operations, an account that left and was later re-invited came back holding delegated-administrator authority nobody had granted it. It is dropped with the membership, the way `DisableAWSServiceAccess` already drops the registrations for a principal it untrusts. (`ListDelegatedAdministrators` never showed the symptom: its handler already drops any registration whose account is not in `accounts`.) `remove_account` left the account's tags behind for the same reason, which `ListTagsForResource` went on answering with and a later re-enrollment restored. `fail_create_account` already drops the tags it reserved on an id that never became an account; removal is the same case. A `TRANSFER_RESPONSIBILITY` handshake written before the handshake carried its transfer id deserializes without one, and nothing backfills it, so a restored organization reported no RESPONSIBILITY_TRANSFER resource at all. The payload falls back to the transfer's own `ActiveHandshakeId`, which covers every restored handshake still OPEN. One that had already resolved is beyond recovery -- resolution is what clears that field -- so this is as far back as the stored data reaches. Two doc comments described the function below the one they had been attached to, after the previous commit inserted a helper between them. Tests: a delegated administrator that leaves is neither answered for nor authorized after rejoining, a removed account's tags are gone, and a handshake stripped of its transfer id still reports the transfer. Each new assertion was checked to fail with the fix reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
vieiralucas
force-pushed
the
feat/2543-multi-organization
branch
from
September 24, 2026 13:23
3cce86f to
af5e157
Compare
`DeregisterDelegatedAdministrator` has two ways to find nothing to drop:
the service principal has other delegates but not this account, or it
has no delegates at all. The second arm passed the SERVICE PRINCIPAL
into an error whose message is "Account {id} is not registered as a
delegated administrator.", so a caller deregistering for a principal
nobody is registered under read "Account config.amazonaws.com is not
registered as a delegated administrator." Both arms now name the
account, which is what AWS reports and what the error code means.
Pre-existing, and reachable before this branch; found while reviewing
the delegated-administrator cleanup that added one more route to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #2543 (second half; the first was #2546).
Organizations state was a process-wide
Option<OrganizationState>— at most one organization could ever exist.CreateOrganizationrejected on "an organization exists" rather than "the caller is already in one":So once any account created an organization, every other account in the process was permanently locked out of creating its own. The reporter's script vends two management accounts and expects two independent organizations with two child accounts each; the second
CreateOrganizationfailed purely because the first organization existed.The state becomes an
OrganizationsRegistrykeyed by organization id. An account still belongs to at most one organization, which is what makes "the caller's organization" well defined, so almost every handler now resolves throughorg_of_accountinstead of reaching for the singleton. Organizations never see each other: a caller in none gets the sameAWSOrganizationsNotInUseExceptionas a process with no organizations at all.Behavior
CreateOrganizationrejects only when the calling account is already a member — same for the CloudFormationAWS::Organizations::Organizationprovisioner.DeleteOrganizationremoves exactly the caller's organization instead of clearing the world.HandshakeConstraintViolationException/Reason=ALREADY_IN_AN_ORGANIZATION, re-checked when the handshake is accepted — the target may have joined elsewhere while the invitation sat open.ListHandshakesForAccountlikewise spans organizations.DescribeHandshakeis limited to the handshake's two parties.AssumeRootresolve the principal's/caller's own organization, so one organization's SCPs can never reach another's accounts.Bugs found and fixed along the way
TerminateResponsibilityTransferclearedactive_handshake_idbefore cloning the snapshot it then read the id back from, so the riding handshake was never cancelled and a withdrawn transfer could still be accepted.TRANSFER_RESPONSIBILITYhandshake left the transfer atREQUESTEDwith a live handshake id — two sources of truth disagreeing. It now moves with the handshake.resolve_handshakeenrolled the target on any accepted handshake, so accepting a transfer would have enrolled the other organization's management account. Enrollment is now INVITE-only.AccessDeniedException, not a misleading not-found).InviteAccountToOrganizationtrustedTarget.Idwithout checking it againstTarget.Type, so{Type: ACCOUNT, Id: "bob@corp.com"}opened a handshake nobody could ever accept. NowInvalidInputException.Reads that were satisfied by "an organization exists" now require membership —
ListParents,ListChildren,ListTagsForResource,DescribeEffectivePolicy,DescribeResourcePolicy(which ignored the caller entirely) and the responsibility-transfer ops. With one organization these were harmless; with two they are cross-organization reads.Persistence
Snapshot schema goes to v2, holding the registry. v1's single-organization field is still read and folded in (
into_registry), so an existing snapshot keeps loading across the upgrade; it is never written again. The loader's version guard is>, not!=, so this is a true in-place upgrade.Introspection (wire change)
GET /_fakecloud/organizations/accountsnow spans every organization: each account carries itsorganizationId, and a neworganizationsarray summarises each one. The flatmanagementAccountId/masterAccountIdstay populated when exactly one organization exists — so single-organization callers are unaffected — and arenullwith several, rather than reporting an arbitrary pick./_fakecloud/organizations/responsibility-transfersrows gainorganizationIdfor the same reason.Test plan
cargo nextest run -p fakecloud-organizations— 160 passed, including new coverage for: two accounts each running their own organization; a member of another organization being refused; cross-organization invite/accept rejection; organization isolation of reads (ListParents/ListChildren/ListTagsForResource/DescribeResourcePolicy/DescribeEffectivePolicy);DescribeHandshakeparty gating; EMAIL-target invite resolution;Target.Type/Target.Idvalidation; the inbound side of a responsibility transfer; source-only terminate; and v1→v2 + v2 round-trip snapshot migration.cargo nextest run -p fakecloud-e2e --test organizations --test organizations_scp_enforcement --test organizations_handshake --test organizations_trusted_services --test organizations_lifecycle --test organizations_introspection --test organizations_persistence --test organizations_h4 --test cloudformation_stack_sets_auto_deployment --test multi_account— 54 passed, includingtwo_accounts_each_run_their_own_organization, an end-to-end replay of the issue's repro against the realaws-sdk-organizations.cargo nextest run -p fakecloud-conformance --test organizations— 8 passed.cargo clippy --workspace --all-targets -- -D warnings— clean.gofmt,ruff,mypy,prettier,eslint— clean.Surface sync
organizationIdon accounts and transfers plus the neworganizationssummary. Every addition is appended or defaulted, and Java/.NET keep delegating constructors, so no positional caller breaks.reference/organizations.mddrops "one organization per fakecloud process" and documents the per-caller rejection, the one-organization-per-account rule and its error code;reference/introspection.mdandservices/organizations.mddocument the new response shape.🤖 Generated with Claude Code
https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
Summary by cubic
Fixes #2543 by replacing the process-wide single-organization state with an
OrganizationsRegistrykeyed by organization id. Previously one account's organization permanently locked every other account out of creating its own; now any account outside an organization can create one, and organizations never see each other.Behavior
CreateOrganization/DeleteOrganizationand the CloudFormation provisioner reject only the caller's own membership and remove only the caller's organization.AssumeRoot, and StackSets auto-deployment stay in the caller's own organization.REQUESTEDand either party able to end an accepted transfer.ListHandshakesForOrganization,ListAWSServiceAccessForOrganization,ListDelegatedAdministrators, andListDelegatedServicesForAccountadmit delegated administrators through the same gate asDescribeResourcePolicy; mutations stay management-only.Bug fixes
TRANSFER_RESPONSIBILITYaction filter.INVITE-only; aTRANSFER_RESPONSIBILITYhandshake now carries the transfer type, start time, and management account/email under the transfer id and stores the id itself.CreateOrganization, invites, acceptance, and thecreate-adminshortcut — and acceptance enrolls the account the party gate proved; a failedCreateAccountdrops the tags it attached synchronously.EMAIL_ALREADY_EXISTSon the completion tick, closed accounts release their address, and the synthetic address belongs to its id even before the account exists.SOURCE_AND_TARGET_CANNOT_MATCH, and a plain member of another organization is refused while a standalone account stays valid.AcceptHandshakerejects an invitee who joined another organization while the invitation sat open and enrolls with a free address; the party gate runs before the state check so a bystander learns only that it is not a party; the staleness re-check also covers transfer targets.INVITEhandshakes only; both invite ops validateTarget.TypeagainstTarget.Idand require it; onlyListInboundResponsibilityTransfershonors the optionalIdfilter;DescribeEffectivePolicyrejects a foreignTargetIdand root ids widen to 12 characters.TRANSFER_RESPONSIBILITYhandshake without a stored transfer id falls back to the transfer'sActiveHandshakeId; andDeregisterDelegatedAdministratornow names the account rather than the service principal when nothing is registered.GET /_fakecloud/organizations/accountsspans all organizations (organizationIdper account plus anorganizationsarray) with flatmanagementAccountId/masterAccountIdoccupied only when exactly one organization exists, across all seven SDKs.Written for commit 7733614. Summary will update on new commits.